diff --git a/.devflow/features/external-model-routing/KNOWLEDGE.md b/.devflow/features/external-model-routing/KNOWLEDGE.md index ba1c2aff..a5a9e202 100644 --- a/.devflow/features/external-model-routing/KNOWLEDGE.md +++ b/.devflow/features/external-model-routing/KNOWLEDGE.md @@ -1,11 +1,11 @@ --- feature: external-model-routing name: External Model Routing & Per-Agent Model Config -description: "Use when working on the proxy lifecycle (enable/disable/status/preflight), the ensure-proxy hook, per-agent model mapping, agent frontmatter rewriting, or the agents TUI. Keywords: proxy, external-model-routing, GPT, agent-models, ensure-proxy, frontmatter, devflow proxy, devflow agents, subswitch, ANTHROPIC_BASE_URL, dormancy, reapplyAgentMapping." +description: "Use when working on the proxy lifecycle (enable/disable/status/preflight), the ensure-proxy hook, per-agent model mapping, agent frontmatter rewriting, or the agents TUI. Keywords: proxy, external-model-routing, GPT, agent-models, ensure-proxy, frontmatter, devflow proxy, devflow agents, subswitch, ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT, dormancy, reapplyAgentMapping, runTui, flags-view, tui." category: architecture -directories: [src/core/proxy-state.ts, src/core/external-models.ts, src/core/agent-models.ts, src/core/agent-state.ts, src/core/agent-frontmatter.ts, src/core/codex-auth-inspect.ts, src/core/model-discovery.ts, src/core/cache.ts, src/core/proxy-log.ts, src/cli/commands/proxy.ts, src/cli/commands/agents.ts, src/cli/agents-view, src/assets/scripts/hooks/ensure-proxy] +directories: [src/core/proxy-state.ts, src/core/external-models.ts, src/core/agent-models.ts, src/core/agent-state.ts, src/core/agent-frontmatter.ts, src/core/codex-auth-inspect.ts, src/core/model-discovery.ts, src/core/cache.ts, src/core/proxy-log.ts, src/cli/commands/proxy.ts, src/cli/commands/agents.ts, src/cli/agents-view, src/cli/tui, src/assets/scripts/hooks/ensure-proxy] created: 2026-07-24 -updated: 2026-08-19 +updated: 2026-08-25 --- # External Model Routing & Per-Agent Model Config @@ -51,7 +51,7 @@ Hard failures at any step (steps 1–9) set `process.exitCode = 1` and return The relay process is intentionally left running on `--disable` for any live Claude Code sessions. The disable path: 1. Read `proxy.json` first to determine `managedPort` for the URL strip. -2. `applyDisableToSettings(parsedSettings, managedPort)` — removes hooks AND strips `ANTHROPIC_BASE_URL` (see invariant below). +2. `applyDisableToSettings(parsedSettings, managedPort)` — removes hooks AND strips `ANTHROPIC_BASE_URL` (port-scoped) and `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` (unconditional) (see invariant below). 3. Writes `proxy.json` `enabled:false` — **keeps** `port`, `binPath`, `configPath`, `resolvedAt`, `devflowVersion` for the next enable. 4. Syncs manifest to `proxy: false`. 5. `revertExternalAgents()` — rewrites installed agent files to shipped default models. @@ -62,7 +62,10 @@ Hard failures (e.g., malformed `settings.json`) set `process.exitCode = 1` and r ### `applyDisableToSettings` — both-operations invariant ```typescript -// CORRECT — both operations run unconditionally; managedPort scopes the URL strip: +// CORRECT — both operations run unconditionally; managedPort scopes only the URL strip. +// _stripProxyEnvFromObject removes CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT +// unconditionally (Devflow is its only producer) and removes ANTHROPIC_BASE_URL only +// when it exactly matches http://127.0.0.1: (port-scoped). export function applyDisableToSettings(settings: Settings, managedPort: number): boolean { const removedHooks = removeProxyHooks(settings); const strippedEnv = _stripProxyEnvFromObject(settings, managedPort); @@ -70,7 +73,7 @@ export function applyDisableToSettings(settings: Settings, managedPort: number): } ``` -The regression that this guards against: `removeProxyHooks(s) || _stripProxyEnvFromObject(s, port)` short-circuits when hooks are present — `_stripProxyEnvFromObject` never runs, leaving `ANTHROPIC_BASE_URL` pointing at a disabled relay in new sessions. Both calls must always evaluate regardless of the other's return value. +The regression that this guards against: `removeProxyHooks(s) || _stripProxyEnvFromObject(s, port)` short-circuits when hooks are present — `_stripProxyEnvFromObject` never runs, leaving `ANTHROPIC_BASE_URL` and `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` in the settings file pointing at a disabled relay in new sessions. Both calls must always evaluate regardless of the other's return value. ### Preflight checks (4 in order, hard-gated) @@ -302,13 +305,13 @@ The TUI follows a pure-reducer / pure-renderer / thin-terminal-shell split (appl - **`state.ts`** — pure keypress reducer. `reduce(state, key) → {state, intent}`. `buildRow()` calls `isDormantExternalModel()` (from external-models) to set dormancy state; `rowState()` delegates to `classifyAgentState()` (from agent-state.ts) so the TUI STATE column and `--list` share one classification vocabulary. `persistedModelFor(row)` and `persistedEffortFor(row)` are exported predicates consumed by both `rowState` (STATE column display) and `mergeTuiRowsIntoMapping` (save merge) — the two sites cannot drift on what value gets written. All types and dirty helpers exported. No I/O. - **`render.ts`** — pure renderer. `renderFrame(state, dims) → string[]`. Exports `FIXED_ROWS` and `computeViewportHeight` — consumed by `terminal.ts` (single source of truth for viewport constants). `COL_STATE = 14` — sized so `'saved-inactive'` (13 chars) renders unclipped at 80-column terminals; row budget is 79 chars total (2 prefix + 18 agent + 32 model + 13 effort + 14 state). -- **`terminal.ts`** — impure shell. Manages alt-screen, raw mode, SIGINT/SIGTERM handlers, SIGWINCH resize. All cleanup wired via `resolve()` inside the Promise constructor — never `process.exit()` inside a finally-guarded scope (avoids PF-014). +- **`terminal.ts`** — thin adapter over the shared generic `runTui` driver (`src/cli/tui/`). Calls `runTui` with `signalAction: 'cancel'`, `continueIntent: 'none'`, and an `onResize` callback (updates `viewportHeight`); no `screen` override means the default `'alt'` is used. Alt-screen management, raw mode, SIGINT/SIGTERM, SIGWINCH, and event-loop cleanup are all handled by the generic driver (avoids PF-014). **`TuiIO` injectable seam** (`terminal.ts`): `runAgentsTui(initialState, io?)` accepts an optional `TuiIO` override with fake `stdin`/`stdout` for testing. The default is `process.stdin`/`process.stdout`. Tests pass `PassThrough` streams to drive the TUI without a real TTY. **`MAX_KEYPRESSES = 50_000`**: Exported constant — hard upper bound on the event loop. Resolves with `action: 'cancel'` on exhaustion. Tests pin this value directly (agents-terminal.test.ts). -**`stdin.pause()` in cleanup**: `runAgentsTui` calls `stdin.resume()` at startup and `stdin.pause()` in cleanup. Without `stdin.pause()`, the resumed stdin TTY handle keeps the Node event loop alive after the TUI resolves and the CLI hangs. +**`stdin.pause()` in cleanup**: The generic `runTui` driver calls `stdin.resume()` at startup and `stdin.pause()` in cleanup. Without `stdin.pause()`, the resumed stdin TTY handle keeps the Node event loop alive after the TUI resolves and the CLI hangs. **`FIXED_ROWS`/`computeViewportHeight` single-sourced from `render.ts`**: `terminal.ts` imports both from render.ts — no duplication. @@ -344,7 +347,7 @@ A user who hardened `settings.json` to `0600` (to protect `ANTHROPIC_API_KEY`) n - **`proxy.json` ENOENT is not an error**: `readProxyState()` returns a default disabled state when the file is missing. Callers that treat ENOENT as an error will get a false negative on fresh installs. - **Port adoption path**: if a relay is already accepting connections on the target port and the health check confirms our identity (`name === 'subswitch'`), preflight returns `adopted: true` and `spawnRelayAndWaitForPort` skips spawning. `spawnedPid` will be absent from `SpawnRelayResult` on this path — `runPostSpawnVerification` must never kill an adopted relay. -- **`stripProxyEnv` is port-scoped (REG-1)**: `stripProxyEnv(settingsJson, managedPort)` removes `ANTHROPIC_BASE_URL` **only when its value exactly matches `http://127.0.0.1:`**. A localhost URL on any other port classifies as `'ours-other-port'` or `'foreign'` and is never touched. Callers must pass the port Devflow owns (from `proxy.json.port` or `DEFAULT_PROXY_PORT`). `readProxyEnvState` uses the pattern `^http://127\.0\.0\.1:\d+$` to classify any localhost URL as `'ours-other-port'` for display purposes only — the strip never uses that broad pattern. +- **`stripProxyEnv` is port-scoped for the URL, unconditional for the window var (REG-1)**: `stripProxyEnv(settingsJson, managedPort)` removes `ANTHROPIC_BASE_URL` **only when its value exactly matches `http://127.0.0.1:`** (protecting foreign gateways on any other port), but removes `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` unconditionally — Devflow is its sole producer, so there is no foreign value to protect. A localhost URL on any other port classifies as `'ours-other-port'` or `'foreign'` and is never touched. Callers must pass the port Devflow owns (from `proxy.json.port` or `DEFAULT_PROXY_PORT`). `readProxyEnvState` uses the pattern `^http://127\.0\.0\.1:\d+$` to classify any localhost URL as `'ours-other-port'` for display purposes only — the strip never uses that broad pattern. - **Remembered port on re-enable**: `--port` has no commander default. When `--port` is omitted, `portOption` is `undefined` and `resolvePort(undefined, priorPort)` returns the remembered port from `proxy.json`. - **Dormant TUI rows**: when proxy is off and an agent has a saved GPT model, `buildRow()` calls `isDormantExternalModel()` and sets `configuredModel='default'` with the GPT name in `dormantModel`. `persistedModelFor(row)` returns `dormantModel` for an untouched dormant row, so `mergeTuiRowsIntoMapping` preserves the GPT mapping entry byte-identical on save even though `configuredModel` shows `'default'`. - **`binPath` must be spawned with `node `**: npm does not guarantee executable bits on installed package binaries. Always spawn as `node `, never `` directly. @@ -371,7 +374,9 @@ A user who hardened `settings.json` to `0600` (to protect `ANTHROPIC_API_KEY`) n - `src/cli/commands/agents.ts` — `agentsCommand`, `validateSetArgs()` (calls `isValidModelName`, zero-spawn), `applySetMapping()`, `buildListRows()`, `mergeTuiRowsIntoMapping()` (consumes `persistedModelFor`/`persistedEffortFor`) - `src/cli/agents-view/state.ts` — pure reducer, `buildRow()`, `isDirtyModel()`, `isDirtyEffort()`, `persistedModelFor()`, `persistedEffortFor()`, `rowState()` (delegates to `classifyAgentState`), `unsavedCount()` - `src/cli/agents-view/render.ts` — pure frame renderer; `COL_STATE = 14`; exports `FIXED_ROWS`, `computeViewportHeight` -- `src/cli/agents-view/terminal.ts` — impure TUI shell, `runAgentsTui()`, `TuiIO`, `MAX_KEYPRESSES` +- `src/cli/agents-view/terminal.ts` — thin adapter over the shared `runTui` driver (`src/cli/tui/terminal.ts`); exports `runAgentsTui()`, re-exports `TuiIO` and `MAX_KEYPRESSES` from tui/ +- `src/cli/tui/terminal.ts` — generic `runTui` driver (`RunTuiSpec`: `signalAction: Exclude`, `continueIntent: C`, `screen?: 'alt'|'inline'`), `normalizeKey`, `TuiIO`, `MAX_KEYPRESSES`, `RenderDims`, `INLINE_MARGIN`; agents-view uses `signalAction='cancel'` + default `'alt'` screen; flags-view uses `signalAction='abort'` + `'inline'` screen +- `src/cli/tui/cells.ts` — cell helper utilities (shared across TUI modules) - `src/assets/scripts/hooks/ensure-proxy` — SessionStart + UserPromptSubmit hook; writes `proxy.pid` after spawn; UserPromptSubmit exits before proxy-state reads; relay spawned via `env -i` 6-var allowlist - `src/cli/commands/init.ts` — proxy preflight block (4-check, no doctor, no spawn); `reapplyAgentMapping` guard after preflight; convergence writes `proxy.json enabled:false` on preflight failure diff --git a/.devflow/features/index.md b/.devflow/features/index.md index 3d6a1bb4..efcd29a9 100644 --- a/.devflow/features/index.md +++ b/.devflow/features/index.md @@ -2,7 +2,7 @@ - **ambient-orchestrator** — src/assets/scripts/hooks, src/cli/commands/ambient.ts, src/core/plugins.ts — Use when modifying the ambient mode hooks (preamble, session-start-orchestrator), the orchestrator charter file (including the feature-knowledge operating rule), the git-marker helper, the ambient CLI toggle, or the plan-handoff fast-path. Keywords: ambient, preamble, orchestrator, charter, plan-handoff, session-start-orchestrator, git-marker, DEVFLOW_BG_UPDATER, devflow ambient, UserPromptSubmit, SessionStart, feature-knowledge. - **dynamic-workflow-engine** — src/assets/commands/dynamic-build.mds, src/assets/commands/dynamic-plan.mds, src/assets/commands/dynamic-tickets.mds, src/assets/commands/dynamic-profile.mds, src/assets/commands/_partials/_engine.mds, src/assets/commands/_partials/_wave.mds, dist/commands, tests/build-mds.test.ts — Use when authoring or modifying the dynamic-* commands (dynamic-build, dynamic-plan, dynamic-tickets, dynamic-profile), the shared engine/wave/preamble/factory MDS partials, or the build-mds test suite that pins doctrine literals. Keywords: dynamic-build, dynamic-plan, dynamic-tickets, dynamic-profile, Workflow tool, agentType, Gate 1, Gate 2, review pass, wave, tickets→plan→build, MDS, _engine.mds, _wave.mds. - **resolve-pipeline** — src/assets/commands/resolve.mds, src/assets/agents/triage.md, src/assets/agents/code.md, src/core/plugins.ts, src/assets/commands/code-review.mds — Use when modifying /resolve or /code-review convergence logic, adding or changing Triage disposition rules, adjusting Code-agent operating modes (issue-fix/validation-fix), touching the resolution-summary.md parser contract, changing the Verification Gate retry loop, understanding how DIFF_FILES flows from git validate-branch into blast-radius triage, or working on traceability operations (fetch-review-threads, resolve-review-threads, post-resolution-summary, check-merge-readiness, THREAD_MAP). Keywords: resolve, triage, disposition matrix, blast-radius, FIX_NOW, FIX_SEPARATE, TECH_DEBT, FALSE_POSITIVE, BY_DESIGN, ESCALATED, resolution-summary, convergence parser, DIFF_FILES, issue-fix, validation-fix, Verification Gate, manage-debt, COMPLIANCE_SKILL_INSTALLED, TRACEABILITY DEGRADED, fetch-review-threads, THREAD_MAP, post-resolution-summary, Third-Party Threads, check-merge-readiness, ext-N, D7, D9, PF-024. -- **installer-shadowing** — src/targets/claude-code/installer.ts, src/targets/claude-code/legacy.ts, src/cli/commands/init.ts, src/cli/commands/init-seed.ts, src/cli/commands/uninstall.ts, src/cli/commands/rules.ts, src/cli/commands/skills.ts, src/core/plugins.ts, src/core/assets.ts, src/core/paths.ts, src/core/manifest.ts, src/core/flags.ts, src/core/feature-config.ts, src/core/orphan-sweep.ts, src/core/migrations.ts, src/cli/commands/compliance-prompts.ts — Use when modifying the install pipeline (installViaFileCopy, installAllRules, composeScripts, InstallReport), adding or changing skill/rule shadow override logic, touching uninstall scope (enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, sweepDevflowNamespaces, resolveProjectDataCleanup) or install-artifact cleanup, extending the CLI skills/rules management commands, working with asset directory accessors (rulesDir, skillsDir, commandsDir) and package-root resolution, modifying the init seeding layer (resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, --reset, knownFlags, knownPlugins, readConfigIfPresent, resolveExistingViewMode, getAllCommandNames, proxy), or working on the compliance wizard step (shouldRunComplianceStep, runComplianceStep, modePromptShown, CompliancePromptIO). Keywords: installViaFileCopy, installAllRules, composeScripts, InstallReport, RuleInstallOutcome, SkillShadowState, RuleShadowState, shadow, unshadow, validateSkillShadow, validateRuleShadow, seedRuleShadow, prefixSkillName, unprefixSkillName, devflow:, skills, rules, uninstall, EISDIR, enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, enumerateDryRunExtras, sweepDevflowNamespaces, resolveProjectDataCleanup, runDryRunPhase, runSelectivePhaseForScope, runFullPhaseForScope, runCleanupPhase, getPackageRoot, isContainedIn, rulesDir, skillsDir, agentsDir, commandsDir, scriptsDir, LEGACY_SKILL_NAMES, sweepOrphanedAssets, SweepResult, sweepOrphans, sweepFailures, SweepFailure, mdFileName, mdEntryName, orphan sweep, getAllSkillNames, getAllCommandNames, getAllAgentNames, DELETED_PLUGIN_NAMES, EXCLUDED, resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, resolveResetGatedInputs, applyCliToggles, knownFlags, knownPlugins, readConfigIfPresent, resolveExistingViewMode, resolveFinalViewMode, reset, init-seed, proxy, reapplyAgentMapping, revertExternalAgents, agent-models.json, proxy.json, proxy-routing.json, proxy.pid, applyDisableToSettings, buildRealPreflightDeps, canonicalise-agent-keys-v1, AnyMigration, migrations.json. +- **installer-shadowing** — src/targets/claude-code/installer.ts, src/targets/claude-code/legacy.ts, src/cli/commands/init.ts, src/cli/commands/init-seed.ts, src/cli/commands/uninstall.ts, src/cli/commands/rules.ts, src/cli/commands/skills.ts, src/cli/commands/flags.ts, src/cli/flags-view, src/cli/tui, src/core/plugins.ts, src/core/assets.ts, src/core/paths.ts, src/core/manifest.ts, src/core/flags.ts, src/core/feature-config.ts, src/core/orphan-sweep.ts, src/core/migrations.ts, src/cli/commands/compliance-prompts.ts — Use when modifying the install pipeline (installViaFileCopy, installAllRules, composeScripts, InstallReport), adding or changing skill/rule shadow override logic, touching uninstall scope (enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, sweepDevflowNamespaces, resolveProjectDataCleanup) or install-artifact cleanup, extending the CLI skills/rules/flags management commands, working with asset directory accessors (rulesDir, skillsDir, commandsDir) and package-root resolution, modifying the init seeding layer (resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, --reset, FlagsRecord, knownPlugins, readConfigIfPresent, resolveExistingViewMode, getAllCommandNames, proxy), working on the flags TUI (FlagsViewState, FlagRow, buildFlagRows, collectFlagRecord, effectiveDisplay, blurb, inline mode, RunTuiSpec screen) or the flags CLI (createFlagsCommand, lookupFlag, persistFlagConfig, formatFlagValue), or working on the compliance wizard step (shouldRunComplianceStep, runComplianceStep, modePromptShown, CompliancePromptIO). Keywords: installViaFileCopy, installAllRules, composeScripts, InstallReport, RuleInstallOutcome, SkillShadowState, RuleShadowState, shadow, unshadow, validateSkillShadow, validateRuleShadow, seedRuleShadow, prefixSkillName, unprefixSkillName, devflow:, skills, rules, uninstall, EISDIR, enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, enumerateDryRunExtras, sweepDevflowNamespaces, resolveProjectDataCleanup, runDryRunPhase, runSelectivePhaseForScope, runFullPhaseForScope, runCleanupPhase, getPackageRoot, isContainedIn, rulesDir, skillsDir, agentsDir, commandsDir, scriptsDir, LEGACY_SKILL_NAMES, sweepOrphanedAssets, SweepResult, sweepOrphans, sweepFailures, SweepFailure, mdFileName, mdEntryName, orphan sweep, getAllSkillNames, getAllCommandNames, getAllAgentNames, DELETED_PLUGIN_NAMES, EXCLUDED, resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, resolveResetGatedInputs, applyCliToggles, FlagsRecord, FlagsRecordValue, getDefaultFlagsRecord, parseManifestFlags, migrateLegacyFlagsToRecord, sanitizeFlagsRecord, coerceFlagValue, parseFlagValueInput, neutralValueOf, isNeutral, countActiveFlags, readViewMode, knownPlugins, readConfigIfPresent, resolveExistingViewMode, resolveFinalViewMode, reset, init-seed, proxy, reapplyAgentMapping, revertExternalAgents, agent-models.json, proxy.json, proxy-routing.json, proxy.pid, applyDisableToSettings, buildRealPreflightDeps, canonicalise-agent-keys-v1, AnyMigration, migrations.json, compliance-prompts, shouldRunComplianceStep, CompliancePromptIO, runComplianceStep, modePromptShown, createFlagsCommand, lookupFlag, persistFlagConfig, FlagsViewState, FlagRow, buildFlagRows, collectFlagRecord, buildStops, cycleForward, cycleBackward, sanitizeCell, padToVisible, truncateVisible, effectiveDisplay, EffectiveDisplay, formatFlagValue, blurb, FlagDefCommon, INLINE_MARGIN, cursorUp, RunTuiSpec, screen, inline. - **learning-capture-system** — src/assets/scripts/hooks, src/assets/agents/learning.md, src/cli/commands/learning.ts, src/core/feature-config.ts, src/core/learning-tuning-config.ts, src/hud/components/learning-counts.ts, src/assets/commands/_partials — Use when modifying capture hooks (capture-prompt/capture-turn/capture-question), the learning or memory pending-turns queues, the Learning agent (src/assets/agents/learning.md), the session-start-context learning directive, the feature-config toggles, the learning tuning config, the decisions content files (decisions.md/pitfalls.md/index.md) or their ledger ops, or the devflow learning CLI. Keywords: capture-prompt, capture-turn, capture-question, queue-append, pending-turns, memory-worker, Learning agent, learning directive, LEARNING MAINTENANCE, DEVFLOW_BG_UPDATER, learning-lock, queue_read_gates, decisions_load, DECISIONS_CONTEXT, feature-config, config.json, learning.json, decisions-ledger, assign-anchor, retire-anchor, render-decisions. -- **external-model-routing** — src/core/proxy-state.ts, src/core/external-models.ts, src/core/agent-models.ts, src/core/agent-state.ts, src/core/agent-frontmatter.ts, src/core/codex-auth-inspect.ts, src/core/model-discovery.ts, src/core/cache.ts, src/core/proxy-log.ts, src/cli/commands/proxy.ts, src/cli/commands/agents.ts, src/cli/agents-view — Use when working on the proxy lifecycle (enable/disable/status/preflight), the ensure-proxy hook, per-agent model mapping, agent frontmatter rewriting, or the agents TUI. Keywords: proxy, external-model-routing, GPT, agent-models, ensure-proxy, frontmatter, devflow proxy, devflow agents, subswitch, ANTHROPIC_BASE_URL, dormancy, reapplyAgentMapping. +- **external-model-routing** — src/core/proxy-state.ts, src/core/external-models.ts, src/core/agent-models.ts, src/core/agent-state.ts, src/core/agent-frontmatter.ts, src/core/codex-auth-inspect.ts, src/core/model-discovery.ts, src/core/cache.ts, src/core/proxy-log.ts, src/cli/commands/proxy.ts, src/cli/commands/agents.ts, src/cli/agents-view, src/cli/tui — Use when working on the proxy lifecycle (enable/disable/status/preflight), the ensure-proxy hook, per-agent model mapping, agent frontmatter rewriting, or the agents TUI. Keywords: proxy, external-model-routing, GPT, agent-models, ensure-proxy, frontmatter, devflow proxy, devflow agents, subswitch, ANTHROPIC_BASE_URL, dormancy, reapplyAgentMapping. - **compliance-feature** — src/core/compliance.ts, src/targets/claude-code/compliance-install.ts, src/cli/commands/compliance.ts, src/assets/skills/compliance, src/assets/rules/compliance.md, src/assets/agents/git.md, src/assets/commands/code-review.mds, src/assets/commands/plan.mds, src/assets/commands/implement.mds, src/assets/commands/resolve.mds, src/assets/commands/release.md — Use when adding or modifying the compliance feature (framework registry, converge contract, CLI, rule stamping), changing how host commands resolve COMPLIANCE_SKILL_INSTALLED, modifying traceability operations in the Git agent (learn-conventions, issue-first, thread resolution, shipped markers, release evidence), or extending the D4 DEGRADED contract. Keywords: compliance, COMPLIANCE_SKILL_INSTALLED, convergeComplianceArtifacts, convergeFromManifest, frameworks, FEATURE_OWNED_SKILLS, traceability, D4, D9, gather-release-evidence, conventions.md, resolve-review-threads, ensure-traceable-issue, stamper, manifest-group, ComplianceFeatureState. diff --git a/.devflow/features/installer-shadowing/KNOWLEDGE.md b/.devflow/features/installer-shadowing/KNOWLEDGE.md index c77d64cd..052697a3 100644 --- a/.devflow/features/installer-shadowing/KNOWLEDGE.md +++ b/.devflow/features/installer-shadowing/KNOWLEDGE.md @@ -1,11 +1,11 @@ --- feature: installer-shadowing name: Installer & Skill/Rule Shadowing -description: "Use when modifying the install pipeline (installViaFileCopy, installAllRules, composeScripts, InstallReport), adding or changing skill/rule shadow override logic, touching uninstall scope (enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, sweepDevflowNamespaces, resolveProjectDataCleanup) or install-artifact cleanup, extending the CLI skills/rules management commands, working with asset directory accessors (rulesDir, skillsDir, commandsDir) and package-root resolution, or modifying the init seeding layer (resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, --reset, knownFlags, knownPlugins, readConfigIfPresent, resolveExistingViewMode, getAllCommandNames, proxy). Keywords: installViaFileCopy, installAllRules, composeScripts, InstallReport, RuleInstallOutcome, SkillShadowState, RuleShadowState, shadow, unshadow, validateSkillShadow, validateRuleShadow, seedRuleShadow, prefixSkillName, unprefixSkillName, devflow:, skills, rules, uninstall, EISDIR, enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, enumerateDryRunExtras, sweepDevflowNamespaces, resolveProjectDataCleanup, runDryRunPhase, runSelectivePhaseForScope, runFullPhaseForScope, runCleanupPhase, getPackageRoot, isContainedIn, rulesDir, skillsDir, agentsDir, commandsDir, scriptsDir, LEGACY_SKILL_NAMES, sweepOrphanedAssets, SweepResult, sweepOrphans, sweepFailures, SweepFailure, mdFileName, mdEntryName, orphan sweep, getAllSkillNames, getAllCommandNames, getAllAgentNames, DELETED_PLUGIN_NAMES, EXCLUDED, resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, resolveResetGatedInputs, applyCliToggles, knownFlags, knownPlugins, readConfigIfPresent, resolveExistingViewMode, resolveFinalViewMode, reset, init-seed, proxy, reapplyAgentMapping, revertExternalAgents, agent-models.json, proxy.json, proxy-routing.json, proxy.pid, applyDisableToSettings, buildRealPreflightDeps, canonicalise-agent-keys-v1, AnyMigration, migrations.json, compliance-prompts, shouldRunComplianceStep, CompliancePromptIO, runComplianceStep, modePromptShown." +description: "Use when modifying the install pipeline (installViaFileCopy, installAllRules, composeScripts, InstallReport), adding or changing skill/rule shadow override logic, touching uninstall scope (enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, sweepDevflowNamespaces, resolveProjectDataCleanup) or install-artifact cleanup, extending the CLI skills/rules/flags management commands, working with asset directory accessors (rulesDir, skillsDir, commandsDir) and package-root resolution, modifying the init seeding layer (resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, --reset, FlagsRecord, knownPlugins, readConfigIfPresent, resolveExistingViewMode, getAllCommandNames, proxy), working on the flags TUI (FlagsViewState, FlagRow, buildFlagRows, collectFlagRecord, effectiveDisplay, blurb, inline mode, RunTuiSpec screen) or the flags CLI (createFlagsCommand, lookupFlag, persistFlagConfig, formatFlagValue), or working on the compliance wizard step (shouldRunComplianceStep, runComplianceStep, modePromptShown, CompliancePromptIO). Keywords: installViaFileCopy, installAllRules, composeScripts, InstallReport, RuleInstallOutcome, SkillShadowState, RuleShadowState, shadow, unshadow, validateSkillShadow, validateRuleShadow, seedRuleShadow, prefixSkillName, unprefixSkillName, devflow:, skills, rules, uninstall, EISDIR, enumerateUserDevFlowContent, removeDevFlowInstallArtifacts, resolveDevflowDirCleanup, installArtifactPaths, enumerateDryRunExtras, sweepDevflowNamespaces, resolveProjectDataCleanup, runDryRunPhase, runSelectivePhaseForScope, runFullPhaseForScope, runCleanupPhase, getPackageRoot, isContainedIn, rulesDir, skillsDir, agentsDir, commandsDir, scriptsDir, LEGACY_SKILL_NAMES, sweepOrphanedAssets, SweepResult, sweepOrphans, sweepFailures, SweepFailure, mdFileName, mdEntryName, orphan sweep, getAllSkillNames, getAllCommandNames, getAllAgentNames, DELETED_PLUGIN_NAMES, EXCLUDED, resolveInitSeed, resolveSeedFeatures, resolveSeedFlags, resolveSeedPlugins, resolveResetGatedInputs, applyCliToggles, FlagsRecord, FlagsRecordValue, getDefaultFlagsRecord, parseManifestFlags, migrateLegacyFlagsToRecord, sanitizeFlagsRecord, coerceFlagValue, parseFlagValueInput, neutralValueOf, isNeutral, countActiveFlags, readViewMode, knownPlugins, readConfigIfPresent, resolveExistingViewMode, resolveFinalViewMode, reset, init-seed, proxy, reapplyAgentMapping, revertExternalAgents, agent-models.json, proxy.json, proxy-routing.json, proxy.pid, applyDisableToSettings, buildRealPreflightDeps, canonicalise-agent-keys-v1, AnyMigration, migrations.json, compliance-prompts, shouldRunComplianceStep, CompliancePromptIO, runComplianceStep, modePromptShown, createFlagsCommand, lookupFlag, persistFlagConfig, FlagsViewState, FlagRow, buildFlagRows, collectFlagRecord, buildStops, cycleForward, cycleBackward, sanitizeCell, padToVisible, truncateVisible, effectiveDisplay, EffectiveDisplay, formatFlagValue, blurb, FlagDefCommon, INLINE_MARGIN, cursorUp, RunTuiSpec, screen, inline." category: architecture -directories: [src/targets/claude-code/installer.ts, src/targets/claude-code/legacy.ts, src/cli/commands/init.ts, src/cli/commands/init-seed.ts, src/cli/commands/uninstall.ts, src/cli/commands/rules.ts, src/cli/commands/skills.ts, src/core/plugins.ts, src/core/assets.ts, src/core/paths.ts, src/core/manifest.ts, src/core/flags.ts, src/core/feature-config.ts, src/core/orphan-sweep.ts, src/core/migrations.ts, src/cli/commands/compliance-prompts.ts] +directories: [src/targets/claude-code/installer.ts, src/targets/claude-code/legacy.ts, src/cli/commands/init.ts, src/cli/commands/init-seed.ts, src/cli/commands/uninstall.ts, src/cli/commands/rules.ts, src/cli/commands/skills.ts, src/cli/commands/flags.ts, src/cli/flags-view, src/cli/tui, src/core/plugins.ts, src/core/assets.ts, src/core/paths.ts, src/core/manifest.ts, src/core/flags.ts, src/core/feature-config.ts, src/core/orphan-sweep.ts, src/core/migrations.ts, src/cli/commands/compliance-prompts.ts] created: 2026-07-13 -updated: 2026-08-22 +updated: 2026-08-25 --- # Installer & Skill/Rule Shadowing @@ -128,14 +128,21 @@ export interface ShadowSkip { `init.ts` iterates `skippedShadows` and emits a warning per entry via an exhaustive switch on `ShadowSkipReason` (with `never` guard). Invalid shadows never cause init to exit non-zero. (applies ADR-010) -### Manifest Snapshots: `knownFlags`, `knownPlugins`, and `proxy` +### Manifest Snapshots: `flags`, `knownPlugins`, and `proxy` -`manifest.ts` stores two registry snapshots at install time: +`manifest.ts` stores the flag state and the plugin snapshot at install time: -- `ManifestData.features.knownFlags?: string[]` — all `FLAG_REGISTRY` IDs at the time of the last install -- `ManifestData.knownPlugins?: string[]` — all `DEVFLOW_PLUGINS` names at the time of the last install +- `ManifestData.features.flags: FlagsRecord` — typed flag record (key-presence = known to this install; `null` value = deliberately unset/neutral; absent key = adopt-on-next-init per ADR-014). Replaces the former `knownFlags: string[]` field; old string[] manifests are auto-migrated by `parseManifestFlags` + `migrateLegacyFlagsToRecord` on first `readManifest`. +- `ManifestData.knownPlugins?: string[]` — all `DEVFLOW_PLUGINS` names at the time of the last install. Absent in pre-7b manifests; `readManifest` self-heals via a local `asStringArray` helper (requires every element to pass `typeof e === 'string'`; a mixed/garbage array self-heals to `undefined`). -Both are absent in pre-7b manifests; `readManifest` self-heals via a local `asStringArray` helper that requires all elements to pass `typeof e === 'string'` — a mixed/garbage array like `[1, null]` self-heals to `undefined`, not just non-arrays. These snapshots are consumed by the init seeding layer to detect newly added flags and plugins. +**`parseManifestFlags(features, knownFlags)`** handles three on-disk shapes for `features.flags`: +- Case A: `string[]` — legacy format; migrated to `FlagsRecord` via `migrateLegacyFlagsToRecord`, folding the separate `features.viewMode` field in. Reports `legacy: true`. +- Case B: `object` — already a `FlagsRecord`; spread into a fresh record (avoids mutation). Reports `legacy: false`. +- Case C: missing/other — defaults to empty record. + +The legacy `features.knownFlags` field from old manifests is read by `readManifest` only to feed Case A migration; it is NOT carried into the returned `ManifestData`. The "known" semantic is encoded entirely in `FlagsRecord` key-presence: a key present in the record = known to this install; an absent key = adopt-on-next-seed. + +`readManifest` calls `sanitizeFlagsRecord` on the parsed result to coerce any stored values back through `coerceFlagValue` — a mild defensive measure against schema drift. The `proxy` and `knownPlugins` snapshots are consumed by the init seeding layer. `ManifestData.features.proxy: boolean` tracks whether external model routing was enabled at the last install. `readManifest` self-heals absent fields to `false` (applies ADR-014 self-heal idiom). The value written to the manifest is the **final resolved value after preflight** — a preflight failure forces `proxyEnabled = false` before the manifest write, so the manifest always reflects the actual settled state. @@ -243,24 +250,26 @@ A dedicated pure-function module (`src/cli/commands/init-seed.ts`) computes the **Composition point**: `resolveInitSeed(seedManifest, seedConfig, settingsSnapshot, plugins) → InitSeed` -`InitSeed` carries: `features: FeatureSeed`, `flags: string[]`, `viewMode: ViewMode`, `workflowPlugins: string[]`, `languagePlugins: string[]`. +`InitSeed` carries: `features: FeatureSeed`, `flags: FlagsRecord`, `workflowPlugins: string[]`, `languagePlugins: string[]`. `viewMode` is encoded inside `flags['view-mode']` (PF-015: all flag state in FlagsRecord) — there is no separate `viewMode` field. **Feature seeding** (`resolveSeedFeatures`): - `memory / learning / knowledge`: projectConfig wins when present (ADR-001 — config.json is the source of truth); falls back to manifest; then registry defaults (all true). - `ambient / hud / rules / proxy`: manifest is the source; registry defaults when manifest absent. `proxy` defaults to `false` in `FEATURE_DEFAULTS` — it is Advanced-only and never part of Recommended defaults. Because proxy seeds from the manifest group (not config.json), `--reset` null-seeds the manifest and correctly resets proxy to `false`. -**Flag seeding** (`resolveSeedFlags`): Fresh install → all default-ON registry flags. Old manifest (no `knownFlags`) → return existing flags as-is. Re-init with `knownFlags` → union existing ∪ {default-ON flags ∉ knownFlags}. Default-OFF flags are NEVER auto-added. +**Flag seeding** (`resolveSeedFlags(manifestFlags: FlagsRecord | null, registry)`): Two branches — (1) `null` (fresh install): all registry flags at their `defaultValue`; (2) non-null: spread the manifest `FlagsRecord`, then for each registry flag whose key is absent from the record, adopt its `defaultValue` (ADR-014: absent key = new to this install → adopt). Unknown IDs from old manifests pass through unchanged for forward-compat. Default-OFF flags adopt `false`/`null` — they arrive in the seed as inactive, not as missing. **Plugin seeding** (`resolveSeedPlugins`): Fresh install → non-optional workflow plugins preselected, empty language list. Old manifest (no `knownPlugins`) → split existing into workflow/language buckets, adopt nothing. Re-init with `knownPlugins` → split + adopt newly-added non-optional selectable plugins ∉ knownPlugins. **Reset gate** (`resolveResetGatedInputs`): `--reset` zeroes seedManifest, seedConfig, AND settingsSnapshot. -**viewMode resolution**: `resolveExistingViewMode(settingsSnapshot) ?? seedManifest?.features.viewMode ?? 'default'`. `resolveExistingViewMode` returns non-default values only — 'default' surfaces as undefined so `??` falls through. +**viewMode resolution**: `resolveInitSeed` resolves view-mode in three-priority order — (1) `resolveExistingViewMode(settingsSnapshot)` (non-`'default'` from current settings.json wins); (2) `readViewMode(flags)` from the spread manifest record (non-`'default'` wins); (3) `'default'`. The resolved value is encoded into `flags['view-mode']` on the returned `InitSeed`. `seedManifest?.features.viewMode` is no longer consulted — that field is retired; view-mode lives entirely in `ManifestData.features.flags['view-mode']`. **CLI toggles** (`applyCliToggles`): Applies explicit CLI feature flags (e.g. `--no-learning`, `--proxy`) on top of the resolved seed. Undefined = not specified; seed value is kept. **`--reset --plugin` rejection**: Combining factory reset with a partial install is rejected before reaching seed resolution. +**Flags applied non-interactively (D40)**: After `applyCliToggles`, `init.ts` applies `enabledFlags` directly — no TUI is opened in either init path. Fresh install: all registry flags at their `defaultValue`. Re-init: spread manifest record, then adopt defaults only for absent flags (ADR-014). Outcome line: `Flags: ${activeCount} active — customize any time with 'devflow flags'`. `getDefaultFlagsRecord` is not imported by init.ts; `viewModeExplicit` is exclusively `!!options.reset` (not set by any interactive input since the TUI was removed). + ### Compliance Prompt Module (`src/cli/commands/compliance-prompts.ts`) A dedicated CLI-layer module (ADR-013 — CLI-layer prompts; core stays UI-agnostic) that owns all compliance wizard UI. Key exports: @@ -286,6 +295,70 @@ The `MIGRATIONS` registry (typed `readonly AnyMigration[]`) has one entry: `cano **Failure mode**: `runGlobalMigration` marks a migration applied for ANY non-throwing return. The `canonicalise-agent-keys-v1` entry catches ALL I/O failures and returns them as `warnings` — it never throws. Result: a failed write is silently marked applied and never retried. Net impact is low because `readAgentMapping` applies `canonicaliseAgentKeys` on EVERY read, so the disk file self-heals on the next write even if the one-time disk migration was lost. A future fix should make genuine I/O failure throw so the runner retries it (distinguished from "malformed file, skip it" which returns correctly). `migrations.json` is removed by `removeDevFlowInstallArtifacts` so migrations re-run cleanly on reinstall. +### Flags CLI (`src/cli/commands/flags.ts`) + +A CLI-layer module that owns the `devflow flags` command surface. All I/O-free flag logic lives in `src/core/flags.ts`; this module owns the Commander wiring, settings I/O, and manifest persistence. + +Key exports: +- **`createFlagsCommand()`** — root Commander for `devflow flags`. Bare invocation on a TTY launches the interactive TUI; on non-TTY, prints a status table and exits 1. +- **`lookupFlag(id)`** — resolves a flag by ID from `FLAG_REGISTRY`; returns `null` for unknown IDs (callers emit an error). +- **`readSettingsSafe(settingsPath)`** — reads settings.json, returning `{ok: true, content}` or `{ok: false, reason}` — never throws. +- **`persistFlagConfig(claudeDir, devflowDir, settingsContent, newRecord)`** — writes the `FlagsRecord` to both `manifest.json` (`features.flags`) and `settings.json` (via `applyFlags`). Returns `true` on success, `false` on I/O failure. Boolean-only flags use `--enable`/`--disable`; non-boolean flags are redirected to `--set`. + +**Display vocabulary** (D-EFFDV — one definition, all surfaces route through `effectiveDisplay`): + +- **`--enable` / `--disable` confirmation**: both call `formatFlagValue(flag, value)` which delegates to `effectiveDisplay`. Vocabulary: `true` → 'on', `false` → 'off'. The former asymmetry ('enabled' for enable, `formatFlagValue` for disable) is gone. +- **`--set` confirmation**: active values route through `formatFlagValue`; `null` echoes literal 'unset' at the call site (the user typed that word explicitly — do not replace it with the effective default). +- **`formatStatusRows` (non-TTY status table)**: not-adopted rows use `effectiveDisplay(flag, neutralValueOf(flag)).text` — shows what the default does rather than printing 'unset'. Format: `not adopted — default: applies on next devflow init`. +- **`--list` defaultLabel**: number flags with `upstreamDefault` print `upstream default: N`; otherwise `flag.defaultValue` as a string or `'none'` (never 'unset'). + +### Flags TUI (`src/cli/flags-view/`, `src/cli/tui/`) + +An interactive terminal UI for editing flag state in one session. Launched exclusively by `devflow flags` bare on a TTY (D40: init no longer opens the flags editor in any path). Uses **inline mode** (see below) — renders in-place in the normal scroll buffer rather than entering the alt screen. + +**`src/cli/flags-view/state.ts`** — pure state machine for the TUI. Key functions: +- `buildFlagRows(registry, record)` — produces the row list from the live `FlagsRecord`; each `FlagRow` holds `id`, `tui` value (TUI-internal representation), `hint`, `blurb` (sourced from `flag.blurb`), and display metadata. +- `collectFlagRecord(rows)` — inverse: reconstructs a `FlagsRecord` from the row list (via `tuiToRecord` per row). +- `buildStops(flag)` — ordered cycle stops for a flag (for enum/boolean/number cycling). +- `cycleForward` / `cycleBackward` — advance or retreat through a flag's stop list. +- `reduce(state, key)` — event reducer; returns `{state, done, saved}`. +- `enterEdit` / `commitEdit` / `insertChar` / `reduceEditMode` — inline text-edit for enum and string flags. +- `recordToTui` / `tuiToRecord` — convert between `FlagsRecord` values and TUI-internal values (TUI uses `null` as the "devflow default" stop; `tuiToRecord` maps that back to `neutralValueOf`). +- `adjustViewport` — scrolling helper (cursor, offset, height, rowCount). + +**`FlagRow.blurb`** — short phrase (≤30 chars) describing what the flag does. Sourced from `flag.blurb` at `buildFlagRows` — no registry reach-back at render time. Rendered as a dim trailing column in the TUI (D-BLURB). + +**`src/cli/flags-view/render.ts`** — frame renderer. Column layout at 80-col reference: + +| Column | Width | Notes | +|--------|-------|-------| +| PREFIX | 2 | cursor mark `❯ ` or ` ` | +| LABEL | 27 | flag label | +| DIRTY | 2 | `● ` when dirty | +| VALUE | 16 | formatted value or edit buffer | +| BLURB | 30 | dim short phrase (HINT in column header) | + +VALUE+BLURB = 46, preserving the prior total from the single VALUE column. All widths scale proportionally with terminal width (`Math.min(1, cols/80)`). Column header uses `gray('VALUE')` padded to `valueW` and `gray('HINT')` for the blurb column (omitted when `blurbW === 0`). + +`formatValue` vocabulary (D-EFFDV — delegates to `effectiveDisplay` for null/neutral values): +- `null` (any kind) → `dim(effectiveDisplay(flag, null).text)`, with ` (default)` appended for number flags +- `boolean true` → `green('on')`, `boolean false` → `yellow('off')` +- Non-boolean active value → `bold(str)` when deviating from devflow default, else plain `str` + +**`src/cli/tui/cells.ts`** — shared cell-rendering helpers used by the flags TUI render layer: +- `sanitizeCell(s)` — strips control characters from cell content (avoids terminal injection). +- `padToVisible(s, width)` — pads a string to `width` visible characters (ANSI-aware). +- `truncateVisible(s, maxWidth)` — truncates to `maxWidth` visible characters (ANSI-aware). + +**`src/cli/flags-view/terminal.ts`** — flags TUI entry point. `runFlagsTui` passes `screen: 'inline'` to `runTui` (D-INLINE) so both the bare `devflow flags` invocation and the init Advanced step render in the normal scroll buffer. + +**`src/cli/tui/terminal.ts`** — generic TUI driver. New additions: + +- **`RunTuiSpec.screen?: 'alt' | 'inline'`** — controls screen mode. Default is `'alt'` (prior behavior; agents-view uses this). `'inline'` renders in-place without entering the alt screen. +- **Inline mode mechanics** (D-INLINE): first frame writes lines directly; subsequent frames use `cursorUp(prevLineCount - 1) + \r` then rewrite + `ERASE_BELOW`; on exit, cursor-up to frame top + `ERASE_BELOW` + `SHOW_CURSOR` erases the widget completely so the caller's clack flow continues uninterrupted. +- **`INLINE_MARGIN = 2`** — lines reserved below the widget so the shell prompt is never clobbered. Height is clamped to `stdout.rows - INLINE_MARGIN` in inline mode. +- **`cursorUp(n): string`** — returns `ESC[nA` for `n > 0`, empty string otherwise; callers need no guard. + ## Integration Patterns ### Shadow Paths (canonical) @@ -313,11 +386,12 @@ The `MIGRATIONS` registry (typed `readonly AnyMigration[]`) has one entry: `cano - **Installing without `npm run build`** — commands, agents, skills, and rules all throw hard errors when their source is absent. Run `npm run build` or `build:mds` before any install. - **Restoring `pluginsDir` to `installAllRules` or `installRuleFile`** — rule source is exclusively `rulesDir()` (flat `src/assets/rules/`); there is no per-plugin subdirectory. - **Combining `--reset` with `--plugin`** — factory reset and partial install are mutually exclusive; init rejects the combination before seeding. -- **Auto-adopting default-OFF flags in `resolveSeedFlags`** — only default-ON flags are auto-adopted when new (∉ knownFlags). Default-OFF flags must always be explicitly user-selected. +- **Expecting `resolveSeedFlags` to only adopt default-ON flags** — it adopts ALL absent registry flags at their `defaultValue`. Default-OFF flags arrive with `false`/`null` (inactive), not as missing. The correct invariant: absent key from an old manifest → adopt registry default (whatever it is); `null` value → deliberately unset/neutral. - **Running `reapplyAgentMapping` before proxy preflight resolves** — must use the final `proxyEnabled` value. Running it earlier materializes GPT model lines even after a preflight failure, breaking the dormancy invariant. - **Putting a name in both `enumerateUserDevFlowContent` and `installArtifactPaths`** — makes the confirmation prompt untruthful (item is presented as user content, then deleted regardless of user answer). A test enforces disjointness. - **Importing `EXCLUDED` as an oracle in tests** — destroys the test's independent literal check and turns invariant guards into tautologies. Pin an independent literal in the test alongside the production import. - **Dry-run preview using only pure helpers instead of the production enumeration path** — `runDryRunPhase` (full mode) must call `enumerateDryRunExtras`, which itself calls `installArtifactPaths`. A test that exercises only the pure helper (`installArtifactPaths` in isolation) does not catch divergence between the preview and the real removal loop. (avoids PF-018) +- **Re-deriving the display vocabulary at a render site instead of calling `effectiveDisplay`** — four render sites (TUI `formatValue`, `--enable/--disable` confirmation, `--status` not-adopted message, `--list` defaultLabel) all route through `effectiveDisplay`. Adding a fifth site that hand-codes 'on'/'off' or shows 'unset' creates vocabulary drift. Always delegate to `effectiveDisplay` (D-EFFDV) or `formatFlagValue` (which does so internally). ## Gotchas @@ -341,7 +415,7 @@ The `MIGRATIONS` registry (typed `readonly AnyMigration[]`) has one entry: `cano - **`resolveExistingViewMode` returns `undefined` for `'default'`.** The 'default' literal is not surfaced — it is treated as "no opinion" so the `??` chain falls through. -- **`knownPlugins` is a top-level field; `knownFlags` is inside `features`.** Both snapshotted at install time. The asymmetric placement mirrors the schema: plugins are top-level in `ManifestData`, flags are nested in `ManifestData.features`. +- **`knownPlugins` is a top-level field; there is no `knownFlags` field.** The plugin snapshot (`ManifestData.knownPlugins`) remains a top-level field. The former `features.knownFlags: string[]` field no longer exists — its semantic ("known to this install") is encoded in `ManifestData.features.flags` key-presence: present key = known, absent key = adopt-on-next-init. Old manifests that still have a `knownFlags` array are consumed inside `parseManifestFlags` during `readManifest` migration and NOT carried into `ManifestData`. - **`proxy` seeds from the manifest group, not the config group.** Unlike `memory`/`learning`/`knowledge` (config.json wins per ADR-001), `proxy` follows the same seeding path as `ambient`/`hud`/`rules` — manifest is authoritative, then registry default (`false`). Do not gate `proxy` on `readConfigIfPresent`. @@ -349,6 +423,12 @@ The `MIGRATIONS` registry (typed `readonly AnyMigration[]`) has one entry: `cano - **Compliance wizard gate keys on `modePromptShown`, never the mode name.** `shouldRunComplianceStep` uses `modePromptShown` (was the Setup-mode `p.select` actually shown?) rather than checking `mode === 'recommended'`. Gating on the mode name would break the `--recommended` promptless contract: `--recommended` resolves `mode='recommended'` but never shows the prompt, so `modePromptShown` stays `false`. Same applies to the non-TTY fallback. (PF-029) +- **`--set` confirmation echoes literal 'unset' for an explicit null input.** When the user types `--set flag=unset`, `parseFlagValueInput` maps that to `null`. The `handleSet` confirmation special-cases `null → 'unset'` at the call site so the user sees their own word reflected back. Active values route through `formatFlagValue` (D-EFFDV) as normal — this is the only site where 'unset' still appears in user-facing output. + +- **Blurb hard-cap is enforced by a registry test, not a TypeScript type.** `flag.blurb` is typed as `string` on `FlagDefCommon` (no length constraint in the type). The ≤30-char cap lives in `tests/flags.test.ts` as a registry-walk test — adding a blurb longer than 30 chars will fail CI but not the TypeScript compiler. + +- **Inline mode (`screen: 'inline'`) does not enter the alt screen.** On exit it cursor-ups to the frame top and `ERASE_BELOW` — the widget is erased and the clack flow continues in the normal scroll buffer. If you attach a flags TUI test expecting `ENTER_ALT` sequences, it will fail for `runFlagsTui` (which passes `screen: 'inline'`) but pass for agents-view tests (which use the default alt mode). Use `screen: 'alt'` explicitly when testing alt-screen behavior. + ## Key Files - `src/core/orphan-sweep.ts` — `sweepOrphanedAssets(dir, knownNames, extractRegistryName) => Promise`; `SweepResult = { scanned, removed, failed }`; `mdFileName` / `mdEntryName` inverse pair; shared by both installer and uninstall; per-item failure isolation on both readdir and rm @@ -357,14 +437,20 @@ The `MIGRATIONS` registry (typed `readonly AnyMigration[]`) has one entry: `cano - `src/core/paths.ts` — `getPackageRoot()` with hard `package.json` assertion; 2-level-up resolution from `dist/core/paths.js`; `isContainedIn(parent, candidate)` pure containment predicate (guards path-traversal in reapplyAgentMapping) - `src/targets/claude-code/legacy.ts` — `LEGACY_SKILL_NAMES` (composed from `LEGACY_SKILLS_PRE_V1`, `LEGACY_SKILLS_V2`, `LEGACY_SKILLS_V2X`); target-specific delete lists for upgrade cleanup - `src/cli/commands/init.ts` — consumes `InstallReport` and `InitSeed`; proxy preflight block using `buildRealPreflightDeps` factory (`swallowSettingsReadError: true`); `reapplyAgentMapping` call (ordering load-bearing, guarded when mapping is empty AND proxy is off); exhaustive `ShadowSkipReason` switch with `never` guard -- `src/cli/commands/init-seed.ts` — pure seeding helpers: `resolveInitSeed`, `resolveSeedFeatures`, `resolveSeedFlags`, `resolveSeedPlugins`, `resolveResetGatedInputs`, `applyCliToggles`, `FEATURE_DEFAULTS` (proxy: false) +- `src/cli/commands/init-seed.ts` — pure seeding helpers: `resolveInitSeed`, `resolveSeedFeatures`, `resolveSeedFlags(manifestFlags: FlagsRecord | null, registry)` (two-branch: null→all defaults, non-null→spread+adopt-absent), `resolveSeedPlugins`, `resolveResetGatedInputs`, `applyCliToggles`, `FEATURE_DEFAULTS` (proxy: false); `InitSeed.flags: FlagsRecord` encodes view-mode in `flags['view-mode']` — no separate `viewMode` field - `src/cli/commands/uninstall.ts` — exported: `removeAllDevFlow`, `removeSelectedPlugins`, `isDevFlowInstalled`, `installArtifactPaths` (SSOT for artifact list), `enumerateDryRunExtras` (derived from installArtifactPaths + skill lists), `sweepDevflowNamespaces` (named selective-path sweep step), `resolveProjectDataCleanup` (pure: cancel→preserve, no process.exit), `enumerateUserDevFlowContent` (skills/rules/preference-profile/learning.json/hud.json — NOT agent-models.json), `removeDevFlowInstallArtifacts` (uses installArtifactPaths; containment guard; `isDir === true` strict equality), `revertExternalAgents` runs on both full and selective paths, `computeAssetsToRemove`, `resolveSecurityRemovalDecision`, `resolveDevflowDirCleanup` (--keep-docs honored); phase runners: `runDryRunPhase`, `runSelectivePhaseForScope`, `runFullPhaseForScope`, `runCleanupPhase` (injected cwd + isTTY) -- `src/core/manifest.ts` — `ManifestData` (with `knownPlugins`, `features.knownFlags`, `features.proxy`), `readManifest` (self-heals via `asStringArray`; proxy absent→false), `writeManifest`, `syncManifestFeature`, `resolvePluginList` (filters `DELETED_PLUGIN_NAMES` via in-memory filter) +- `src/core/manifest.ts` — `ManifestData` (`features.flags: FlagsRecord` — key-presence = known, null = neutral, absent = adopt-on-init; `knownPlugins?: string[]`; `features.proxy`); `parseManifestFlags(features, knownFlags)` — three-shape migration: string[]→`migrateLegacyFlagsToRecord`, object→spread, missing→empty; `readManifest` — self-heals legacy `knownFlags` (consumed in migration, not stored), proxy absent→false, applies `sanitizeFlagsRecord`; `writeManifest`, `syncManifestFeature`, `resolvePluginList` (filters `DELETED_PLUGIN_NAMES` via in-memory filter) - `src/core/plugins.ts` — `prefixSkillName`, `unprefixSkillName`, `SKILL_NAMESPACE`, `DEVFLOW_PLUGINS` (21 plugins — no devflow-audit-claude), `buildFullSkillsMap`, `buildRulesMap`, `getAllSkillNames`, `getAllCommandNames`, `getAllAgentNames`, `partitionSelectablePlugins`, `EXCLUDED` (module-level export), `LEGACY_PLUGIN_NAMES`, `LEGACY_COMMAND_NAMES`, `LEGACY_RULE_NAMES`, `DELETED_PLUGIN_NAMES` (['devflow-audit-claude']) - `src/core/migrations.ts` — `MIGRATIONS: readonly AnyMigration[]` (one entry: `canonicalise-agent-keys-v1`, scope `'global'`); `AnyMigration = Migration<'global'> | Migration<'per-project'>` discriminated union; `canonicaliseAgentKeys` returns `{agents, didMutate, renamed, dropped, guardDropped}`; `parseAgentMappingEnvelope` shared with `readAgentMapping`; failure-as-warning means a failed write is permanently skipped (self-healed by `readAgentMapping`) - `src/cli/commands/proxy.ts` — `applyDisableToSettings`, `buildRealPreflightDeps`, `addProxyHooks`, `removeProxyHooks`, `applyProxyEnv`, `stripProxyEnv` -- `src/core/flags.ts` — `FLAG_REGISTRY`, `resolveExistingViewMode`, `resolveFinalViewMode`, `applyFlags`, `stripFlags`, `getDefaultFlags` +- `src/core/flags.ts` — `FLAG_REGISTRY` (28 flags, each with `blurb: string` on `FlagDefCommon` — ≤30 chars, hard-capped by registry test); `FlagsRecord` (`Record`), `FlagsRecordValue` (`FlagValue | null`); `effectiveDisplay(flag, value): EffectiveDisplay` (D-EFFDV one-definition seam — never returns 'unset': boolean→'on'/'off', enum null→neutralValue, number null→devflow/upstream default, string null→'—'); `formatFlagValue` delegates to `effectiveDisplay`; `getDefaultFlagsRecord`, `sanitizeFlagsRecord`, `migrateLegacyFlagsToRecord`, `coerceFlagValue`, `parseFlagValueInput`, `neutralValueOf`, `isNeutral`, `countActiveFlags`, `readViewMode`; `applyFlags(settingsJson, FlagsRecord)`, `stripFlags`, `resolveExistingViewMode`, `resolveFinalViewMode` - `src/core/feature-config.ts` — `readConfig`, `readConfigIfPresent`, `writeConfig`, `updateFeature` +- `src/cli/commands/flags.ts` — `createFlagsCommand` (bare TTY→TUI inline mode, bare non-TTY→status table+exit 1); `lookupFlag(id)` (null for unknown); `readSettingsSafe(settingsPath)` (Result-returning); `persistFlagConfig(claudeDir, devflowDir, settingsContent, newRecord)` (writes FlagsRecord to manifest + settings.json); `formatStatusRows` uses `effectiveDisplay` for not-adopted rows; `--set` confirmation special-cases null→literal 'unset' +- `src/cli/flags-view/state.ts` — `FlagsViewState`, `FlagRow` (includes `blurb: string` sourced from `flag.blurb`); `buildFlagRows(registry, record)`, `collectFlagRecord(rows)`; `buildStops`, `cycleForward`, `cycleBackward`; `recordToTui`/`tuiToRecord` value converters; `reduce(state, key) → {state, done, saved}`; `enterEdit`/`commitEdit`/`insertChar`/`reduceEditMode`; `adjustViewport` +- `src/cli/flags-view/render.ts` — column layout: PREFIX 2, LABEL 27, DIRTY 2, VALUE 16, BLURB 30; `formatValue` delegates to `effectiveDisplay` for null; boolean → green 'on' / yellow 'off'; HINT column header; blurb rendered dim and truncated to `blurbW` +- `src/cli/flags-view/terminal.ts` — `runFlagsTui` passes `screen: 'inline'` to `runTui` (D-INLINE); sole launch path is `devflow flags` bare on a TTY (D40: init does not open the flags editor) +- `src/cli/tui/terminal.ts` — `runTui` generic driver; `RunTuiSpec.screen?: 'alt' | 'inline'` (default 'alt'; agents-view uses alt, flags uses inline); `INLINE_MARGIN = 2`; `cursorUp(n)` helper; inline mode: cursor-up repaints, ERASE_BELOW on exit, height clamped to stdout.rows - INLINE_MARGIN +- `src/cli/tui/cells.ts` — `sanitizeCell(s)`, `padToVisible(s, width)`, `truncateVisible(s, maxWidth)` — ANSI-aware cell rendering helpers used by flags TUI render layer ## Related @@ -372,7 +458,7 @@ The `MIGRATIONS` registry (typed `readonly AnyMigration[]`) has one entry: `cano - ADR-003: End-state not transition — governs removals and legacy cleanup; cancel/decline on uninstall falls through to `removeDevFlowInstallArtifacts` rather than `process.exit()` so cleanup always runs - ADR-010: Shadow tolerance — governs `installViaFileCopy` as sole install path and warn-and-install-source (not hard-fail) for invalid shadows; hard-error policy applies only to declared Devflow sources - ADR-013: Core/adapter boundary — governs `init-seed.ts` living in `src/cli/commands/` (CLI-init-specific logic) rather than `src/core/` -- ADR-014: State-aware re-init — governs `readManifest` self-heal idiom (`proxy` absent→false) and the `knownFlags`/`knownPlugins` snapshot pattern for detecting newly added registry entries +- ADR-014: State-aware re-init — governs `readManifest` self-heal idiom (`proxy` absent→false), FlagsRecord key-presence as the "known" encoding (absent key = adopt-on-init), and the `knownPlugins` snapshot pattern for detecting newly added plugins - PF-009: Per-item failure isolation — per-rule try/catch inside `installRuleFile`; `rules --enable` wraps `installAllRules`; proxy preflight failure warns + forces off without aborting init; `sweepOrphanedAssets` outer/inner independent catches; proxy artifact removal is per-item non-fatal; non-fatal catches can mask systematic TypeErrors when optional properties are not narrowed - PF-012: LEGACY_* lists deletion-risk — lists split between `src/targets/claude-code/legacy.ts` (skill) and `src/core/plugins.ts` (plugin/command/rule); both must be retained across upgrades - PF-014: process.exit() skips cleanup — governs the cancel/decline path in user-scope uninstall; `removeDevFlowInstallArtifacts` must execute on every non-confirm path; `resolveProjectDataCleanup` maps cancel→false (preserve) instead of process.exit() diff --git a/CLAUDE.md b/CLAUDE.md index fd93e88d..145a7a49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Debug logs stored at `~/.devflow/logs/{project-slug}/`. **Debug Tracing**: Single global toggle covering all hooks. Enabled via `devflow debug --enable/--disable/--status` CLI or by setting `DEVFLOW_HOOK_DEBUG=1` in `~/.claude/settings.json` env block (survives reinstalls). All hooks share the `src/assets/scripts/hooks/debug-trace` helper script (sourced via `hook-bootstrap`) so tracing behavior is consistent and updated in one place. Two-phase logging: pre-CWD traces go to global `~/.devflow/logs/.hook-debug.log`; post-CWD traces go to per-project `~/.devflow/logs/{project-slug}/.hook-debug.log`. A 5MB size guard prevents unbounded growth. applies ADR-007 -**Claude Code Flags**: Typed registry (`src/core/flags.ts`) for managing Claude Code feature flags (env vars and top-level settings). Pure functions `applyFlags`/`stripFlags`/`getDefaultFlags` follow the `applyViewMode`/`stripViewMode` pattern. Flags (20 total): default ON — `tui`, `tool-search`, `lsp`, `prompt-caching-1h`, `show-turn-duration`, `clear-context-on-plan`, `disable-bundled-skills`, `pin-sonnet-4-6`; default OFF — `brief`, `thinking-summaries`, `subprocess-env-scrub`, `disable-nonessential-traffic`, `forked-subagents`, `disable-adaptive-thinking`, `always-thinking`, `disable-git-instructions`, `disable-compact`, `disable-1m-context`, `disable-autoupdater`, `agent-teams`. Manageable via `devflow flags --enable/--disable/--status/--list`. Stored in manifest `features.flags: string[]`. View mode (`default`/`verbose`/`focus`) stored in manifest `features.viewMode?: string` and applied to `settings.json` as the `viewMode` key; `applyViewMode`/`stripViewMode` utilities colocated in `flags.ts`. +**Claude Code Flags**: Typed registry (`src/core/flags.ts`) for managing Claude Code feature flags (env vars and top-level settings). Four kinds: `boolean` (on/off), `enum` (validated domain), `number` (bounded integer), `string` (validated with maxLength). 28 flags total: recommended (default ON) — `tui`, `tool-search`, `lsp`, `prompt-caching-1h`, `show-turn-duration`, `clear-context-on-plan`, `disable-bundled-skills`, `pin-sonnet-4-6`, `max-concurrent-subagents` (number, devflow default 40, upstream default 20); optional boolean (default OFF) — `brief`, `thinking-summaries`, `subprocess-env-scrub`, `disable-nonessential-traffic`, `forked-subagents`, `disable-adaptive-thinking`, `always-thinking`, `disable-git-instructions`, `disable-compact`, `disable-1m-context`, `disable-autoupdater`, `agent-teams`, `enable-todo-tools`; valued (default unset) — `subagent-spawn-depth` (number, upstream default 3), `workflow-size-guideline` (enum: `small|medium|large|unrestricted`), `default-model` (string), `goal-checkin-minutes` (number, upstream default 30 min), `spellcheck` (string), `view-mode` (enum: `default|verbose|focus`, devflow default `default`). Stored in manifest `features.flags: Record` — entry-presence = known, `null` = deliberately unset (neutral, deletes the target key), absent = adopt-on-next-init. Pipeline: `applyFlags(settingsJson, FlagsRecord)` / `stripFlags(settingsJson)` — `applyViewMode`/`stripViewMode` retired; view-mode is an enum flag with `neutralValue: 'default'` (the `viewMode` settings.json key is written only when non-default); `resolveExistingViewMode`/`resolveFinalViewMode` remain exported for init.ts external-mode preservation. `devflow flags` bare on TTY launches the interactive flags editor TUI; bare on non-TTY prints a status table to stdout and exits 1. Registry entries carry a `blurb` field (≤30-char per-flag short hint) shown as a dim HINT column in the TUI and in `--status` rows. Display vocabulary via `effectiveDisplay`: booleans render 'on'/'off' (off is dim); neutral/unset enum shows `neutralValue` dim; unset number shows its applicable default dim with ' (default)' suffix; unset string shows '—' dim; an actively set non-boolean renders plain (at devflow default) or bold (deviating); the literal 'unset' is never a displayed value. TUI rendering: `RunTuiSpec.screen?: 'alt' | 'inline'`; flags editor runs inline (renders in-place in the normal scroll buffer, no alt-screen); agents-view defaults to alt. Manageable via `devflow flags --enable/--disable/--set /--unset /--status/--list`; `--enable`/`--disable` are boolean-only — non-boolean flags are redirected to `--set`. **Feature Knowledge Bases**: Per-feature `.devflow/features/` directory containing KNOWLEDGE.md files that capture area-specific patterns, conventions, architecture, and gotchas. Uses a **write-through** model: load = direct file-I/O reading `.devflow/features/index.md` (regenerable cache) with frontmatter-glob fallback over `features/*/KNOWLEDGE.md` (source of truth) + verify-against-code on read; save = in-command write-through via a simplified Knowledge agent that writes `KNOWLEDGE.md` + the `index.md` line directly (no `.create-result.json`, no external scripts, no lock). **Git-tracked & shared (amends ADR-021 for `features/`)**: the root `.gitignore` carve-out (`.devflow/*` + level-by-level `!` re-includes, written byte-identically by `ensure-root-gitignore` / `ensureDevflowGitignore`) un-ignores `.devflow/features/index.md` + every `{slug}/KNOWLEDGE.md` while the rest of `.devflow/` stays local; after writing, the **Knowledge agent commits those two paths to the current worktree branch itself** by running git via its Bash tool (scoped `commit --only` pathspec, never `git add -A`, **never push, never force**, no commit script — per the LLM-vs-plumbing principle the commit is the agent's, not a deterministic helper). A user opts back out by re-adding `.devflow/features/` to their own `.gitignore`. Existing installs upgrade once via the versioned `.root-gitignore-configured-v3` marker (v2→v3 adds the `!.devflow/conventions.md` re-include). Freshness = write-through + verify-on-read (NO git-staleness, NO SessionEnd eval, NO Learning task). `index.md` line format: `- **{slug}** — {areas} — {Use-when description}`; frontmatter is authoritative if the line is lost. MDS module: `src/assets/commands/_partials/_knowledge.mds` (defines/exports `knowledge_load` and `knowledge_writeback` partials) + 9 host `.mds` sources in `src/assets/commands/` compiled to `dist/commands/` by `scripts/build-mds.ts` (`npm run build:mds`). `knowledge_load` is used up-front by: implement, plan, resolve, code-review, self-review, research, bug-analysis. `knowledge_writeback` is used at workflow end by: implement, resolve, self-review, explore, debug. explore/debug do NOT load up-front (intentional asymmetry). Config gate: single `knowledge: true|false` in feature config (default true) — gates write-back only; load is ungated. CLI: `devflow knowledge list` (read index.md / frontmatter glob), `devflow knowledge --enable/--disable/--status` (flip config). Note: `/debug` keeps FEATURE_KNOWLEDGE orchestrator-local (investigation workers examine code without pre-loaded context). Toggleable via `devflow knowledge --enable/--disable/--status` or `devflow init --knowledge/--no-knowledge`. @@ -63,11 +63,11 @@ Debug logs stored at `~/.devflow/logs/{project-slug}/`. Knowledge write-back is in-command (not a background pipeline): gated by `devflow knowledge --enable/--disable` (flips `knowledge` in feature config); Knowledge agent writes directly at workflow end. -**External Model Routing (Devflow Proxy)**: Routes Devflow agents through GPT models via an OpenAI/Codex subscription using a local relay. Feature state is manifest-gated (like ambient/hud/rules, per ADR-001): `manifest.features.proxy` is the source of truth; `~/.devflow/proxy.json` holds runtime authority (enabled, port, binPath, configPath, resolvedAt, devflowVersion). `~/.devflow/proxy-routing.json` holds the routing config (port only — bare `{port}` object; the 0.2.0 routing runtime rejects unrecognised keys). The `ensure-proxy` hook (SessionStart + UserPromptSubmit, registered/removed by `addProxyHooks`/`removeProxyHooks`) auto-starts the relay when a session begins. `ANTHROPIC_BASE_URL=http://127.0.0.1:` is injected into (and stripped from) `settings.json` at CLI enable/disable time via `applyProxyEnv`/`stripProxyEnv`, not by the hook. Toggle via `devflow proxy --enable/--disable/--status` or via the Advanced init wizard. Enabling runs `runProxyPreflight` (4 checks: bin, codex auth, port, settings), spawns the relay, then gates on a post-spawn doctor verification against the live relay (doctor requires a running relay to pass); on doctor failure the enable rolls back, killing the relay only if it spawned it. Init runs the same preflight but never spawns or runs doctor — the first session's ensure-proxy hook starts the relay; on init preflight failure: warning + force-disabled, init never aborted (avoids PF-009). Disabling reverts agent frontmatter to Claude defaults but preserves the model mapping for re-enable. Default OFF; Advanced-only — never part of Recommended defaults. +**External Model Routing (Devflow Proxy)**: Routes Devflow agents through GPT models via an OpenAI/Codex subscription using a local relay. Feature state is manifest-gated (like ambient/hud/rules, per ADR-001): `manifest.features.proxy` is the source of truth; `~/.devflow/proxy.json` holds runtime authority (enabled, port, binPath, configPath, resolvedAt, devflowVersion). `~/.devflow/proxy-routing.json` holds the routing config (port only — bare `{port}` object; the 0.2.0 routing runtime rejects unrecognised keys). The `ensure-proxy` hook (SessionStart + UserPromptSubmit, registered/removed by `addProxyHooks`/`removeProxyHooks`) auto-starts the relay when a session begins. `ANTHROPIC_BASE_URL` and `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` are injected into (and stripped from) `settings.json` at CLI enable/disable time via `applyProxyEnv`/`stripProxyEnv`, not by the hook (the window var is removed unconditionally on strip; the URL delete is port-scoped to avoid clobbering foreign gateways). Toggle via `devflow proxy --enable/--disable/--status` or via the Advanced init wizard. Enabling runs `runProxyPreflight` (4 checks: bin, codex auth, port, settings), spawns the relay, then gates on a post-spawn doctor verification against the live relay (doctor requires a running relay to pass); on doctor failure the enable rolls back, killing the relay only if it spawned it. Init runs the same preflight but never spawns or runs doctor — the first session's ensure-proxy hook starts the relay; on init preflight failure: warning + force-disabled, init never aborted (avoids PF-009). Disabling reverts agent frontmatter to Claude defaults but preserves the model mapping for re-enable. Default OFF; Advanced-only — never part of Recommended defaults. -**Per-Agent Model Configuration**: User overrides to agent model assignments persist in `~/.devflow/agent-models.json` (deviations only — absent entry = shipped default). `reapplyAgentMapping` runs after every `devflow init` post-install to re-apply user overrides to freshly copied agent files. `revertExternalAgents` reverts all agents to shipped defaults (called on proxy disable and before agent removal on uninstall). GPT model assignments are **dormant** when routing is off — they are stored in `agent-models.json` but not written to agent frontmatter until routing is enabled. Manage via `devflow agents` TUI or `devflow agents --list/--set/--reset`. Core source files: `src/core/agent-frontmatter.ts` (pure rewrite engine), `src/core/agent-models.ts` (schema + apply/revert), `src/core/external-models.ts` (CLAUDE_MODEL_ALIASES, isClaudeModelName, isDormantExternalModel — leaf module), `src/core/model-discovery.ts` (discoverExternalModels, getExternalModelsCached, cache-warming), `src/core/cache.ts` (cache read/write, 0700/0600 permissions, parseRawEnvelope), `src/core/proxy-log.ts` (scrubChildEnv, openProxyLog, relay env allowlisting), `src/core/proxy-state.ts` (state I/O), `src/cli/commands/proxy.ts` (CLI + hook wiring), `src/cli/commands/agents.ts` (CLI), `src/cli/agents-view/` (TUI — state, render, terminal). +**Per-Agent Model Configuration**: User overrides to agent model assignments persist in `~/.devflow/agent-models.json` (deviations only — absent entry = shipped default). `reapplyAgentMapping` runs after every `devflow init` post-install to re-apply user overrides to freshly copied agent files. `revertExternalAgents` reverts all agents to shipped defaults (called on proxy disable and before agent removal on uninstall). GPT model assignments are **dormant** when routing is off — they are stored in `agent-models.json` but not written to agent frontmatter until routing is enabled. Manage via `devflow agents` TUI or `devflow agents --list/--set/--reset`. Core source files: `src/core/agent-frontmatter.ts` (pure rewrite engine), `src/core/agent-models.ts` (schema + apply/revert), `src/core/external-models.ts` (CLAUDE_MODEL_ALIASES, isClaudeModelName, isDormantExternalModel — leaf module), `src/core/model-discovery.ts` (discoverExternalModels, getExternalModelsCached, cache-warming), `src/core/cache.ts` (cache read/write, 0700/0600 permissions, parseRawEnvelope), `src/core/proxy-log.ts` (scrubChildEnv, openProxyLog, relay env allowlisting), `src/core/proxy-state.ts` (state I/O), `src/cli/commands/proxy.ts` (CLI + hook wiring), `src/cli/commands/agents.ts` (CLI), `src/cli/agents-view/` (TUI — state, render, terminal; thin adapter over the shared `src/cli/tui/` driver). -**Two-Mode Init**: `devflow init` offers Recommended (sensible defaults, quick setup) or Advanced (full interactive flow) after plugin selection. `--recommended` / `--advanced` CLI flags for non-interactive use. Recommended applies: ambient ON, memory ON, learning ON, rules ON, HUD ON, default-ON flags, .claudeignore ON, auto-install safe-delete if trash CLI detected, user-mode security deny list, viewMode preserved from existing settings.json. Advanced path adds a view mode selector (default/verbose/focus) after Claude Code flags and a proxy prompt (external model routing — default OFF, requires Codex auth; never part of Recommended defaults). Use `--learning/--no-learning` to toggle the learning agent independently. Use `--rules/--no-rules` to toggle rules independently. Use `--proxy/--no-proxy` to set external model routing (Advanced-only; init runs preflight on enable). Use `--compliance `/`--no-compliance` to set compliance non-interactively (enable with comma-separated framework IDs or disable preserving frameworks; default: off; `--compliance`/`--no-compliance` bypasses the wizard entirely). The compliance wizard step (select which regulatory frameworks to install — GDPR, HIPAA, PCI DSS, SOC 2, ISO 27001, SOX) runs in **both** init paths via `shouldRunComplianceStep`: Advanced always runs it; Recommended only runs it when the user reached the mode-select prompt interactively (`modePromptShown=true`) — `--recommended` flag and non-TTY invocations preserve their promptless contracts. The step shows a "Current setting:" note for re-init legibility, uses a `p.select` (Yes/No) instead of a confirm to avoid Enter-through ambiguity, and emits an outcome line for unambiguous state visibility (per PF-029). **State-aware re-init**: on re-init the wizard reads the prior manifest, config, and settings.json and pre-seeds every prompt with existing values, skipping the Recommended/Advanced question entirely. Use `--reset` for a factory reset that ignores all prior state (mutually exclusive with `--plugin`). +**Two-Mode Init**: `devflow init` offers Recommended (sensible defaults, quick setup) or Advanced (full interactive flow) after plugin selection. `--recommended` / `--advanced` CLI flags for non-interactive use. Recommended applies: ambient ON, memory ON, learning ON, rules ON, HUD ON, default-ON flags, .claudeignore ON, auto-install safe-delete if trash CLI detected, user-mode security deny list, viewMode preserved from existing settings.json. Both init paths apply seeded flag values non-interactively — fresh install: registry defaults; re-init: existing manifest values preserved, defaults adopted only for newly-added flags (ADR-014); `view-mode` resolved from existing settings.json at seed time — and emit an outcome line pointing to `devflow flags` for customization. Advanced path adds a proxy prompt (external model routing — default OFF, requires Codex auth; never part of Recommended defaults). Use `--learning/--no-learning` to toggle the learning agent independently. Use `--rules/--no-rules` to toggle rules independently. Use `--proxy/--no-proxy` to set external model routing (Advanced-only; init runs preflight on enable). Use `--compliance `/`--no-compliance` to set compliance non-interactively (enable with comma-separated framework IDs or disable preserving frameworks; default: off; `--compliance`/`--no-compliance` bypasses the wizard entirely). The compliance wizard step (select which regulatory frameworks to install — GDPR, HIPAA, PCI DSS, SOC 2, ISO 27001, SOX) runs in **both** init paths via `shouldRunComplianceStep`: Advanced always runs it; Recommended only runs it when the user reached the mode-select prompt interactively (`modePromptShown=true`) — `--recommended` flag and non-TTY invocations preserve their promptless contracts. The step shows a "Current setting:" note for re-init legibility, uses a `p.select` (Yes/No) instead of a confirm to avoid Enter-through ambiguity, and emits an outcome line for unambiguous state visibility (per PF-029). **State-aware re-init**: on re-init the wizard reads the prior manifest, config, and settings.json and pre-seeds every prompt with existing values, skipping the Recommended/Advanced question entirely. Use `--reset` for a factory reset that ignores all prior state (mutually exclusive with `--plugin`). **Migrations**: Run-once migrations execute automatically on `devflow init`, tracked at `~/.devflow/migrations.json` (scope-independent; single file regardless of user-scope vs local-scope installs). To add a 2.x migration, append an entry to `MIGRATIONS` in `src/core/migrations.ts`. Scopes: `global` (runs once per machine, no project context) vs `per-project` (sweeps all discovered Claude-enabled projects in parallel). Failures are non-fatal — migrations retry on next init. The registry holds 2.x entries only (first: canonicalise-agent-keys-v1); no 1.x upgrade path. @@ -78,7 +78,9 @@ devflow/ ├── src/ │ ├── cli.ts # CLI entry point │ ├── cli/ # CLI command modules (init, init-seed, uninstall, ambient, learning, flags, knowledge, rules, debug, hud, proxy, agents, compliance) -│ │ └── agents-view/ # Per-agent model config TUI (state.ts, render.ts, terminal.ts) +│ │ ├── tui/ # Generic TUI shell — runTui driver, normalizeKey, cell helpers +│ │ ├── flags-view/ # Claude Code flags editor TUI — standalone `devflow flags` command, inline screen mode (state.ts, render.ts, terminal.ts, index.ts) +│ │ └── agents-view/ # Per-agent model config TUI (state.ts, render.ts, terminal.ts) — adapter over tui/ │ ├── core/ # Shared logic (plugins.ts registry, paths.ts, assets.ts, flags.ts, fs-atomic.ts, migrations.ts, agent-frontmatter.ts, agent-models.ts, external-models.ts, proxy-state.ts, …) │ ├── hud/ # HUD module (TypeScript source — index.ts, render.ts, components/, …) │ ├── targets/claude-code/ # Claude Code install target (installer, hooks.ts, post-install, claude-paths, legacy, templates/) @@ -277,7 +279,7 @@ Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore - Rules are flat `.md` files (no subdirectory nesting) in `src/assets/rules/{name}.md`; the installer validates against the registry ### Token Optimization -- Sub-agents cannot invoke other sub-agents (by design) +- Subagent nesting is real since Claude Code 2.1.219 (upstream spawn-depth default 3, deliberately kept; tunable via `devflow flags --set subagent-spawn-depth=N`); nested fan-outs share the concurrency pool — `max-concurrent-subagents` default 40 is sized for typical devflow parallel waves - Use parallel execution where possible - Leverage `.claudeignore` for context reduction diff --git a/docs/cli-reference.md b/docs/cli-reference.md index de21b937..18c6ee35 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -194,17 +194,51 @@ If you shadow `compliance`, the shadow's own tokens are replaced at install time ## Feature Flags ```bash -npx devflow-kit flags --list # List all flags with current state -npx devflow-kit flags --enable # Enable a flag -npx devflow-kit flags --disable # Disable a flag -npx devflow-kit flags --status # Show enabled flags +npx devflow-kit flags # Interactive TUI (TTY only); non-TTY prints status table + exits 1 +npx devflow-kit flags --status # Show current flag states (non-destructive) +npx devflow-kit flags --list # List all flags with kind, target, and default +npx devflow-kit flags --enable # Enable boolean flag(s), comma-separated +npx devflow-kit flags --disable # Disable boolean flag(s), comma-separated +npx devflow-kit flags --set # Set a flag value (repeatable); use 'unset' as value to clear +npx devflow-kit flags --unset # Reset flag(s) to neutral, comma-separated ``` -Notable flags (default OFF): - -| Flag | Default | Description | -|------|---------|-------------| -| `agent-teams` | OFF | Enables Claude Code's experimental Agent Teams via `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`. Enable with `devflow flags --enable agent-teams`. | +`--enable` and `--disable` accept boolean flags only. Non-boolean flags (enum, number, string) use `--set id=value`. Passing a non-boolean id to `--enable`/`--disable` prints an error and redirects to `--set`. + +All 28 flags by kind and devflow default: + +| Flag ID | Kind | Target | Devflow Default | +|---------|------|--------|-----------------| +| `tui` | boolean | setting `tui` | `true` (fullscreen) | +| `tool-search` | boolean | env `ENABLE_TOOL_SEARCH` | `true` | +| `lsp` | boolean | env `ENABLE_LSP_TOOL` | `true` | +| `prompt-caching-1h` | boolean | env `ENABLE_PROMPT_CACHING_1H` | `true` | +| `show-turn-duration` | boolean | setting `showTurnDuration` | `true` | +| `clear-context-on-plan` | boolean | setting `showClearContextOnPlanAccept` | `true` | +| `disable-bundled-skills` | boolean | setting `disableBundledSkills` | `true` | +| `pin-sonnet-4-6` | boolean | env `ANTHROPIC_DEFAULT_SONNET_MODEL` | `true`¹ | +| `max-concurrent-subagents` | number | env `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` | `40` (upstream: 20) | +| `brief` | boolean | env `CLAUDE_CODE_BRIEF` | `false` | +| `thinking-summaries` | boolean | setting `showThinkingSummaries` | `false` | +| `subprocess-env-scrub` | boolean | env `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` | `false` | +| `disable-nonessential-traffic` | boolean | env `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `false` | +| `forked-subagents` | boolean | env `CLAUDE_CODE_FORK_SUBAGENT` | `false` | +| `disable-adaptive-thinking` | boolean | env `CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING` | `false` | +| `always-thinking` | boolean | setting `alwaysThinkingEnabled` | `false` | +| `disable-git-instructions` | boolean | env `CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS` | `false` | +| `disable-compact` | boolean | env `DISABLE_COMPACT` | `false` | +| `disable-1m-context` | boolean | env `CLAUDE_CODE_DISABLE_1M_CONTEXT` | `false` | +| `disable-autoupdater` | boolean | env `DISABLE_AUTOUPDATER` | `false` | +| `agent-teams` | boolean | env `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` | `false` | +| `enable-todo-tools` | boolean | env `CLAUDE_CODE_ENABLE_TODO_TOOLS` | `false` | +| `subagent-spawn-depth` | number | env `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` | unset (upstream: 3) | +| `workflow-size-guideline` | enum | setting `workflowSizeGuideline` | unset (`small\|medium\|large\|unrestricted`) | +| `default-model` | string | env `ANTHROPIC_DEFAULT_MODEL` | unset | +| `goal-checkin-minutes` | number | env `CLAUDE_CODE_GOAL_CHECKIN_MINUTES` | unset (upstream: 30 min) | +| `spellcheck` | string | setting `spellcheck` | unset | +| `view-mode` | enum | setting `viewMode` | `default` (key omitted when default) | + +¹ Boolean flags targeting an env var write the flag's configured string value when enabled (e.g., `claude-sonnet-4-6` for `pin-sonnet-4-6`), not `1` or `true`. The env var is deleted when the flag is disabled or unset. ## External Model Routing (Devflow Proxy) @@ -221,9 +255,9 @@ npx devflow-kit proxy --enable --port # Enable on a specific port (default: | Option | Description | |--------|-------------| -| `--enable` | Enable routing — runs preflight, writes `~/.devflow/proxy.json` and `~/.devflow/proxy-routing.json`, starts and verifies the relay, injects `ANTHROPIC_BASE_URL` into `settings.json`, applies saved agent model mapping | +| `--enable` | Enable routing — runs preflight, writes `~/.devflow/proxy.json` and `~/.devflow/proxy-routing.json`, starts and verifies the relay, injects `ANTHROPIC_BASE_URL` and `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` into `settings.json`, applies saved agent model mapping | | `--disable` | Disable routing — reverts agent frontmatter to Claude defaults, removes env override; mapping is preserved for re-enable; the relay process is left running for live sessions (a manual `kill ` hint is shown) | -| `--status` | Show feature state (enabled/disabled, port), relay process and PID, `ANTHROPIC_BASE_URL` env state, Codex auth content (not just existence), external-mapped agent count, cached model registry, and proxy log path | +| `--status` | Show feature state (enabled/disabled, port), relay process and PID, `ANTHROPIC_BASE_URL` and `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` env state, Codex auth content (not just existence), external-mapped agent count, cached model registry, and proxy log path | | `--port ` | Override the relay port (default 4141); takes effect on next enable | Takes effect in new Claude Code sessions after `--enable`. The relay auto-starts on `SessionStart` via the `ensure-proxy` hook; `UserPromptSubmit` exits immediately with no action (SessionStart handles all relay-start and warning logic). Routing state is stored in `~/.devflow/proxy.json`; per-agent model mapping in `~/.devflow/agent-models.json`. diff --git a/docs/reference/claude-code-flags-probe.md b/docs/reference/claude-code-flags-probe.md new file mode 100644 index 00000000..f949aaae --- /dev/null +++ b/docs/reference/claude-code-flags-probe.md @@ -0,0 +1,35 @@ +# Claude Code Flags — Phase 0 Probe Findings + +**Probe date**: 2026-08-23 +**Claude Code version**: 2.1.241 +**Purpose**: Binary verification of env var names and domain values before adding flags to the registry. + +## Findings + +### `keybindingFlavor` — CUT + +Domain is unverifiable. The strings `'emacs'`, `'readline'`, and `'classic'` appear in +the binary but in unrelated contexts (Node.js module names, VS Code terminal settings). +Behavioral probes via `claude --version` produced no validation output. Not added to the +registry. + +### `workflowSizeGuideline` — INCLUDED (enum) + +Domain `small|medium|large|unrestricted` verified from binary strings: a 4-value cluster +at adjacent string offsets, adjacent to Workflows feature description text. Added as an +enum flag. + +### Env var names — all confirmed present in binary + +- `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` +- `CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH` +- `CLAUDE_CODE_ENABLE_TODO_TOOLS` +- `CLAUDE_CODE_GOAL_CHECKIN_MINUTES` +- `ANTHROPIC_DEFAULT_MODEL` + +## Methodology + +Strings inspected via binary grep over the Claude Code executable. Adjacent-offset +clustering confirms a domain enum when the candidate values appear as a tight cluster +near feature description text. Single occurrences in unrelated modules are not +considered verification. diff --git a/docs/reference/file-organization.md b/docs/reference/file-organization.md index 9c524831..76bc3176 100644 --- a/docs/reference/file-organization.md +++ b/docs/reference/file-organization.md @@ -14,12 +14,14 @@ devflow/ │ │ │ # uninstall.ts, safe-delete.ts, security.ts, debug.ts, │ │ │ # capture.ts, legacy-hooks.ts, compliance.ts, proxy.ts, │ │ │ # agents.ts, knowledge/ -│ │ └── utils/ # (empty — utilities moved to src/core/) +│ │ ├── tui/ # Generic TUI shell — runTui driver, normalizeKey, cell helpers +│ │ ├── flags-view/ # Claude Code flags editor TUI — standalone `devflow flags` command, inline screen mode (state.ts, render.ts, terminal.ts, index.ts) +│ │ └── agents-view/ # Per-agent model config TUI (state.ts, render.ts, terminal.ts) │ ├── core/ # Shared logic (single source of truth for registry + utilities) │ │ ├── plugins.ts # DEVFLOW_PLUGINS registry — 21 plugin entries │ │ ├── paths.ts # getPackageRoot + asset path helpers │ │ ├── assets.ts # skillsDir, agentsDir, rulesDir, commandsDir, scriptsDir -│ │ ├── flags.ts # Claude Code flag registry (20 flags) +│ │ ├── flags.ts # Claude Code flag registry (28 flags) │ │ ├── fs-atomic.ts # Atomic write helper (D34) │ │ ├── manifest.ts # Manifest read/write │ │ ├── migrations.ts # Run-once migration registry (2.x entries only; first: canonicalise-agent-keys-v1) @@ -223,7 +225,7 @@ Devflow claims four namespaces inside `~/.claude/`: | Rules | `~/.claude/rules/devflow/` | One `.md` file per rule (e.g., `security.md`) | | Skills | `~/.claude/skills/devflow:*/` | One directory per skill (e.g., `devflow:software-design/`) | -These four namespaces hold the installed asset files. `devflow init` also writes `~/.claude/settings.json` (hook registrations, flags, view mode) and `~/.devflow/` state files (manifest, migrations tracking, proxy config). The `devflow:` prefix on skills prevents collisions with other tool ecosystems. +These four namespaces hold the installed asset files. `devflow init` also writes `~/.claude/settings.json` (hook registrations, flags — including the `view-mode` enum flag, whose `viewMode` settings key is written only when non-default; `neutralValue: 'default'` suppresses the key when view-mode is left at its default value) and `~/.devflow/` state files (manifest, migrations tracking, proxy config). The `devflow:` prefix on skills prevents collisions with other tool ecosystems. ### Orphan Sweep (install and selective uninstall) diff --git a/src/cli.ts b/src/cli.ts index 802869f4..2cb10175 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -68,8 +68,9 @@ program.action(() => { program.help(); }); -// Parse arguments -program.parse(); +// Parse arguments — parseAsync so async command handlers propagate rejection +// rather than surfacing as an unhandled rejection with a bare stack trace. +await program.parseAsync(); // Show help if no arguments if (!process.argv.slice(2).length) { diff --git a/src/cli/agents-view/render.ts b/src/cli/agents-view/render.ts index 06208eab..9e0df0d6 100644 --- a/src/cli/agents-view/render.ts +++ b/src/cli/agents-view/render.ts @@ -30,9 +30,9 @@ import { yellow, cyan, gray, - truncate, stripAnsi, -} from '../../hud/colors.js'; +} from '../../core/ansi.js'; +import { padToVisible, truncateVisible, sanitizeCell } from '../tui/cells.js'; import { isDirtyModel, isDirtyEffort, @@ -67,7 +67,7 @@ const COL_EFFORT = 13; const COL_STATE = 14; // --------------------------------------------------------------------------- -// Name formatter — TUI only (Fix 4) +// Name formatter — TUI only // --------------------------------------------------------------------------- /** @@ -92,42 +92,6 @@ export function formatAgentName(name: string): string { // Cell renderers (pure, return styled string) // --------------------------------------------------------------------------- -function padToVisible(s: string, width: number): string { - // Pad by visible length (strip ANSI, then pad with spaces). - const visible = stripAnsi(s); - const padding = Math.max(0, width - visible.length); - return s + ' '.repeat(padding); -} - -function truncateVisible(s: string, maxWidth: number): string { - const raw = stripAnsi(s); - if (raw.length <= maxWidth) return s; - // Re-truncate the unstyled version and rebuild — simpler than ANSI-aware slice. - return truncate(raw, maxWidth); -} - -/** Layout-breaking whitespace that stripAnsi deliberately preserves. */ -const LAYOUT_BREAKING_WS = /[\t\n]/g; - -/** - * Sanitize an untrusted string for a fixed-width TUI cell. - * - * stripAnsi strips escape sequences and C0 controls but, by contract, KEEPS - * TAB (\x09) and LF (\x0a) — correct for its own callers, wrong for a cell in - * a fixed-width frame. Orphan row names are arbitrary JSON keys read from - * agent-models.json, so neither is hypothetical: - * - LF emits a newline inside a frame line, breaking renderFrame's - * one-string-per-terminal-line contract and desyncing terminal.ts's - * cursor arithmetic (it writes ERASE_EOL + '\n' per returned line). - * - TAB measures as one character in padToVisible but occupies up to eight - * terminal columns, so every column to its right is misaligned. - * Both collapse to a single space; the raw key is untouched, so the save-path - * merge still targets the real mapping key. - */ -function sanitizeCell(s: string): string { - return stripAnsi(s).replace(LAYOUT_BREAKING_WS, ' '); -} - /** Options for renderModelCell — named to prevent silent argument transposition. */ interface RenderModelCellOptions { readonly row: AgentRow; @@ -141,10 +105,10 @@ interface RenderModelCellOptions { /** * Render the model cell for a given row, considering cursor/active/dirty state. * - * Three branches (Fix 1 — alias annotation removed): + * Three branches: * 1. configuredModel === 'default' → "default (shippedDefault)" [+ dormant hint] * 2. off-cycle pin → "model (unavailable)" - * 3. in-cycle model → bare name (aliases already rendered as picker names by buildRow) + * 3. in-cycle model → bare name (aliases stored as picker names by buildRow) * * Off-cycle pin (AC-F4): when configuredModel is absent from modelCycle * (retired/unavailable model), show "model (unavailable)". @@ -282,10 +246,9 @@ export function renderFrame( modelCycle, } = state; - const viewportHeight = Math.max( - MIN_VIEWPORT, - dims.rows - FIXED_ROWS, - ); + // state.viewportHeight is the single owner — clamped to a MIN so tests that + // set viewportHeight explicitly render exactly that many data rows. + const viewportHeight = Math.max(MIN_VIEWPORT, state.viewportHeight); // Column widths — shrink gracefully at narrow terminals. const totalContent = 2 + COL_AGENT + COL_MODEL + COL_EFFORT + COL_STATE; // prefix + 4 cols @@ -342,7 +305,7 @@ export function renderFrame( // Sanitize the name — mandatory for orphan rows (arbitrary JSON keys from // agent-models.json may contain escape sequences, newlines or tabs injected // by a hostile file). - // Exactly ONE call site for formatAgentName (Fix 4): TUI only; --list is lowercase. + // formatAgentName is TUI-only; --list uses the raw lowercase name. const safeName = formatAgentName(sanitizeCell(row.name)); const nameCell = padToVisible( isCursor ? bold(truncateVisible(safeName, agentW)) : truncateVisible(safeName, agentW), diff --git a/src/cli/agents-view/terminal.ts b/src/cli/agents-view/terminal.ts index d2209b53..89c56b72 100644 --- a/src/cli/agents-view/terminal.ts +++ b/src/cli/agents-view/terminal.ts @@ -1,119 +1,28 @@ /** - * Thin impure shell for the devflow agents TUI. + * Thin adapter — devflow agents TUI shell over the generic runTui driver. * * applies ADR-013: impure I/O shell in CLI layer; pure logic lives in state.ts/render.ts. * avoids PF-014: all cleanup wired via Promise resolve — never process.exit() inside * a finally-guarded scope. Cleanup is idempotent and runs on save, cancel, * SIGINT, SIGTERM, and keypress limit exhaustion. + * avoids PF-017: this is the thin adapter, not a copy of the generic shell. * - * Bounded: MAX_KEYPRESSES = 50_000 hard limit (reliability rule — every loop bounded). + * Public API (frozen — agents-terminal.test.ts is the acceptance gate): + * - runAgentsTui(initialState, io?) → Promise + * - MAX_KEYPRESSES (re-exported from shared shell) + * - TuiIO (re-exported from shared shell) + * - TuiResult * - * Returns a Promise resolving to { action: 'save'|'cancel', state } on any - * terminal event that terminates the TUI. + * Bounded: MAX_KEYPRESSES = 50_000 hard limit (re-exported from src/cli/tui/terminal.ts). */ -import * as readline from 'readline'; import { reduce } from './state.js'; -import { renderFrame, FIXED_ROWS, computeViewportHeight } from './render.js'; +import { renderFrame, computeViewportHeight } from './render.js'; import type { AgentsViewState } from './state.js'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Hard upper bound on keypress events — resolves with 'cancel' on exhaustion. */ -export const MAX_KEYPRESSES = 50_000; - -// --------------------------------------------------------------------------- -// Terminal escape sequences -// --------------------------------------------------------------------------- - -const ESC = '\x1b'; -const ENTER_ALT = `${ESC}[?1049h`; -const LEAVE_ALT = `${ESC}[?1049l`; -const HIDE_CURSOR = `${ESC}[?25l`; -const SHOW_CURSOR = `${ESC}[?25h`; -/** Move cursor to top-left without clearing (less flicker than full clear). */ -const HOME = `${ESC}[H`; -/** Erase from cursor to end of line. */ -const ERASE_EOL = `${ESC}[K`; - -// --------------------------------------------------------------------------- -// Keypress normalization -// --------------------------------------------------------------------------- - -interface ReadlineKey { - name?: string; - ctrl?: boolean; - sequence?: string; -} - -function normalizeKey(str: string, key: ReadlineKey | null | undefined): string { - if (key?.ctrl && key.name === 'c') return 'ctrl-c'; - const name = key?.name ?? ''; - switch (name) { - case 'up': return 'up'; - case 'down': return 'down'; - case 'left': return 'left'; - case 'right': return 'right'; - case 'tab': return 'tab'; - case 'return': return 'enter'; - case 'escape': return 'escape'; - case 'space': return 'space'; - default: - return str ?? name; - } -} - -// --------------------------------------------------------------------------- -// Optional I/O injection (for testing) -// --------------------------------------------------------------------------- - -/** - * Minimal stdin/stdout surface required by the TUI shell. - * Default values are process.stdin/stdout. Exposed so tests can pass fake streams. - * - * `stdin` is typed as `NodeJS.ReadableStream` (which extends `NodeJS.EventEmitter`) - * so `readline.emitKeypressEvents` accepts it directly — no bridging cast needed. - * `PassThrough` and other `Readable` subclasses satisfy this interface. - */ -export interface TuiIO { - stdin: NodeJS.ReadableStream & { - isTTY?: boolean; - setRawMode?: (mode: boolean) => void; - }; - stdout: NodeJS.EventEmitter & { - rows?: number; - columns?: number; - write(data: string, cb?: (err?: Error | null) => void): boolean; - }; -} - -// --------------------------------------------------------------------------- -// Dims / viewport -// --------------------------------------------------------------------------- - -function getDims(stdout: TuiIO['stdout']): { rows: number; cols: number } { - return { - rows: stdout.rows ?? 24, - cols: stdout.columns ?? 80, - }; -} - -// --------------------------------------------------------------------------- -// Redraw -// --------------------------------------------------------------------------- - -function redraw(state: AgentsViewState, stdout: TuiIO['stdout']): void { - const dims = getDims(stdout); - const lines = renderFrame(state, dims); - - let out = HOME; - for (const line of lines) { - out += line + ERASE_EOL + '\n'; - } - stdout.write(out); -} +import { runTui, type TuiIO } from '../tui/terminal.js'; +export { MAX_KEYPRESSES } from '../tui/terminal.js'; +export type { TuiIO } from '../tui/terminal.js'; +import type { Intent } from './state.js'; // --------------------------------------------------------------------------- // TuiResult @@ -140,112 +49,25 @@ export async function runAgentsTui( initialState: AgentsViewState, io?: Partial, ): Promise { - // D-SEAM: default to process.stdin/stdout; callers (tests) may inject fakes. - const stdin: TuiIO['stdin'] = (io?.stdin ?? process.stdin) as TuiIO['stdin']; - const stdout: TuiIO['stdout'] = (io?.stdout ?? process.stdout) as TuiIO['stdout']; - - // ── Enable readline keypress events ───────────────────────────────────── - // TuiIO.stdin is NodeJS.ReadableStream, which readline.emitKeypressEvents expects - // directly. PassThrough (tests) and process.stdin (production) both satisfy it. - readline.emitKeypressEvents(stdin); - - // ── Enter alt-screen, hide cursor ─────────────────────────────────────── - stdout.write(ENTER_ALT + HIDE_CURSOR); - - // ── Raw mode ───────────────────────────────────────────────────────────── - if (stdin.isTTY && typeof stdin.setRawMode === 'function') { - stdin.setRawMode(true); - } - stdin.resume(); - - return new Promise((resolve) => { - let state = initialState; - let cleaned = false; - let keypressCount = 0; - - // Initial viewport size - const dims = getDims(stdout); - state = { ...state, viewportHeight: computeViewportHeight(dims.rows) }; - redraw(state, stdout); - - // ── Cleanup (idempotent) ─────────────────────────────────────────────── - function cleanup(): void { - if (cleaned) return; - cleaned = true; - - stdin.removeListener('keypress', onKeypress); - process.removeListener('SIGINT', onSigint); - process.removeListener('SIGTERM', onSigterm); - stdout.removeListener('resize', onResize); - - if (stdin.isTTY && typeof stdin.setRawMode === 'function') { - try { stdin.setRawMode(false); } catch { /* ignore */ } - } - - // Pause stdin to release the ref'd TTY handle — mirrors the stdin.resume() - // at startup. Without this the resumed stdin keeps the event loop alive and - // the CLI (which has no forced process.exit) hangs after the TUI resolves. - stdin.pause(); - - stdout.write(LEAVE_ALT + SHOW_CURSOR); - } - - function settle(result: TuiResult): void { - cleanup(); - resolve(result); - } - - // ── Resize handler ───────────────────────────────────────────────────── - function onResize(): void { - const d = getDims(stdout); - state = { ...state, viewportHeight: computeViewportHeight(d.rows) }; - redraw(state, stdout); - } - - // ── Keypress handler ─────────────────────────────────────────────────── - function onKeypress(str: string, key: ReadlineKey): void { - keypressCount++; - if (keypressCount > MAX_KEYPRESSES) { - // Hard safety bound — cancel on exhaustion (avoids unbounded event loop) - settle({ action: 'cancel', state }); - return; - } - - const normalized = normalizeKey(str, key); - const { state: next, intent } = reduce(state, normalized); - state = next; - - switch (intent) { - case 'save': - settle({ action: 'save', state }); - return; - case 'cancel': - settle({ action: 'cancel', state }); - return; - case 'none': - redraw(state, stdout); - return; - default: { - const _: never = intent; - void _; - redraw(state, stdout); - } - } - } - - // ── Signal handlers ──────────────────────────────────────────────────── - function onSigint(): void { - settle({ action: 'cancel', state }); - } - - function onSigterm(): void { - settle({ action: 'cancel', state }); - } - - // Register all listeners - stdin.on('keypress', onKeypress); - process.on('SIGINT', onSigint); - process.on('SIGTERM', onSigterm); - stdout.on('resize', onResize); + // C='none' makes runTui return Promise<{ intent: Exclude; state }>. + // Exclude = 'save' | 'cancel', which matches TuiResult.action exactly — + // no casts needed, and adding a new Intent member is a compile error here + // (exhaustiveness enforced at the type level). + const result = await runTui({ + initialState, + reduce, + renderFrame, + onResize: (state, dims) => ({ + ...state, + viewportHeight: computeViewportHeight(dims.rows), + }), + signalAction: 'cancel', + continueIntent: 'none', + io, }); + + return { + action: result.intent, + state: result.state, + }; } diff --git a/src/cli/commands/agents.ts b/src/cli/commands/agents.ts index 68a03576..8aa76ece 100644 --- a/src/cli/commands/agents.ts +++ b/src/cli/commands/agents.ts @@ -776,7 +776,16 @@ export const agentsCommand = new Command('agents') // Lazy-import terminal to avoid loading readline/tty in non-TTY paths const { runAgentsTui } = await import('../agents-view/terminal.js'); - const result = await runAgentsTui(tuiState); + // Wrap: runTui rejects on initial-render failure or handler throw. + // On rejection: log and bail — no partial write (avoids PF-014 process.exit). + let result; + try { + result = await runAgentsTui(tuiState); + } catch (err) { + p.log.error(`Agent editor failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } if (result.action === 'cancel') { p.outro(color.dim('No changes made.')); diff --git a/src/cli/commands/flags.ts b/src/cli/commands/flags.ts index 1fd3c6ea..df072e4e 100644 --- a/src/cli/commands/flags.ts +++ b/src/cli/commands/flags.ts @@ -1,139 +1,677 @@ +/** + * devflow flags — Manage Claude Code feature flags. + * + * D-P3-1: Typed flags CLI rewrite (Phase 3). + * - createFlagsCommand() factory — fresh Commander instance per call; + * used by tests; src/cli.ts consumes the flagsCommand singleton export. + * - Persist pipeline: convergeFlagsIntoSettings (fold-before-strip) — the + * single pipeline entry point shared with init.ts (ARCH-H1, PF-015/017). + * - PF-014 (process.exit swallows async work): all error paths set + * process.exitCode = 1 and return; never call process.exit(). + * - PF-015 (multi-artifact fan-out): compute record first; settings write + * and manifest write handled independently with their own error paths. + * - PF-022 (applies-on-restart): bare non-TTY invocation prints status table + * with a note that changes apply on restart. + * - PF-023 (validate at the sink): parseFlagValueInput → coerceFlagValue + * runs inside the core helpers before any write. + */ + import { Command } from 'commander'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as p from '@clack/prompts'; import color from 'picocolors'; -import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js'; -import { FLAG_REGISTRY, applyFlags, stripFlags, getDefaultFlags } from '../../core/flags.js'; +import { + getClaudeDirectory, + getDevFlowDirectory, +} from '../../targets/claude-code/claude-paths.js'; +import { + FLAG_REGISTRY, + findFlag, + convergeFlagsIntoSettings, + parseFlagValueInput, + formatFlagValue, + effectiveDisplay, + neutralValueOf, + describeFlagKind, + expectedInputFor, + type ClaudeCodeFlag, + type FlagsRecord, + type FlagsRecordValue, +} from '../../core/flags.js'; import { readManifest, writeManifest } from '../../core/manifest.js'; +import { writeFileAtomicExclusive } from '../../core/fs-atomic.js'; +import { sanitizeCell } from '../tui/cells.js'; +// Static imports for pure view-state helpers — no TTY machinery (applies PF-017). +// runFlagsTui stays lazily imported in handleBare to keep TTY module out of +// --list/--status code paths; buildFlagRows and collectFlagRecord are pure. +import { buildFlagRows, collectFlagRecord } from '../flags-view/state.js'; +import type { FlagsTuiResult } from '../flags-view/terminal.js'; + +// ─── Internal helpers ───────────────────────────────────────────────────────── /** - * Resolve current enabled flags from manifest (falls back to defaults if no manifest). + * Read and parse settings.json. + * ENOENT → returns `{ content: '{}', ok: true }`. + * Malformed JSON → returns `{ ok: false, reason: string }`. + * + * NEVER silently falls back to '{}' on malformed JSON — that would clobber the + * user's settings. The caller must abort with exit code 1 on !ok (avoids PF-023). */ -async function resolveEnabledFlags(devflowDir: string): Promise { - const manifest = await readManifest(devflowDir); - if (manifest) { - return manifest.features.flags; +async function readSettingsSafe( + settingsPath: string, +): Promise<{ ok: true; content: string } | { ok: false; reason: string }> { + let raw: string; + try { + raw = await fs.readFile(settingsPath, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { ok: true, content: '{}' }; + } + return { ok: false, reason: `Cannot read settings.json: ${(err as Error).message}` }; } - return getDefaultFlags(); + + // REL-M2 + PERF-L4: single parse — validate root shape and return raw string. + // The plain-object guard catches null/array roots before they reach applyFlags/stripFlags + // (applies PF-023 — validate at the sink; early rejection gives actionable error messages). + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, reason: 'settings.json is malformed — fix it before changing flags' }; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, reason: 'settings.json must be a JSON object — fix it before changing flags' }; + } + return { ok: true, content: raw }; } /** - * Update settings.json with the given flag set. + * Discriminated result for persistFlagConfig. + * + * Makes the absent-manifest state unrepresentable as success (TS-H2 / ARCH-H2 / + * REL-H2 / PF-015). Three distinct outcomes: + * - ok:true — both settings.json and manifest.json were written. + * - ok:false + failed — one or both artifact writes failed; messages + exitCode + * already set inside the function (per-artifact independence). + * - ok:false + reason:'no-manifest' — no manifest present; flag state not + * recorded. An absent manifest is a failure, not a no-op. + * + * Callers print success ("Flags saved.", "X enabled", …) ONLY when ok === true. + * Exported so applyTuiResult can reference it in its return type. + */ +export type PersistResult = + | { ok: true } + | { ok: false; failed: ReadonlyArray<'settings' | 'manifest'> } + | { ok: false; reason: 'no-manifest' }; + +/** + * Persist a FlagsRecord to settings.json and manifest. + * + * Uses `convergeFlagsIntoSettings` (ARCH-H1: fold-before-strip pipeline) so the + * invariant lives in the pipeline, not at call sites. This ensures that: + * - An externally-set /focus survives unless viewModeExplicit is true (PF-015). + * - Valued flags not yet claimed by devflow (absent from the manifest record) + * have their existing settings values preserved rather than stripped (REG-H1, + * SEC-M3, ADR-014). + * + * PF-015: settings write and manifest write are evaluated independently. + * Each failure is reported with its own message and exit code 1. + * The second write is never skipped due to the first succeeding or failing. + * + * Returns a discriminated PersistResult (never a boolean — avoids the two-state + * lie that cannot express the third "manifest absent" outcome). Success is tracked + * in LOCALS, never read back off `process.exitCode` (avoids PF-014, PF-015). */ -async function updateSettingsFlags(claudeDir: string, flagIds: string[]): Promise { +async function persistFlagConfig( + claudeDir: string, + devflowDir: string, + settingsContent: string, + newRecord: FlagsRecord, + // ARCH-M2 + PERF-L1: caller passes the already-read manifest so this function + // does not re-read it (two snapshots, one write; readManifest also self-heals + // = extra write). null → {ok:false,reason:'no-manifest'} (C2 discriminant intact). + manifest: NonNullable>> | null, + opts: { viewModeExplicit: boolean } = { viewModeExplicit: false }, +): Promise { + // D15: convergeFlagsIntoSettings is the fold-before-strip pipeline entry point + // (applies PF-015, PF-017, REG-H1, ARCH-H1). ownedRecord is omitted so the + // `newRecord` (the manifest record) serves as the owned set — a key present in + // the manifest means devflow previously claimed it; absent = never written by + // devflow, so the existing settings value is adopted. + const { settings: updatedSettings, record: foldedRecord } = convergeFlagsIntoSettings( + settingsContent, + newRecord, + opts, + ); + + // PF-015: accumulate each artifact's failure independently; combine at the end. + const failed: Array<'settings' | 'manifest'> = []; + + // Settings write — independent error path (avoids PF-015 fan-out). const settingsPath = path.join(claudeDir, 'settings.json'); - let content: string; try { - content = await fs.readFile(settingsPath, 'utf-8'); - // Validate that content is parseable JSON before passing to stripFlags/applyFlags - JSON.parse(content); - } catch { - content = '{}'; + await writeFileAtomicExclusive(settingsPath, updatedSettings); + } catch (err) { + p.log.error(`Failed to write settings.json: ${err instanceof Error ? err.message : String(err)}`); + failed.push('settings'); + process.exitCode = 1; + // PF-015: still attempt the manifest write — evaluate each artifact independently. + } + + // Manifest write — independent error path (avoids PF-015 fan-out). + // Uses foldedRecord (not newRecord) so adopted values are persisted to the + // manifest, keeping manifest ↔ settings.json in sync. + // + // An absent manifest is a FAILURE, not a no-op (TS-H2 / ARCH-H2 / REL-H2): + // returning success here would tell the user "Flags saved." while the manifest + // was never updated — reverted on the next `devflow init`. + if (!manifest) { + p.log.error('No devflow manifest found — flag selections were not recorded. Run devflow init first.'); + process.exitCode = 1; + // Return the dedicated discriminant so callers cannot accidentally suppress it. + return { ok: false, reason: 'no-manifest' }; + } + + manifest.features.flags = foldedRecord; + manifest.updatedAt = new Date().toISOString(); + try { + await writeManifest(devflowDir, manifest); + } catch (err) { + p.log.error(`Failed to write manifest.json: ${err instanceof Error ? err.message : String(err)}`); + failed.push('manifest'); + process.exitCode = 1; } - const stripped = stripFlags(content); - const updated = applyFlags(stripped, flagIds); - await fs.writeFile(settingsPath, updated, 'utf-8'); + + // PF-015: OR the locals afterwards — never compose required side effects with ||/&&. + return failed.length > 0 ? { ok: false, failed } : { ok: true }; +} + +// ─── Shared utilities ───────────────────────────────────────────────────────── + +/** Loaded manifest + settings.json content for the mutating CLI branches. */ +interface FlagContext { + manifest: NonNullable>>; + settingsContent: string; } /** - * Update manifest with the given flag set. + * Load manifest and settings.json for the mutating CLI branches. + * + * Returns a discriminated result — never exits itself. The dispatcher or handler + * reports the reason and sets process.exitCode = 1 on failure (avoids PF-014). + * One shared load path means a fix lands once, not four times (applies PF-017 — + * the four copies of the same preamble are exactly the "fix on one site, miss the + * other three" shape). */ -async function updateManifestFlags(devflowDir: string, flagIds: string[]): Promise { +async function loadFlagContext( + claudeDir: string, + devflowDir: string, +): Promise<{ ok: true; value: FlagContext } | { ok: false; reason: string }> { const manifest = await readManifest(devflowDir); - if (!manifest) return; - manifest.features.flags = flagIds; - manifest.updatedAt = new Date().toISOString(); - await writeManifest(devflowDir, manifest); + if (!manifest) { + return { ok: false, reason: 'No devflow installation found — run devflow init first' }; + } + const settingsResult = await readSettingsSafe(path.join(claudeDir, 'settings.json')); + if (!settingsResult.ok) { + return { ok: false, reason: settingsResult.reason }; + } + return { ok: true, value: { manifest, settingsContent: settingsResult.content } }; } /** - * Parse and validate comma-separated flag IDs against the registry. - * Exits with error if any IDs are unknown. + * Format the current FlagsRecord as a status table — one row per registry flag. + * + * Shared between --status (p.log.info sink) and bare non-TTY (process.stdout.write + * sink). Both call sites choose their own sink; this function produces the row + * strings only (CPLX-SF5, CONS-M3: the longer "not adopted — default X applies + * on next devflow init" wording is kept in both surfaces; the short form dropped + * the actionable second half). + * + * Returns plain strings — sanitizeCell strips control characters to prevent a + * persisted LF/TAB from reshaping the line-oriented table (applies SEC-M1). */ -function parseFlagIds(input: string): string[] { - const ids = input.split(',').map(s => s.trim()).filter(Boolean); - const invalid = ids.filter(id => !FLAG_REGISTRY.some(f => f.id === id)); +function formatStatusRows(record: FlagsRecord): string[] { + return FLAG_REGISTRY.map(flag => { + const value = Object.prototype.hasOwnProperty.call(record, flag.id) + ? record[flag.id] + : undefined; + // sanitizeCell: defence in depth — a persisted LF/TAB must not inject extra + // rows into the line-oriented table (applies SEC-M1). + // D-EFFDV: effectiveDisplay supplies the default label so 'unset' never appears. + const rawDisplay = value !== undefined + ? formatFlagValue(flag, value) + : `not adopted — default: ${effectiveDisplay(flag, neutralValueOf(flag)).text} applies on next devflow init`; + const displayValue = sanitizeCell(rawDisplay); + return `${flag.id.padEnd(28)} ${displayValue}`; + }); +} + +// ─── Branch handlers ────────────────────────────────────────────────────────── +// +// One named async handler per CLI branch — each is independently readable and +// carries one responsibility. The dispatcher (createFlagsCommand action) is ~15 +// lines and routes without logic of its own (ARCH-M1, CPLX-H1). - if (invalid.length > 0) { - p.log.error(`Unknown flag(s): ${invalid.join(', ')}`); - p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`); - process.exit(1); +/** Handle --list: read-only registry dump, no manifest required. */ +async function handleList(): Promise { + p.intro(color.bgCyan(color.black(' Claude Code Flags '))); + for (const flag of FLAG_REGISTRY) { + const kindLabel = describeFlagKind(flag); + const targetInfo = flag.target.type === 'env' + ? `env ${flag.target.key}` + : `setting ${flag.target.key}`; + // D-EFFDV: number flags show the upstream default when present so the + // registry dump is meaningful even for flags with no devflow defaultValue. + const defaultLabel = flag.kind === 'number' && flag.upstreamDefault !== undefined + ? `upstream default: ${flag.upstreamDefault}` + : flag.defaultValue !== undefined && flag.defaultValue !== null + ? String(flag.defaultValue) + : 'none'; + const recLabel = flag.recommended ? color.green('recommended') : color.dim('optional'); + p.log.info( + `${color.bold(flag.id.padEnd(28))} ${recLabel.padEnd(20)} ${color.dim(kindLabel.padEnd(36))} ${color.dim(targetInfo)}`, + ); + p.log.info( + ` ${color.dim(flag.hint)} — default: ${color.cyan(defaultLabel)}`, + ); } + p.outro(color.dim('Use --enable / --disable / --set / --unset to manage flags')); +} - return ids; +/** Handle --status: read-only status table, degrades gracefully without a manifest. */ +async function handleStatus(devflowDir: string): Promise { + p.intro(color.bgCyan(color.black(' Claude Code Flags — Status '))); + const manifest = await readManifest(devflowDir); + if (!manifest) { + p.log.warn('Devflow is not installed — run devflow init first'); + p.log.info('Showing registry defaults only:'); + } + const record: FlagsRecord = manifest?.features.flags ?? {}; + for (const row of formatStatusRows(record)) { + p.log.info(row); + } + p.outro(color.dim('Use --enable / --disable / --set / --unset to change flags')); } -interface FlagsOptions { - enable?: string; - disable?: string; - status?: boolean; - list?: boolean; +/** + * Handle --enable/--disable: set boolean flags to the given value. + * + * Collapsed from two identical 50-line branches into one handler parameterized by + * `value: boolean` — the only deltas were the record assignment (true vs false) + * and one error-message string (--set vs --unset as the suggested alternative) + * (CPLX-H2 — applies PF-017: one fix lands once, not twice). + */ +async function handleSetBooleans( + claudeDir: string, + devflowDir: string, + ids: string[], + value: boolean, +): Promise { + // Validate: must be known boolean flags only. + // Collect the validated flag definitions so the success loop can use them + // directly — avoids findFlag(id) re-lookups after the guard (TS-S1). + const flagDefs: ClaudeCodeFlag[] = []; + for (const id of ids) { + const flag = findFlag(id); + if (!flag) { + p.log.error(`Unknown flag: ${color.bold(id)}`); + p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`); + process.exitCode = 1; + return; + } + if (flag.kind !== 'boolean') { + const alt = value ? `--set ${id}=value` : `--unset ${id}`; + p.log.error(`${color.bold(id)} is a ${flag.kind} flag — use ${color.bold(alt)} to ${value ? 'set' : 'clear'} it`); + process.exitCode = 1; + return; + } + flagDefs.push(flag); + } + + // Manifest required for mutating ops (avoids settings/manifest desync) + const ctx = await loadFlagContext(claudeDir, devflowDir); + if (!ctx.ok) { + p.log.error(ctx.reason); + process.exitCode = 1; + return; + } + + // PF-015: compute new record before any write + const newRecord: FlagsRecord = { ...ctx.value.manifest.features.flags }; + for (const flag of flagDefs) { + newRecord[flag.id] = value; + } + + const result = await persistFlagConfig(claudeDir, devflowDir, ctx.value.settingsContent, newRecord, ctx.value.manifest); + + if (result.ok) { + for (const flag of flagDefs) { + // D-EFFDV: formatFlagValue routes through effectiveDisplay — one vocabulary + // shared with --status and TUI so the three surfaces cannot drift. + p.log.success(`${flag.id} ${formatFlagValue(flag, value)}`); + } + } } -export const flagsCommand = new Command('flags') - .description('Manage Claude Code feature flags') - .option('--enable ', 'Enable flag(s), comma-separated') - .option('--disable ', 'Disable flag(s), comma-separated') - .option('--status', 'Show current flag states') - .option('--list', 'List all available flags') - .action(async (options: FlagsOptions) => { - const claudeDir = getClaudeDirectory(); - const devflowDir = getDevFlowDirectory(); - - if (options.list) { - p.intro(color.bgCyan(color.black(' Claude Code Flags '))); - const defaults = new Set(getDefaultFlags()); - for (const flag of FLAG_REGISTRY) { - const status = defaults.has(flag.id) ? color.green('default ON') : color.dim('default OFF'); - const targetInfo = flag.target.type === 'env' - ? `env.${flag.target.key}` - : `setting.${flag.target.key}`; - p.log.info(`${color.bold(flag.id)} — ${flag.label} (${status})`); - p.log.info(` ${color.dim(flag.description)} → ${color.dim(targetInfo)}`); - } +/** Handle --set id=value (repeatable): validate all assignments then persist. */ +async function handleSet( + claudeDir: string, + devflowDir: string, + setValues: string[], +): Promise { + // Phase: parse and validate ALL assignments before any mutation. + const assignments: Array<{ id: string; flag: ClaudeCodeFlag; value: FlagsRecordValue }> = []; + + for (const assignment of setValues) { + // Split on first = only — rest is the value (e.g. spellcheck=a=b → id='spellcheck', value='a=b') + const eqIdx = assignment.indexOf('='); + if (eqIdx === -1) { + p.log.error(`Invalid --set format: ${color.bold(assignment)} — expected id=value`); + process.exitCode = 1; return; } + const id = assignment.slice(0, eqIdx); + const text = assignment.slice(eqIdx + 1); - if (options.status) { - p.intro(color.bgCyan(color.black(' Claude Code Flags '))); - const enabled = new Set(await resolveEnabledFlags(devflowDir)); - for (const flag of FLAG_REGISTRY) { - const state = enabled.has(flag.id) ? color.green('enabled') : color.dim('disabled'); - p.log.info(`${flag.id.padEnd(25)} ${state}`); - } + // Prototype pollution guard (applies PF-023) + if (id === '__proto__' || id === 'constructor' || id === 'prototype') { + p.log.error(`Unknown flag: ${color.bold(id)}`); + process.exitCode = 1; return; } - if (options.enable) { - const ids = parseFlagIds(options.enable); - const current = await resolveEnabledFlags(devflowDir); - const updated = [...new Set([...current, ...ids])]; + const flag = findFlag(id); + if (!flag) { + p.log.error(`Unknown flag: ${color.bold(id)}`); + p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`); + process.exitCode = 1; + return; + } - await updateSettingsFlags(claudeDir, updated); - await updateManifestFlags(devflowDir, updated); + const value = parseFlagValueInput(flag, text); + if (value === null && text !== 'unset') { + // parseFlagValueInput returns null both for 'unset' and for invalid values. + // If the input isn't literally 'unset', the null means invalid. + p.log.error(`Invalid value for ${color.bold(id)}: ${color.bold(text)}`); + p.log.info(`Expected: ${expectedInputFor(flag)}`); + process.exitCode = 1; + return; + } - for (const id of ids) { - p.log.success(`${id} enabled`); - } + assignments.push({ id, flag, value }); + } + + // All assignments valid — load manifest + settings + const ctx = await loadFlagContext(claudeDir, devflowDir); + if (!ctx.ok) { + p.log.error(ctx.reason); + process.exitCode = 1; + return; + } + + // PF-015: compute final record before any write + const newRecord: FlagsRecord = { ...ctx.value.manifest.features.flags }; + for (const { id, flag, value } of assignments) { + // null from parseFlagValueInput for literal 'unset' → use neutral value + newRecord[id] = value ?? neutralValueOf(flag); + } + + // viewModeExplicit: true when the user explicitly assigned view-mode in --set. + // This lets the chosen value override an externally-set /focus. + const viewModeExplicit = assignments.some(a => a.id === 'view-mode'); + const result = await persistFlagConfig( + claudeDir, devflowDir, ctx.value.settingsContent, newRecord, ctx.value.manifest, + { viewModeExplicit }, + ); + + if (result.ok) { + for (const { id, flag, value } of assignments) { + // null means the user typed 'unset' explicitly — echo their word back. + // For active values, route through formatFlagValue (D-EFFDV vocabulary). + const displayText = value === null ? 'unset' : formatFlagValue(flag, value); + p.log.success(`${id} = ${displayText}`); + } + } +} + +/** Handle --unset ids: reset flags to their neutral values. */ +async function handleUnset( + claudeDir: string, + devflowDir: string, + ids: string[], +): Promise { + // Validate: must be known flags (any kind). + // Collect the validated flag definitions so the mutation loop can use them + // directly — avoids findFlag(id) re-lookups after the guard (TS-S1). + const flagDefs: ClaudeCodeFlag[] = []; + for (const id of ids) { + const flag = findFlag(id); + if (!flag) { + p.log.error(`Unknown flag: ${color.bold(id)}`); + p.log.info(`Available: ${FLAG_REGISTRY.map(f => f.id).join(', ')}`); + process.exitCode = 1; return; } + flagDefs.push(flag); + } - if (options.disable) { - const ids = parseFlagIds(options.disable); - const current = await resolveEnabledFlags(devflowDir); - const toDisable = new Set(ids); - const updated = current.filter(id => !toDisable.has(id)); + const ctx = await loadFlagContext(claudeDir, devflowDir); + if (!ctx.ok) { + p.log.error(ctx.reason); + process.exitCode = 1; + return; + } - await updateSettingsFlags(claudeDir, updated); - await updateManifestFlags(devflowDir, updated); + // PF-015: compute new record before any write + const newRecord: FlagsRecord = { ...ctx.value.manifest.features.flags }; + for (const flag of flagDefs) { + newRecord[flag.id] = neutralValueOf(flag); + } - for (const id of ids) { - p.log.success(`${id} disabled`); - } + // viewModeExplicit: true when the user explicitly unset view-mode. + const viewModeExplicit = ids.includes('view-mode'); + const result = await persistFlagConfig( + claudeDir, devflowDir, ctx.value.settingsContent, newRecord, ctx.value.manifest, + { viewModeExplicit }, + ); + + if (result.ok) { + for (const id of ids) { + p.log.success(`${id} unset`); + } + } +} + +/** + * Apply a TUI result to disk — the save/persist seam extracted from handleBare. + * + * Enables seam testing of the TUI→persist wiring without a real TTY (closes + * the interactive-surface coverage gap per PF-017(c)). The test drives + * runFlagsTui with PassThrough streams, feeds its result here, and asserts + * the whole post-state of both artifacts (manifest + settings.json) per PF-015. + * + * @param result TUI result from runFlagsTui — action 'save', 'cancel', or 'abort'. + * @param freshSettingsContent Settings.json content re-read AFTER the TUI closed + * (see REL-M3 in handleBare — caller owns the re-read). + * @param manifest Loaded manifest threaded from loadFlagContext before TUI launch. + * @param claudeDir Path to ~/.claude directory (for settings.json write). + * @param devflowDir Path to ~/.devflow directory (for manifest write). + * @returns 'saved' on successful persist, 'unchanged' for cancel/abort, + * or the PersistResult error discriminant when persist fails + * (persistFlagConfig already logged + set exitCode in that case). + */ +export async function applyTuiResult( + result: FlagsTuiResult, + freshSettingsContent: string, + manifest: NonNullable>>, + claudeDir: string, + devflowDir: string, +): Promise<'saved' | 'unchanged' | Extract> { + if (result.action !== 'save') { + return 'unchanged'; + } + + const existingRecord: FlagsRecord = manifest.features.flags; + const newRecord = collectFlagRecord(result.rows); + // viewModeExplicit: true if the user changed the view-mode row in the TUI + const viewModeExplicit = newRecord['view-mode'] !== existingRecord['view-mode']; + + const persistResult = await persistFlagConfig( + claudeDir, devflowDir, freshSettingsContent, newRecord, manifest, + { viewModeExplicit }, + ); + if (persistResult.ok) { + return 'saved'; + } + return persistResult; +} + +/** + * Handle bare invocation (no subcommand flags). + * + * D-P5-1: TTY path launches the interactive flags TUI via lazy import; + * non-TTY path prints a status table + note to stderr + exitCode 1. + * CONS-M3: formatStatusRows() is the shared row formatter — non-TTY now uses + * the longer "not adopted — default X applies on next devflow init" wording, + * matching --status (convergence of the two divergent status surfaces). + * + * TS-H2 / ARCH-H2 / REL-H2: TTY path reuses loadFlagContext (the same guard + * that mutating handlers use) before importing or launching the TUI. An absent or + * unreadable manifest is a hard-refuse: the TUI must not launch, and settings.json + * must not be touched. This prevents the silent-factory-reset path (TUI seeded + * from {} writes settings.json; next devflow init re-adopts registry defaults and + * silently reverts everything the user confirmed). The non-TTY path degrades + * gracefully (status table only, no writes, no manifest required). + */ +async function handleBare( + claudeDir: string, + devflowDir: string, +): Promise { + // REL-H1: require both stdin AND stdout to be TTYs. + // Gating on process.stdout.isTTY alone lets `devflow flags < /dev/null` enter + // alt-screen while stdin ends immediately, leaving the terminal stranded with + // hidden cursor on exit. Precedent: agents.ts uses the same two-flag predicate. + if (process.stdin.isTTY && process.stdout.isTTY) { + // ── Manifest + settings required before the TUI may launch ────────── + // Reuses loadFlagContext — the same guard as --enable/--disable/--set/--unset. + // If the manifest is absent or unreadable, we refuse here and settings.json + // is never touched (avoids TS-H2 / ARCH-H2 / REL-H2 silent half-write). + const ctx = await loadFlagContext(claudeDir, devflowDir); + if (!ctx.ok) { + p.log.error(ctx.reason); + process.exitCode = 1; return; } + const record: FlagsRecord = ctx.value.manifest.features.flags; - // No option — show help - p.log.info('Usage: devflow flags --status | --list | --enable | --disable '); - }); + // ── Build initial rows from registry + current record ────────────── + // buildFlagRows is a static import (pure — no TTY); only runFlagsTui is lazy. + const initialRows = buildFlagRows(record); + + // ── Launch TUI ──────────────────────────────────────────────────── + const { runFlagsTui } = await import('../flags-view/index.js'); + // Wrap: runTui rejects on initial-render failure or handler throw. + // On rejection: log and bail — no settings write (avoids PF-014 process.exit). + let result; + try { + result = await runFlagsTui(initialRows); + } catch (err) { + p.log.error(`Flags editor failed: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + if (result.action === 'save') { + // REL-M3: re-read settings.json AFTER the human-paced TUI session closes. + // The read captured before runFlagsTui is a stale snapshot by the time the + // user saves — any concurrent writer (proxy enable, devflow agents, Claude + // Code /config) that ran during the session would be silently overwritten by + // the atomic rename in writeFileAtomicExclusive. Re-reading rebases the flag + // write onto current content and ensures convergeFlagsIntoSettings sees the + // fresh viewMode (applies PF-022 — file state, not config state, is reality). + const freshSettings = await readSettingsSafe(path.join(claudeDir, 'settings.json')); + if (!freshSettings.ok) { + p.log.error(freshSettings.reason); + process.exitCode = 1; + return; + } + const outcome = await applyTuiResult(result, freshSettings.content, ctx.value.manifest, claudeDir, devflowDir); + if (outcome === 'saved') { + p.outro(color.green('Flags saved.')); + } + // Error outcomes: persistFlagConfig already logged and set exitCode. + } else { + p.outro(color.dim('No changes made.')); + } + } else { + // non-TTY: status table — degrades gracefully without manifest (read-only). + const manifest = await readManifest(devflowDir); + const record: FlagsRecord = manifest?.features.flags ?? {}; + for (const row of formatStatusRows(record)) { + process.stdout.write(`${row}\n`); + } + process.stderr.write('Note: interactive TUI requires a TTY. Use --enable/--disable/--set/--unset for mutations.\n'); + process.exitCode = 1; + } +} + +// ─── Command factory ────────────────────────────────────────────────────────── + +/** Accumulator for repeatable --set options. */ +function collectSet(val: string, prev: string[]): string[] { + return prev.concat(val); +} + +/** + * Create a fresh flags Command instance. + * + * Call this in tests to get a clean Commander instance per test case — avoids + * Commander's internal option-value state leaking across tests. + * + * Bare invocation (no subcommand): + * - TTY: launches the interactive flags TUI (lazy import keeps TTY machinery + * out of --list/--status code paths). + * - non-TTY: prints status table to stdout + note to stderr + exitCode 1. + */ +export function createFlagsCommand(): Command { + return new Command('flags') + .description('Manage Claude Code feature flags') + .option('--list', 'List all available flags with metadata') + .option('--status', 'Show current flag states') + .option('--enable ', 'Enable boolean flag(s), comma-separated') + .option('--disable ', 'Disable boolean flag(s), comma-separated') + .option( + '--set ', + 'Set flag value (repeatable): id=value. Use "unset" as value to clear.', + collectSet, + [] as string[], + ) + .option('--unset ', 'Reset flag(s) to neutral (comma-separated)') + .action(async (options: { + list?: boolean; + status?: boolean; + enable?: string; + disable?: string; + set?: string[]; + unset?: string; + }) => { + const claudeDir = getClaudeDirectory(); + const devflowDir = getDevFlowDirectory(); + const splitIds = (s: string): string[] => s.split(',').map(t => t.trim()).filter(Boolean); + + if (options.list) return handleList(); + if (options.status) return handleStatus(devflowDir); + if (options.enable !== undefined) return handleSetBooleans(claudeDir, devflowDir, splitIds(options.enable), true); + if (options.disable !== undefined) return handleSetBooleans(claudeDir, devflowDir, splitIds(options.disable), false); + if (options.set && options.set.length > 0) return handleSet(claudeDir, devflowDir, options.set); + if (options.unset !== undefined) return handleUnset(claudeDir, devflowDir, splitIds(options.unset)); + return handleBare(claudeDir, devflowDir); + }); +} + +// ─── Singleton export ───────────────────────────────────────────────────────── +// +// src/cli.ts imports this; end-to-end tests should use createFlagsCommand() +// instead to get a fresh instance per test. +export const flagsCommand = createFlagsCommand(); diff --git a/src/cli/commands/init-seed.ts b/src/cli/commands/init-seed.ts index 9c2bca4f..eb33c7a0 100644 --- a/src/cli/commands/init-seed.ts +++ b/src/cli/commands/init-seed.ts @@ -4,7 +4,7 @@ * Computes the initial state (seed) for init prompts from: * - The existing manifest (from a prior install) * - The project feature config (.devflow/config.json) - * - The current settings.json snapshot (for viewMode) + * - The current settings.json snapshot (for view-mode resolution) * - The plugin registry * * All exported functions are pure — no I/O, no side effects. @@ -14,7 +14,15 @@ * agent-neutral, target-agnostic utilities). */ -import { resolveExistingViewMode, FLAG_REGISTRY, type ClaudeCodeFlag, type ViewMode } from '../../core/flags.js'; +import { + resolveExistingViewMode, + FLAG_REGISTRY, + defaultValueOf, + readViewMode, + type ClaudeCodeFlag, + type FlagsRecord, + type ViewMode, +} from '../../core/flags.js'; import { type FeatureConfig } from '../../core/feature-config.js'; import { type ManifestData } from '../../core/manifest.js'; import { partitionSelectablePlugins, type PluginDefinition } from '../../core/plugins.js'; @@ -34,7 +42,6 @@ export interface FeatureSeed { proxy: boolean; /** * Compliance feature seed — seeded from the manifest (manifest-group, like proxy). - * Full init wiring (framework multi-select, CLI toggle) is a later phase. * Default: {enabled:false, frameworks:[]} — compliance is opt-in, never auto-enabled. */ compliance: ComplianceFeatureState; @@ -55,8 +62,8 @@ export const FEATURE_DEFAULTS: FeatureSeed = { /** The complete initial state passed from the hoisted-reads block to init prompts. */ export interface InitSeed { features: FeatureSeed; - flags: string[]; - viewMode: ViewMode; + /** FlagsRecord with all registry flags at their resolved values. view-mode is encoded here. */ + flags: FlagsRecord; workflowPlugins: string[]; languagePlugins: string[]; } @@ -107,47 +114,46 @@ export function resolveSeedFeatures( } /** - * Resolve the enabled flag set for the init seed. + * Resolve the flag record for the init seed. * - * @param enabledFlags - Currently-enabled flag IDs from the manifest, - * or null for a fresh install (no prior manifest). - * @param knownFlags - Snapshot of all flag IDs known at the last install - * (manifest.features.knownFlags), or undefined when - * the manifest pre-dates the snapshot feature. - * @param registry - Flag registry to consult; injectable for tests. + * Returns a FlagsRecord containing ALL registry flags at their resolved values. + * FlagsRecord key-presence encodes the "known" concept (ADR-014): present key = + * known at last install, absent key = new to this install → adopt default on seed. + * + * @param manifestFlags - FlagsRecord from the manifest, or null for fresh install. + * @param registry - Flag registry to consult; injectable for tests. * * Rules: - * - null enabledFlags (fresh) → all default-ON flags in the registry - * - knownFlags === undefined (old manifest, migration) → return enabledFlags - * as-is; adopt nothing new (safe: user's prior choices preserved) - * - Otherwise → enabledFlags ∪ {default-ON flags whose id ∉ knownFlags} - * (newly added registry entries that the user never saw before are auto-adopted) - * - Default-OFF flags are NEVER auto-added regardless of knownFlags + * - null manifestFlags (fresh install) → all flags at registry defaults + * - Entry present → keep (coerceFlagValue is applied at read + * time via sanitizeFlagsRecord; PF-023) + * - Entry absent → adopt registry default (ADR-014) + * - Unknown IDs from old manifest → pass through unchanged (forward-compat) + * + * Applies ADR-014: absent key = unknown to this install → adopt default. + * view-mode is not set here; resolveInitSeed sets flags['view-mode'] after composing. */ export function resolveSeedFlags( - enabledFlags: string[] | null, - knownFlags: string[] | undefined, + manifestFlags: FlagsRecord | null, registry: readonly ClaudeCodeFlag[] = FLAG_REGISTRY, -): string[] { - // Fresh install → all default-ON flags from the registry - if (enabledFlags === null) { - return registry.filter(f => f.defaultEnabled).map(f => f.id); - } - - // Old manifest without a knownFlags snapshot → adopt nothing new - if (knownFlags === undefined) { - return [...enabledFlags]; +): FlagsRecord { + // Fresh install → all flags at registry defaults + if (manifestFlags === null) { + const result: FlagsRecord = {}; + for (const flag of registry) { + result[flag.id] = defaultValueOf(flag); // single default-rule source (CONS-M2) + } + return result; } - // Re-init with a knownFlags snapshot: union existing + newly-added default-ON entries - const knownSet = new Set(knownFlags); - const result = new Set(enabledFlags); + // Existing install: copy present entries then adopt defaults for absent flags. + // Unknown IDs from the old manifest pass through unchanged (forward-compat). + const result: FlagsRecord = { ...manifestFlags }; for (const flag of registry) { - if (flag.defaultEnabled && !knownSet.has(flag.id)) { - result.add(flag.id); - } + if (flag.id in result) continue; // known → keep + result[flag.id] = defaultValueOf(flag); // single default-rule source (CONS-M2) } - return [...result]; + return result; } /** @@ -220,10 +226,12 @@ export function resolveSeedPlugins( /** * Compose the full init seed from manifest, project config, settings, and registry. * - * viewMode priority: existing settings.json (non-default) → manifest → 'default' + * view-mode priority: existing settings.json (non-default) → manifest → 'default'. + * The resolved view mode is encoded into flags['view-mode'] so all flag state lives + * in one FlagsRecord (applying PF-015: fold before strip — the fold happens here). * * This is the single composition point; callers (init.ts hoist block) call this - * once and pass `seed` down to Phase 4's prompt wiring. + * once and pass `seed` down to prompt wiring. */ export function resolveInitSeed( seedManifest: ManifestData | null, @@ -233,22 +241,34 @@ export function resolveInitSeed( ): InitSeed { const features = resolveSeedFeatures(seedManifest, seedConfig); - // null for a fresh install (no manifest); string[] from manifest otherwise - const enabledFlags: string[] | null = seedManifest !== null ? seedManifest.features.flags : null; - const flags = resolveSeedFlags(enabledFlags, seedManifest?.features.knownFlags); + // features.flags is FlagsRecord; null for fresh install (no manifest). + // seedManifest?.features.flags may be absent at runtime on very old manifests not yet + // healed — ?? null collapses to fresh-install behavior (all flags at registry defaults). + const manifestFlags: FlagsRecord | null = seedManifest?.features.flags ?? null; + const flags = resolveSeedFlags(manifestFlags); const manifestPlugins: string[] | null = seedManifest !== null ? seedManifest.plugins : null; const { workflowPlugins, languagePlugins } = resolveSeedPlugins( manifestPlugins, seedManifest?.knownPlugins, plugins, ); - // viewMode: non-default setting wins; else manifest; else 'default' - const viewMode: ViewMode = - resolveExistingViewMode(settingsSnapshot) ?? - seedManifest?.features.viewMode ?? - 'default'; + // Encode the resolved view mode into flags['view-mode'] (PF-015: all flag state in FlagsRecord). + // Priority: existing settings.json (non-default) → flags['view-mode'] from manifest → 'default'. + // resolveExistingViewMode returns undefined when absent or 'default' — treated as no-opinion. + const existingViewMode = resolveExistingViewMode(settingsSnapshot); + const manifestViewMode = readViewMode(flags); // already in flags via resolveSeedFlags spread + let resolvedViewMode: ViewMode; + if (existingViewMode !== undefined) { + resolvedViewMode = existingViewMode; // settings.json non-default wins + } else if (manifestViewMode !== 'default') { + resolvedViewMode = manifestViewMode; // manifest non-default wins + } else { + resolvedViewMode = 'default'; // fall back to neutral + } - return { features, flags, viewMode, workflowPlugins, languagePlugins }; + // Return a fresh spread rather than mutating flags in place — keeps this function pure + // per the module docblock and avoids aliasing if the caller inspects seed.flags. + return { features, flags: { ...flags, 'view-mode': resolvedViewMode }, workflowPlugins, languagePlugins }; } /** @@ -286,8 +306,7 @@ export function resolveResetGatedInputs( * Per-key: `toggles.X ?? base.X` — an explicit CLI value (true/false) wins; * undefined means "user did not specify this flag, keep the seed value". * - * Used in Phase 4 to honour --ambient/--no-ambient etc. passed alongside - * --recommended. + * Applies explicit --ambient/--no-ambient etc. passed alongside --recommended. */ export function applyCliToggles( base: FeatureSeed, diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 53a687f1..e89d0fca 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -42,7 +42,7 @@ import { stripDevflowTeammateModeFromJson } from '../../core/teammate-mode-clean import { addHudStatusLine, removeHudStatusLine } from './hud.js'; import { loadConfig as loadHudConfig, saveConfig as saveHudConfig } from '../../hud/config.js'; import { readManifest, writeManifest, resolvePluginList, detectUpgrade, type ManifestData } from '../../core/manifest.js'; -import { applyFlags, stripFlags, applyViewMode, stripViewMode, FLAG_REGISTRY, ViewMode, resolveExistingViewMode, resolveFinalViewMode } from '../../core/flags.js'; +import { convergeFlagsIntoSettings, countActiveFlags, readViewMode, type FlagsRecord } from '../../core/flags.js'; import { addContextHook, removeContextHook, hasContextHook } from './context.js'; import { writeFileAtomicExclusive } from '../../core/fs-atomic.js'; import { writeConfig, readConfigIfPresent, type FeatureConfig } from '../../core/feature-config.js'; @@ -381,7 +381,7 @@ export const initCommand = new Command('init') scope, features: { ambient: false, memory: false, hud: true, knowledge: false, - learning: false, rules: false, flags: [], proxy: false, + learning: false, rules: false, flags: {}, proxy: false, compliance: existingHudManifest?.features.compliance ?? { enabled: false, frameworks: [] }, }, installedAt: now, @@ -630,12 +630,11 @@ export const initCommand = new Command('init') // CLI override applied below in both Recommended and Advanced paths. let complianceEnabled = seed.features.compliance.enabled; let complianceFrameworks = seed.features.compliance.frameworks; - let enabledFlags = seed.flags; - let viewMode: ViewMode = seed.viewMode; - // viewModeExplicit: true when the user made an explicit interactive selection or --reset was passed. - // Used by resolveFinalViewMode to decide whether to clobber an externally-set /focus value. - // --reset forces viewMode back to 'default': resolveResetGatedInputs empties the settings snapshot - // so seed.viewMode collapses to 'default', and explicit=true makes that 'default' win at write time. + let enabledFlags: FlagsRecord = { ...seed.flags }; + // viewModeExplicit: true when --reset is passed; signals resolveFinalViewMode to let the + // seed-time view-mode win over an externally-set value in settings.json. + // --reset empties the settings snapshot via resolveResetGatedInputs so seed.flags['view-mode'] + // collapses to 'default', and explicit=true makes it take effect at settings write time. let viewModeExplicit = !!options.reset; let claudeignoreEnabled = !!earlyGitRoot; let discoveredProjects: string[] = []; @@ -703,7 +702,7 @@ export const initCommand = new Command('init') proxyEnabled = effectiveFeatures.proxy; complianceEnabled = effectiveFeatures.compliance.enabled; complianceFrameworks = effectiveFeatures.compliance.frameworks; - // enabledFlags and viewMode are already initialised to seed values above. + // enabledFlags is already initialised to seed.flags above. // Compute safe-delete block synchronously so we know whether to fetch installed version if (profilePath && safeDeleteAvailable) { @@ -730,7 +729,7 @@ export const initCommand = new Command('init') } // Print summary - const defaultFlagCount = enabledFlags.length; + const defaultFlagCount = countActiveFlags(enabledFlags); const complianceSummary = formatComplianceSummary(complianceEnabled, complianceFrameworks); const summaryLines = [ `Ambient mode: ${ambientEnabled ? 'enabled' : 'disabled'}`, @@ -741,8 +740,8 @@ export const initCommand = new Command('init') `Knowledge bases: ${knowledgeEnabled ? 'enabled' : 'disabled'}`, `Ext model routing: ${proxyEnabled ? 'enabled' : 'disabled'}`, `Compliance: ${complianceSummary}`, - `View mode: ${viewMode}`, - `Claude Code flags: ${defaultFlagCount} enabled`, + `View mode: ${readViewMode(enabledFlags)}`, + `Claude Code flags: ${defaultFlagCount} configured`, `${claudeignoreEnabled ? '.claudeignore: created' : ''}`, `${safeDeleteAction !== 'skip' ? 'Safe delete: installed' : ''}`, ].filter(l => l.trim()).join('\n'); @@ -948,68 +947,15 @@ export const initCommand = new Command('init') // CLI override (isTTY is guaranteed true by the non-TTY guard above). If it ever // did, the seed values assigned at declaration stand — which is the right default. - // Claude Code flags multiselect (advanced only) - const recommended = FLAG_REGISTRY.filter(f => f.defaultEnabled); - const optional = FLAG_REGISTRY.filter(f => !f.defaultEnabled); - const flagChoices = [ - ...recommended.map(f => ({ - value: f.id, - label: f.label, - hint: `${f.hint} · recommended`, - })), - { value: '_separator', label: color.dim('── Optional (skip if unsure) ──'), hint: '' }, - ...optional.map(f => ({ - value: f.id, - label: f.label, - hint: f.hint, - })), - ]; - p.note( - 'Recommended flags are pre-selected. Optional flags are for\n' + - 'advanced users — if you don\'t recognize one, skip it.', - 'Claude Code Flags', - ); - - const flagSelection = await p.multiselect({ - message: 'Claude Code flags', - options: flagChoices, - // Pre-seeded from prior state; fresh installs start with all default-ON flags. - initialValues: seed.flags, - required: false, - }); - - if (p.isCancel(flagSelection)) { - p.cancel('Installation cancelled.'); - process.exit(0); - } - enabledFlags = flagSelection.filter(id => id !== '_separator'); - - // View mode selector (advanced only) - p.note( - 'Controls how much detail Claude Code shows in the transcript.\n' + - '• default — normal display with expandable tool output\n' + - '• verbose — shows everything including thinking blocks\n' + - '• focus — minimal: prompt, one-line tool summaries, final response', - 'View Mode', - ); - const viewModeChoice = await p.select({ - message: 'View mode', - options: [ - { value: 'default', label: 'Default', hint: 'expandable tool output · recommended' }, - { value: 'verbose', label: 'Verbose', hint: 'shows everything including thinking' }, - { value: 'focus', label: 'Focus', hint: 'minimal output, one-line summaries' }, - ], - // Pre-seeded from prior state (fresh installs default to 'default'). - initialValue: seed.viewMode, - }); - if (p.isCancel(viewModeChoice)) { - p.cancel('Installation cancelled.'); - process.exit(0); + /** + * D40: init applies seeded flag defaults non-interactively. Flags are customized + * exclusively via `devflow flags`; re-init preserves existing values and adopts + * registry defaults only for absent flags (ADR-014). No TUI is opened during init. + */ + { + const activeCount = countActiveFlags(enabledFlags); + p.log.info(`Flags: ${activeCount} active — customize any time with 'devflow flags'`); } - viewMode = viewModeChoice as 'default' | 'verbose' | 'focus'; - // Mark as explicit: user actively selected this mode, so resolveFinalViewMode will - // let it win over an externally-set /focus value. - viewModeExplicit = true; // .claudeignore prompt if (earlyGitRoot) { @@ -1660,19 +1606,25 @@ export const initCommand = new Command('init') // Strip Devflow-managed teammateMode ("auto"). User-set values (e.g. "tmux") are preserved. content = stripDevflowTeammateModeFromJson(content); - // Claude Code flags — strip all managed keys, then re-apply selected flags - content = stripFlags(content); - content = applyFlags(content, enabledFlags); - - // Resolve the final viewMode to write. - // - explicit=true (interactive selection or --reset): selected value always wins - // - explicit=false (recommended/non-TTY): preserve an externally-set /focus value; - // otherwise use the seeded viewMode (which already reflects the prior manifest value) - viewMode = resolveFinalViewMode(resolveExistingViewMode(content), viewMode, viewModeExplicit); - - // View mode — strip then apply for upgrade safety - content = stripViewMode(content); - content = applyViewMode(content, viewMode); + // Claude Code flags — convergeFlagsIntoSettings is the single pipeline entry point + // (ARCH-H1, applies PF-015/PF-017/ADR-014): fold valued flags and view-mode from + // existing settings before strip, then strip all managed keys and apply the folded + // record. ownedRecord=existingManifest?.features.flags??null distinguishes keys + // devflow previously wrote (must not be overridden by fold) from keys newly adopted + // by resolveSeedFlags from registry defaults (may be overridden by fold to preserve + // user-set hand values — e.g., a hand-set concurrency of '8' survives upgrade). + { + const { settings: flaggedContent, record: foldedFlags } = convergeFlagsIntoSettings( + content, + enabledFlags, + { + viewModeExplicit, + ownedRecord: existingManifest?.features.flags ?? null, + }, + ); + content = flaggedContent; + enabledFlags = foldedFlags; + } // Proxy hooks (SessionStart + UserPromptSubmit) — strip-then-add, idempotent. // Parse Settings once for the hook mutation; env mutation stays in string space. @@ -1977,11 +1929,9 @@ export const initCommand = new Command('init') knowledge: knowledgeEnabled, learning: learningEnabled, rules: rulesEnabled, + // FlagsRecord written directly — key-presence encodes "known" (ADR-014). + // view-mode is encoded as flags['view-mode'] (the resolved final value). flags: enabledFlags, - // Snapshot of known flag ids at this install — used by resolveSeedFlags on next init - // to detect new default-ON flags and auto-adopt them. - knownFlags: FLAG_REGISTRY.map(f => f.id), - viewMode, security: securityMode, // Final resolved value — may be forced off by preflight failure. proxy: proxyEnabled, diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 0b826cf6..ddb91b83 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -134,22 +134,39 @@ function getDevflowVersion(): string | null { // ─── Internal object helpers (used for single-pass atomic settings write) ──── /** - * Mutate a parsed Settings object in place: set ANTHROPIC_BASE_URL to our relay. - * Returns true when the object was changed (used to detect if a write is needed). + * D-P4-1: Env var paired with ANTHROPIC_BASE_URL so Claude Code does not enforce + * its conservative context-window limit on relay-routed (non-Claude-model) sessions. + * Stripped together with the URL — always governed by URL ownership, never independently. + */ +const UNKNOWN_MODEL_WINDOW_ENV = 'CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT'; + +/** + * Mutate a parsed Settings object in place: set ANTHROPIC_BASE_URL to our relay + * and set UNKNOWN_MODEL_WINDOW_ENV to '1'. + * + * D-P4-1: Each condition is evaluated independently (PF-015 — no short-circuit that + * skips the second write when the first reports no change). + * + * Returns true when the object was changed by either assignment. */ function _applyProxyEnvToObject(settings: Settings, port: number): boolean { const s = settings as Record; s.env = (s.env as Record | undefined) ?? {}; const env = s.env as Record; const newUrl = proxyBaseUrl(port); - if (env.ANTHROPIC_BASE_URL === newUrl) return false; + // D-P4-1: evaluate each condition independently before combining (avoids PF-015 short-circuit) + const urlChanged = env.ANTHROPIC_BASE_URL !== newUrl; + const windowVarChanged = env[UNKNOWN_MODEL_WINDOW_ENV] !== '1'; env.ANTHROPIC_BASE_URL = newUrl; - return true; + env[UNKNOWN_MODEL_WINDOW_ENV] = '1'; + return urlChanged || windowVarChanged; } /** - * Mutate a parsed Settings object in place: remove ANTHROPIC_BASE_URL only when - * its value exactly matches our relay on the given managed port. + * Mutate a parsed Settings object in place: remove UNKNOWN_MODEL_WINDOW_ENV + * unconditionally (Devflow is its only producer — there is no foreign value to protect), + * and remove ANTHROPIC_BASE_URL only when it exactly matches our relay on the given + * managed port. * * Scoped to `managedPort` so a user's own localhost gateway (LiteLLM, * local Ollama proxy, etc.) on ANY other port is never clobbered. @@ -159,16 +176,34 @@ function _applyProxyEnvToObject(settings: Settings, port: number): boolean { * - enable path → the new port being applied (followed immediately by _applyProxyEnvToObject) * - uninstall → proxy.json.port (or DEFAULT_PROXY_PORT) * - * Returns true when the object was changed. + * D-P4-1: URL ownership gates the URL delete only; the window var is always ours to + * remove (applies PF-015, ADR-003). Each outcome is evaluated into a local and OR-ed + * afterwards — never short-circuit composed inline (PF-015). + * + * Returns true when the object was changed by either deletion. */ function _stripProxyEnvFromObject(settings: Settings, managedPort: number): boolean { const s = settings as Record; const env = s.env as Record | undefined; - if (typeof env?.ANTHROPIC_BASE_URL !== 'string') return false; - if (env.ANTHROPIC_BASE_URL !== proxyBaseUrl(managedPort)) return false; - delete env.ANTHROPIC_BASE_URL; + if (!env) return false; + + // Devflow is the only producer of this var — always remove it, regardless of whether + // the URL is still ours. Port-scoping protects a FOREIGN url value; there is no + // foreign value of this key to protect. (applies PF-015, ADR-003) + const hadWindowVar = env[UNKNOWN_MODEL_WINDOW_ENV] !== undefined; + delete env[UNKNOWN_MODEL_WINDOW_ENV]; + + let removedUrl = false; + if ( + typeof env.ANTHROPIC_BASE_URL === 'string' && + env.ANTHROPIC_BASE_URL === proxyBaseUrl(managedPort) + ) { + delete env.ANTHROPIC_BASE_URL; + removedUrl = true; + } + if (Object.keys(env).length === 0) delete s.env; - return true; + return removedUrl || hadWindowVar; // OR the locals — never compose with || inline (PF-015) } /** Internal: add ensure-proxy hook to one event. Returns true when added. */ @@ -1641,6 +1676,9 @@ async function runEnable(portOption: string | undefined): Promise { s.stop(color.green('External model routing enabled')); + // D-P4-1 / PF-022: applies-on-restart — env var takes effect only for new sessions + p.log.info(color.dim('Context-window enforcement disabled for relay-routed models — applies to new Claude Code sessions')); + if (adopted) { p.log.info(`Relay already running on port ${port} — adopted`); } else { diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 41a153ac..c4c5b210 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -24,7 +24,7 @@ import { detectShell, getProfilePath } from '../../core/safe-delete.js'; import { isAlreadyInstalled, removeFromProfile } from '../../core/safe-delete-install.js'; import { removeManagedSettings, stripUserDenyList, detectDenyState, DEVFLOW_HISTORICAL_DENY } from '../../targets/claude-code/post-install.js'; import { writeFileAtomicExclusive } from '../../core/fs-atomic.js'; -import { stripFlags, stripViewMode } from '../../core/flags.js'; +import { stripFlags } from '../../core/flags.js'; import { stripDevflowTeammateModeFromJson } from '../../core/teammate-mode-cleanup.js'; import { getPackageRoot, isContainedIn } from '../../core/paths.js'; @@ -811,8 +811,7 @@ export async function runCleanupPhase(opts: { settingsContent = removeDreamHook(settingsContent); settingsContent = removeHudStatusLine(settingsContent); settingsContent = removeContextHook(settingsContent); - settingsContent = stripFlags(settingsContent); - settingsContent = stripViewMode(settingsContent); + settingsContent = stripFlags(settingsContent); // also strips viewMode via view-mode registry entry settingsContent = stripDevflowTeammateModeFromJson(settingsContent); // Remove proxy hooks and ANTHROPIC_BASE_URL env in a single parse-mutate-serialize pass. // REG-1: scope the URL strip to the port Devflow manages — use the pre-captured port diff --git a/src/cli/flags-view/index.ts b/src/cli/flags-view/index.ts new file mode 100644 index 00000000..5b90ed73 --- /dev/null +++ b/src/cli/flags-view/index.ts @@ -0,0 +1,9 @@ +/** + * flags-view public barrel. + * + * Commands should import from this barrel to avoid coupling to internals. + */ + +export { runFlagsTui, type FlagsTuiResult } from './terminal.js'; +export { buildFlagRows, collectFlagRecord, type FlagRow, type FlagsViewState } from './state.js'; +export { computeViewportHeight, FIXED_ROWS } from './render.js'; diff --git a/src/cli/flags-view/render.ts b/src/cli/flags-view/render.ts new file mode 100644 index 00000000..2c581984 --- /dev/null +++ b/src/cli/flags-view/render.ts @@ -0,0 +1,344 @@ +/** + * Pure TUI frame renderer for the devflow flags view. + * + * applies ADR-013: CLI-layer view module; zero fs/tty imports. + * avoids PF-014: pure function, no process.exit(), no I/O. + * + * Layout (FIXED_ROWS = 10, viewport = state.viewportHeight — single owner): + * 1 Title " Devflow Flags" + * 2 Set / modified summary + * 3 Column header " FLAG VALUE" (scaled; offsets match data-row label/value) + * 4 Scroll-up indicator " ↑ N more" (blank if none) + * 5+ Viewport rows (one per visible flag) + * -5 Scroll-down indicator " ↓ N more" (blank if none) + * -4 (blank) + * -3 Hint line 1 (flag description/hint for selected flag) + * -2 Hint line 2 (error message while editing, else edit keybindings) + * -1 Unsaved count line (blank when 0) + * 0 Keybinding footer + * + * Data row columns (chars at the 80-col reference width — total 77): + * PREFIX : 2 (cursor mark "❯ " or " ") + * LABEL : 27 (flag label, padded / truncated; scaled by cols/80 at other widths) + * DIRTY : 2 ("● " when dirty, else " ") + * VALUE : 16 (formatted value or edit buffer; scaled by cols/80 at other widths) + * BLURB : 30 (dim per-flag short phrase; scaled by cols/80 at other widths) + * + * Column split: VALUE+BLURB = 46, preserving total width from the prior single VALUE column. + * + * Edit buffer rendering: + * Text before caret + inverse(charAtCaret|' ') + text after caret + * inverse() = ESC[7m ... ESC[0m (reverse video) + */ + +import { + bold, + dim, + yellow, + cyan, + gray, + green, + red, + inverse, +} from '../../core/ansi.js'; +import { effectiveDisplay } from '../../core/flags.js'; +import { padToVisible, truncateVisible, sanitizeCell } from '../tui/cells.js'; +import type { FlagsViewState, FlagRow } from './state.js'; +import type { RenderDims } from '../tui/terminal.js'; + +// ─── Layout constants ───────────────────────────────────────────────────────── + +/** Non-viewport fixed lines in a rendered frame (see layout comment above). */ +export const FIXED_ROWS = 10; +const MIN_VIEWPORT = 1; + +const COL_LABEL = 27; // flag label +// D-BLURB: VALUE+BLURB = 46 preserves the prior total; split as 16+30 at 80-col. +const COL_VALUE = 16; // value or edit buffer +const COL_BLURB = 30; // per-flag short phrase (dim) + +// ─── computeViewportHeight ──────────────────────────────────────────────────── + +/** Return the number of data rows the terminal can display given its height. */ +export function computeViewportHeight(termRows: number): number { + return Math.max(MIN_VIEWPORT, termRows - FIXED_ROWS); +} + +// ─── Value formatting ───────────────────────────────────────────────────────── + +/** + * Format a row's configuredValue for display. + * + * Value vocabulary (D-EFFDV — one-definition seam; never shows 'unset'): + * null (enum neutral) → dim neutralValue text (e.g. dim('default')) + * null (number) → dim ' (default)' or dim('—') + * null (string) → dim('—') + * boolean true → green 'on' + * boolean false → yellow 'off' + * non-boolean at devflow default → plain string + * non-boolean deviating from devflow default → bold string + * + * Colour vocabulary (one colour, one semantic — applies ADR-016's amendment lesson): + * cyan = focus indicator (chevron wrapper ‹ › on the cursor row only) + * yellow = dirty indicator (unconditional ●) and boolean 'off' + * green = boolean 'on' + * bold = non-boolean value deviating from devflow default + * + * disk-sourced values are routed through sanitizeCell to prevent TAB/LF + * layout breaks inside the fixed-width TUI cell (avoids PF-023). + */ +function formatValue(row: FlagRow): string { + const v = row.configuredValue; + if (v === null) { + // Non-boolean neutral: show effective default, dimmed. + // D-EFFDV: delegate to effectiveDisplay — one definition, all sites. + const { text } = effectiveDisplay(row.def, null); + // Append ' (default)' for number flags so the value origin is clear. + // Enum neutral shows its meaningful name (e.g. 'default'); string null shows '—'. + const display = row.kind === 'number' ? text + ' (default)' : text; + return dim(display); + } + if (typeof v === 'boolean') return v ? green('on') : yellow('off'); + // Non-boolean active: sanitize; bold signals deviation (cyan is reserved for focus) + const str = sanitizeCell(String(v)); + if (!Object.is(v, row.devflowDefault)) return bold(str); + return str; +} + +// ─── Edit buffer rendering ──────────────────────────────────────────────────── + +/** + * Render the edit buffer with an inverse-video caret marker, windowed to budget. + * + * Caret semantics: the caret is BETWEEN characters (text cursor position). + * - caret = 0: inverse on buf[0] (or space for empty buffer) + * - caret = n < len: inverse on buf[n] + * - caret = len: inverse on a trailing space (end of string) + * + * When the plain buffer length exceeds `budget`, the buffer is windowed so the + * caret stays at or near the right edge of the visible region. The inverse() + * marker is inserted AFTER windowing, so it always survives the size constraint. + */ +function renderBuffer(buffer: string, caret: number, budget: number): string { + const safe = buffer.replace(/[\x00-\x1f\x7f]/g, ''); // strip control chars from display + const safeLen = safe.length; + + if (safeLen === 0) { + // Empty buffer: show inverse on a blank space + return inverse(' '); + } + + // Clamp caret to [0, safeLen]; safeLen means "trailing space" (past last char). + const clampedCaret = Math.max(0, Math.min(caret, safeLen)); + + // Window the buffer to fit within budget visible chars, keeping caret visible. + // The window follows the caret: push it as far right as possible so the caret + // is at or near the right edge. + let windowStart = 0; + if (safeLen > budget) { + // Position caret at the rightmost slot; clamp so the window stays in bounds. + windowStart = Math.min( + Math.max(0, clampedCaret - budget + 1), + Math.max(0, safeLen - budget), + ); + } + + const windowed = safe.slice(windowStart, windowStart + budget); + const windowedCaret = clampedCaret - windowStart; + + if (windowedCaret <= 0) { + return inverse(windowed[0]) + windowed.slice(1); + } + if (windowedCaret >= windowed.length) { + return windowed + inverse(' '); + } + return windowed.slice(0, windowedCaret) + inverse(windowed[windowedCaret]) + windowed.slice(windowedCaret + 1); +} + +// ─── Row renderer ───────────────────────────────────────────────────────────── + +/** + * Render a single data row. + * Column widths are passed in from renderFrame so the header and rows share one binding. + * + * D-BLURB: blurbW is passed alongside valueW; both are scaled by renderFrame. + */ +function renderRow( + row: FlagRow, + isCursor: boolean, + isEditing: boolean, + editBuffer: string, + editCaret: number, + labelW: number, + valueW: number, + blurbW: number, +): string { + const prefix = isCursor ? '❯ ' : ' '; + + const isDirty = row.configuredValue !== row.originalValue; + // Dirty dot is yellow unconditionally — dirtiness must be readable on every row, + // not only the cursor row. + const dirtyDot = isDirty ? yellow('● ') : ' '; + + // Sanitize label (registry literal; sanitizeCell prevents TAB/LF layout breaks). + const rawLabel = sanitizeCell(row.label); + const labelCell = padToVisible( + isCursor ? bold(truncateVisible(rawLabel, labelW)) : truncateVisible(rawLabel, labelW), + labelW, + ); + + // Chevrons (cyan ‹ ›) mark the focused control / live edit buffer. + // Colour vocabulary: cyan = focus only; deviation uses bold (see formatValue). + // The chevrons take 4 visible chars (‹ + space + space + ›); budget accordingly. + // + // Composition rule: colour AFTER measuring — each styled segment is self-contained + // so an inner RESET (e.g. from green('on')) does not kill the outer cyan. + // cyan('‹ ') + + cyan(' ›') + // rather than cyan(`‹ ${content} ›`), which terminates the outer cyan at the + // inner RESET, leaving the closing chevron unstyled (applies ADR-016 amendment lesson). + const chevronBudget = valueW - 4; + let valueCell: string; + if (isCursor && isEditing) { + // Live edit buffer: renderBuffer windows to chevronBudget and inserts the + // inverse() caret AFTER windowing, so the caret always survives (ARCH-M7b fix). + const bufStr = renderBuffer(editBuffer, editCaret, chevronBudget); + valueCell = cyan('‹ ') + bufStr + cyan(' ›'); + } else if (isCursor) { + // Focused control: truncateVisible is safe here — it fires on plain text only + // when the value exceeds budget; the chevrons are in their own cyan segments. + const fmtVal = formatValue(row); + valueCell = padToVisible(cyan('‹ ') + truncateVisible(fmtVal, chevronBudget) + cyan(' ›'), valueW); + } else { + const fmtVal = formatValue(row); + valueCell = padToVisible(truncateVisible(fmtVal, valueW), valueW); + } + + // D-BLURB: short phrase, dim, truncated to blurbW. row.blurb is sourced from + // flag.blurb at buildFlagRows — no registry reach-back needed here (ARCH-M4). + const blurbCell = blurbW > 0 + ? ' ' + dim(truncateVisible(sanitizeCell(row.blurb), blurbW - 1)) + : ''; + + return `${prefix}${labelCell}${dirtyDot}${valueCell}${blurbCell}`; +} + +// ─── renderFrame ───────────────────────────────────────────────────────────── + +/** + * Render a complete flags TUI frame as an array of strings (one per terminal line). + * No newlines within strings. Safe at any dims (narrows gracefully). + */ +export function renderFrame( + state: FlagsViewState, + dims: RenderDims, +): string[] { + const { rows, cursor, viewportOffset, editing } = state; + // state.viewportHeight is the single owner — clamped to a MIN so tests that + // set viewportHeight explicitly render exactly that many data rows. + const viewportHeight = Math.max(MIN_VIEWPORT, state.viewportHeight); + const totalRows = rows.length; + + // ── Column widths (hoisted here so header and rows share one binding) ────── + // D-BLURB: blurbW is scaled alongside labelW/valueW; both VALUE+BLURB columns + // shrink proportionally so the total width stays at the prior COL_VALUE budget. + const scale = Math.min(1, dims.cols / 80); + const labelW = Math.max(8, Math.floor(COL_LABEL * scale)); + const valueW = Math.max(8, Math.floor(COL_VALUE * scale)); + const blurbW = Math.max(0, Math.floor(COL_BLURB * scale)); + + // ── Determine visible row range ─────────────────────────────────────────── + const lastVisible = Math.min(totalRows - 1, viewportOffset + viewportHeight - 1); + const visibleRows = rows.slice(viewportOffset, lastVisible + 1); + const rowsAbove = viewportOffset; + const rowsBelow = Math.max(0, totalRows - (lastVisible + 1)); + + // ── Title line ──────────────────────────────────────────────────────────── + const titleLine = bold(' Devflow Flags'); + + // ── Set / modified summary ──────────────────────────────────────────────── + const totalSet = rows.filter(r => r.configuredValue !== null).length; + const totalDirty = rows.filter(r => r.configuredValue !== r.originalValue).length; + let summaryLine = dim(` ${totalSet} active flags`); + if (totalDirty > 0) { + summaryLine += dim(` · `) + yellow(`${totalDirty} modified`); + } + + // ── Column header (uses same labelW/valueW/blurbW as rows so offsets are identical) ── + // D-BLURB: HINT column header aligns with the blurb column in data rows. + const colHeader = + ' ' + + padToVisible(gray('FLAG'), labelW) + + ' ' + + padToVisible(gray('VALUE'), valueW) + + (blurbW > 0 ? ' ' + gray('HINT') : ''); + + // ── Scroll indicators ───────────────────────────────────────────────────── + const upIndicator = rowsAbove > 0 ? dim(` ↑ ${rowsAbove} more`) : ''; + const downIndicator = rowsBelow > 0 ? dim(` ↓ ${rowsBelow} more`) : ''; + + // ── Rendered data rows ──────────────────────────────────────────────────── + const renderedRows: string[] = visibleRows.map((row, relIdx) => { + const absIdx = viewportOffset + relIdx; + const isCursor = absIdx === cursor; + const isEditing = isCursor && editing !== null; + return renderRow( + row, + isCursor, + isEditing, + editing?.buffer ?? '', + editing?.caret ?? 0, + labelW, + valueW, + blurbW, + ); + }); + + // ── Hint zone ───────────────────────────────────────────────────────────── + const selectedRow = rows[cursor]; + // row.hint is populated by buildFlagRows from flag.def.hint — no registry reach-back (ARCH-M4). + const selectedHint = selectedRow ? selectedRow.hint : ''; + const hintLine1 = selectedHint + ? dim(truncateVisible(` ${selectedHint}`, dims.cols)) + : ''; + + let hintLine2: string; + if (editing !== null) { + if (editing.error) { + hintLine2 = red(truncateVisible(` ✕ ${editing.error}`, dims.cols)); + } else { + hintLine2 = dim(' enter confirm esc cancel edit backspace delete'); + } + } else { + hintLine2 = dim(' space/←→ cycle e edit d default u unset enter save esc cancel'); + } + + // ── Unsaved changes ─────────────────────────────────────────────────────── + // Reuse totalDirty computed above — avoids a duplicate full-array scan (PERF-L2). + const unsavedLine = + totalDirty > 0 + ? ` ${yellow(`${totalDirty} unsaved change${totalDirty === 1 ? '' : 's'}`)}` + : ''; + + // ── Keybinding footer ───────────────────────────────────────────────────── + const footerText = dim( + truncateVisible(' ↑↓/jk move enter save esc/q cancel ctrl-c abort', dims.cols), + ); + + // ── Assemble ────────────────────────────────────────────────────────────── + const out: string[] = [ + titleLine, + summaryLine, + colHeader, + upIndicator, + ...renderedRows, + downIndicator, + '', + hintLine1, + hintLine2, + unsavedLine, + footerText, + ]; + + return out; +} + diff --git a/src/cli/flags-view/state.ts b/src/cli/flags-view/state.ts new file mode 100644 index 00000000..382225be --- /dev/null +++ b/src/cli/flags-view/state.ts @@ -0,0 +1,631 @@ +/** + * Pure keypress reducer for the devflow flags TUI. + * + * applies ADR-013: CLI-layer view module; consumes src/core/ imports only. + * applies ADR-016: one syntax, one semantic — value vocabulary. + * avoids PF-014: pure functions only — no process.exit(), no I/O. + * avoids PF-017: generic shell in tui/terminal.ts; this module is pure logic. + * + * viewMode GLUE RULE: view-mode's neutralValue ('default') maps to null in the TUI. + * `buildFlagRows` maps record value 'default' → null via `recordToTui` (core/flags.ts); + * `collectFlagRecord` maps null → 'default' via `tuiToRecord` (core/flags.ts). + * Number 0 is ACTIVE — null ≠ 0. Both functions live next to neutralValueOf, their + * definition dependency (PF-017 one-shared-definition corollary). + * + * Strict number parsing: leading/trailing whitespace and leading zeros are + * invalid ('007' → error, ' 8' → error). This rejects pathological inputs + * before they reach coerceFlagValue (applies PF-023). + * + * Buffer hard cap: BUFFER_MAX_LEN = 64 chars (paste-flood guard). + * + * allowUnset semantics: + * - boolean: false — no null stop, 'u' is noop + * - enum/number/string: true — 'u' sets null; enum with neutralValue includes + * null in the cycle as the first stop (round-trips through collectFlagRecord). + * + * FlagRow invariant: all rows are built by buildFlagRows from FLAG_REGISTRY. + * Every row.id maps to a known flag definition (row.def). collectFlagRecord and + * commitEdit rely on this — there is no unknown-flag fallback. + */ + +import { + FLAG_REGISTRY, + defaultValueOf, + coerceFlagValue, + parseFlagValueInput, + recordToTui, + tuiToRecord, + type ClaudeCodeFlag, + type FlagsRecord, + type FlagsRecordValue, +} from '../../core/flags.js'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Hard cap on edit buffer length — protects against paste floods. */ +export const BUFFER_MAX_LEN = 64; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** A single row in the flags TUI. Immutable by convention. */ +export interface FlagRow { + readonly id: string; + readonly label: string; + readonly hint: string; + /** + * Short phrase (≤ 30 chars) describing what the flag does. + * Shown as a dim trailing column in the TUI and appended to --status rows. + * D-BLURB: sourced from flag.blurb — single definition, consumed at render sites. + */ + readonly blurb: string; + /** + * The registry definition for this flag. Embedded so commitEdit and collectFlagRecord + * can access flag metadata (kind, bounds, neutralValue) without a module-global + * registry reach-back via findFlag. All rows are built from FLAG_REGISTRY, so this + * is always defined — no unknown-flag branch is needed at consumers (ARCH-M4). + */ + readonly def: ClaudeCodeFlag; + /** Discriminant for cycling vs text-editing behaviour. */ + readonly kind: 'boolean' | 'enum' | 'number' | 'string'; + /** + * Ordered cycle stops for space/left/right cycling. + * Empty for text rows (number/string) — those use text edit mode instead. + * boolean: [true, false] + * enum with neutralValue: [null, ...non-neutral values] + * enum without neutralValue: [...values] + */ + readonly stops: readonly FlagsRecordValue[]; + /** + * True when 'u' may set this row to null. + * false for boolean (boolean neutral is false, not null). + * true for all enum/number/string rows. + */ + readonly allowUnset: boolean; + /** Current value (mutable session value — changes on keypress). */ + readonly configuredValue: FlagsRecordValue; + /** + * Value at row construction — used for dirty detection. + * configuredValue !== originalValue → row is dirty. + */ + readonly originalValue: FlagsRecordValue; + /** Devflow default value in TUI coordinates (null for neutralValue mappings). */ + readonly devflowDefault: FlagsRecordValue; + /** Whether this flag is in the recommended section. */ + readonly recommended: boolean; +} + +/** Text edit state while a number/string row is being edited. */ +export interface EditState { + readonly buffer: string; + readonly caret: number; + readonly error: string | null; +} + +/** + * The intent produced by a keypress in the TUI. + * + * none — stay in the event loop; no persisting action. + * save — persist the current rows to settings.json and the manifest. + * cancel — user pressed esc or q; keep the seeded (original) values unchanged. + * abort — ctrl-c or OS interrupt; restore terminal state and exit immediately. + * + * The cancel vs abort distinction is load-bearing at the init.ts consumer: + * cancel means "no changes, continue the wizard"; abort means "terminate the process". + */ +export type FlagsIntent = 'none' | 'save' | 'cancel' | 'abort'; + +/** Full TUI state — immutable by convention. */ +export interface FlagsViewState { + readonly rows: readonly FlagRow[]; + readonly cursor: number; + readonly viewportOffset: number; + readonly viewportHeight: number; + /** Non-null while a text row is being edited. */ + readonly editing: EditState | null; +} + +/** + * The result of a single keypress through the reducer. + * + * `state` is the new TUI state (unchanged when the key has no effect). + * `intent` signals what the TUI loop should do next: + * none — redraw with the new state, continue the loop. + * save — exit the loop and persist the rows to disk. + * cancel — exit the loop, make no writes. + * abort — exit the loop, restore terminal, terminate. + */ +export interface ReduceResult { + readonly state: FlagsViewState; + readonly intent: FlagsIntent; +} + +// ─── Viewport helpers ───────────────────────────────────────────────────────── + +/** Adjust viewport offset so cursor stays visible. */ +function adjustViewport( + cursor: number, + viewportOffset: number, + viewportHeight: number, + rowCount: number, +): number { + if (viewportHeight <= 0 || rowCount === 0) return 0; + + let offset = viewportOffset; + if (cursor < offset) offset = cursor; + if (cursor >= offset + viewportHeight) offset = cursor - viewportHeight + 1; + + const maxOffset = Math.max(0, rowCount - viewportHeight); + return Math.max(0, Math.min(offset, maxOffset)); +} + +// ─── Row building ───────────────────────────────────────────────────────────── + +/** Compute the cycle stops for a flag in TUI coordinates. */ +function buildStops(flag: ClaudeCodeFlag): readonly FlagsRecordValue[] { + switch (flag.kind) { + case 'boolean': + return [true, false]; + case 'enum': { + if (flag.neutralValue !== undefined) { + // null is the TUI representation of neutralValue + const nonNeutral = flag.values.filter(v => v !== flag.neutralValue); + return [null, ...nonNeutral]; + } + // No neutralValue: cycle over the declared values + return [...flag.values]; + } + case 'number': + case 'string': + return []; // text edit mode + } +} + +/** Compute the devflow default in TUI coordinates. */ +function buildDevflowDefault(flag: ClaudeCodeFlag): FlagsRecordValue { + const base = defaultValueOf(flag); // single default-rule source (CONS-M2) + if (flag.kind === 'boolean') return base; + if (base === null) return null; // undefined defaultValue → null + // Apply the same neutralValue mapping used for record values + return recordToTui(flag, base); +} + +/** Build the initial TUI value for a row from a FlagsRecord. */ +function buildConfiguredValue(flag: ClaudeCodeFlag, record: FlagsRecord): FlagsRecordValue { + const id = flag.id; + if (!(id in record)) { + // Key absent from record — fall back to devflow default + return buildDevflowDefault(flag); + } + const raw = record[id]; + return recordToTui(flag, raw); +} + +/** + * Build the FlagRow array from FLAG_REGISTRY and an existing record. + * + * Row order matches FLAG_REGISTRY order. Every produced row embeds its registry + * definition as `row.def` — this is the FlagRow invariant: collectFlagRecord and + * commitEdit use row.def directly and assume all row ids are registry-derived. + * There is no unknown-flag fallback (ARCH-M4, CONS-S1). + * + * viewMode GLUE: record value 'default' → configuredValue null (via recordToTui). + * devflowDefault for view-mode = null (maps from neutralValue 'default'). + */ +export function buildFlagRows(record: FlagsRecord): FlagRow[] { + return FLAG_REGISTRY.map((flag): FlagRow => { + const stops = buildStops(flag); + const allowUnset = flag.kind !== 'boolean'; + const devflowDefault = buildDevflowDefault(flag); + const configuredValue = buildConfiguredValue(flag, record); + + return { + id: flag.id, + label: flag.label, + hint: flag.hint, + blurb: flag.blurb, + def: flag, + kind: flag.kind, + stops, + allowUnset, + configuredValue, + originalValue: configuredValue, + devflowDefault, + recommended: flag.recommended, + }; + }); +} + +/** + * Collect the current TUI row values back into a FlagsRecord. + * + * FlagRow invariant (see buildFlagRows): every row embeds its registry definition + * as row.def. There is no unknown-flag path — rows are always produced by + * buildFlagRows from FLAG_REGISTRY (ARCH-M4, CONS-S1). + * + * viewMode GLUE: null → neutralValue (e.g. 'default') for enum flags with neutralValue, + * via tuiToRecord (core/flags.ts). All other null values pass through as null. + */ +export function collectFlagRecord(rows: readonly FlagRow[]): FlagsRecord { + const record: FlagsRecord = {}; + for (const row of rows) { + record[row.id] = tuiToRecord(row.def, row.configuredValue); + } + return record; +} + +// ─── Cycle helpers ──────────────────────────────────────────────────────────── + +function cycleForward(stops: readonly FlagsRecordValue[], current: FlagsRecordValue): FlagsRecordValue { + // Use === rather than Object.is: -0 vs 0 doesn't survive the JSON round-trip so the + // distinction is unreachable in practice. Keeps parity with render.ts dirty-detection (!==/!==). + const idx = stops.findIndex(s => s === current); + if (idx === -1) return stops[0]; + return stops[(idx + 1) % stops.length]; +} + +function cycleBackward(stops: readonly FlagsRecordValue[], current: FlagsRecordValue): FlagsRecordValue { + // See cycleForward: === over Object.is for the same reason. + const idx = stops.findIndex(s => s === current); + if (idx === -1) return stops[stops.length - 1]; + return stops[(idx - 1 + stops.length) % stops.length]; +} + +/** Update a single row in the rows array (all other rows unchanged). */ +function updateRow( + rows: readonly FlagRow[], + cursor: number, + patch: Partial, +): readonly FlagRow[] { + return rows.map((r, i) => (i === cursor ? { ...r, ...patch } : r)); +} + +/** Move cursor by delta (+1 = down, -1 = up) and adjust the viewport. */ +function move(state: FlagsViewState, delta: -1 | 1): ReduceResult { + const n = state.rows.length; + const newCursor = Math.max(0, Math.min(n - 1, state.cursor + delta)); + const newOffset = adjustViewport(newCursor, state.viewportOffset, state.viewportHeight, n); + if (newCursor === state.cursor && newOffset === state.viewportOffset) { + return { state, intent: 'none' }; + } + return { state: { ...state, cursor: newCursor, viewportOffset: newOffset }, intent: 'none' }; +} + +/** Advance or retreat the cycle stop for a cycling row. */ +function cycle(state: FlagsViewState, row: FlagRow, dir: 'forward' | 'backward'): ReduceResult { + const next = + dir === 'forward' + ? cycleForward(row.stops, row.configuredValue) + : cycleBackward(row.stops, row.configuredValue); + return { + state: { ...state, rows: updateRow(state.rows, state.cursor, { configuredValue: next }) }, + intent: 'none', + }; +} + +// ─── Edit mode helpers ──────────────────────────────────────────────────────── + +/** Format a value as an edit buffer string. */ +function valueToBuffer(value: FlagsRecordValue): string { + if (value === null) return ''; + return String(value); +} + +/** Enter edit mode for the current row — pre-fill buffer with current value. */ +function enterEdit(state: FlagsViewState): FlagsViewState { + const row = state.rows[state.cursor]; + if (row.stops.length !== 0) return state; // not a text row + const buffer = valueToBuffer(row.configuredValue); + return { + ...state, + editing: { buffer, caret: buffer.length, error: null }, + }; +} + +/** + * Commit the current edit buffer for a text row. + * + * Contract: + * - Empty buffer + allowUnset → commit null (unset) + * - Empty buffer + !allowUnset → error "Value is required" + * - For number/string flags: delegate to parseFlagValueInput (applies PF-023 — + * strict grammar enforced at the core sink, not per-caller). Error messages + * distinguish format failures (padded/hex/leading-zeros) from bounds failures. + * - parseFlagValueInput returns null on invalid input → stay editing + error + */ +function commitEdit(state: FlagsViewState): FlagsViewState { + const { editing, rows, cursor } = state; + if (!editing) return state; + + const row = rows[cursor]; + // row.def is always defined — FlagRow invariant: all rows are built from FLAG_REGISTRY (ARCH-M4). + const flagDef = row.def; + if (flagDef.kind === 'boolean' || flagDef.kind === 'enum') return state; + + const buf = editing.buffer; + + // Empty buffer + if (buf === '') { + if (row.allowUnset) { + // Commit as null (unset) + return { + ...state, + editing: null, + rows: updateRow(rows, cursor, { configuredValue: null }), + }; + } else { + return { + ...state, + editing: { ...editing, error: 'Value is required' }, + }; + } + } + + // Number flag: parseFlagValueInput enforces strict decimal grammar (avoids PF-023). + // Provide specific error messages to distinguish format from bounds failures. + if (flagDef.kind === 'number') { + if (buf !== buf.trim()) { + return { ...state, editing: { ...editing, error: 'No leading or trailing spaces allowed' } }; + } + if (/^[+-]?0\d/.test(buf)) { + return { ...state, editing: { ...editing, error: 'Leading zeros are not allowed (e.g. use 7, not 007)' } }; + } + const coerced = parseFlagValueInput(flagDef, buf); + if (coerced === null) { + const parts: string[] = []; + if (flagDef.min !== undefined) parts.push(`min ${flagDef.min}`); + if (flagDef.max !== undefined) parts.push(`max ${flagDef.max}`); + if (flagDef.integer) parts.push('must be an integer'); + return { + ...state, + editing: { ...editing, error: parts.length ? `Invalid value (${parts.join(', ')})` : 'Must be a valid number' }, + }; + } + return { + ...state, + editing: null, + rows: updateRow(rows, cursor, { configuredValue: coerced }), + }; + } + + // String flag: coerceFlagValue handles maxLength and control-char rejection. + // At this point flagDef.kind is 'string' (boolean and enum are guarded above, + // number returned in its own branch). + const coerced = coerceFlagValue(flagDef, buf); + if (coerced === null) { + const msg = flagDef.maxLength !== undefined ? `Max ${flagDef.maxLength} characters` : 'Invalid value'; + return { ...state, editing: { ...editing, error: msg } }; + } + return { + ...state, + editing: null, + rows: updateRow(rows, cursor, { configuredValue: coerced }), + }; +} + +/** + * ASCII control characters (C0 + DEL). These must never enter the edit buffer: + * - coerceFlagValue rejects any string containing them, so a buffer holding one + * can only ever fail to commit — and the failure message would name length, + * not the real cause. + * - renderBuffer strips them for display, so the rendered string and the buffer + * would have different lengths and the caret would land on the wrong character. + * normalizeKey passes ctrl-modified keys through as their raw control byte + * (e.g. ctrl-a → \x01), so this is reachable by ordinary typing, not just hostile input. + */ +const CONTROL_CHAR = /[\x00-\x1f\x7f]/; + +/** Insert a printable character at the caret position (bounded by BUFFER_MAX_LEN). */ +function insertChar(editing: EditState, char: string): EditState { + if (editing.buffer.length >= BUFFER_MAX_LEN) return editing; + if (CONTROL_CHAR.test(char)) return editing; // never buffer an uncommittable char + const { buffer, caret } = editing; + const next = buffer.slice(0, caret) + char + buffer.slice(caret); + return { buffer: next, caret: caret + 1, error: null }; +} + +/** + * Handle a key while in edit mode. Returns the new state. + * + * `editing` is passed as a parameter so callers can pass the already-narrowed + * `EditState` value (callers guard `state.editing !== null` before calling), + * eliminating the non-null assertion (TS-S1). + */ +function reduceEditMode(state: FlagsViewState, key: string, editing: EditState): FlagsViewState { + + switch (key) { + case 'enter': + return commitEdit(state); + + case 'escape': + // Discard edit — restore without changing configuredValue + return { ...state, editing: null }; + + case 'backspace': { + if (editing.caret === 0) return { ...state, editing: { ...editing, error: null } }; + const buf = editing.buffer; + const next = buf.slice(0, editing.caret - 1) + buf.slice(editing.caret); + return { + ...state, + editing: { buffer: next, caret: editing.caret - 1, error: null }, + }; + } + + case 'delete': { + const buf = editing.buffer; + if (editing.caret >= buf.length) return { ...state, editing: { ...editing, error: null } }; + const next = buf.slice(0, editing.caret) + buf.slice(editing.caret + 1); + return { + ...state, + editing: { buffer: next, caret: editing.caret, error: null }, + }; + } + + case 'home': + return { ...state, editing: { ...editing, caret: 0, error: null } }; + + case 'end': + return { + ...state, + editing: { ...editing, caret: editing.buffer.length, error: null }, + }; + + case 'left': { + const next = Math.max(0, editing.caret - 1); + return { ...state, editing: { ...editing, caret: next, error: null } }; + } + + case 'right': { + const next = Math.min(editing.buffer.length, editing.caret + 1); + return { ...state, editing: { ...editing, caret: next, error: null } }; + } + + // up/down: ignored while editing; j/k intentionally NOT listed here so they + // insert literally (spec: literal q d u j k — default-model and spellcheck + // may contain 'j'/'k' as part of a command or path). + case 'up': + case 'down': + return state; + + // normalizeKey maps the space bar to the NAME 'space', not to ' '. Without this + // case the default branch below drops it (5 chars, not 1), so a space could never + // be typed — silently. spellcheck's whole purpose is to hold a shell command + // ("aspell list"), and default-model likewise; both were unenterable as multi-word + // values, with no error and no visual cue that the key had been ignored. + case 'space': + return { ...state, editing: insertChar(editing, ' ') }; + + default: { + // Printable character: single char, not ctrl + if (key.length === 1) { + return { ...state, editing: insertChar(editing, key) }; + } + return state; + } + } +} + +/** + * Apply a new viewport height (terminal resize) and re-clamp the scroll offset. + * + * adjustViewport otherwise only runs on up/down, so a resize alone changed the + * height without moving the offset: shrinking a tall terminal with the cursor + * below the new fold left the cursor outside the visible slice, and renderFrame + * draws the `❯` marker only for rows inside that slice — so the selection marker + * vanished entirely until the user pressed an arrow key. + * + * Pure — returns a new state. + */ +export function resizeViewport(state: FlagsViewState, viewportHeight: number): FlagsViewState { + return { + ...state, + viewportHeight, + viewportOffset: adjustViewport( + state.cursor, + state.viewportOffset, + viewportHeight, + state.rows.length, + ), + }; +} + +// ─── reduce ─────────────────────────────────────────────────────────────────── + +/** + * Pure keypress reducer. + * + * Key dispatch: + * Editing mode (editing !== null): all keys handled by reduceEditMode. + * - enter → commitEdit + * - escape → discard edit (exit edit mode, value unchanged) + * - backspace/delete/home/end/left/right → buffer manipulation + * - up/down/j/k → noop (navigation suppressed while editing) + * - single char → insertChar (bounded at BUFFER_MAX_LEN) + * + * Browse mode (editing === null): + * - up/k, down/j → navigate, adjust viewport + * - space: text row → enterEdit; cycling row → cycleForward + * - left → cycleBackward (cycling rows only; noop on text rows) + * - right → cycleForward (cycling rows only; noop on text rows) + * - enter: text row → enterEdit; cycling row → save intent + * - e: text row → enterEdit; cycling row → noop + * - d → set devflowDefault + * - u → setNull (allowUnset only; noop for boolean) + * - escape/q → cancel intent + * - ctrl-c → abort intent + */ +export function reduce(state: FlagsViewState, key: string): ReduceResult { + const n = state.rows.length; + + // Delegate to edit mode handler. + // ctrl-c is handled BEFORE delegating: reduceEditMode has no case for it, so it + // would fall through to 'none' and be swallowed. Raw mode suppresses the SIGINT + // that would otherwise rescue the user, so ctrl-c was completely dead while + // editing — the only way out was to discover escape first. + if (state.editing !== null) { + if (key === 'ctrl-c') return { state, intent: 'abort' }; + // Pass the narrowed editing (not null) — eliminates state.editing! inside reduceEditMode (TS-S1). + const next = reduceEditMode(state, key, state.editing); + return { state: next, intent: 'none' }; + } + + // Browse mode + // + // Keys that produce an intent regardless of row count come first. escape/q and + // ctrl-c already had no emptiness guard; enter must still save on an empty list + // (the one deliberate exception — do not fold into the unified guard below). + if (key === 'escape' || key === 'q') return { state, intent: 'cancel' }; + if (key === 'ctrl-c') return { state, intent: 'abort' }; + if (key === 'enter' && n === 0) return { state, intent: 'save' }; + + // Unified emptiness guard: all remaining browse-mode keys are no-ops on an empty list. + if (n === 0) return { state, intent: 'none' }; + + const row = state.rows[state.cursor]; + + switch (key) { + case 'up': + case 'k': + return move(state, -1); + + case 'down': + case 'j': + return move(state, +1); + + case 'space': + return row.stops.length === 0 + ? { state: enterEdit(state), intent: 'none' } + : cycle(state, row, 'forward'); + + case 'left': + return row.stops.length === 0 ? { state, intent: 'none' } : cycle(state, row, 'backward'); + + case 'right': + return row.stops.length === 0 ? { state, intent: 'none' } : cycle(state, row, 'forward'); + + case 'enter': + return row.stops.length === 0 + ? { state: enterEdit(state), intent: 'none' } + : { state, intent: 'save' }; + + case 'e': + return row.stops.length === 0 + ? { state: enterEdit(state), intent: 'none' } + : { state, intent: 'none' }; + + case 'd': + return { + state: { ...state, rows: updateRow(state.rows, state.cursor, { configuredValue: row.devflowDefault }) }, + intent: 'none', + }; + + case 'u': + if (!row.allowUnset) return { state, intent: 'none' }; + return { + state: { ...state, rows: updateRow(state.rows, state.cursor, { configuredValue: null }) }, + intent: 'none', + }; + + default: + return { state, intent: 'none' }; + } +} diff --git a/src/cli/flags-view/terminal.ts b/src/cli/flags-view/terminal.ts new file mode 100644 index 00000000..f27041b2 --- /dev/null +++ b/src/cli/flags-view/terminal.ts @@ -0,0 +1,96 @@ +/** + * Thin adapter — devflow flags TUI shell over the generic runTui driver. + * + * applies ADR-013: impure I/O shell in CLI layer; pure logic lives in state.ts/render.ts. + * avoids PF-014: cleanup wired via Promise resolve — never process.exit() inside + * a finally-guarded scope. + * avoids PF-017: thin adapter over the generic shell (src/cli/tui/terminal.ts). + * + * Public API: + * - runFlagsTui(initialRows, io?) → Promise + * + * Bounded: MAX_KEYPRESSES = 50_000 hard limit (re-exported from src/cli/tui/terminal.ts). + */ + +import { reduce, resizeViewport } from './state.js'; +import { renderFrame, computeViewportHeight } from './render.js'; +import type { FlagsViewState, FlagRow } from './state.js'; +import type { FlagsIntent } from './state.js'; +import { runTui, type TuiIO } from '../tui/terminal.js'; + +export { MAX_KEYPRESSES } from '../tui/terminal.js'; +export type { TuiIO } from '../tui/terminal.js'; + +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + +/** + * The result returned by runFlagsTui when the user exits the TUI. + * + * save — user pressed enter to persist; rows contain the final flag values. + * cancel — user pressed esc or q; values unchanged from initial rows. + * abort — user pressed ctrl-c or triggered an OS interrupt; terminal is + * restored and the process should exit (load-bearing distinction + * at the init.ts consumer, which treats abort as a process exit signal + * and cancel as "no changes, continue the wizard"). + * + * `rows` is always the final TUI row state; the action discriminant tells + * the caller whether to persist the values or discard them. + */ +export interface FlagsTuiResult { + readonly action: 'save' | 'cancel' | 'abort'; + readonly rows: readonly FlagRow[]; +} + +// --------------------------------------------------------------------------- +// runFlagsTui +// --------------------------------------------------------------------------- + +/** + * Launch the interactive flags TUI. + * + * @param initialRows - Initial flag rows (built by buildFlagRows). + * @param io - Optional I/O override (defaults to process.stdin/stdout). Pass fake + * streams in tests to drive the TUI without a real TTY. + * @returns Promise resolving to { action, rows } when the user saves, cancels, or aborts. + */ +export async function runFlagsTui( + initialRows: readonly FlagRow[], + io?: Partial, +): Promise { + const initialState: FlagsViewState = { + rows: initialRows, + cursor: 0, + viewportOffset: 0, + // Placeholder height — onResize overwrites this at startup with actual terminal dims + viewportHeight: 10, + editing: null, + }; + + // C='none' makes runTui return Promise<{ intent: Exclude; state }>. + // Exclude = 'save' | 'cancel' | 'abort', which matches + // FlagsTuiResult.action exactly — no casts needed, and adding a new FlagsIntent member + // is a compile error here (exhaustiveness enforced at the type level). + // + // D-INLINE: flags editor uses inline mode — renders in-place in the scroll buffer + // without entering the alt screen. This is friendlier for devflow init's wizard + // context where the flags editor is embedded in a multi-step interactive flow. + const result = await runTui({ + initialState, + reduce, + renderFrame, + // resizeViewport re-clamps viewportOffset for the new height — setting the + // height alone can strand the cursor outside the visible slice. + onResize: (state, dims) => resizeViewport(state, computeViewportHeight(dims.rows)), + signalAction: 'abort', + continueIntent: 'none', + screen: 'inline', + io, + }); + + return { + action: result.intent, + rows: result.state.rows, + }; +} diff --git a/src/cli/tui/cells.ts b/src/cli/tui/cells.ts new file mode 100644 index 00000000..5a6ef67f --- /dev/null +++ b/src/cli/tui/cells.ts @@ -0,0 +1,50 @@ +/** + * Shared TUI cell helpers — shared by agents-view and flags-view. + * + * Applies PF-017: generified into a shared module rather than copy-adapted per consumer. + * Pure functions, no I/O. + */ + +import { stripAnsi, truncate } from '../../core/ansi.js'; + +// --------------------------------------------------------------------------- +// Cell padding and truncation +// --------------------------------------------------------------------------- + +/** + * Pad a string to `width` visible characters. + * Padding is measured against the ANSI-stripped visible length. + */ +export function padToVisible(s: string, width: number): string { + const visible = stripAnsi(s); + const padding = Math.max(0, width - visible.length); + return s + ' '.repeat(padding); +} + +/** + * Truncate a string to at most `maxWidth` visible characters. + * Uses the ANSI-stripped length for measurement; rebuilds from the stripped value + * so styling is not carried across the truncation boundary. + */ +export function truncateVisible(s: string, maxWidth: number): string { + const raw = stripAnsi(s); + if (raw.length <= maxWidth) return s; + // Re-truncate the unstyled version — simpler than ANSI-aware slice. + return truncate(raw, maxWidth); +} + +/** + * Sanitize an untrusted string for a fixed-width TUI cell. + * + * stripAnsi strips escape sequences and C0 controls but, by contract, KEEPS + * TAB (\x09) and LF (\x0a). Both are layout-breaking in a fixed-width frame: + * - LF emits a newline inside a frame line, breaking the one-string-per- + * terminal-line contract and desyncing the cursor arithmetic in the shell. + * - TAB measures as one visible character but occupies up to eight terminal + * columns, misaligning every column to its right. + * Both collapse to a single space; raw key is untouched for save-path merges. + */ +const LAYOUT_BREAKING_WS = /[\t\n]/g; +export function sanitizeCell(s: string): string { + return stripAnsi(s).replace(LAYOUT_BREAKING_WS, ' '); +} diff --git a/src/cli/tui/terminal.ts b/src/cli/tui/terminal.ts new file mode 100644 index 00000000..664451b8 --- /dev/null +++ b/src/cli/tui/terminal.ts @@ -0,0 +1,447 @@ +/** + * Generic TUI shell — shared by agents-view and flags-view. + * + * applies ADR-013: impure I/O shell in CLI layer; pure logic lives in state + render. + * avoids PF-014: cleanup wired via Promise resolve — never process.exit() inside + * a finally-guarded scope. + * avoids PF-017: one generic shell, thin adapters per TUI — not copy-adapted per consumer. + * + * Bounded: MAX_KEYPRESSES = 50_000 hard limit (reliability rule — every loop bounded). + * + * Frame output contract — alt mode (avoids stale-frame ghosting on terminal shrink): + * - Each frame line ends with ERASE_EOL (clears to end of line). + * - Lines are joined with '\n' EXCEPT the last, which has no trailing '\n'. + * - ERASE_BELOW (ESC[0J) is appended after the last line to erase content below + * the frame on every redraw. + * + * Frame output contract — inline mode (D-INLINE): + * - No ENTER_ALT/LEAVE_ALT; renders in place in the normal scroll buffer. + * - First frame: write lines directly, track prevLineCount. + * - Subsequent frames: cursor-up (prevLineCount-1) + \r, rewrite lines, ERASE_BELOW. + * - Exit: cursor-up to frame top, ERASE_BELOW, SHOW_CURSOR — erases widget completely. + * - Height is clamped to stdout.rows - INLINE_MARGIN to prevent terminal scroll. + */ + +import * as readline from 'readline'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Hard upper bound on keypress events — resolves with signalAction on exhaustion. */ +export const MAX_KEYPRESSES = 50_000; + +/** + * Lines reserved below the inline widget so the shell prompt is never clobbered. + * D-INLINE: height clamped to stdout.rows - INLINE_MARGIN in inline mode. + */ +export const INLINE_MARGIN = 2; + +// --------------------------------------------------------------------------- +// Terminal escape sequences +// --------------------------------------------------------------------------- + +const ESC = '\x1b'; +const ENTER_ALT = `${ESC}[?1049h`; +const LEAVE_ALT = `${ESC}[?1049l`; +const HIDE_CURSOR = `${ESC}[?25l`; +const SHOW_CURSOR = `${ESC}[?25h`; +/** Move cursor to top-left without clearing (less flicker than full clear). */ +const HOME = `${ESC}[H`; +/** Erase from cursor to end of line. */ +const ERASE_EOL = `${ESC}[K`; +/** Erase from cursor to end of screen. */ +const ERASE_BELOW = `${ESC}[0J`; +/** + * Move cursor up N lines (D-INLINE: used by inline-mode repaints). + * Returns an empty string for n ≤ 0 so callers need no guard. + */ +const cursorUp = (n: number): string => (n > 0 ? `${ESC}[${n}A` : ''); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ReadlineKey { + name?: string; + ctrl?: boolean; + sequence?: string; +} + +/** Terminal dimensions. */ +export interface RenderDims { + readonly rows: number; + readonly cols: number; +} + +/** + * Minimal stdin/stdout surface required by the TUI shell. + * Exposed so tests can pass fake streams without a real TTY. + * + * `stdin` is typed as `NodeJS.ReadableStream` (extends `NodeJS.EventEmitter`) + * so `readline.emitKeypressEvents` accepts it directly. + * `PassThrough` and other `Readable` subclasses satisfy this interface. + */ +export interface TuiIO { + stdin: NodeJS.ReadableStream & { + isTTY?: boolean; + setRawMode?: (mode: boolean) => void; + }; + stdout: NodeJS.EventEmitter & { + rows?: number; + columns?: number; + write(data: string, cb?: (err?: Error | null) => void): boolean; + }; +} + +/** + * Spec object for runTui. All pure functions; I/O only via `io`. + * + * @template S TUI state type. + * @template A Full intent union (e.g. `'none' | 'save' | 'cancel'`). Must extend string + * so the `!==` comparison in the driver is always a string equality check. + * @template C The "continue" intent — the member of A that means "keep running". + * `extends A` enforces it is a valid member of the union. + * + * D-TS: the three-generic form makes `runTui`'s return type carry the invariant that + * the resolved intent is never `continueIntent`: + * Promise<{ intent: Exclude; state: S }> + * Adding a new member to A without updating the adapter's result type is a compile error. + */ +export interface RunTuiSpec { + /** Initial state before the first frame renders. */ + initialState: S; + /** Pure keypress reducer — returns next state and intent. */ + reduce: (state: S, key: string) => { state: S; intent: A }; + /** Pure frame renderer — returns one string per terminal line (no newlines in strings). */ + renderFrame: (state: S, dims: RenderDims) => string[]; + /** + * Called on terminal resize AND once at startup with the current terminal dims. + * Returns a new state (typically with updated viewportHeight). + * Optional — when absent, state is unchanged on resize. + */ + onResize?: (state: S, dims: RenderDims) => S; + /** + * The intent to return when a signal (SIGINT/SIGTERM) or MAX_KEYPRESSES + * exhaustion forces exit. Typed as `Exclude` — it can never be the + * continue intent, so the constraint is expressed in the type. Typically 'cancel' or 'abort'. + */ + signalAction: Exclude; + /** + * The intent value that means "keep running — redraw and wait for the next key". + * Any other value from reduce causes the TUI to resolve. + * Typed as `C` (the continue-intent parameter) so adapters need no casts. + */ + continueIntent: C; + /** Optional I/O override (defaults to process.stdin/stdout). Inject fakes in tests. */ + io?: Partial; + /** + * Screen mode: + * 'alt' — enter the alternate screen buffer (default; agents-view uses this). + * 'inline' — render in-place in the normal scroll buffer with cursor-up repaints; + * no ENTER_ALT/LEAVE_ALT; erases widget on exit; height clamped to + * stdout.rows - INLINE_MARGIN. D-INLINE: flags editor uses inline mode. + */ + screen?: 'alt' | 'inline'; +} + +// --------------------------------------------------------------------------- +// Keypress normalization +// --------------------------------------------------------------------------- + +/** + * Normalize a readline keypress event to a canonical key string. + * Maps backspace/delete/home/end to named tokens (raw bytes otherwise). + */ +export function normalizeKey(str: string | undefined, key: ReadlineKey | null | undefined): string { + if (key?.ctrl && key.name === 'c') return 'ctrl-c'; + const name = key?.name ?? ''; + switch (name) { + case 'up': return 'up'; + case 'down': return 'down'; + case 'left': return 'left'; + case 'right': return 'right'; + case 'tab': return 'tab'; + case 'return': return 'enter'; + case 'escape': return 'escape'; + case 'space': return 'space'; + case 'backspace': return 'backspace'; + case 'delete': return 'delete'; + case 'home': return 'home'; + case 'end': return 'end'; + default: + return str ?? name; + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getDims(stdout: TuiIO['stdout']): RenderDims { + return { + rows: stdout.rows ?? 24, + cols: stdout.columns ?? 80, + }; +} + +/** + * Write a complete frame to stdout. + * + * Frame output contract: + * HOME + each_line + ERASE_EOL + '\n' (except no '\n' after last line) + ERASE_BELOW + * + * ERASE_BELOW clears stale content below the frame when the terminal shrinks. + * No trailing '\n' on the last line keeps the cursor on that line so ERASE_BELOW + * erases exactly from the last data row to the bottom. + */ +function renderToStdout( + state: S, + stdout: TuiIO['stdout'], + renderFrame: (state: S, dims: RenderDims) => string[], +): void { + const dims = getDims(stdout); + // D-REL-M1: clamp to terminal height so HOME-anchored redraws never desync + // on small panes. max(1, …) ensures at least one line is always written. + const lines = renderFrame(state, dims).slice(0, Math.max(1, dims.rows)); + + let out = HOME; + for (let i = 0; i < lines.length; i++) { + out += lines[i] + ERASE_EOL; + if (i < lines.length - 1) out += '\n'; + } + out += ERASE_BELOW; + stdout.write(out); +} + +// --------------------------------------------------------------------------- +// runTui — generic driver +// --------------------------------------------------------------------------- + +/** + * Launch a generic interactive TUI. + * + * In 'alt' mode (default): enters the alternate screen buffer, hides the cursor, + * enables raw mode, and redraws by moving to HOME on each keypress. + * + * In 'inline' mode (D-INLINE): renders in-place in the normal scroll buffer. + * Repaints use cursor-up instead of ENTER_ALT/HOME. Height is clamped to + * stdout.rows - INLINE_MARGIN. Widget is erased completely on exit. + * + * Resolves when `reduce` returns an intent !== `spec.continueIntent`, when a + * signal fires, or when MAX_KEYPRESSES is exhausted. + * + * @returns Promise resolving to `{ intent, state }` at exit. + */ +export async function runTui( + spec: RunTuiSpec, +): Promise<{ intent: Exclude; state: S }> { + // D-SEAM: default to process streams; callers (tests) may inject fakes. + const stdin: TuiIO['stdin'] = (spec.io?.stdin ?? process.stdin) as TuiIO['stdin']; + const stdout: TuiIO['stdout'] = (spec.io?.stdout ?? process.stdout) as TuiIO['stdout']; + const isInline = spec.screen === 'inline'; + + // REL-H1 driver bail: reject BEFORE any terminal mutation when stdin is not a + // TTY and no spec.io.stdin was injected. + // + // Without this guard: alt-screen is entered, raw mode is skipped (no setRawMode + // on non-TTY stdin), stdin ends immediately (no keypresses), the promise never + // settles, the process exits, and cleanup() never runs — leaving the terminal + // in alt-screen with hidden cursor. + // + // spec.io?.stdin injected = test / pipe path that owns its own stream lifecycle. + // That path may deliberately pass a non-isTTY stream (e.g. PassThrough in tests) + // and is exempted from this guard. + if (!spec.io?.stdin && !stdin.isTTY) { + throw new Error( + 'runTui: stdin is not a TTY — use process.stdin on a real TTY or inject spec.io.stdin', + ); + } + + // ── Enable readline keypress events ───────────────────────────────────── + readline.emitKeypressEvents(stdin); + + return new Promise<{ intent: Exclude; state: S }>((resolve, reject) => { + let state = spec.initialState; + let cleaned = false; + let keypressCount = 0; + // D-INLINE: tracks how many lines the last inline frame occupied. + // Used for cursor-up repaint and widget-erase on exit. Zero = no frame written yet. + let prevLineCount = 0; + + // ── Inline-mode helpers ────────────────────────────────────────────── + function getInlineDims(): RenderDims { + const d = getDims(stdout); + return { rows: Math.max(1, d.rows - INLINE_MARGIN), cols: d.cols }; + } + + /** + * Render one inline frame in-place. + * First call: writes lines directly, sets prevLineCount. + * Subsequent calls: cursor-up (prevLineCount-1) + \r, rewrites, ERASE_BELOW. + * D-INLINE: ERASE_BELOW handles shrinking frames without a high-watermark. + */ + function renderInline(s: S): void { + const dims = getInlineDims(); + const lines = spec.renderFrame(s, dims).slice(0, dims.rows); + const lineCount = lines.length; + + let out = ''; + if (prevLineCount > 0) { + // Move back to start of previous frame + out += cursorUp(prevLineCount - 1) + '\r'; + } + for (let i = 0; i < lineCount; i++) { + out += lines[i] + ERASE_EOL; + if (i < lineCount - 1) out += '\n'; + } + // Erase stale lines below current frame (handles shrinking frames) + out += ERASE_BELOW; + stdout.write(out); + prevLineCount = lineCount; + } + + /** Dispatch render to the appropriate mode. */ + function doRender(s: S): void { + if (isInline) { + renderInline(s); + } else { + renderToStdout(s, stdout, spec.renderFrame); + } + } + + // ── Guarded startup — terminal setup, initial resize, and first render ─ + // + // All operations that modify terminal state run inside this try block so + // that any throw (including setRawMode EIO on a detached TTY) routes + // through cleanup(). cleanup() is a function declaration and is therefore + // hoisted, so it is callable here even though its textual definition + // appears later. removeListener on a not-yet-registered listener is a + // no-op, making partial setup safe to tear down. + try { + // D-SEC-S3: enter screen and enable raw mode inside the guarded block + // so a setRawMode throw cannot leave the terminal stranded. + // D-INLINE: inline mode skips ENTER_ALT — renders in the scroll buffer. + stdout.write(isInline ? HIDE_CURSOR : ENTER_ALT + HIDE_CURSOR); + if (stdin.isTTY && typeof stdin.setRawMode === 'function') { + stdin.setRawMode(true); + } + stdin.resume(); + + // Apply initial resize (sets viewportHeight from actual terminal dims). + const initialDims = isInline ? getInlineDims() : getDims(stdout); + if (spec.onResize) { + state = spec.onResize(state, initialDims); + } + doRender(state); + } catch (err) { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + + // ── Cleanup (idempotent) ──────────────────────────────────────────────── + function cleanup(): void { + if (cleaned) return; + cleaned = true; + + stdin.removeListener('keypress', onKeypress); + process.removeListener('SIGINT', onSigint); + process.removeListener('SIGTERM', onSigterm); + stdout.removeListener('resize', onResize); + + if (stdin.isTTY && typeof stdin.setRawMode === 'function') { + try { stdin.setRawMode(false); } catch { /* ignore */ } + } + + // Pause stdin to release the ref'd TTY handle — mirrors the stdin.resume() + // at startup. Without this the resumed stdin keeps the event loop alive + // and the CLI hangs after the TUI resolves. + stdin.pause(); + + if (isInline) { + // D-INLINE: erase widget and restore cursor. + // Move to start of frame, erase to bottom, show cursor. + let out = prevLineCount > 1 ? cursorUp(prevLineCount - 1) + '\r' : '\r'; + if (prevLineCount > 0) out += ERASE_BELOW; + out += SHOW_CURSOR; + stdout.write(out); + } else { + stdout.write(LEAVE_ALT + SHOW_CURSOR); + } + } + + function settle(intent: Exclude, finalState: S): void { + cleanup(); + resolve({ intent, state: finalState }); + } + + /** + * Tear down and reject. Used when a handler throws. + * + * A throw inside an EventEmitter listener does NOT reject the enclosing + * promise — it escapes as an uncaughtException and kills the process with + * cleanup() never having run, leaving raw mode and alt-screen set. Routing + * every handler failure through here keeps the PF-014 invariant (cleanup + * always runs) while still surfacing the error rather than swallowing it. + */ + function fail(err: unknown): void { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + } + + // ── Resize handler ───────────────────────────────────────────────────── + function onResize(): void { + try { + const d = isInline ? getInlineDims() : getDims(stdout); + if (spec.onResize) { + state = spec.onResize(state, d); + } + doRender(state); + } catch (err) { + fail(err); + } + } + + // ── Keypress handler ─────────────────────────────────────────────────── + function onKeypress(str: string | undefined, key: ReadlineKey | undefined): void { + try { + keypressCount++; + if (keypressCount > MAX_KEYPRESSES) { + // Hard safety bound — exit on exhaustion (avoids unbounded event loop). + settle(spec.signalAction, state); + return; + } + + const normalized = normalizeKey(str, key); + const { state: next, intent } = spec.reduce(state, normalized); + state = next; + + if (intent !== spec.continueIntent) { + // D-TS: TS cannot narrow A to Exclude from a !== check on a generic C. + // The invariant holds at runtime: any intent that is not continueIntent is Exclude. + settle(intent as Exclude, state); + return; + } + doRender(state); + } catch (err) { + fail(err); + } + } + + // ── Signal handlers ──────────────────────────────────────────────────── + function onSigint(): void { + settle(spec.signalAction, state); + } + + function onSigterm(): void { + settle(spec.signalAction, state); + } + + // Register all listeners + stdin.on('keypress', onKeypress); + process.on('SIGINT', onSigint); + process.on('SIGTERM', onSigterm); + stdout.on('resize', onResize); + }); +} diff --git a/src/core/ansi.ts b/src/core/ansi.ts new file mode 100644 index 00000000..1670b449 --- /dev/null +++ b/src/core/ansi.ts @@ -0,0 +1,91 @@ +/** + * ANSI color helpers — no dependencies, precompiled escape sequences. + * + * Neutral home for terminal-primitive helpers shared across CLI layers + * (src/cli/tui/, src/cli/flags-view/, src/cli/agents-view/) and the HUD + * feature module (src/hud/). Re-exported verbatim from src/hud/colors.ts + * so all existing HUD call sites remain untouched. + * + * applies ADR-013: src/core/ = agent-neutral logic; ANSI primitives have no + * feature coupling and belong here, not in a feature module. + * applies PF-017 corollary: one shared definition over per-consumer copies. + */ + +const ESC = '\x1b['; +const RESET = `${ESC}0m`; + +export function bold(s: string): string { + return `${ESC}1m${s}${RESET}`; +} +export function dim(s: string): string { + return `${ESC}2m${s}${RESET}`; +} +export function red(s: string): string { + return `${ESC}31m${s}${RESET}`; +} +export function green(s: string): string { + return `${ESC}32m${s}${RESET}`; +} +export function yellow(s: string): string { + return `${ESC}33m${s}${RESET}`; +} +export function blue(s: string): string { + return `${ESC}34m${s}${RESET}`; +} +export function magenta(s: string): string { + return `${ESC}35m${s}${RESET}`; +} +export function cyan(s: string): string { + return `${ESC}36m${s}${RESET}`; +} +export function gray(s: string): string { + return `${ESC}90m${s}${RESET}`; +} +export function white(s: string): string { + return `${ESC}37m${s}${RESET}`; +} +export function orange(s: string): string { + return `${ESC}38;5;208m${s}${RESET}`; +} +export function brightRed(s: string): string { + return `${ESC}91m${s}${RESET}`; +} +export function boldRed(s: string): string { + return `${ESC}1;31m${s}${RESET}`; +} +export function bgGreen(s: string): string { + return `${ESC}42m${s}${RESET}`; +} +export function bgYellow(s: string): string { + return `${ESC}43m${s}${RESET}`; +} +export function bgRed(s: string): string { + return `${ESC}41m${s}${RESET}`; +} +export function inverse(s: string): string { + return `${ESC}7m${s}${RESET}`; +} + +export function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + '…' : s; +} + +// S2 — Terminal-escape and control-character sanitization (HIGH, pre-existing defect). +// +// The prior pattern (/\x1b\[[0-9;]*m/g) matched only SGR sequences (colour). +// The broadened ANSI_PATTERN also covers: +// CSI sequences — \x1b[ ... with intermediate bytes, any final byte +// OSC sequences — \x1b] ... terminated by BEL (\x07) or ST (\x1b\\) +// Two-byte C1 — \x1b followed by any single character in the C1 range +// CTRL_PATTERN removes non-printable C0 control chars that are not TAB (\x09) +// or standard newlines (\x0a, \x0d). Together they prevent agent names +// embedded in model IDs from injecting escape sequences into --list output. + +const ANSI_PATTERN = + /\x1b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g; + +const CTRL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g; + +export function stripAnsi(s: string): string { + return s.replace(ANSI_PATTERN, '').replace(CTRL_PATTERN, ''); +} diff --git a/src/core/flags.ts b/src/core/flags.ts index 720705f1..d4088986 100644 --- a/src/core/flags.ts +++ b/src/core/flags.ts @@ -1,151 +1,344 @@ /** - * Claude Code flag registry. + * Claude Code flag registry — typed, extensible mechanism for managing + * Claude Code feature flags and settings. * - * Typed, extensible mechanism for managing Claude Code feature flags. - * Pure functions: applyFlags, stripFlags, getDefaultFlags — no I/O. + * Pure functions: applyFlags, stripFlags, getDefaultFlagsRecord — no I/O. + * + * D14: Typed registry — flags carry kind (boolean|enum|number|string), target + * (env|setting), and per-kind defaultValue. Neutral values delete their target + * key; active values write the appropriate payload. Number 0 is ACTIVE. Sink + * validation via coerceFlagValue (applies PF-023: validate at the convergence + * point every caller reaches). applyFlags(settingsJson, FlagsRecord) is the + * sole API; init.ts works directly with FlagsRecord (no legacy string[] bridge). */ -export interface ClaudeCodeFlag { - id: string; - label: string; - description: string; - hint: string; - target: - | { type: 'env'; key: string; value: string } - | { type: 'setting'; key: string; value: boolean | string }; - defaultEnabled: boolean; +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Discriminant for the FlagDef union — determines which per-kind fields are present. */ +export type FlagKind = 'boolean' | 'enum' | 'number' | 'string'; + +/** A concrete flag value (never null). */ +export type FlagValue = boolean | number | string; + +/** + * A flag record value: the flag's value or null. + * null = known + deliberately unset (neutral = delete the target key). + */ +export type FlagsRecordValue = FlagValue | null; + +/** + * The complete flag state record. Keys are flag IDs; values are the current + * value or null (neutral). Unknown keys are forward-compatible (skipped by + * applyFlags). Absent keys are NOT the same as null — absent = unknown to + * this install (adopted on next seed per ADR-014 semantics). + */ +export type FlagsRecord = Record; + +/** Where the flag's value is written in settings.json. */ +export type FlagTarget = + | { readonly type: 'env'; readonly key: string } + | { readonly type: 'setting'; readonly key: string }; + +// ── Per-kind interfaces ──────────────────────────────────────────────────────── + +interface FlagDefCommon { + readonly id: string; + readonly label: string; + readonly description: string; + /** One-line what + why hint shown in the UI (keep ≤ ~76 cols). */ + readonly hint: string; + /** + * Short phrase (target ≤ 30 chars, hard-capped by registry test) describing + * what the flag does. Shown as a dim trailing column in every TUI row and + * appended to each --status row. Registry test enforces the cap. + * + * D-BLURB: one-definition seam — lives here next to hint and description + * rather than separately derived at render sites. + */ + readonly blurb: string; + /** UI partitioning only: true = recommended section; false = optional section. */ + readonly recommended: boolean; + readonly target: FlagTarget; +} + +/** A boolean on/off flag. `onPayload` is written when the flag is enabled. */ +export interface BooleanFlagDef extends FlagDefCommon { + readonly kind: 'boolean'; + /** The value written to the target when the flag is ON. Env targets must use strings. */ + readonly onPayload: string | boolean; + /** Default value; false = neutral for booleans (key is deleted when false). */ + readonly defaultValue: boolean; +} + +/** An enum flag. `neutralValue` is the value that means "no preference" (key is deleted). */ +export interface EnumFlagDef extends FlagDefCommon { + readonly kind: 'enum'; + readonly values: readonly string[]; + readonly valueHints?: Readonly>>; + /** When set, this value is neutral — applying it removes the target key. */ + readonly neutralValue?: string; + readonly defaultValue: string | undefined; } +/** A numeric flag. null = neutral. Number 0 is ACTIVE (not neutral). */ +export interface NumberFlagDef extends FlagDefCommon { + readonly kind: 'number'; + readonly defaultValue: number | undefined; + readonly min?: number; + readonly max?: number; + readonly integer?: boolean; + /** Upstream default (for informational display). */ + readonly upstreamDefault?: number; +} + +/** + * A string flag. null = neutral. + * `wrapKey` — if set, the value is written as `{ [wrapKey]: value }` (e.g. spellcheck). + */ +export interface StringFlagDef extends FlagDefCommon { + readonly kind: 'string'; + readonly defaultValue: string | undefined; + readonly wrapKey?: string; + readonly maxLength?: number; +} + +/** Discriminated union of all flag types. Discriminant: `kind`. */ +export type ClaudeCodeFlag = BooleanFlagDef | EnumFlagDef | NumberFlagDef | StringFlagDef; + +// ─── Registry ───────────────────────────────────────────────────────────────── + +// Phase 0 probe findings: see docs/reference/claude-code-flags-probe.md + +/** + * Ordered registry of all Claude Code flags managed by devflow. + * + * IDs are the stable manifest keys (`features.flags` in the devflow manifest). + * Array order drives the `--list` table and TUI row order — intentional changes + * to order are display changes and should be made deliberately. + * + * Not every Claude Code env var belongs here. One notable exclusion: + * `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` — deliberately + * proxy-owned. It is paired with `ANTHROPIC_BASE_URL` in proxy.ts and its + * lifecycle is coupled to relay enable/disable; strip is handled by + * `stripProxyEnv` (src/cli/commands/proxy.ts). Adding it here would create a + * second owner and double-strip it on uninstall. (mirrors agent-teams note) + */ export const FLAG_REGISTRY: readonly ClaudeCodeFlag[] = [ - // === Recommended (default ON) === + + // ══ Recommended (default ON) ══════════════════════════════════════════════ + { id: 'tui', label: 'Fullscreen terminal UI', description: 'Flicker-free fullscreen rendering', - hint: 'Modern fullscreen interface', - target: { type: 'setting', key: 'tui', value: 'fullscreen' }, - defaultEnabled: true, + hint: 'Enables fullscreen mode — flicker-free and cursor-stable', + blurb: 'fullscreen terminal UI', + kind: 'boolean', + target: { type: 'setting', key: 'tui' }, + onPayload: 'fullscreen', + recommended: true, + defaultValue: true, }, { id: 'tool-search', label: 'Deferred tool loading', description: 'Load tool schemas on demand instead of all at startup', - hint: 'Faster startup', - target: { type: 'env', key: 'ENABLE_TOOL_SEARCH', value: 'true' }, - defaultEnabled: true, + hint: 'Defers tool schema loading to first use — smaller initial context', + blurb: 'deferred tool schema loading', + kind: 'boolean', + target: { type: 'env', key: 'ENABLE_TOOL_SEARCH' }, + onPayload: 'true', + recommended: true, + defaultValue: true, }, { id: 'lsp', label: 'LSP support', description: 'Enable Language Server Protocol integration', - hint: 'Code intelligence from your editor', - target: { type: 'env', key: 'ENABLE_LSP_TOOL', value: 'true' }, - defaultEnabled: true, + hint: 'Activates LSP tool so Claude can query your editor code intelligence', + blurb: 'editor code intelligence', + kind: 'boolean', + target: { type: 'env', key: 'ENABLE_LSP_TOOL' }, + onPayload: 'true', + recommended: true, + defaultValue: true, }, { id: 'prompt-caching-1h', label: 'Extended prompt cache', description: 'Extend prompt cache TTL from 5min to 1h', - hint: 'Cheaper long sessions', - target: { type: 'env', key: 'ENABLE_PROMPT_CACHING_1H', value: 'true' }, - defaultEnabled: true, + hint: 'Extends cache TTL from 5 min to 1 hr — cheaper long sessions', + blurb: '1-hour prompt cache TTL', + kind: 'boolean', + target: { type: 'env', key: 'ENABLE_PROMPT_CACHING_1H' }, + onPayload: 'true', + recommended: true, + defaultValue: true, }, { id: 'show-turn-duration', label: 'Show turn duration', description: 'Display timing info after each turn', - hint: 'See how long each response takes', - target: { type: 'setting', key: 'showTurnDuration', value: true }, - defaultEnabled: true, + hint: 'Shows wall-clock time for each turn — useful for spotting slow paths', + blurb: 'wall-clock time per turn', + kind: 'boolean', + target: { type: 'setting', key: 'showTurnDuration' }, + onPayload: true, + recommended: true, + defaultValue: true, }, { id: 'clear-context-on-plan', label: 'Clear context on plan accept', description: 'Clear context window when accepting a plan', - hint: 'Clean slate after planning', - target: { type: 'setting', key: 'showClearContextOnPlanAccept', value: true }, - defaultEnabled: true, + hint: 'Clears context on plan accept so implementation starts with full budget', + blurb: 'clear context on plan accept', + kind: 'boolean', + target: { type: 'setting', key: 'showClearContextOnPlanAccept' }, + onPayload: true, + recommended: true, + defaultValue: true, }, { id: 'disable-bundled-skills', label: 'Disable bundled skills', description: "Remove Claude Code's built-in skills and workflows (devflow provides its own)", - hint: 'Cleaner skill list', - target: { type: 'setting', key: 'disableBundledSkills', value: true }, - defaultEnabled: true, + hint: "Removes Claude Code's built-in skills — devflow installs its own set", + blurb: 'remove built-in CC skills', + kind: 'boolean', + target: { type: 'setting', key: 'disableBundledSkills' }, + onPayload: true, + recommended: true, + defaultValue: true, }, { id: 'pin-sonnet-4-6', label: 'Pin Sonnet to 4.6', description: 'Pin the default Sonnet model to claude-sonnet-4-6', - hint: 'Stable, deterministic Sonnet version', - target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL', value: 'claude-sonnet-4-6' }, - defaultEnabled: true, + hint: 'Pins Sonnet to 4.6 — stable, deterministic alias across model updates', + blurb: 'pin Sonnet to 4.6 model', + kind: 'boolean', + target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL' }, + onPayload: 'claude-sonnet-4-6', + recommended: true, + defaultValue: true, + }, + { + // Devflow fan-outs routinely exceed the upstream default of 20. + // Set to 40 by default so parallel Code/Review/Research waves don't + // silently queue. upstreamDefault recorded for display. (applies PF-023 bounds) + id: 'max-concurrent-subagents', + label: 'Max concurrent subagents', + description: 'Maximum number of subagents Claude Code will spawn concurrently', + hint: 'Sets concurrent subagent cap; upstream default is 20 — devflow uses 40', + blurb: 'parallel subagent cap', + kind: 'number', + target: { type: 'env', key: 'CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS' }, + recommended: true, + defaultValue: 40, + min: 1, + max: 100, // devflow sanity bound (applies PF-023) + integer: true, + upstreamDefault: 20, }, - // === Optional (default OFF) — skip these if you're unsure === + + // ══ Optional (default OFF) — skip these if you're unsure ══════════════════ + { id: 'brief', label: 'Brief output mode', description: 'Reduce verbosity of Claude Code output', - hint: 'Shorter responses', - target: { type: 'env', key: 'CLAUDE_CODE_BRIEF', value: 'true' }, - defaultEnabled: false, + hint: 'Reduces output verbosity — shorter responses, less explanation', + blurb: 'shorter, less verbose output', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_BRIEF' }, + onPayload: 'true', + recommended: false, + defaultValue: false, }, { id: 'thinking-summaries', label: 'Thinking summaries', description: 'Show thinking summaries during reasoning', - hint: 'See reasoning previews', - target: { type: 'setting', key: 'showThinkingSummaries', value: true }, - defaultEnabled: false, + hint: 'Surfaces condensed reasoning previews during extended thinking', + blurb: 'condensed reasoning previews', + kind: 'boolean', + target: { type: 'setting', key: 'showThinkingSummaries' }, + onPayload: true, + recommended: false, + defaultValue: false, }, { id: 'subprocess-env-scrub', label: 'Subprocess env scrub', description: 'Strip cloud credentials from subprocesses', - hint: 'Security: strip cloud creds from subprocesses', - target: { type: 'env', key: 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB', value: '1' }, - defaultEnabled: false, + hint: 'Strips cloud credentials (AWS, GCP, Azure) from subprocess env', + blurb: 'strip cloud credentials', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB' }, + onPayload: '1', + recommended: false, + defaultValue: false, }, { id: 'disable-nonessential-traffic', label: 'Disable non-essential traffic', description: 'Suppress usage metrics telemetry', - hint: 'No telemetry', - target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: 'true' }, - defaultEnabled: false, + hint: 'Suppresses usage telemetry sent back to Anthropic', + blurb: 'suppress usage telemetry', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' }, + onPayload: 'true', + recommended: false, + defaultValue: false, }, { id: 'forked-subagents', label: 'Forked subagents', description: 'Better subagent perf on external builds', - hint: 'Faster parallel agents (experimental)', - target: { type: 'env', key: 'CLAUDE_CODE_FORK_SUBAGENT', value: '1' }, - defaultEnabled: false, + hint: 'Enables forked subagent model — faster parallel agents (experimental)', + blurb: 'faster parallel agents', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_FORK_SUBAGENT' }, + onPayload: '1', + recommended: false, + defaultValue: false, }, { id: 'disable-adaptive-thinking', label: 'Disable adaptive thinking', description: 'Disable adaptive reasoning on Opus/Sonnet 4.6', - hint: 'Fixed thinking budget', - target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING', value: 'true' }, - defaultEnabled: false, + hint: 'Disables adaptive thinking budget — fixes compute per turn', + blurb: 'fixed compute per turn', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING' }, + onPayload: 'true', + recommended: false, + defaultValue: false, }, { id: 'always-thinking', label: 'Always enable thinking', description: 'Enable extended thinking by default', - hint: 'Thinking on every turn', - target: { type: 'setting', key: 'alwaysThinkingEnabled', value: true }, - defaultEnabled: false, + hint: 'Forces extended thinking on every turn, including non-complex ones', + blurb: 'extended thinking always', + kind: 'boolean', + target: { type: 'setting', key: 'alwaysThinkingEnabled' }, + onPayload: true, + recommended: false, + defaultValue: false, }, { id: 'disable-git-instructions', label: 'Disable git instructions', description: 'Remove git workflow instructions from system prompt', - hint: 'Smaller system prompt', - target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS', value: 'true' }, - defaultEnabled: false, + hint: 'Removes git workflow from system prompt — saves tokens in each turn', + blurb: 'remove git system prompt', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS' }, + onPayload: 'true', + recommended: false, + defaultValue: false, }, // NOTE: DISABLE_COMPACT and DISABLE_AUTOUPDATER intentionally omit the CLAUDE_CODE_ prefix — // these names are defined by upstream Claude Code and must match exactly. @@ -153,85 +346,733 @@ export const FLAG_REGISTRY: readonly ClaudeCodeFlag[] = [ id: 'disable-compact', label: 'Disable auto-compaction', description: 'Disable automatic context compaction', - hint: 'Keep full context (uses more tokens)', - target: { type: 'env', key: 'DISABLE_COMPACT', value: 'true' }, - defaultEnabled: false, + hint: 'Disables auto-compaction — retains full context at the cost of more tokens', + blurb: 'retain full context always', + kind: 'boolean', + target: { type: 'env', key: 'DISABLE_COMPACT' }, + onPayload: 'true', + recommended: false, + defaultValue: false, }, { + // v2.1.223 semantics: disables the 1M-token context window experiment and + // falls back to the standard context budget for the model. id: 'disable-1m-context', label: 'Disable 1M context window', - description: 'Use standard context window instead of extended 1M', - hint: 'Use smaller context window', - target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT', value: 'true' }, - defaultEnabled: false, + description: 'Disable the 1M-token context window experiment (v2.1.223+)', + hint: 'Opts out of the 1M context experiment — uses standard context budget', + blurb: 'use standard context budget', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT' }, + onPayload: 'true', + recommended: false, + defaultValue: false, }, { id: 'disable-autoupdater', label: 'Disable auto-updater', description: 'Prevent automatic update checks', - hint: 'No automatic updates', - target: { type: 'env', key: 'DISABLE_AUTOUPDATER', value: 'true' }, - defaultEnabled: false, + hint: 'Prevents automatic update checks — manage updates manually', + blurb: 'manual update management', + kind: 'boolean', + target: { type: 'env', key: 'DISABLE_AUTOUPDATER' }, + onPayload: 'true', + recommended: false, + defaultValue: false, }, { id: 'agent-teams', label: 'Agent Teams (experimental)', description: 'Enable Claude Code experimental Agent Teams', - hint: 'Peer agents / teammate mode — experimental', - target: { type: 'env', key: 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', value: '1' }, - defaultEnabled: false, + hint: 'Enables peer-agent teammate mode — experimental, may change any release', + blurb: 'peer-agent teammate mode', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' }, + onPayload: '1', + recommended: false, + defaultValue: false, // Note: the legacy `teammateMode:"auto"` settings key is stripped by // src/core/teammate-mode-cleanup.ts during uninstall (stripDevflowTeammateModeFromJson). // The env var above is the only surface managed by FLAG_REGISTRY for this flag. }, + + { + // Upstream: restores Todo/TaskCreate tools removed by default in Opus 4.8+, + // Sonnet 5+, and Fable 5+. Set to '1' to re-enable. + id: 'enable-todo-tools', + label: 'Enable todo/task tools', + description: 'Restore Todo and TaskCreate tools removed by default in newer models', + hint: 'Re-enables Todo/TaskCreate tools on Opus 4.8+ / Sonnet 5+ / Fable 5+', + blurb: 'restore Todo/Task tools', + kind: 'boolean', + target: { type: 'env', key: 'CLAUDE_CODE_ENABLE_TODO_TOOLS' }, + onPayload: '1', + recommended: false, + defaultValue: false, + }, + + // ── Valued flags (number/enum/string) ──────────────────────────────────── + + { + // Domain: unset by default; set only when users want a non-default spawn depth. + // upstreamDefault: 3 (recorded for display). PF-023 bounds: max 10. + id: 'subagent-spawn-depth', + label: 'Max subagent spawn depth', + description: 'Maximum depth of nested subagent spawning', + hint: 'Caps nested spawn depth; upstream default is 3 — raise only when needed', + blurb: 'nested spawn depth limit', + kind: 'number', + target: { type: 'env', key: 'CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH' }, + recommended: false, + defaultValue: undefined, + min: 1, + max: 10, // devflow sanity bound (applies PF-023) + integer: true, + upstreamDefault: 3, + }, + { + // Phase 0: domain verified small|medium|large|unrestricted from binary + // (4-value cluster at adjacent string offsets, adjacent to Workflows feature text). + id: 'workflow-size-guideline', + label: 'Workflow size guideline', + description: 'Guide Claude on the expected size of workflow plans', + hint: 'Hints preferred plan scale: small/medium/large/unrestricted', + blurb: 'plan scale hint', + kind: 'enum', + target: { type: 'setting', key: 'workflowSizeGuideline' }, + values: ['small', 'medium', 'large', 'unrestricted'], + recommended: false, + defaultValue: undefined, + }, + { + id: 'default-model', + label: 'Default model', + description: 'Override the default model for Claude Code', + hint: 'Sets ANTHROPIC_DEFAULT_MODEL — overrides session-level model selection', + blurb: 'override default model', + kind: 'string', + target: { type: 'env', key: 'ANTHROPIC_DEFAULT_MODEL' }, + recommended: false, + defaultValue: undefined, + maxLength: 64, + }, + { + // Upstream default: 30 min. 0 = disabled (still ACTIVE — written to env). + // PF-023 bounds: max 1440 (24h). min 0 (0 = off, explicit value not neutral). + id: 'goal-checkin-minutes', + label: 'Goal check-in interval', + description: 'Interval in minutes for Claude to check in on task goals', + hint: 'Periodic goal check-ins every N min; 0 = off; upstream default is 30', + blurb: 'goal check-in interval', + kind: 'number', + target: { type: 'env', key: 'CLAUDE_CODE_GOAL_CHECKIN_MINUTES' }, + recommended: false, + defaultValue: undefined, + min: 0, // 0 = off (ACTIVE, not neutral — written as "0") + max: 1440, // devflow sanity bound: 24 hours (applies PF-023) + integer: true, + upstreamDefault: 30, + }, + { + // Writes as { command: value } per Claude Code spellcheck setting shape. + id: 'spellcheck', + label: 'Spellcheck command', + description: 'Custom spellcheck command for Claude Code', + hint: 'Sets the external spell-check command (written as {command: ...})', + blurb: 'external spell-check command', + kind: 'string', + target: { type: 'setting', key: 'spellcheck' }, + recommended: false, + defaultValue: undefined, + wrapKey: 'command', + maxLength: 256, // devflow sanity bound (applies PF-023) + }, + { + // view-mode folded into the registry; neutralValue 'default' deletes the viewMode key. + // VIEW_MODES, ViewMode, resolveExistingViewMode, and resolveFinalViewMode remain exported + // for init.ts and other callers that read/resolve view-mode in the settings pipeline. + id: 'view-mode', + label: 'View mode', + description: 'Interface view mode (default / verbose / focus)', + hint: "Controls view mode; 'default' removes the key (Claude Code native default)", + blurb: 'interface view mode', + kind: 'enum', + target: { type: 'setting', key: 'viewMode' }, + values: ['default', 'verbose', 'focus'], + valueHints: { + default: 'Standard view (no override)', + verbose: 'Show all tool output and reasoning', + focus: 'Minimal UI — hides secondary panels', + }, + neutralValue: 'default', + recommended: false, + defaultValue: 'default', + }, ]; +// Pre-built lookup for O(1) flag-by-id access. +const FLAG_REGISTRY_MAP = new Map( + FLAG_REGISTRY.map(f => [f.id, f]), +); + /** - * Return IDs of all flags that are enabled by default. + * O(1) flag lookup backed by FLAG_REGISTRY_MAP. + * Returns undefined when the id is not in the registry. */ -export function getDefaultFlags(): string[] { - return FLAG_REGISTRY.filter(f => f.defaultEnabled).map(f => f.id); +export function findFlag(id: string): ClaudeCodeFlag | undefined { + return FLAG_REGISTRY_MAP.get(id); } +// ─── Core value helpers ─────────────────────────────────────────────────────── + /** - * Apply enabled flags to a settings JSON string. - * Sets env vars for env-type flags and top-level keys for setting-type flags. - * Ignores unknown flag IDs (forward-compatible with old manifests). + * Returns the neutral value for a flag — the value that means "no preference" + * (applying neutral deletes the target key). + * + * - boolean: false (false = off = no key written) + * - enum: neutralValue if defined, else null + * - number: null (no number, including 0, is neutral — 0 is ACTIVE) + * - string: null */ -export function applyFlags(settingsJson: string, flagIds: string[]): string { - const settings = JSON.parse(settingsJson) as Record; - const flagMap = new Map(FLAG_REGISTRY.map(f => [f.id, f])); +export function neutralValueOf(flag: ClaudeCodeFlag): FlagsRecordValue { + switch (flag.kind) { + case 'boolean': return false; + case 'enum': return flag.neutralValue ?? null; + case 'number': return null; + case 'string': return null; + } +} - for (const id of flagIds) { - const flag = flagMap.get(id); - if (!flag) continue; +/** + * Returns true when `value` is the neutral value for `flag`. + * null is always neutral. Number 0 is NOT neutral. + */ +export function isNeutral(flag: ClaudeCodeFlag, value: FlagsRecordValue): boolean { + if (value === null) return true; + return value === neutralValueOf(flag); +} - if (flag.target.type === 'env') { - settings.env ??= {}; - (settings.env as Record)[flag.target.key] = flag.target.value; +/** + * Map a record value to a TUI value. + * + * viewMode GLUE RULE (PF-017 one-shared-definition corollary): the mapping lives here, + * next to neutralValueOf — the definition it depends on — not across a module boundary. + * enum with neutralValue: neutralValue → null in TUI (null is the TUI representation + * of "use the default"; the key is deleted when persisted). + * All other values pass through unchanged. + * + * Consumers: flags-view/state.ts (buildFlagRows, buildDevflowDefault, collectFlagRecord). + */ +export function recordToTui(flag: ClaudeCodeFlag, v: FlagsRecordValue): FlagsRecordValue { + if (v === null) return null; + if (flag.kind === 'enum' && flag.neutralValue !== undefined) { + if (v === flag.neutralValue) return null; + } + return v; +} + +/** + * Map a TUI value back to a record value. + * + * viewMode GLUE RULE (PF-017 one-shared-definition corollary): inverse of recordToTui, + * co-located with that function so the round-trip contract is auditable in one place. + * enum with neutralValue: null → neutralValue (e.g. 'default'). + * All other values pass through unchanged. + * + * Consumers: flags-view/state.ts (collectFlagRecord). + */ +export function tuiToRecord(flag: ClaudeCodeFlag, v: FlagsRecordValue): FlagsRecordValue { + if (v === null && flag.kind === 'enum' && flag.neutralValue !== undefined) { + return flag.neutralValue; + } + return v; +} + +/** + * Validate and coerce `raw` to a safe value for `flag` at the sink. + * Returns null when the value is invalid (hostile-value defence — applies PF-023). + * + * Number invariants: finite, within [min, max], integer when required. + * String invariants: within maxLength, no control characters. + * Enum invariants: value must be in the declared values array. + * Boolean invariants: must be a boolean. + */ +export function coerceFlagValue(flag: ClaudeCodeFlag, raw: unknown): FlagsRecordValue { + if (raw === null || raw === undefined) return null; + + switch (flag.kind) { + case 'boolean': { + if (typeof raw !== 'boolean') return null; + return raw; + } + case 'enum': { + if (typeof raw !== 'string') return null; + if (!(flag.values as readonly string[]).includes(raw)) return null; + return raw; + } + case 'number': { + if (typeof raw !== 'number') return null; + if (!Number.isFinite(raw)) return null; // rejects Infinity, NaN, 1e309 + if (flag.min !== undefined && raw < flag.min) return null; + if (flag.max !== undefined && raw > flag.max) return null; + if (flag.integer === true && !Number.isInteger(raw)) return null; + return raw; + } + case 'string': { + if (typeof raw !== 'string') return null; + // Empty string is UNSET, never an active value — caller should pass null for unset. + if (raw === '') return null; + if (flag.maxLength !== undefined && raw.length > flag.maxLength) return null; + // Reject ASCII control chars except \t (horizontal tab is benign in commands). + // LF (\x0a) MUST be rejected: `spellcheck` is executed as a shell command, where + // a newline is a statement separator, and the --status table is line-oriented. + // The range \x0a-\x1f covers LF through US, with \x09 (TAB) as the sole omission. + if (/[\x00-\x08\x0a-\x1f\x7f]/.test(raw)) return null; + return raw; + } + } +} + +/** + * Parse a CLI text input to a FlagsRecordValue. + * 'unset' (literal) → null for any flag. + * + * Number branch uses strict decimal grammar (applies PF-023 — invariant at the sink + * every caller reaches, not per-caller): rejects empty, padded, hex, exponent, + * and leading-zero forms. Equivalent to the TUI's strict parsing so both entry + * points share one grammar. + * + * String branch: empty string → null (empty is UNSET, not an active value). + */ +export function parseFlagValueInput(flag: ClaudeCodeFlag, text: string): FlagsRecordValue { + if (text === 'unset') return null; + switch (flag.kind) { + case 'boolean': { + if (text === 'true') return true; + if (text === 'false') return false; + return null; + } + case 'enum': + return coerceFlagValue(flag, text); + case 'number': { + // Strict decimal grammar: reject empty, padded, hex, exponent, and leading zeros. + // Number('') === 0, Number(' 3 ') === 3, Number('0x5') === 5, Number('1e1') === 10 — + // all would pass bare Number() but violate the strict grammar contract. + if (text === '' || text !== text.trim()) return null; + if (!/^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(text)) return null; + return coerceFlagValue(flag, Number(text)); + } + case 'string': + // Empty string → null (empty is UNSET); coerceFlagValue handles the rest. + return coerceFlagValue(flag, text); + } +} + +/** + * Returns a human-readable kind label for a flag — used by --list output. + * + * Exhaustive switch (no default): TypeScript narrows on `flag.kind` so + * each branch sees the narrowed subtype directly. + * + * Output examples: + * boolean → 'boolean' + * enum [small|medium|large|…] → 'enum [small|medium|large|…]' + * number min=1 max=100 integer → 'number min=1 max=100 integer' + * string maxLen=64 → 'string maxLen=64' + */ +export function describeFlagKind(flag: ClaudeCodeFlag): string { + switch (flag.kind) { + case 'boolean': + return 'boolean'; + case 'enum': + return `enum [${flag.values.join('|')}]`; + case 'number': { + const parts: string[] = []; + if (flag.min !== undefined) parts.push(`min=${flag.min}`); + if (flag.max !== undefined) parts.push(`max=${flag.max}`); + if (flag.integer) parts.push('integer'); + return `number${parts.length ? ' ' + parts.join(' ') : ''}`; + } + case 'string': + return `string${flag.maxLength !== undefined ? ` maxLen=${flag.maxLength}` : ''}`; + } +} + +/** + * Returns the expected-input hint shown by --set when a value is invalid. + * + * Exhaustive switch: TypeScript narrows each arm directly. + * + * Output examples: + * boolean → 'true|false|unset' + * enum → 'small|medium|large|unrestricted|unset' + * number → 'a valid number value or unset' + * string → 'a valid string value or unset' + */ +export function expectedInputFor(flag: ClaudeCodeFlag): string { + switch (flag.kind) { + case 'boolean': + return 'true|false|unset'; + case 'enum': + return `${flag.values.join('|')}|unset`; + case 'number': + return 'a valid number value or unset'; + case 'string': + return 'a valid string value or unset'; + } +} + +/** + * Effective display result — plain text plus a flag indicating whether + * the text came from a default rather than an explicit value. + */ +export interface EffectiveDisplay { + /** Human-readable label (never 'unset'). */ + readonly text: string; + /** True when the value is neutral/null and `text` comes from the default. */ + readonly isDefault: boolean; +} + +/** + * Returns the effective display for a flag value — the single source of truth + * for every render site (TUI cell, --status rows, --list default label, + * --enable/--disable confirmation text). + * + * Never returns 'unset' — always shows what the flag effectively does: + * boolean true → { text: 'on', isDefault: false } + * boolean false/null → { text: 'off', isDefault: true } + * enum active value → { text: value, isDefault: false } + * enum neutral/null → { text: neutralValue ?? '—', isDefault: true } + * number active value → { text: String(value), isDefault: false } + * number null → { text: String(defaultValue ?? upstreamDefault), isDefault: true } + * or { text: '—', isDefault: true } when no default exists + * string active value → { text: value, isDefault: false } + * string null → { text: '—', isDefault: true } + * + * D-EFFDV: one-definition seam consumed by formatFlagValue, render.ts formatValue, + * formatStatusRows (Site D), and handleList defaultLabel (Site C). + * All display sites must route through here — never re-derive independently. + */ +export function effectiveDisplay(flag: ClaudeCodeFlag, value: FlagsRecordValue): EffectiveDisplay { + // Boolean: true = 'on' (active), false/null = 'off' (neutral but meaningful) + if (flag.kind === 'boolean') { + const active = value === true; + return { text: active ? 'on' : 'off', isDefault: !active }; + } + + // Active non-null non-neutral value for enum/number/string + if (value !== null && !isNeutral(flag, value)) { + return { text: String(value), isDefault: false }; + } + + // Neutral or null: show what the flag effectively defaults to + switch (flag.kind) { + case 'enum': { + const neutral = flag.neutralValue ?? '—'; + return { text: neutral, isDefault: true }; + } + case 'number': { + const def = flag.defaultValue ?? flag.upstreamDefault; + return { text: def !== undefined ? String(def) : '—', isDefault: true }; + } + case 'string': + return { text: '—', isDefault: true }; + } +} + +/** + * Format a flag value for display (CLI output, confirmation messages). + * + * Delegates to effectiveDisplay — see its JSDoc for the full vocabulary. + * D-EFFDV: one definition, all sites route through effectiveDisplay. + * + * NOTE: --set confirmation for an explicit 'unset' input should special-case + * null → literal 'unset' at the call site, since the user named it explicitly. + */ +export function formatFlagValue(flag: ClaudeCodeFlag, value: FlagsRecordValue): string { + return effectiveDisplay(flag, value).text; +} + +/** + * Count flags in `record` that have active (non-neutral) values. + * Unknown IDs are counted if their value is truthy. + */ +export function countActiveFlags(record: FlagsRecord): number { + let count = 0; + for (const [id, value] of Object.entries(record)) { + if (value === null) continue; + const flag = FLAG_REGISTRY_MAP.get(id); + if (flag) { + if (!isNeutral(flag, value)) count++; + } else if (value) { + // Unknown flag ID: count if truthy + count++; + } + } + return count; +} + +/** + * Read the view-mode from a FlagsRecord. + * Returns 'default' when the entry is absent, null, or unrecognised. + */ +export function readViewMode(record: FlagsRecord): ViewMode { + const v = record['view-mode']; + if (typeof v === 'string' && (VIEW_MODES as readonly string[]).includes(v)) { + return v as ViewMode; + } + return 'default'; +} + +/** + * Sanitize a FlagsRecord by coercing each known flag's value through + * coerceFlagValue. + * + * Known flag IDs (applies ADR-014 key-presence semantics): + * - explicit null input → kept as null (deliberately unset) + * - valid non-null input → kept as coerced value + * - invalid non-null input → KEY DROPPED (absent = adopt default on next init, + * which is safer than writing null = "deliberately unset" for a corrupt value) + * + * Unknown flag IDs (forward-compat): + * - primitive values (boolean, number, string, null) → kept as-is + * - non-primitive values (objects, arrays) → DROPPED to avoid laundering + * untrusted shapes into FlagsRecordValue (applies PF-023) + * + * D39: `__proto__`, `constructor`, `prototype` are always skipped. + */ +export function sanitizeFlagsRecord(record: FlagsRecord): FlagsRecord { + const result: FlagsRecord = {}; + for (const [id, value] of Object.entries(record)) { + // D39: prototype pollution guard — skip dangerous own-property names that + // would invoke [[Set]] accessors on the result object and mutate its prototype. + if (id === '__proto__' || id === 'constructor' || id === 'prototype') continue; + const flag = FLAG_REGISTRY_MAP.get(id); + if (flag) { + if (value === null) { + // Explicit null = deliberately unset: preserve key-presence semantics. + result[id] = null; + } else { + const coerced = coerceFlagValue(flag, value); + if (coerced !== null) { + result[id] = coerced; + } + // else: invalid non-null value → DROP the key so the flag is re-adopted + // on next init from registry defaults (safer than writing null = "unset"). + } + } else { + // Unknown id: forward-compat pass-through for primitive/null values only. + // Non-primitive values (objects, arrays) are dropped — laundering an + // arbitrary object into FlagsRecordValue violates the type contract. + if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { + result[id] = value; + } + } + } + return result; +} + +// ─── Record builders ────────────────────────────────────────────────────────── + +/** + * Per-kind default-value rule (single authoritative source — CONS-M2). + * + * - boolean: flag.defaultValue (always a boolean — never collapses to null) + * - enum / number / string: flag.defaultValue ?? null + * (undefined defaultValue → null = adopt-on-next-init semantics) + * + * Call sites: getDefaultFlagsRecord, resolveSeedFlags (init-seed.ts), + * buildDevflowDefault (flags-view/state.ts). Adding a fifth kind or changing + * the null-collapse rule requires updating only this function. + */ +export function defaultValueOf(flag: ClaudeCodeFlag): FlagsRecordValue { + return flag.kind === 'boolean' ? flag.defaultValue : (flag.defaultValue ?? null); +} + +/** + * Return a FlagsRecord with every registered flag set to its defaultValue. + * Flags with undefined defaultValue get null. + * This record has an entry for EVERY flag — use it for initial seeding. + */ +export function getDefaultFlagsRecord(): FlagsRecord { + return Object.fromEntries(FLAG_REGISTRY.map(f => [f.id, defaultValueOf(f)])); +} + +// ─── Migration helper ───────────────────────────────────────────────────────── + +/** + * Migrate a legacy (string-array) enabled-flags manifest to a typed FlagsRecord. + * Called by manifest.ts self-healing when it encounters an old string-array manifest. + * + * Contract (applies ADR-014 transition semantics): + * - knownIds defined → knownSet = knownIds ∪ enabledIds + * - knownIds undefined → knownSet = full current registry ∪ enabledIds + * (pre-knownFlags manifests: all flags known, so adopt-nothing is expressed + * as value = enabledIds.includes(id) rather than absent entry) + * - Boolean registry flags in knownSet → value = enabledIds.includes(id) + * - Registry flags NOT in knownSet → NO entry (adopted on next seed) + * - Unknown enabled IDs (not in registry) → `true` preserved + * - viewMode fold: 'view-mode' = legacyViewMode ?? 'default' + */ +export function migrateLegacyFlagsToRecord( + enabledIds: string[], + knownIds?: string[], + legacyViewMode?: ViewMode, +): FlagsRecord { + const enabledSet = new Set(enabledIds); + + const knownSet: Set = + knownIds !== undefined + ? new Set([...knownIds, ...enabledIds]) + : new Set([...FLAG_REGISTRY.map(f => f.id), ...enabledIds]); + + const result: FlagsRecord = {}; + + for (const flag of FLAG_REGISTRY) { + // view-mode is handled separately at the end + if (flag.id === 'view-mode') continue; + + if (!knownSet.has(flag.id)) { + // Not known at last install → NO entry (will be adopted on next seed) + continue; + } + + if (flag.kind === 'boolean') { + result[flag.id] = enabledSet.has(flag.id); + } else { + // Valued flags: legacy string arrays never contain them; null = neutral + result[flag.id] = null; + } + } + + // Unknown enabled IDs (not in any registry) preserved as true + for (const id of enabledIds) { + if (!FLAG_REGISTRY_MAP.has(id)) { + result[id] = true; + } + } + + // viewMode fold: always written so the view-mode entry is explicit + result['view-mode'] = legacyViewMode ?? 'default'; + + return result; +} + +// ─── Apply / Strip ──────────────────────────────────────────────────────────── + +/** + * Return `v` as a `Record` only when it is a plain object. + * Returns undefined for arrays, null, or non-objects. + * + * Used as a guard at every `settings.env` access point so that a malformed + * `"env": []` in settings.json cannot cause `Object.keys([]).length === 0` + * to delete the entire env key, losing user-set env vars (applies TS-M3). + */ +function asPlainObject(v: unknown): Record | undefined { + return typeof v === 'object' && v !== null && !Array.isArray(v) + ? (v as Record) + : undefined; +} + +/** Compute the value to write to settings.json for an active flag. */ +function buildPayload(flag: ClaudeCodeFlag, value: FlagValue): unknown { + switch (flag.kind) { + case 'boolean': + return flag.onPayload; + case 'enum': + return value as string; + case 'number': + // Env targets receive string values; setting targets receive numbers. + return flag.target.type === 'env' ? String(value as number) : value; + case 'string': { + const s = value as string; + return flag.wrapKey ? { [flag.wrapKey]: s } : s; + } + } +} + +/** + * Apply a FlagsRecord to a settings JSON string. + * + * - Unknown flag IDs are skipped (forward-compatible with future flags). + * - `coerceFlagValue` is called at the sink before applying (applies PF-023). + * - Neutral values delete their target key. + * - Env payloads for number flags are stringified ('40', never 40). + * - Setting payloads for string flags with wrapKey are shaped ({ command: v }). + * - env object is created on demand; deleted when it becomes empty. + * - `__proto__`, `constructor`, `prototype` keys are silently skipped. + */ +export function applyFlags(settingsJson: string, flags: FlagsRecord): string { + // REL-M2 sink guard (applies PF-023): a non-plain-object root (null, array, scalar) + // would cause a silent no-op or a confusing TypeError deep inside the loop. + // Throw early with a clear message so every caller path is self-guarding. + const root = JSON.parse(settingsJson); + if (root === null || typeof root !== 'object' || Array.isArray(root)) { + throw new Error('applyFlags: settings.json root must be a plain object'); + } + const settings = root as Record; + + for (const [id, value] of Object.entries(flags)) { + // Prototype pollution guard + if (id === '__proto__' || id === 'constructor' || id === 'prototype') continue; + + const flag = FLAG_REGISTRY_MAP.get(id); + if (!flag) continue; // unknown id — skip for forward compat + + // Coerce at the sink (applies PF-023: validate at the convergence point) + const safe = coerceFlagValue(flag, value); + + if (isNeutral(flag, safe)) { + // Neutral → delete the target key + if (flag.target.type === 'env') { + // asPlainObject guard: "env": [] must not delete a user's env var (applies TS-M3) + const env = asPlainObject(settings.env); + if (env) delete env[flag.target.key]; + } else { + delete settings[flag.target.key]; + } } else { - settings[flag.target.key] = flag.target.value; + const payload = buildPayload(flag, safe as FlagValue); + if (flag.target.type === 'env') { + if (!asPlainObject(settings.env)) { + settings.env = {}; + } + (settings.env as Record)[flag.target.key] = payload; + } else { + settings[flag.target.key] = payload; + } } } + // Clean up empty env object; asPlainObject guard avoids matching "env": [] + const env = asPlainObject(settings.env); + if (env && Object.keys(env).length === 0) { + delete settings.env; + } + return JSON.stringify(settings, null, 2) + '\n'; } /** * Strip all flag-managed keys from a settings JSON string. - * Removes env vars and top-level settings controlled by the flag registry. - * Cleans up empty env object when last entry is removed. + * Registry-driven unconditional delete. Now covers viewMode (via view-mode + * registry entry) and object-valued settings (spellcheck → key deleted). + * Cleans up empty env object. Strip-then-apply idempotence preserved (INV-1). */ export function stripFlags(settingsJson: string): string { - const settings = JSON.parse(settingsJson) as Record; - - const env = settings.env as Record | undefined; + // REL-M2 sink guard (applies PF-023): mirror of applyFlags — throw early on a + // non-plain-object root so every caller path is self-guarding. + const root = JSON.parse(settingsJson); + if (root === null || typeof root !== 'object' || Array.isArray(root)) { + throw new Error('stripFlags: settings.json root must be a plain object'); + } + const settings = root as Record; + // asPlainObject guard: "env": [] must not have its keys iterated as an object (applies TS-M3) + const env = asPlainObject(settings.env); for (const flag of FLAG_REGISTRY) { if (flag.target.type === 'env') { - if (env) { - delete env[flag.target.key]; - } + if (env) delete env[flag.target.key]; } else { delete settings[flag.target.key]; } @@ -244,6 +1085,8 @@ export function stripFlags(settingsJson: string): string { return JSON.stringify(settings, null, 2) + '\n'; } +// ─── viewMode helpers ───────────────────────────────────────────────────────── + const VIEW_MODE_KEY = 'viewMode'; /** All valid view mode values. Used for validation at manifest read boundaries. */ @@ -252,31 +1095,6 @@ export const VIEW_MODES = ['default', 'verbose', 'focus'] as const; /** The viewMode field type — a narrowed union of the three supported modes. */ export type ViewMode = (typeof VIEW_MODES)[number]; -/** - * Apply a view mode to a settings JSON string. - * 'default' removes the viewMode key (Claude Code default behaviour); - * 'verbose' and 'focus' set the key explicitly. - */ -export function applyViewMode(settingsJson: string, mode: ViewMode): string { - const settings = JSON.parse(settingsJson) as Record; - if (mode === 'default') { - delete settings[VIEW_MODE_KEY]; - } else { - settings[VIEW_MODE_KEY] = mode; - } - return JSON.stringify(settings, null, 2) + '\n'; -} - -/** - * Strip the viewMode key from a settings JSON string. - * Used during uninstall / flag strip to restore Claude Code defaults. - */ -export function stripViewMode(settingsJson: string): string { - const settings = JSON.parse(settingsJson) as Record; - delete settings[VIEW_MODE_KEY]; - return JSON.stringify(settings, null, 2) + '\n'; -} - /** * Extract the non-default view mode from a settings JSON string. * @@ -311,20 +1129,11 @@ export function resolveExistingViewMode(settingsJson: string): ViewMode | undefi /** * Resolve the final view mode to write, combining an existing settings value, - * an init-prompt-selected value, and whether the selection was explicit (via - * CLI flag) or implicit (prompt default / recommended path). - * - * @param current - Existing viewMode from settings.json (resolveExistingViewMode). - * undefined means no opinion in the current settings. - * @param selected - What the init prompt (or recommended path) would use. - * @param explicit - true when the user made an explicit interactive selection in - * the Advanced init prompt, or when --reset was passed (which - * forces viewMode back to 'default' and sets explicit=true so - * that 'default' wins over any externally-set value). + * an init-prompt-selected value, and whether the selection was explicit. * * Rules: * 1. explicit ⇒ selected wins (user intent is unambiguous, even 'default') - * 2. non-default current ⇒ current wins (preserve externally-set mode, e.g. /focus) + * 2. non-default current ⇒ current wins (preserve externally-set mode) * 3. else ⇒ selected */ export function resolveFinalViewMode( @@ -336,3 +1145,137 @@ export function resolveFinalViewMode( if (current !== undefined && current !== 'default') return current; return selected; } + +// ─── Fold-before-strip pipeline ─────────────────────────────────────────────── + +/** + * Fold-before-strip pipeline — the single authoritative entry point for all + * settings.json mutation paths (applies PF-015, PF-017, ADR-014). + * + * Both `init.ts` and `persistFlagConfig` (flags.ts) MUST call this instead of + * invoking `stripFlags` + `applyFlags` directly; the invariant lives in the + * pipeline, not at call sites. + * + * Fold semantics (D15-adopt): + * + * view-mode (Step 1): resolved via `resolveFinalViewMode` so an externally-set + * `/focus` survives unless `viewModeExplicit` is true. + * + * Valued flags — enum/number/string, excluding view-mode (Step 2): + * The "claimed" set is determined by `opts.ownedRecord`: + * - `undefined` → use `record` itself (persistFlagConfig path — the manifest + * record IS what devflow claims) + * - `null` → nothing previously owned (fresh install) + * - `FlagsRecord`→ the original manifest flags BEFORE seeding (init path) + * + * A flag is "claimed" when it is present and non-null in the claimed set. + * Claimed: record value wins (devflow previously set this value). + * Unclaimed: fold from settings — if the user has a value in settings.json, + * adopt it into the record (ADR-014 adoption, devflow takes ownership). + * + * Boolean flags: never folded — on/off is always record-driven. + * + * The fold MUST run on pre-strip content — `stripFlags` removes the target + * keys, making any fold after strip vacuous. + * + * Uninstall note: `src/cli/commands/uninstall.ts` calls `stripFlags` directly + * with no record argument, preserving its full-sweep semantics. Do not change. + * + * Pure function: no I/O. + * + * @param settingsJson Current settings.json content (pre-strip) + * @param record FlagsRecord to fold into and apply + * @param opts.viewModeExplicit true when the caller explicitly selected a view + * mode (TUI row changed or `--set view-mode=...` passed) + * @param opts.ownedRecord Prior ownership set; see semantics above. + * Init path: `existingManifest?.features.flags ?? null`. + * persistFlagConfig path: omit (undefined). + * @returns `{ settings: updated JSON string, record: folded FlagsRecord }` + */ +export function convergeFlagsIntoSettings( + settingsJson: string, + record: FlagsRecord, + opts: { + viewModeExplicit: boolean; + ownedRecord?: FlagsRecord | null; + }, +): { settings: string; record: FlagsRecord } { + // ── Step 1: fold view-mode (must read pre-strip) ────────────────────────── + // PF-015: resolveExistingViewMode reads the viewMode key. stripFlags removes + // it as part of the view-mode registry entry. Reading after strip silently + // reverts an externally-set /focus. + const folded: FlagsRecord = { + ...record, + 'view-mode': resolveFinalViewMode( + resolveExistingViewMode(settingsJson), + readViewMode(record), + opts.viewModeExplicit, + ), + }; + + // ── Step 2: fold existing values for valued flags (pre-strip) ──────────── + // D15-adopt: for unclaimed valued flags, read the current settings value and + // adopt it into the record. Claimed flags (previously set by devflow) keep + // their record value; boolean flags are never folded. + // + // "Claimed" is determined by opts.ownedRecord: + // undefined → use `record` (persistFlagConfig: manifest record = owned set) + // null → nothing claimed (fresh install) + // FlagsRecord → original manifest flags before seeding (init path) + const claimedIn: FlagsRecord | null = + opts.ownedRecord !== undefined ? opts.ownedRecord : record; + + let parsed: Record; + try { + parsed = JSON.parse(settingsJson) as Record; + } catch { + parsed = {}; + } + const env = asPlainObject(parsed.env); + + for (const flag of FLAG_REGISTRY) { + if (flag.kind === 'boolean') continue; // boolean flags: record-driven only + if (flag.id === 'view-mode') continue; // already handled above + + // Check whether devflow previously owned this flag's key. + // Any presence in claimedIn — including null (explicitly unset) — means + // devflow owns the slot; the record value (or its absence) wins over settings. + // Absence from claimedIn means devflow never wrote it → fold from settings. + const previouslyOwned = + claimedIn !== null && + Object.prototype.hasOwnProperty.call(claimedIn, flag.id); + if (previouslyOwned) continue; + + // Read the raw value from settings.json (before strip removes it) + const rawVal = + flag.target.type === 'env' + ? env?.[flag.target.key] + : parsed[flag.target.key]; + if (rawVal === undefined) continue; + + // Unwrap wrapKey-shaped values (e.g., spellcheck: { command: 'hunspell' } → 'hunspell') + let toCoerce: unknown = rawVal; + if (flag.kind === 'string' && flag.wrapKey !== undefined) { + const obj = asPlainObject(rawVal); + toCoerce = obj !== undefined ? obj[flag.wrapKey] : undefined; + } + if (toCoerce === undefined) continue; + + // Env vars store numbers as strings ('8') — convert to number for coercion + if (flag.kind === 'number' && typeof toCoerce === 'string') { + const n = Number(toCoerce); + toCoerce = Number.isFinite(n) ? n : toCoerce; + } + + const coerced = coerceFlagValue(flag, toCoerce); + if (coerced !== null) { + folded[flag.id] = coerced; + } + } + + // ── Step 3: strip all managed keys, then apply the folded record ────────── + const stripped = stripFlags(settingsJson); + const settings = applyFlags(stripped, folded); + + return { settings, record: folded }; +} diff --git a/src/core/manifest.ts b/src/core/manifest.ts index d5d08baf..4beb83be 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -1,8 +1,15 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import { LEGACY_PLUGIN_NAMES, DELETED_PLUGIN_NAMES } from './plugins.js'; -import { VIEW_MODES, ViewMode } from './flags.js'; +import { + VIEW_MODES, + type ViewMode, + type FlagsRecord, + migrateLegacyFlagsToRecord, + sanitizeFlagsRecord, +} from './flags.js'; import { normalizeComplianceFeature, type ComplianceFeatureState } from './compliance.js'; +import { writeFileAtomicExclusive } from './fs-atomic.js'; /** * Where the Devflow security deny list is installed. @@ -36,15 +43,14 @@ export interface ManifestData { /** Renamed from decisions — self-healed from features.decisions on read */ learning: boolean; rules: boolean; - flags: string[]; /** - * Snapshot of all FLAG_REGISTRY ids written at the last install. - * Used by resolveSeedFlags to detect new default-ON flags added to the - * registry since the previous install and auto-adopt them. - * Absent in pre-7b manifests — readManifest self-heals to undefined. + * Typed flag state record (keyed by flag id). + * Absent key = unknown to this install (adopted on next seed per ADR-014). + * Null value = known + deliberately unset (neutral). + * Boolean value = known + explicitly enabled (true) or disabled (false). + * Old string[] manifests are auto-migrated via migrateLegacyFlagsToRecord on read. */ - knownFlags?: string[]; - viewMode?: ViewMode; + flags: FlagsRecord; /** * Security deny list location. 'user' = ~/.claude/settings.json, * 'managed' = system-level managed settings, 'none' = not installed. @@ -68,8 +74,74 @@ export interface ManifestData { updatedAt: string; } +/** + * Parse features.flags across the three on-disk shapes; reports whether a heal is owed. + * + * Case A: string[] — legacy format, migrated to FlagsRecord (fold viewMode in). + * Case B: object — already a FlagsRecord, fold lingering viewMode if present. + * Case C: missing/other — default to empty record. + * + * The returned `legacy` flag is true only for Case A (array). Callers use it as the + * flags-specific clause of needsHeal, keeping the legacy-artifact knowledge in one + * place and letting needsHeal derive from the parse result instead of re-inspecting + * features.flags after the fact. + */ +function parseManifestFlags( + features: Record, + knownFlags: string[] | undefined, +): { flags: FlagsRecord; legacy: boolean } { + const rawFlags = features.flags; + + if (Array.isArray(rawFlags)) { + // Case A: string[] → FlagsRecord migration. + // Filter to strings only (malformed elements are silently dropped). + const enabledIds = (rawFlags as unknown[]).filter(e => typeof e === 'string') as string[]; + // Extract legacyViewMode for the migration fold. + const rawViewMode = features.viewMode; + const legacyViewMode = + typeof rawViewMode === 'string' && (VIEW_MODES as readonly string[]).includes(rawViewMode) + ? (rawViewMode as ViewMode) + : undefined; + return { flags: migrateLegacyFlagsToRecord(enabledIds, knownFlags, legacyViewMode), legacy: true }; + } + + if (rawFlags !== null && typeof rawFlags === 'object') { + // Case B: already a FlagsRecord. Spread to avoid mutating the parsed value. + // Single cast: rawFlags is already confirmed to be a non-null, non-array object. + // sanitizeFlagsRecord (called by the outer readManifest) validates all values, + // dropping invalid ones — so the double assertion is unnecessary here (applies TS-M3). + const flagsRecord: FlagsRecord = { ...(rawFlags as FlagsRecord) }; + // Fold lingering viewMode into flags['view-mode'] when the record lacks a + // non-default value (e.g. a manifest written by an older init that stored viewMode + // as a separate deprecated field alongside a FlagsRecord with view-mode:null). + const rawViewMode = features.viewMode; + if (typeof rawViewMode === 'string' && (VIEW_MODES as readonly string[]).includes(rawViewMode)) { + const existing = flagsRecord['view-mode']; + if (existing === null || existing === undefined || existing === 'default') { + flagsRecord['view-mode'] = rawViewMode as ViewMode; + } + } + return { flags: flagsRecord, legacy: false }; + } + + // Case C: missing/malformed → empty record + return { flags: {}, legacy: false }; +} + /** * Read and parse the manifest file. Returns null if missing or corrupt. + * + * Self-heals the following on-disk inconsistencies (applies ADR-014): + * - features.kb → features.knowledge rename + * - features.decisions → features.learning rename + * - features.flags as string[] → FlagsRecord (via migrateLegacyFlagsToRecord) + * - features.viewMode folded into flags['view-mode'] and stripped from result + * - features.knownFlags stripped from result (folded into FlagsRecord key-presence) + * - features.proxy absent → false + * - features.compliance absent/malformed → {enabled:false, frameworks:[]} + * + * D39: heal-write failure returns the migrated in-memory manifest (not null). + * The on-disk format remains unhealed; next read triggers another attempt. */ export async function readManifest(devflowDir: string): Promise { const manifestPath = path.join(devflowDir, 'manifest.json'); @@ -90,6 +162,7 @@ export async function readManifest(devflowDir: string): Promise Array.isArray(val) && (val as unknown[]).every(e => typeof e === 'string') ? (val as string[]) : undefined; - const knownFlags = asStringArray(features.knownFlags); const knownPlugins = asStringArray(data.knownPlugins); + // knownFlags is consumed here for migration; NOT carried into the returned manifest + const knownFlags = asStringArray(features.knownFlags); + + // ── Parse flags ──────────────────────────────────────────────────────────── + // Delegates to parseManifestFlags (three cases: A=string[], B=object, C=missing). + // `flagsWereLegacy` is true only when the on-disk shape was a string[] (Case A), + // keeping the needsHeal predicate in lockstep with the parse branch above. + const { flags: parsedFlags, legacy: flagsWereLegacy } = parseManifestFlags(features, knownFlags); + + // PF-023 + D39: sanitize all values; block prototype pollution keys. + const sanitizedFlags = sanitizeFlagsRecord(parsedFlags); + + // needsHeal when any legacy artifact is present on disk + const needsHeal = + features.kb !== undefined || + features.decisions !== undefined || + flagsWereLegacy || + features.knownFlags !== undefined || + features.viewMode !== undefined; + + const SECURITY_MODES = ['none', 'user', 'managed'] as const; const manifest: ManifestData = { version: data.version as string, @@ -123,18 +213,19 @@ export async function readManifest(devflowDir: string): Promise { await fs.mkdir(devflowDir, { recursive: true }); const manifestPath = path.join(devflowDir, 'manifest.json'); - await fs.writeFile(manifestPath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); + await writeFileAtomicExclusive(manifestPath, JSON.stringify(data, null, 2) + '\n'); } /** diff --git a/src/core/teammate-mode-cleanup.ts b/src/core/teammate-mode-cleanup.ts index edb08db9..902bbc7c 100644 --- a/src/core/teammate-mode-cleanup.ts +++ b/src/core/teammate-mode-cleanup.ts @@ -2,8 +2,8 @@ * Strip `teammateMode: "auto"` from a freshly parsed copy of the settings JSON. * Returns the serialised JSON string (with trailing newline). * - * Pure string→string — matches the pipeline pattern used by stripFlags / - * stripViewMode so uninstall.ts can chain it without a separate parse/stringify. + * Pure string→string — matches the pipeline pattern used by stripFlags so + * uninstall.ts can chain it without a separate parse/stringify. * Only removes the key when the value is exactly `"auto"`; user-set values * (`"tmux"`, `"in-process"`, etc.) are preserved as-is. * diff --git a/src/hud/colors.ts b/src/hud/colors.ts index a17b75e0..e724e19c 100644 --- a/src/hud/colors.ts +++ b/src/hud/colors.ts @@ -1,80 +1,28 @@ /** - * ANSI color helpers — no dependencies, precompiled escape sequences. - * Used by HUD components for direct terminal output (not @clack/prompts). + * ANSI color helpers — re-exported from src/core/ansi.ts. + * + * The canonical implementation lives in src/core/ansi.ts (agent-neutral home, + * applies ADR-013). This file is a re-export barrel so all existing HUD + * component call sites continue to resolve `../colors.js` without change. */ - -const ESC = '\x1b['; -const RESET = `${ESC}0m`; - -export function bold(s: string): string { - return `${ESC}1m${s}${RESET}`; -} -export function dim(s: string): string { - return `${ESC}2m${s}${RESET}`; -} -export function red(s: string): string { - return `${ESC}31m${s}${RESET}`; -} -export function green(s: string): string { - return `${ESC}32m${s}${RESET}`; -} -export function yellow(s: string): string { - return `${ESC}33m${s}${RESET}`; -} -export function blue(s: string): string { - return `${ESC}34m${s}${RESET}`; -} -export function magenta(s: string): string { - return `${ESC}35m${s}${RESET}`; -} -export function cyan(s: string): string { - return `${ESC}36m${s}${RESET}`; -} -export function gray(s: string): string { - return `${ESC}90m${s}${RESET}`; -} -export function white(s: string): string { - return `${ESC}37m${s}${RESET}`; -} -export function orange(s: string): string { - return `${ESC}38;5;208m${s}${RESET}`; -} -export function brightRed(s: string): string { - return `${ESC}91m${s}${RESET}`; -} -export function boldRed(s: string): string { - return `${ESC}1;31m${s}${RESET}`; -} -export function bgGreen(s: string): string { - return `${ESC}42m${s}${RESET}`; -} -export function bgYellow(s: string): string { - return `${ESC}43m${s}${RESET}`; -} -export function bgRed(s: string): string { - return `${ESC}41m${s}${RESET}`; -} - -export function truncate(s: string, max: number): string { - return s.length > max ? s.slice(0, max - 1) + '\u2026' : s; -} - -// S2 — Terminal-escape and control-character sanitization (HIGH, pre-existing defect). -// -// The prior pattern (/\x1b\[[0-9;]*m/g) matched only SGR sequences (colour). -// The broadened ANSI_PATTERN also covers: -// CSI sequences — \x1b[ ... with intermediate bytes, any final byte -// OSC sequences — \x1b] ... terminated by BEL (\x07) or ST (\x1b\\) -// Two-byte C1 — \x1b followed by any single character in the C1 range -// CTRL_PATTERN removes non-printable C0 control chars that are not TAB (\x09) -// or standard newlines (\x0a, \x0d). Together they prevent agent names -// embedded in model IDs from injecting escape sequences into --list output. - -const ANSI_PATTERN = - /\x1b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])/g; - -const CTRL_PATTERN = /[\x00-\x08\x0b-\x1f\x7f]/g; - -export function stripAnsi(s: string): string { - return s.replace(ANSI_PATTERN, '').replace(CTRL_PATTERN, ''); -} +export { + bold, + dim, + red, + green, + yellow, + blue, + magenta, + cyan, + gray, + white, + orange, + brightRed, + boldRed, + bgGreen, + bgYellow, + bgRed, + inverse, + truncate, + stripAnsi, +} from '../core/ansi.js'; diff --git a/tests/agents-render.test.ts b/tests/agents-render.test.ts index b669e01e..cc702f23 100644 --- a/tests/agents-render.test.ts +++ b/tests/agents-render.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect } from 'vitest'; -import { renderFrame, buildModelCycle, formatAgentName } from '../src/cli/agents-view/index.js'; +import { renderFrame, buildModelCycle, formatAgentName, FIXED_ROWS } from '../src/cli/agents-view/index.js'; import { stripAnsi, yellow } from '../src/hud/colors.js'; import type { AgentsViewState, AgentRow } from '../src/cli/agents-view/state.js'; import { type ExternalModelCatalog } from '../src/core/model-discovery.js'; @@ -518,6 +518,32 @@ describe('AC-P3-WIDTH: no line exceeds terminal width', () => { }); }); +// --------------------------------------------------------------------------- +// viewportHeight ownership — state.viewportHeight is the single owner (ARCH-M5) +// --------------------------------------------------------------------------- + +describe('viewportHeight ownership', () => { + it('renders exactly state.viewportHeight data rows regardless of dims.rows', () => { + // dims.rows=24 would give 24-FIXED_ROWS(9)=15 rows, but state says 2. + // After the ARCH-M5 fix, renderFrame reads state.viewportHeight directly. + const rows = [ + makeRow({ name: 'code', shippedDefault: 'sonnet' }), + makeRow({ name: 'design', shippedDefault: 'opus' }), + makeRow({ name: 'diagnose', shippedDefault: 'opus' }), + makeRow({ name: 'skim', shippedDefault: 'haiku' }), + makeRow({ name: 'git', shippedDefault: 'haiku' }), + ]; + const state = makeState({ + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: 2, + }); + const lines = renderFrame(state, { rows: 24, cols: 80 }); + expect(lines.length).toBe(FIXED_ROWS + 2); + }); +}); + // --------------------------------------------------------------------------- // Minimal / empty state // --------------------------------------------------------------------------- diff --git a/tests/flags-cli.test.ts b/tests/flags-cli.test.ts new file mode 100644 index 00000000..dca3edc3 --- /dev/null +++ b/tests/flags-cli.test.ts @@ -0,0 +1,1137 @@ +/** + * Phase 3 — flags CLI rewrite (createFlagsCommand factory). + * + * Harness follows the hud-enable-selfheal pattern: + * - vi.mock @clack/prompts (declared before imports — vitest hoisting requirement) + * - vi.stubEnv CLAUDE_CODE_DIR/DEVFLOW_DIR to temp dirs + * - Fresh Command instance per test via createFlagsCommand() + * - Real temp files on disk; async fs operations + * + * Whole-post-state discipline (applies PF-015 + ADR-003): ONE representative test + * per mutation verb (--enable, --disable, --set, --unset) asserts the COMPLETE + * settings.json object and COMPLETE manifest.features.flags record via toEqual. + * Other tests use key-picks for brevity on non-representative paths. + * + * Applies PF-014 (process.exitCode, never process.exit) — every error path sets + * process.exitCode = 1 and returns; tests reset exitCode in beforeEach/afterEach. + */ + +// --------------------------------------------------------------------------- +// Mocks — declared before module imports (vitest hoisting requirement) +// --------------------------------------------------------------------------- + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + }, + note: vi.fn(), + confirm: vi.fn(async () => false), + select: vi.fn(async () => 'cancel'), + isCancel: vi.fn(() => false), + cancel: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Imports AFTER mocks +// --------------------------------------------------------------------------- + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as p from '@clack/prompts'; +import type { Command } from 'commander'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { PassThrough } from 'stream'; +import { createFlagsCommand, applyTuiResult } from '../src/cli/commands/flags.js'; +import { makeManifest } from './helpers.js'; +import type { FlagsRecord } from '../src/core/flags.js'; +import { readManifest } from '../src/core/manifest.js'; +// Direct import from terminal.js (not index.js) so the REL-M3 mock of index.js +// does not affect the seam test's runFlagsTui reference (PF-017(c)). +import { runFlagsTui } from '../src/cli/flags-view/terminal.js'; +import { buildFlagRows } from '../src/cli/flags-view/state.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Canonical minimal manifest JSON for flags tests — flags: {} (fresh install). */ +function makeEmptyFlagsManifest(): string { + const m = makeManifest({ features: { ...makeManifest().features, flags: {} } }); + return JSON.stringify(m, null, 2) + '\n'; +} + +/** Manifest with a specific FlagsRecord. */ +function makeManifestWithFlags(flags: FlagsRecord): string { + const m = makeManifest({ features: { ...makeManifest().features, flags } }); + return JSON.stringify(m, null, 2) + '\n'; +} + +/** Parse a manifest JSON string and return features.flags. */ +function parseFlagsRecord(json: string): FlagsRecord { + return (JSON.parse(json) as { features: { flags: FlagsRecord } }).features.flags; +} + +/** Parse a settings JSON string and return the full parsed object. */ +function parseSettings(json: string): Record { + return JSON.parse(json) as Record; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('flags CLI — createFlagsCommand factory', () => { + let tmpClaudeDir: string; + let tmpDevflowDir: string; + let flagsCmd: Command; + + beforeEach(async () => { + tmpClaudeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flags-cli-claude-')); + tmpDevflowDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flags-cli-devflow-')); + + // vi.stubEnv tracks mutations; vi.unstubAllEnvs() in afterEach restores. + vi.stubEnv('CLAUDE_CODE_DIR', tmpClaudeDir); + vi.stubEnv('DEVFLOW_DIR', tmpDevflowDir); + + // Fresh command per test — avoids Commander option-value leakage between tests. + flagsCmd = createFlagsCommand(); + + // Reset exit code before each test (PF-014: commands set exitCode, not process.exit). + process.exitCode = 0; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + process.exitCode = 0; + await fs.rm(tmpClaudeDir, { recursive: true, force: true }); + await fs.rm(tmpDevflowDir, { recursive: true, force: true }); + }); + + // ─── --list ─────────────────────────────────────────────────────────────────── + + describe('--list', () => { + it('runs without error (no manifest required)', async () => { + // --list must work without a manifest — registry only + await flagsCmd.parseAsync(['--list'], { from: 'user' }); + expect(process.exitCode).toBe(0); + }); + + it('runs without error even when settings.json is absent', async () => { + // No settings.json, no manifest — still must succeed + await flagsCmd.parseAsync(['--list'], { from: 'user' }); + expect(process.exitCode).toBe(0); + }); + }); + + // ─── --status ───────────────────────────────────────────────────────────────── + + describe('--status', () => { + it('degrades gracefully when no manifest exists', async () => { + // No manifest.json — status must not set exitCode = 1 (degrade gracefully) + await flagsCmd.parseAsync(['--status'], { from: 'user' }); + expect(process.exitCode).toBe(0); + }); + + it('runs successfully with a manifest', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ tui: true, lsp: false }), + 'utf-8', + ); + await flagsCmd.parseAsync(['--status'], { from: 'user' }); + expect(process.exitCode).toBe(0); + }); + + it('whole-post-state: does not mutate files', async () => { + const initialManifest = makeManifestWithFlags({ tui: true }); + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), initialManifest, 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), '{}', 'utf-8'); + + await flagsCmd.parseAsync(['--status'], { from: 'user' }); + + // Files must be byte-identical — status is read-only + const manifestAfter = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + const settingsAfter = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + expect(manifestAfter).toBe(initialManifest); + expect(JSON.parse(settingsAfter)).toEqual({}); + }); + }); + + // ─── --enable (boolean only) ────────────────────────────────────────────────── + + describe('--enable', () => { + it('whole-post-state: enables a boolean flag (tui)', async () => { + // Start: empty manifest flags, no settings.json + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + // Whole-post-state: complete settings.json and complete flags record (PF-015). + // convergeFlagsIntoSettings always writes view-mode via resolveFinalViewMode, + // so the flags record also contains 'view-mode':'default' (neutral → not written + // to settings.json). toEqual on both artifacts catches unexpected extra writes. + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings).toEqual({ tui: 'fullscreen' }); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags).toEqual({ tui: true, 'view-mode': 'default' }); + }); + + it('whole-post-state: enabling an already-enabled flag is idempotent', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ tui: true }), + 'utf-8', + ); + // Apply tui setting to simulate already-enabled state + await fs.writeFile( + path.join(tmpClaudeDir, 'settings.json'), + JSON.stringify({ tui: 'fullscreen' }, null, 2) + '\n', + 'utf-8', + ); + + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings.tui).toBe('fullscreen'); + }); + + it('error on valued (non-boolean) flag via --enable', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + // 'max-concurrent-subagents' is a number flag — --enable must reject it + await flagsCmd.parseAsync(['--enable', 'max-concurrent-subagents'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + // Files must be byte-untouched (settings.json absent = no new file created) + const settingsExists = await fs.access(path.join(tmpClaudeDir, 'settings.json')) + .then(() => true) + .catch(() => false); + expect(settingsExists).toBe(false); + }); + + it('error on enum flag via --enable', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + await flagsCmd.parseAsync(['--enable', 'view-mode'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + + it('error: no manifest → abort with exit code 1', async () => { + // No manifest.json at all + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + + it('error: unknown flag id → exit code 1', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + await flagsCmd.parseAsync(['--enable', 'not-a-real-flag'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + }); + + // ─── --disable (boolean only) ───────────────────────────────────────────────── + + describe('--disable', () => { + it('whole-post-state: disabling tui removes the setting key (false = neutral)', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ tui: true }), + 'utf-8', + ); + // Pre-apply the tui setting so strip has something to remove + await fs.writeFile( + path.join(tmpClaudeDir, 'settings.json'), + JSON.stringify({ tui: 'fullscreen' }, null, 2) + '\n', + 'utf-8', + ); + + await flagsCmd.parseAsync(['--disable', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + // Whole-post-state: complete settings.json and complete flags record (PF-015). + // tui=false is neutral for boolean flags → tui key deleted from settings; + // empty result is {} (applyFlags cleans up empty env, same logic applies to root). + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings).toEqual({}); + + // manifest: tui: false (deliberately disabled — not absent) + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags).toEqual({ tui: false, 'view-mode': 'default' }); + }); + + it('error on valued flag via --disable', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + await flagsCmd.parseAsync(['--disable', 'workflow-size-guideline'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + + it('error: no manifest → abort', async () => { + await flagsCmd.parseAsync(['--disable', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + }); + + // ─── --set id=value ─────────────────────────────────────────────────────────── + + describe('--set', () => { + it('whole-post-state: set a number flag (max-concurrent-subagents=50)', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=50'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + // Whole-post-state: complete settings.json and complete flags record (PF-015). + // Starting from flags:{} with no settings.json → only the env entry is written. + // toEqual on the full object catches any unexpected keys written or omitted. + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings).toEqual({ env: { CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: '50' } }); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags).toEqual({ 'max-concurrent-subagents': 50, 'view-mode': 'default' }); + }); + + it('whole-post-state: set an enum flag (workflow-size-guideline=large)', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'workflow-size-guideline=large'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings.workflowSizeGuideline).toBe('large'); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags['workflow-size-guideline']).toBe('large'); + }); + + it('whole-post-state: set a string flag (default-model)', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'default-model=claude-haiku-4-5'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect((settings.env as Record)?.ANTHROPIC_DEFAULT_MODEL).toBe('claude-haiku-4-5'); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags['default-model']).toBe('claude-haiku-4-5'); + }); + + it('whole-post-state: set a boolean flag to true', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'brief=true'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect((settings.env as Record)?.CLAUDE_CODE_BRIEF).toBe('true'); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags['brief']).toBe(true); + }); + + it('view-mode=focus: writes viewMode setting + record entry', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'view-mode=focus'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings.viewMode).toBe('focus'); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags['view-mode']).toBe('focus'); + }); + + it('view-mode=default: key deleted from settings (default is neutral for view-mode)', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ 'view-mode': 'verbose' }), + 'utf-8', + ); + await fs.writeFile( + path.join(tmpClaudeDir, 'settings.json'), + JSON.stringify({ viewMode: 'verbose' }, null, 2) + '\n', + 'utf-8', + ); + + await flagsCmd.parseAsync(['--set', 'view-mode=default'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + // 'default' is neutralValue for view-mode → key deleted + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings.viewMode).toBeUndefined(); + + // Record: 'default' stored (neutral value is still recorded) + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags['view-mode']).toBe('default'); + }); + + it('split on first = only: spellcheck=a=b → value is "a=b"', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'spellcheck=a=b'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + // spellcheck has wrapKey: 'command' → written as { command: 'a=b' } + expect((settings.spellcheck as Record)?.command).toBe('a=b'); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags['spellcheck']).toBe('a=b'); + }); + + it('idempotent: second identical --set produces byte-identical settings.json', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=60'], { from: 'user' }); + const settingsAfterFirst = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + + // Fresh command instance to avoid state leakage + const flagsCmd2 = createFlagsCommand(); + vi.stubEnv('CLAUDE_CODE_DIR', tmpClaudeDir); + vi.stubEnv('DEVFLOW_DIR', tmpDevflowDir); + await flagsCmd2.parseAsync(['--set', 'max-concurrent-subagents=60'], { from: 'user' }); + const settingsAfterSecond = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + + expect(settingsAfterSecond).toBe(settingsAfterFirst); + }); + + // ─── Hostile inputs — exit code 1 AND files byte-untouched ─────────────── + + it('hostile: __proto__=x → unknown id → exit code 1, no files written', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + // No settings.json initially + await flagsCmd.parseAsync(['--set', '__proto__=x'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + // settings.json must not have been created + const settingsExists = await fs.access(path.join(tmpClaudeDir, 'settings.json')) + .then(() => true).catch(() => false); + expect(settingsExists).toBe(false); + }); + + it('hostile: max-concurrent-subagents=1e309 → not finite → exit code 1, files untouched', async () => { + const initialManifest = makeEmptyFlagsManifest(); + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), initialManifest, 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), '{}', 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=1e309'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + // Both files must be byte-identical to their initial state + const manifestAfter = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + const settingsAfter = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + expect(manifestAfter).toBe(initialManifest); + expect(settingsAfter).toBe('{}'); + }); + + it('hostile: max-concurrent-subagents=12; rm -rf / → NaN → exit code 1, files untouched', async () => { + const initialManifest = makeEmptyFlagsManifest(); + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), initialManifest, 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), '{}', 'utf-8'); + + // Semicolon is passed through by Commander as part of the value string + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=12; rm -rf /'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + const manifestAfter = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + const settingsAfter = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + expect(manifestAfter).toBe(initialManifest); + expect(settingsAfter).toBe('{}'); + }); + + it('unknown id → exit code 1, files untouched', async () => { + const initialManifest = makeEmptyFlagsManifest(); + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), initialManifest, 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), '{}', 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'no-such-flag=foo'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + const manifestAfter = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + const settingsAfter = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + expect(manifestAfter).toBe(initialManifest); + expect(settingsAfter).toBe('{}'); + }); + + it('malformed settings.json → exit code 1, manifest untouched', async () => { + const initialManifest = makeEmptyFlagsManifest(); + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), initialManifest, 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), 'not valid json', 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=50'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + // Manifest must be untouched + const manifestAfter = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + expect(manifestAfter).toBe(initialManifest); + }); + + it('no manifest → exit code 1', async () => { + // No manifest.json + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=50'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + }); + + // ─── --unset ids ────────────────────────────────────────────────────────────── + + describe('--unset', () => { + it('whole-post-state: unset a number flag → null in record, key deleted from settings', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ 'max-concurrent-subagents': 50 }), + 'utf-8', + ); + await fs.writeFile( + path.join(tmpClaudeDir, 'settings.json'), + JSON.stringify({ env: { CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: '50' } }, null, 2) + '\n', + 'utf-8', + ); + + await flagsCmd.parseAsync(['--unset', 'max-concurrent-subagents'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + // Whole-post-state: complete settings.json and complete flags record (PF-015). + // null is neutral for number flags → env key deleted; empty env block deleted too + // → settings becomes {} (applyFlags cleanup, line ~960-963 in flags.ts). + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings).toEqual({}); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + expect(flags).toEqual({ 'max-concurrent-subagents': null, 'view-mode': 'default' }); + }); + + it('whole-post-state: unset a boolean flag → false in record, key deleted from settings', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ tui: true }), + 'utf-8', + ); + await fs.writeFile( + path.join(tmpClaudeDir, 'settings.json'), + JSON.stringify({ tui: 'fullscreen' }, null, 2) + '\n', + 'utf-8', + ); + + await flagsCmd.parseAsync(['--unset', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + const settings = parseSettings(await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8')); + expect(settings.tui).toBeUndefined(); + + const flags = parseFlagsRecord(await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8')); + // boolean flag unset → false (neutral for boolean) + expect(flags['tui']).toBe(false); + }); + + it('error: no manifest → exit code 1', async () => { + await flagsCmd.parseAsync(['--unset', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + + it('error: unknown flag id → exit code 1', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + await flagsCmd.parseAsync(['--unset', 'no-such-flag'], { from: 'user' }); + expect(process.exitCode).toBe(1); + }); + }); + + // ─── bare TTY invocation — manifest guard (TS-H2 / ARCH-H2 / REL-H2 pin) ────── + // + // When BOTH process.stdin.isTTY and process.stdout.isTTY are true and the + // manifest is absent or corrupt, handleBare must hard-refuse BEFORE importing + // or launching the TUI. The fix: reuse loadFlagContext (the same guard mutating + // handlers use) at the top of the TTY branch. settings.json must NOT be touched. + // + // REL-H1: the predicate now requires BOTH stdin and stdout to be TTYs. + // + // RED proof: before the fix, handleBare seeds from {} and proceeds into the TUI + // import (or tries to), possibly writing settings.json; exitCode stays 0. + + describe('bare TTY invocation — manifest guard', () => { + let origStdoutIsTTY: boolean | undefined; + let origStdinIsTTY: boolean | undefined; + + beforeEach(() => { + origStdoutIsTTY = (process.stdout as { isTTY?: boolean }).isTTY; + origStdinIsTTY = (process.stdin as { isTTY?: boolean }).isTTY; + // REL-H1: both stdin AND stdout must be TTYs for the interactive path to engage. + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + vi.mocked(p.log.error).mockClear(); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: origStdoutIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: origStdinIsTTY, configurable: true }); + }); + + it('no manifest → hard-refuse, exitCode 1, p.log.error, settings.json not written', async () => { + // No manifest.json — loadFlagContext must fire before the TUI import. + await flagsCmd.parseAsync([], { from: 'user' }); + + expect(process.exitCode).toBe(1); + expect(vi.mocked(p.log.error)).toHaveBeenCalledWith( + expect.stringContaining('No devflow installation found'), + ); + // settings.json must NOT have been created — the guard fires before any write. + const settingsExists = await fs.access(path.join(tmpClaudeDir, 'settings.json')) + .then(() => true).catch(() => false); + expect(settingsExists, 'settings.json must not be written when manifest is absent').toBe(false); + }); + + it('corrupt manifest → hard-refuse, exitCode 1, p.log.error, settings.json not written', async () => { + // readManifest returns null for malformed JSON — same as absent (avoids PF-023). + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), 'not valid json', 'utf-8'); + + await flagsCmd.parseAsync([], { from: 'user' }); + + expect(process.exitCode).toBe(1); + expect(vi.mocked(p.log.error)).toHaveBeenCalledWith( + expect.stringContaining('No devflow installation found'), + ); + const settingsExists = await fs.access(path.join(tmpClaudeDir, 'settings.json')) + .then(() => true).catch(() => false); + expect(settingsExists, 'settings.json must not be written when manifest is unreadable').toBe(false); + }); + }); + + // ─── bare invocation — stdout TTY but stdin non-TTY → non-TTY path (REL-H1) ── + // + // REL-H1: the TUI predicate requires BOTH stdin AND stdout to be TTYs. + // When only stdout is a TTY (e.g. output redirected from a script that sets + // process.stdout.isTTY = true but pipes stdin), the non-TTY path is taken: + // status table to stdout, note to stderr, exitCode = 1, zero writes. + // + // RED proof: before the fix, the predicate checked only process.stdout.isTTY, + // so this scenario entered the interactive branch and attempted to open the TUI. + + describe('bare invocation — stdout TTY but stdin non-TTY → non-TTY path (REL-H1)', () => { + let origStdoutIsTTY: boolean | undefined; + + beforeEach(() => { + origStdoutIsTTY = (process.stdout as { isTTY?: boolean }).isTTY; + // Set stdout TTY but do NOT set stdin (stays undefined = falsy in vitest). + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: origStdoutIsTTY, configurable: true }); + }); + + it('status table to stdout, note to stderr, exitCode 1, zero writes', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + const manifestBefore = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + + const captured = { stdout: '', stderr: '' }; + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: string | Uint8Array) => { + if (typeof c === 'string') captured.stdout += c; + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((c: string | Uint8Array) => { + if (typeof c === 'string') captured.stderr += c; + return true; + }); + + try { + await flagsCmd.parseAsync([], { from: 'user' }); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + + // Non-TTY path: status table to stdout + note to stderr + expect(captured.stdout).toContain('tui'); + expect(captured.stderr).toContain('Note:'); + expect(process.exitCode).toBe(1); + // Zero writes — manifest must be byte-identical + const manifestAfter = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + expect(manifestAfter).toBe(manifestBefore); + }); + }); + + // ─── --set no-manifest: REG-SF2 pin ────────────────────────────────────────── + // + // --set must hard-error via loadFlagContext when no manifest exists, and + // settings.json must remain unwritten. This is REG-SF2: discriminated-result + // truthfulness covers the --set/--unset no-manifest surface. + + describe('--set no-manifest (REG-SF2)', () => { + it('no manifest → exitCode 1, settings.json not written', async () => { + // No manifest.json — loadFlagContext must abort before any write. + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=50'], { from: 'user' }); + + expect(process.exitCode).toBe(1); + const settingsExists = await fs.access(path.join(tmpClaudeDir, 'settings.json')) + .then(() => true).catch(() => false); + expect(settingsExists, 'settings.json must not be written when manifest is absent').toBe(false); + }); + }); + + // ─── bare non-TTY invocation ────────────────────────────────────────────────── + // + // src/cli/commands/flags.ts:509-520: when no args are passed and the terminal is not + // a TTY, the command prints a status table to stdout, one note to stderr, sets + // exitCode = 1, and writes NOTHING to disk. + // + // In the vitest environment process.stdout.isTTY is undefined (falsy) so the non-TTY + // branch is taken automatically when no other option flag is present. + + describe('bare non-TTY invocation', () => { + it('zero args → status table to stdout, note to stderr, exitCode 1, zero writes', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + const manifestBefore = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + + const captured = { stdout: '', stderr: '' }; + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: string | Uint8Array) => { + if (typeof c === 'string') captured.stdout += c; + return true; + }); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((c: string | Uint8Array) => { + if (typeof c === 'string') captured.stderr += c; + return true; + }); + + try { + await flagsCmd.parseAsync([], { from: 'user' }); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + + // Status table: one line per registry flag — stdout must contain a known flag id + expect(captured.stdout).toContain('tui'); + // Exactly one stderr note line + expect(captured.stderr).toContain('Note:'); + // Exit code must be 1 (non-TTY path always fails with a hint) + expect(process.exitCode).toBe(1); + // Zero writes — manifest must be byte-for-byte identical after the run + const manifestAfter = await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'); + expect(manifestAfter).toBe(manifestBefore); + }); + }); + + // ─── malformed settings.json guard ─────────────────────────────────────────── + + describe('malformed settings.json guard', () => { + it('--enable aborts on malformed settings.json (never silently clobbers)', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), 'not valid json at all', 'utf-8'); + + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + // settings.json must remain untouched (not silently clobbered with {}) + const settingsAfter = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + expect(settingsAfter).toBe('not valid json at all'); + }); + + it('--set aborts on malformed settings.json (never silently clobbers)', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), 'not valid json at all', 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=25'], { from: 'user' }); + expect(process.exitCode).toBe(1); + + // settings.json must remain byte-untouched (anti-clobber guard, same as --enable) + const settingsAfter = await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'); + expect(settingsAfter).toBe('not valid json at all'); + }); + + it('ENOENT settings.json → treated as {} (not an error)', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + // No settings.json — should succeed (ENOENT starts from {}) + + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + expect(process.exitCode).toBe(0); + }); + }); + + // ─── confirmation output under the REAL initial exitCode ───────────────────── + // + // Regression guard. Every other test in this file sets `process.exitCode = 0` + // in beforeEach, which does not reproduce a real CLI invocation: Node starts a + // process with `process.exitCode === undefined`, NOT 0. The mutating paths used + // to gate their confirmation output on `process.exitCode === 0`, so on a real + // run that test was false and `devflow flags --enable X` completed silently — + // it wrote both artifacts and told the user nothing. The harness normalised away + // the exact condition under which production failed (the PF-018 shape: a green + // test that cannot observe the defect it is meant to guard). + // + // These tests restore exitCode to `undefined` to reproduce a real invocation and + // assert on the emitted confirmation rather than on the exit code. + + describe('confirmation output (exitCode starts undefined, as in a real process)', () => { + beforeEach(() => { + vi.mocked(p.log.success).mockClear(); + process.exitCode = undefined; + }); + + /** Success lines emitted by the command under test. */ + function successLines(): string[] { + return vi.mocked(p.log.success).mock.calls.map(c => String(c[0])); + } + + it('--enable emits a success line on a clean run', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + + expect(successLines()).toContain('tui on'); + expect(process.exitCode).toBeFalsy(); // undefined or 0 — never 1 + }); + + it('--disable emits a success line on a clean run', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ tui: true }), + 'utf-8', + ); + + await flagsCmd.parseAsync(['--disable', 'tui'], { from: 'user' }); + + expect(successLines()).toContain('tui off'); + }); + + it('--set emits a success line on a clean run', async () => { + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--set', 'max-concurrent-subagents=25'], { from: 'user' }); + + expect(successLines()).toContain('max-concurrent-subagents = 25'); + }); + + it('--unset emits a success line on a clean run', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({ 'max-concurrent-subagents': 40 }), + 'utf-8', + ); + + await flagsCmd.parseAsync(['--unset', 'max-concurrent-subagents'], { from: 'user' }); + + expect(successLines()).toContain('max-concurrent-subagents unset'); + }); + + it("a pre-existing unrelated exitCode=1 does not suppress this run's confirmation", async () => { + // Success is tracked in locals, never read back off the process-global exit + // code — an earlier unrelated failure must not misreport this operation. + process.exitCode = 1; + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), makeEmptyFlagsManifest(), 'utf-8'); + + await flagsCmd.parseAsync(['--enable', 'tui'], { from: 'user' }); + + expect(successLines()).toContain('tui on'); + }); + }); + + // ─── view-mode preservation through persistFlagConfig (SEC-M3 / ARCH-H1) ───── + // + // Pinning: any mutation (--enable, --set non-view-mode) must NOT destroy a + // user-set viewMode:'focus' that devflow does not own (absent from manifest). + + describe('view-mode preservation through persistFlagConfig', () => { + it('--enable brief: viewMode:"focus" survives when manifest has no view-mode entry', async () => { + // Scenario: user ran /focus in Claude Code → settings.json has viewMode:'focus' + // Manifest: no 'view-mode' key (devflow never wrote it) + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({}), // no view-mode entry + 'utf-8', + ); + await fs.writeFile( + path.join(tmpClaudeDir, 'settings.json'), + JSON.stringify({ viewMode: 'focus', hooks: {} }, null, 2) + '\n', + 'utf-8', + ); + + await flagsCmd.parseAsync(['--enable', 'brief'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + // whole-post-state: viewMode must survive the strip+apply pass + const settings = parseSettings( + await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'), + ); + expect(settings.viewMode, 'viewMode:"focus" must survive --enable brief').toBe('focus'); + }); + + it('--set view-mode=verbose: explicitly overrides the /focus-set viewMode', async () => { + // When the user explicitly targets view-mode, the record value wins over settings + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeManifestWithFlags({}), + 'utf-8', + ); + await fs.writeFile( + path.join(tmpClaudeDir, 'settings.json'), + JSON.stringify({ viewMode: 'focus', hooks: {} }, null, 2) + '\n', + 'utf-8', + ); + + await flagsCmd.parseAsync(['--set', 'view-mode=verbose'], { from: 'user' }); + expect(process.exitCode).toBe(0); + + const settings = parseSettings( + await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'), + ); + expect(settings.viewMode, '--set view-mode=verbose must override /focus').toBe('verbose'); + }); + }); + + // ─── REL-M3: concurrent settings.json write during TUI session survives ─────── + // + // handleBare re-reads settings.json AFTER runFlagsTui returns, not before. + // A concurrent writer (e.g. `devflow proxy --enable` setting ANTHROPIC_BASE_URL) + // that ran while the TUI was open would be silently clobbered by the stale + // pre-TUI snapshot if the re-read were absent (applies PF-022). + // + // vi.doMock + vi.resetModules() isolate the mock to this describe block; the + // mock's runFlagsTui simulates a concurrent write before returning {action:'save'}. + + describe('bare TUI save — concurrent settings.json write survives (REL-M3)', () => { + let origStdoutIsTTY: boolean | undefined; + let origStdinIsTTY: boolean | undefined; + + beforeEach(() => { + origStdoutIsTTY = (process.stdout as { isTTY?: boolean }).isTTY; + origStdinIsTTY = (process.stdin as { isTTY?: boolean }).isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: origStdoutIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: origStdinIsTTY, configurable: true }); + // Remove the doMock registration and clear module cache so subsequent tests + // get the real flags-view implementation. + vi.unmock('../src/cli/flags-view/index.js'); + vi.resetModules(); + }); + + it('ANTHROPIC_BASE_URL written during TUI session is not clobbered by stale pre-TUI snapshot', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeEmptyFlagsManifest(), + 'utf-8', + ); + // settings.json starts empty — the concurrent write will add the env key. + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), '{}', 'utf-8'); + + const settingsPath = path.join(tmpClaudeDir, 'settings.json'); + + // Mock flags-view so runFlagsTui simulates a concurrent write before returning. + // buildFlagRows/collectFlagRecord return minimal stubs; only the concurrent + // write timing matters for this regression. + vi.doMock('../src/cli/flags-view/index.js', () => ({ + buildFlagRows: () => [], + collectFlagRecord: () => ({}), + runFlagsTui: async () => { + // Concurrent write — simulates `devflow proxy --enable` running while the + // TUI was open (applies PF-022: file state is reality, not config state). + await fs.writeFile( + settingsPath, + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://localhost:9090' } }, null, 2) + '\n', + 'utf-8', + ); + return { action: 'save' as const, rows: [] as never[] }; + }, + })); + // Clear the module cache so the fresh import of flags.ts picks up the mock + // when its handleBare calls await import('../flags-view/index.js'). + vi.resetModules(); + + const { createFlagsCommand } = await import('../src/cli/commands/flags.js'); + const freshCmd = createFlagsCommand(); + + // Suppress any residual process.stdout.write calls (p.outro is already mocked). + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await freshCmd.parseAsync([], { from: 'user' }); + } finally { + stdoutSpy.mockRestore(); + } + + // REL-M3 regression: ANTHROPIC_BASE_URL written by the concurrent writer + // must survive the re-read+persist in handleBare — not clobbered by the + // stale pre-TUI snapshot. + const settings = parseSettings(await fs.readFile(settingsPath, 'utf-8')); + expect( + (settings.env as Record | undefined)?.ANTHROPIC_BASE_URL, + 'concurrent ANTHROPIC_BASE_URL must not be clobbered by stale pre-TUI snapshot', + ).toBe('http://localhost:9090'); + + // TUI save succeeded → exitCode must not be 1. + expect(process.exitCode).toBeFalsy(); + }); + }); + + // ─── bare TUI rejection — runFlagsTui rejects → log.error + exitCode 1 ───────── + // + // REG-SF1 hardening: runTui can reject on initial-render failure or handler throw. + // handleBare must catch the rejection, emit p.log.error, set exitCode=1, and NOT + // write settings.json. Uses vi.doMock to make runFlagsTui reject (C3 precedent). + + describe('bare TUI rejection — runFlagsTui rejects → log.error + exitCode 1', () => { + let origStdoutIsTTY: boolean | undefined; + let origStdinIsTTY: boolean | undefined; + + beforeEach(() => { + origStdoutIsTTY = (process.stdout as { isTTY?: boolean }).isTTY; + origStdinIsTTY = (process.stdin as { isTTY?: boolean }).isTTY; + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process.stdout, 'isTTY', { value: origStdoutIsTTY, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: origStdinIsTTY, configurable: true }); + vi.unmock('../src/cli/flags-view/index.js'); + vi.resetModules(); + }); + + it('runFlagsTui rejection → p.log.error, exitCode 1, settings.json not written', async () => { + await fs.writeFile( + path.join(tmpDevflowDir, 'manifest.json'), + makeEmptyFlagsManifest(), + 'utf-8', + ); + // No settings.json written before — absence is evidence no write occurred. + + vi.doMock('../src/cli/flags-view/index.js', () => ({ + buildFlagRows: () => [], + collectFlagRecord: () => ({}), + runFlagsTui: async () => { + throw new Error('render failed: raw-mode unsupported'); + }, + })); + vi.resetModules(); + + const { createFlagsCommand } = await import('../src/cli/commands/flags.js'); + const freshCmd = createFlagsCommand(); + + vi.mocked(p.log.error).mockClear(); + await freshCmd.parseAsync([], { from: 'user' }); + + expect(process.exitCode).toBe(1); + expect(vi.mocked(p.log.error)).toHaveBeenCalledWith( + expect.stringContaining('render failed: raw-mode unsupported'), + ); + // settings.json must NOT have been created. + const settingsExists = await fs.access(path.join(tmpClaudeDir, 'settings.json')) + .then(() => true).catch(() => false); + expect(settingsExists, 'settings.json must not be written on TUI rejection').toBe(false); + }); + }); + + // ─── applyTuiResult seam — TUI→persist wiring (TEST-M5) ────────────────────── + // + // PF-017(c): an interactive surface has no automated test until a human runs it + // in a real TTY. applyTuiResult closes this coverage gap: the extracted save + // handler is called directly with a PassThrough-driven TUI result. + // + // PF-015: both save and cancel paths assert the WHOLE post-state of both + // artifacts (manifest.features.flags + settings.json) — not per-key picks. + + describe('applyTuiResult seam — TUI→persist wiring (PF-015 + PF-017(c))', () => { + function makeStreams() { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + (stdin as unknown as { isTTY: boolean }).isTTY = false; + (stdin as unknown as { setRawMode: (m: boolean) => void }).setRawMode = (_m: boolean) => {}; + (stdout as unknown as { rows: number }).rows = 24; + (stdout as unknown as { columns: number }).columns = 80; + return { stdin, stdout }; + } + + function sendKey(stdin: PassThrough, key: string): void { + stdin.push(key); + } + + it('save path: TUI toggle+enter → applyTuiResult → whole post-state matches expected flags', async () => { + // Arrange: tui=true in manifest; settings.json empty + const initialFlags: FlagsRecord = { tui: true }; + const manifestContent = makeManifestWithFlags(initialFlags); + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), manifestContent, 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), '{}', 'utf-8'); + + const manifest = (await readManifest(tmpDevflowDir))!; + + // Drive runFlagsTui with PassThrough streams: space toggles tui true→false, + // then enter on a boolean row triggers save intent. + const { stdin, stdout } = makeStreams(); + const rowsIn = buildFlagRows(initialFlags); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + + await new Promise(r => setTimeout(r, 10)); + sendKey(stdin, ' '); // toggle tui: true → false + await new Promise(r => setTimeout(r, 5)); + sendKey(stdin, '\r'); // enter on boolean row = save intent + + const tuiResult = await tui; + expect(tuiResult.action).toBe('save'); + + // Act: applyTuiResult (the seam) — freshSettingsContent is the re-read value + // that handleBare would provide in production (caller owns the re-read per REL-M3). + const outcome = await applyTuiResult(tuiResult, '{}', manifest, tmpClaudeDir, tmpDevflowDir); + expect(outcome).toBe('saved'); + + // Assert whole post-state of both artifacts (PF-015) + const manifestAfter = JSON.parse( + await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'), + ) as { features: { flags: FlagsRecord } }; + // tui=false is recorded in manifest (deliberately disabled — not absent) + expect(manifestAfter.features.flags.tui).toBe(false); + + const settingsAfter = JSON.parse( + await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'), + ) as Record; + // tui=false is the neutral value for a boolean flag → the key is deleted from settings + expect(settingsAfter.tui).toBeUndefined(); + }); + + it('cancel path: TUI esc → applyTuiResult → returns unchanged, both artifacts untouched', async () => { + // Arrange: non-trivial initial state so we can verify nothing was mutated + const initialFlags: FlagsRecord = { tui: true }; + const manifestContent = makeManifestWithFlags(initialFlags); + const settingsContent = JSON.stringify({ tui: 'fullscreen' }, null, 2) + '\n'; + await fs.writeFile(path.join(tmpDevflowDir, 'manifest.json'), manifestContent, 'utf-8'); + await fs.writeFile(path.join(tmpClaudeDir, 'settings.json'), settingsContent, 'utf-8'); + + const manifest = (await readManifest(tmpDevflowDir))!; + + // Drive runFlagsTui to cancel via esc + const { stdin, stdout } = makeStreams(); + const rowsIn = buildFlagRows(initialFlags); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + + await new Promise(r => setTimeout(r, 10)); + sendKey(stdin, '\x1b'); // esc = cancel + + const tuiResult = await tui; + expect(tuiResult.action).toBe('cancel'); + + // Act + const outcome = await applyTuiResult(tuiResult, settingsContent, manifest, tmpClaudeDir, tmpDevflowDir); + expect(outcome).toBe('unchanged'); + + // Assert whole post-state — both artifacts must be byte-identical (PF-015) + expect( + await fs.readFile(path.join(tmpDevflowDir, 'manifest.json'), 'utf-8'), + ).toBe(manifestContent); + expect( + await fs.readFile(path.join(tmpClaudeDir, 'settings.json'), 'utf-8'), + ).toBe(settingsContent); + }); + }); +}); diff --git a/tests/flags-view-render.test.ts b/tests/flags-view-render.test.ts new file mode 100644 index 00000000..7acf5c50 --- /dev/null +++ b/tests/flags-view-render.test.ts @@ -0,0 +1,640 @@ +/** + * Tests for src/cli/flags-view/render.ts — pure frame renderer. + * + * Tests-first (RED-GREEN): written before the implementation. + * + * Pinned behaviours (per execution plan): + * - computeViewportHeight(rows) = rows - FIXED_ROWS (≥ 1) + * - renderFrame returns one string per terminal line (no embedded newlines) + * - Frame contains ERASE_EOL (ESC[K]) at end of each line (from shell; render + * does NOT add ERASE_EOL — the shell wraps it — but renderFrame strings must + * NOT themselves embed newlines) + * - Boolean row displays enabled/disabled + * - Enum row displays current value or 'unset' + * - Number row displays current value or 'unset' + * - Editing row shows buffer with inverse-video caret + * - hint zone: last non-empty row shows flag.hint + * - Up/down indicators when rows overflow viewport + * - Narrow width (< 80): render doesn't crash + * - No trailing newline in any line string + */ + +import { describe, it, expect } from 'vitest'; +import { renderFrame, computeViewportHeight, FIXED_ROWS } from '../src/cli/flags-view/render.js'; +import { buildFlagRows } from '../src/cli/flags-view/state.js'; +import { FLAG_REGISTRY } from '../src/core/flags.js'; +import type { FlagsViewState } from '../src/cli/flags-view/state.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DIMS_80x24 = { rows: 24, cols: 80 }; +const DIMS_80x40 = { rows: 40, cols: 80 }; +const DIMS_60x24 = { rows: 24, cols: 60 }; // narrow +const DIMS_80x15 = { rows: 15, cols: 80 }; // short + +/** Strip ANSI escape sequences so assertions operate on plain text. */ +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +function makeState(overrides: Partial = {}): FlagsViewState { + const rows = buildFlagRows({}); + return { + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: computeViewportHeight(DIMS_80x24.rows), + editing: null, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// FIXED_ROWS and computeViewportHeight +// --------------------------------------------------------------------------- + +describe('flags-view-render — FIXED_ROWS and computeViewportHeight', () => { + it('FIXED_ROWS is 10', () => { + expect(FIXED_ROWS).toBe(10); + }); + + it('computeViewportHeight(24) = 24 - FIXED_ROWS = 14', () => { + expect(computeViewportHeight(24)).toBe(14); + }); + + it('computeViewportHeight(10) = 1 (minimum)', () => { + // rows - FIXED_ROWS = 0, clamp to 1 + expect(computeViewportHeight(FIXED_ROWS)).toBe(1); + }); + + it('computeViewportHeight(5) = 1 (minimum even when would be negative)', () => { + expect(computeViewportHeight(5)).toBe(1); + }); + + it('computeViewportHeight(40) = 30', () => { + expect(computeViewportHeight(40)).toBe(30); + }); +}); + +// --------------------------------------------------------------------------- +// renderFrame — basic contract +// --------------------------------------------------------------------------- + +describe('flags-view-render — renderFrame basic contract', () => { + it('returns an array of strings', () => { + const state = makeState(); + const lines = renderFrame(state, DIMS_80x24); + expect(Array.isArray(lines)).toBe(true); + expect(lines.length).toBeGreaterThan(0); + }); + + it('no line contains a newline character', () => { + const state = makeState(); + const lines = renderFrame(state, DIMS_80x24); + for (const line of lines) { + expect(line).not.toContain('\n'); + } + }); + + it('sanitizeCell: embedded \\n and \\t in a string value produce one line per row (no layout break)', () => { + // `devflow flags --set $'spellcheck=a\nb'` persists a LF; coerceFlagValue permits + // TAB/LF so the value reaches the renderer. sanitizeCell must collapse both to space + // so the one-string-per-terminal-line contract is preserved. + const rows = buildFlagRows({ spellcheck: 'aspell\tcheck\nline2' }); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const frameLines = renderFrame(state, DIMS_80x24); + // Every string in the returned array must be free of newlines and tabs + for (const line of frameLines) { + expect(line).not.toContain('\n'); + expect(line).not.toContain('\t'); + } + // And the total line count must still equal FIXED_ROWS + viewportHeight (no extra lines) + const viewportHeight = computeViewportHeight(DIMS_80x24.rows); + expect(frameLines.length).toBe(FIXED_ROWS + viewportHeight); + }); + + it('renders exactly FIXED_ROWS + viewportHeight lines', () => { + const state = makeState(); + const lines = renderFrame(state, DIMS_80x24); + // FLAG_REGISTRY has more rows than the viewport can show, so the viewport is fully + // filled: renderedRows.length = viewportHeight, total = FIXED_ROWS + viewportHeight. + const viewportHeight = computeViewportHeight(DIMS_80x24.rows); + expect(FLAG_REGISTRY.length).toBeGreaterThan(viewportHeight); // confirm premise + expect(lines.length).toBe(FIXED_ROWS + viewportHeight); + }); + + it('no line is longer than cols visible characters (no content overflow)', () => { + const state = makeState(); + const lines = renderFrame(state, DIMS_80x24); + // Strip ANSI for length check + const ESC_PATTERN = /\x1b\[[0-9;]*m/g; + for (const line of lines) { + const visible = line.replace(ESC_PATTERN, ''); + expect(visible.length).toBeLessThanOrEqual(80); + } + }); +}); + +// --------------------------------------------------------------------------- +// Per-kind rendering +// --------------------------------------------------------------------------- + +describe('flags-view-render — per-kind value display', () => { + it('boolean flag shows "on" when true', () => { + // Applies PF-018 mechanism 7: assert the SPECIFIC cursor row, not the joined + // frame. D-EFFDV: vocabulary is 'on'/'off', never 'enabled'/'disabled'. + const rows = buildFlagRows({ tui: true }); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + const cursorRow = lines.find(l => stripAnsi(l).startsWith('❯'))!; + expect(cursorRow).toBeDefined(); + expect(stripAnsi(cursorRow)).toContain('on'); + expect(stripAnsi(cursorRow)).not.toContain('off'); // negative control + }); + + it('boolean flag shows "off" when false', () => { + // Applies PF-018 mechanism 7: assert the SPECIFIC cursor row, not the joined + // frame. D-EFFDV: vocabulary is 'on'/'off', never 'enabled'/'disabled'. + const rows = buildFlagRows({ tui: false }); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + const cursorRow = lines.find(l => stripAnsi(l).startsWith('❯'))!; + expect(cursorRow).toBeDefined(); + expect(stripAnsi(cursorRow)).toContain('off'); + expect(stripAnsi(cursorRow)).not.toContain('‹ on'); // negative control: not 'on' in chevron + }); + + it('enum flag shows the value when set', () => { + const rows = buildFlagRows({ 'view-mode': 'verbose' }); + // Find the index of view-mode row — scroll viewport to make it visible + const vmIdx = rows.findIndex(r => r.id === 'view-mode'); + const state = makeState({ rows, cursor: vmIdx, viewportOffset: vmIdx }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + expect(joined).toContain('verbose'); + }); + + it('view-mode shows neutralValue "default" when null (D-EFFDV: never "unset")', () => { + // Applies PF-018 mechanism 7: assert the specific cursor row. + // D-EFFDV: enum null → neutralValue text; view-mode neutralValue is 'default'. + const rows = buildFlagRows({}); // view-mode absent → null (TUI neutral) + const vmIdx = rows.findIndex(r => r.id === 'view-mode'); + const state = makeState({ rows, cursor: vmIdx, viewportOffset: vmIdx }); + const lines = renderFrame(state, DIMS_80x24); + const cursorRow = lines.find(l => stripAnsi(l).startsWith('❯'))!; + expect(cursorRow).toBeDefined(); + expect(stripAnsi(cursorRow)).toContain('default'); + expect(stripAnsi(cursorRow)).not.toContain('verbose'); // negative control + expect(stripAnsi(cursorRow)).not.toContain('focus'); // negative control + expect(stripAnsi(cursorRow)).not.toContain('unset'); // negative control: 'unset' banned + }); + + it('number flag shows value when set', () => { + // Applies PF-018 mechanism 7: assert the cursor row, not the joined frame. + // The frame always includes '40' from the devflow-default for max-concurrent-subagents. + const rows = buildFlagRows({ 'max-concurrent-subagents': 40 }); + const mcIdx = rows.findIndex(r => r.id === 'max-concurrent-subagents'); + const state = makeState({ rows, cursor: mcIdx, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + const cursorRow = lines.find(l => stripAnsi(l).startsWith('❯'))!; + expect(cursorRow).toBeDefined(); + expect(stripAnsi(cursorRow)).toContain('40'); + expect(stripAnsi(cursorRow)).not.toContain('unset'); // negative control + }); + + it('number flag shows upstream default with "(default)" suffix when null (D-EFFDV: never "unset")', () => { + // Applies PF-018 mechanism 7: assert the specific cursor row. + // D-EFFDV: number null → effectiveDisplay → upstreamDefault text + ' (default)' suffix. + // subagent-spawn-depth has no devflow defaultValue but upstreamDefault: 3. + const rows = buildFlagRows({ 'subagent-spawn-depth': null }); + const sdIdx = rows.findIndex(r => r.id === 'subagent-spawn-depth'); + const state = makeState({ rows, cursor: sdIdx, viewportOffset: sdIdx }); + const lines = renderFrame(state, DIMS_80x24); + const cursorRow = lines.find(l => stripAnsi(l).startsWith('❯'))!; + expect(cursorRow).toBeDefined(); + expect(stripAnsi(cursorRow)).toContain('(default)'); + expect(stripAnsi(cursorRow)).not.toContain('unset'); // negative control: 'unset' banned + expect(stripAnsi(cursorRow)).not.toContain('on'); // negative control + expect(stripAnsi(cursorRow)).not.toContain('off'); // negative control + }); +}); + +// --------------------------------------------------------------------------- +// Dirty dot +// --------------------------------------------------------------------------- + +describe('flags-view-render — dirty dot', () => { + it('shows dirty indicator when configuredValue !== originalValue', () => { + const rows = buildFlagRows({ tui: true }); + // Modify configuredValue but keep originalValue + const modified = rows.map(r => + r.id === 'tui' ? { ...r, configuredValue: false } : r, + ); + const state = makeState({ rows: modified, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + // Some dirt indicator — '*' or '●' or 'modified' or similar + // The exact char is implementation-defined, so check for at least one of the common ones + expect(joined.includes('*') || joined.includes('●') || joined.includes('•')).toBe(true); + }); + + it('no dirty indicator when clean', () => { + // Applies PF-018 mechanism 4: Array.isArray is satisfied by any return value. + // render.ts:162 pins the dirty indicator to yellow('● ') (exactly '●' in plain + // text), so assert its absence when configuredValue === originalValue. + const rows = buildFlagRows({ tui: true }); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + const plain = lines.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); + expect(plain).not.toContain('●'); + }); +}); + +// --------------------------------------------------------------------------- +// Cursor indicator +// --------------------------------------------------------------------------- + +describe('flags-view-render — cursor indicator', () => { + it('selected row shows ❯ prefix; no other row shares it', () => { + // Applies PF-018 mechanism 7: the browse-hint line always contains '→', making + // the disjunction vacuous. Assert the specific cursor row carries ❯ and that + // exactly one data row has it (negative control). + const state = makeState({ cursor: 0 }); + const lines = renderFrame(state, DIMS_80x24); + // Exactly one line must start with ❯ (the cursor row) + const cursorLines = lines.filter(l => stripAnsi(l).startsWith('❯')); + expect(cursorLines).toHaveLength(1); + // The cursor row must name the tui flag (cursor=0 → row 0 = 'Fullscreen terminal UI') + expect(stripAnsi(cursorLines[0])).toContain('Fullscreen terminal UI'); + }); +}); + +// --------------------------------------------------------------------------- +// Edit mode rendering +// --------------------------------------------------------------------------- + +describe('flags-view-render — edit mode', () => { + it('edit mode shows buffer with inverse-video caret', () => { + const rows = buildFlagRows({ 'max-concurrent-subagents': 40 }); + const mcIdx = rows.findIndex(r => r.id === 'max-concurrent-subagents'); + const state = makeState({ + rows, + cursor: mcIdx, + viewportOffset: 0, + editing: { buffer: '40', caret: 2, error: null }, + }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + // Should contain the buffer text + expect(joined).toContain('40'); + // Should contain inverse video escape sequence ESC[7m (reverse video) or ESC[7m + expect(joined).toContain('\x1b[7m'); + }); + + it('edit mode shows error message when error is set', () => { + const rows = buildFlagRows({}); + const mcIdx = rows.findIndex(r => r.id === 'max-concurrent-subagents'); // index 8 + const state = makeState({ + rows, + cursor: mcIdx, + viewportOffset: 0, // mcIdx=8 is within first 14 visible rows + editing: { buffer: '007', caret: 3, error: 'Leading zeros are not allowed' }, + }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + // Error message should appear somewhere + expect(joined).toContain('Leading zeros'); + }); + + it('caret at start shows inverse on first char', () => { + const rows = buildFlagRows({ 'max-concurrent-subagents': 40 }); + const mcIdx = rows.findIndex(r => r.id === 'max-concurrent-subagents'); + const state = makeState({ + rows, + cursor: mcIdx, + viewportOffset: 0, + editing: { buffer: '40', caret: 0, error: null }, + }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + // inverse on first char: ESC[7m4 + expect(joined).toContain('\x1b[7m4'); + }); + + it('empty buffer with caret shows inverse on blank space', () => { + const rows = buildFlagRows({}); + const mcIdx = rows.findIndex(r => r.id === 'max-concurrent-subagents'); // index 8 + const state = makeState({ + rows, + cursor: mcIdx, + viewportOffset: 0, // mcIdx=8 is within first 14 visible rows + editing: { buffer: '', caret: 0, error: null }, + }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + // Inverse video on blank/space + expect(joined).toContain('\x1b[7m'); + }); +}); + +// --------------------------------------------------------------------------- +// Viewport indicators +// --------------------------------------------------------------------------- + +describe('flags-view-render — viewport overflow indicators', () => { + it('shows scroll-up indicator when viewportOffset > 0', () => { + // Applies PF-018 mechanism 7: '↑' appears in the footer keybinding line + // unconditionally. Assert lines[3] — the dedicated upIndicator slot in the + // frame layout — which is empty when no rows are above and populated otherwise. + // state.viewportHeight is the single owner (ARCH-M5 fix — see viewportHeight + // ownership tests); viewportHeight:3 here means exactly 3 data rows are drawn. + const rows = buildFlagRows({}); + const state: FlagsViewState = { + rows, + cursor: 3, + viewportOffset: 3, // 3 rows above the viewport + viewportHeight: 3, + editing: null, + }; + const lines = renderFrame(state, DIMS_80x24); + // upIndicator is always at lines[3] (layout: title[0], summary[1], header[2], upIndicator[3]) + expect(stripAnsi(lines[3])).toMatch(/↑ \d+ more/); + // Negative control: no rows are below with cursor=3, viewportOffset=3, viewportHeight=3, + // rows.length=28 → rowsBelow = 28 - (3+3) = 22, so downIndicator IS populated + // (lines[4+3]=lines[7]). Just confirm upIndicator is row-specific, not footer. + expect(stripAnsi(lines[lines.length - 1])).not.toMatch(/↑ \d+ more/); // footer not the indicator + }); + + it('shows scroll-down indicator when rows extend below viewport', () => { + // Applies PF-018 mechanism 7: '↓' and 'v' appear in the footer line + // unconditionally. Assert lines[4+viewportHeight] — the dedicated downIndicator + // slot — instead of the joined frame. + const rows = buildFlagRows({}); + const state: FlagsViewState = { + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: 3, // only show 3 rows of 28 + editing: null, + }; + const lines = renderFrame(state, DIMS_80x24); + // downIndicator is at lines[4 + viewportHeight] = lines[7] + expect(stripAnsi(lines[7])).toMatch(/↓ \d+ more/); + // Negative control: no rows are above + expect(stripAnsi(lines[3])).toBe(''); // upIndicator slot is empty + }); +}); + +// --------------------------------------------------------------------------- +// Hint zone +// --------------------------------------------------------------------------- + +describe('flags-view-render — hint zone', () => { + it('shows hint text for the selected flag', () => { + const rows = buildFlagRows({}); + const state = makeState({ rows, cursor: 0 }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + // The hint for 'tui' (index 0) should appear + const tuiFlag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + // hint may be truncated; check at least the beginning + expect(joined).toContain(tuiFlag.hint.slice(0, 20)); + }); + + it('shows hint for a different selected row', () => { + const rows = buildFlagRows({}); + const briefIdx = rows.findIndex(r => r.id === 'brief'); + const state = makeState({ rows, cursor: briefIdx }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + const briefFlag = FLAG_REGISTRY.find(f => f.id === 'brief')!; + expect(joined).toContain(briefFlag.hint.slice(0, 15)); + }); +}); + +// --------------------------------------------------------------------------- +// Narrow width +// --------------------------------------------------------------------------- + +describe('flags-view-render — narrow width', () => { + it('does not crash on narrow terminal (cols=60)', () => { + const state = makeState(); + const lines = renderFrame(state, DIMS_60x24); + expect(Array.isArray(lines)).toBe(true); + expect(lines.length).toBeGreaterThan(0); + }); + + it('does not crash on very narrow terminal (cols=30)', () => { + const state = makeState(); + const lines = renderFrame(state, { rows: 24, cols: 30 }); + expect(Array.isArray(lines)).toBe(true); + }); + + it('does not crash on short terminal (rows=15)', () => { + const h = computeViewportHeight(DIMS_80x15.rows); + const state = makeState({ viewportHeight: h }); + const lines = renderFrame(state, DIMS_80x15); + expect(Array.isArray(lines)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Column header alignment (CONS-M4) +// --------------------------------------------------------------------------- + +describe('flags-view-render — column header alignment', () => { + it('FLAG column starts at same offset as data label cell (ANSI-stripped)', () => { + // At 80 cols (scale=1): labelW = COL_LABEL = 27. + // Data row layout: prefix(2) + label(27) + dirty(2) + value(16) + blurb(30) + // Header layout must match: 2 spaces + FLAG(padded to 27) + 2 spaces + VALUE(padded to 16) + HINT + // → FLAG at col 2 (same as label), VALUE at col 2+27+2=31 (same as value cell start). + const rows = buildFlagRows({}); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + const ESC_PATTERN = /\x1b\[[0-9;]*m/g; + const stripped = lines.map(l => l.replace(ESC_PATTERN, '')); + + // Header is the third line (index 2): title, summary, header + const header = stripped[2]; + // First data row is the fifth line (index 4): title, summary, header, scroll-up-indicator, data + const dataRow = stripped[4]; + + const flagOffset = header.indexOf('FLAG'); + const valueOffset = header.indexOf('VALUE'); + expect(flagOffset).toBeGreaterThanOrEqual(0); + expect(valueOffset).toBeGreaterThanOrEqual(0); + + // FLAG must start at offset 2 (matching 2-char prefix in data rows) + expect(flagOffset).toBe(2); + + // VALUE must start at 2 + labelW + 2. + // At 80 cols: labelW = floor(27 * min(1, 80/80)) = 27, so VALUE at 31. + expect(valueOffset).toBe(31); + + // Also confirm that the first non-space character in the data row label area + // sits at offset 2 (cursor row: '❯ ' prefix, then label). + // The cursor marker '❯' is at col 0, space at col 1, label starts at col 2. + expect(dataRow[0]).toBe('❯'); + expect(dataRow[1]).toBe(' '); + // label content starts at col 2 — first char of the flag label + expect(flagOffset).toBe(2); + }); + + it('FLAG and VALUE columns align on narrow terminal (cols=60)', () => { + // At 60 cols: scale = 60/80 = 0.75, labelW = floor(27*0.75)=20, valueW = floor(16*0.75)=12. + // Header: 2 + labelW(20) + 2 = VALUE at col 24 (position depends only on labelW, not valueW). + const rows = buildFlagRows({}); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_60x24); + const ESC_PATTERN = /\x1b\[[0-9;]*m/g; + const stripped = lines.map(l => l.replace(ESC_PATTERN, '')); + + const header = stripped[2]; + const flagOffset = header.indexOf('FLAG'); + const valueOffset = header.indexOf('VALUE'); + expect(flagOffset).toBe(2); + // labelW at 60 cols: max(8, floor(27 * min(1, 60/80))) = max(8, floor(20.25)) = 20 + expect(valueOffset).toBe(2 + 20 + 2); // = 24 + }); +}); + +// --------------------------------------------------------------------------- +// viewportHeight ownership — state.viewportHeight is the single owner (ARCH-M5) +// --------------------------------------------------------------------------- + +describe('flags-view-render — viewportHeight ownership', () => { + it('renders exactly state.viewportHeight data rows regardless of dims.rows', () => { + // dims.rows=24 would give computeViewportHeight(24)=14 rows, but state says 3. + // After the ARCH-M5 fix, renderFrame reads state.viewportHeight directly. + const rows = buildFlagRows({}); + const state: FlagsViewState = { + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: 3, + editing: null, + }; + const lines = renderFrame(state, DIMS_80x24); + expect(lines.length).toBe(FIXED_ROWS + 3); + }); + + it('renders exactly state.viewportHeight data rows when state says 1', () => { + const rows = buildFlagRows({}); + const state: FlagsViewState = { + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: 1, + editing: null, + }; + const lines = renderFrame(state, DIMS_80x40); + expect(lines.length).toBe(FIXED_ROWS + 1); + }); +}); + +// --------------------------------------------------------------------------- +// Unsaved changes indicator +// --------------------------------------------------------------------------- + +describe('flags-view-render — unsaved changes section', () => { + it('shows unsaved count when rows are dirty', () => { + const rows = buildFlagRows({ tui: true }); + const modified = rows.map(r => + r.id === 'tui' ? { ...r, configuredValue: false as boolean | string | number | null } : r, + ); + const state = makeState({ rows: modified }); + const lines = renderFrame(state, DIMS_80x24); + const joined = lines.join('\n'); + // Strip ANSI escape sequences and assert the exact unsaved indicator text + const ESC_PATTERN = /\x1b\[[0-9;]*m/g; + const plain = joined.replace(ESC_PATTERN, ''); + expect(plain).toContain('1 unsaved change'); + }); +}); + +// --------------------------------------------------------------------------- +// ARCH-M7a: chevron composition — closing chevron styled in its own cyan segment +// --------------------------------------------------------------------------- + +describe('flags-view-render — ARCH-M7a: chevron composition', () => { + it('focused row with coloured value has closing chevron in cyan (not unstyled after inner RESET)', () => { + // tui flag (row 0) is boolean; value true → green('on'). + // Before fix: cyan(`‹ ${green('on')} ›`) emits inner RESET before ' ›', + // leaving the closing chevron unstyled (ESC[0m ›). + // After fix: cyan('‹ ') + green('on') + cyan(' ›') — each segment self-contained; + // the closing chevron is always inside its own ESC[36m ... ESC[0m span. + const rows = buildFlagRows({ tui: true }); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + + const ESC_PATTERN = /\x1b\[[0-9;]*m/g; + const cursorRow = lines.find(l => l.replace(ESC_PATTERN, '').startsWith('❯')); + expect(cursorRow).toBeDefined(); + + // cyan(' ›') = '\x1b[36m ›\x1b[0m'; the closing chevron must be preceded by ESC[36m + expect(cursorRow!).toContain('\x1b[36m ›'); + }); +}); + +// --------------------------------------------------------------------------- +// ARCH-M7b: caret survival — long buffer does not lose the inverse-video caret +// --------------------------------------------------------------------------- + +describe('flags-view-render — ARCH-M7b: caret survival beyond chevron budget', () => { + it('60-char buffer with caret at end still shows inverse-video caret in 80-col frame', () => { + // chevronBudget at 80 cols = valueW(16) - 4 = 12. + // A 60-char buffer exceeds the budget; the caret at position 60 (trailing space) + // must still appear as ESC[7m (inverse video) in the cursor row. + // + // Before fix: truncateVisible strips ANSI from the buffer output, discarding ESC[7m. + // After fix: renderBuffer windows the plain buffer to budget width before inserting + // inverse(), so the caret escape always survives. + const rows = buildFlagRows({}); + const mcIdx = rows.findIndex(r => r.id === 'max-concurrent-subagents'); + const longBuffer = 'a'.repeat(60); // 60 > chevronBudget(12) + const state = makeState({ + rows, + cursor: mcIdx, + viewportOffset: 0, + editing: { buffer: longBuffer, caret: 60, error: null }, // caret at end + }); + const lines = renderFrame(state, DIMS_80x24); + + const ESC_PATTERN = /\x1b\[[0-9;]*m/g; + const cursorRow = lines.find(l => l.replace(ESC_PATTERN, '').startsWith('❯')); + expect(cursorRow).toBeDefined(); + + // The inverse-video escape must be present in the cursor row + expect(cursorRow!).toContain('\x1b[7m'); + }); +}); + +// --------------------------------------------------------------------------- +// ARCH-M7c: deviation signal — non-boolean deviating value uses bold, not cyan +// --------------------------------------------------------------------------- + +describe('flags-view-render — ARCH-M7c: deviation signal', () => { + it('non-boolean deviating value on non-cursor row uses bold not cyan (applies ADR-016 amendment lesson)', () => { + // max-concurrent-subagents devflowDefault=40; value 20 deviates. + // Before fix: formatValue returns cyan('20'), conflating "focus" and "deviation" + // — one colour, two semantics (ADR-016 amendment lesson). + // After fix: formatValue returns bold('20'); cyan = focus indicator only (chevrons). + // + // cursor at row 0 (not mcIdx) so the max-concurrent-subagents row is non-cursor; + // no cyan chevrons appear on it. + const rows = buildFlagRows({ 'max-concurrent-subagents': 20 }); + const state = makeState({ rows, cursor: 0, viewportOffset: 0 }); + const lines = renderFrame(state, DIMS_80x24); + + const ESC_PATTERN = /\x1b\[[0-9;]*m/g; + // Find the non-cursor row whose ANSI-stripped content includes the flag label + const mcRow = lines.find(l => l.replace(ESC_PATTERN, '').includes('Max concurrent')); + expect(mcRow).toBeDefined(); + + // The deviating value must be rendered with bold (ESC[1m), not cyan (ESC[36m). + expect(mcRow!).toContain('\x1b[1m'); // bold — deviation signal + expect(mcRow!).not.toContain('\x1b[36m'); // NOT cyan — cyan = focus only + }); +}); diff --git a/tests/flags-view-state.test.ts b/tests/flags-view-state.test.ts new file mode 100644 index 00000000..4e0ac297 --- /dev/null +++ b/tests/flags-view-state.test.ts @@ -0,0 +1,837 @@ +/** + * Tests for src/cli/flags-view/state.ts — pure reducer + row builder. + * + * Tests-first (RED-GREEN): written before the implementation. + * + * Pinned behaviours (per execution plan): + * - Navigation up/down + viewport clamp + * - Boolean toggle via space/←/→ + * - Enum cycle including view-mode glue (stops: [null,'verbose','focus'] — NO 'default') + * - Text rows enter edit mode via space/enter/e + * - Edit commit: valid inputs, invalid inputs stay editing + error + * - Empty buffer → unset for allowUnset rows + * - '007' / ' 8' → strict number format → stay editing + error + * - cap+1 (101 for max-concurrent-subagents) → stay editing + error + * - esc discards edit only (back to browse, value unchanged) + * - d = set devflow default + * - u = unset (allowUnset rows only — noop on boolean) + * - dirty-revert: cycle away and back → NOT dirty + * - up/down ignored while editing + * - buffer hard-bounded at 64 on paste-like bulk insert + * - collectFlagRecord maps view-mode null → 'default' + */ + +import { describe, it, expect } from 'vitest'; +import { + reduce, + resizeViewport, + buildFlagRows, + collectFlagRecord, + BUFFER_MAX_LEN, + type FlagsViewState, + type FlagRow, +} from '../src/cli/flags-view/state.js'; +import type { FlagsRecord } from '../src/core/flags.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Make a minimal FlagsViewState for testing with an explicit set of rows. */ +function makeState( + rows: FlagRow[], + overrides: Partial> = {}, +): FlagsViewState { + return { + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: 10, + editing: null, + ...overrides, + }; +} + +/** Build a single FlagRow from the registry for a given flag id. */ +function rowFor(id: string, record: FlagsRecord = {}): FlagRow { + const rows = buildFlagRows(record); + const row = rows.find(r => r.id === id); + if (!row) throw new Error(`Flag '${id}' not found in registry`); + return row; +} + +/** Apply a sequence of key strings to a state, return final state. */ +function applyKeys(state: FlagsViewState, keys: string[]): FlagsViewState { + let current = state; + for (const key of keys) { + current = reduce(current, key).state; + } + return current; +} + +/** + * Enter edit mode on a text row and type a sequence of normalized keys. + * Routes input through the real reducer so the keyboard→buffer path is exercised + * (applies PF-018 mechanism 7: proves the behaviour named by the test exists). + */ +function typeInto(id: string, keys: string[]): FlagsViewState { + let state = makeState([rowFor(id)]); + state = reduce(state, 'e').state; + expect(state.editing, 'expected to be in edit mode').not.toBeNull(); + for (const k of keys) state = reduce(state, k).state; + return state; +} + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +describe('flags-view-state — navigation', () => { + it('down moves cursor', () => { + const rows = [rowFor('tui'), rowFor('lsp')]; + const state = makeState(rows, { cursor: 0 }); + const next = reduce(state, 'down').state; + expect(next.cursor).toBe(1); + }); + + it('up moves cursor', () => { + const rows = [rowFor('tui'), rowFor('lsp')]; + const state = makeState(rows, { cursor: 1 }); + const next = reduce(state, 'up').state; + expect(next.cursor).toBe(0); + }); + + it('up at top clamps to 0', () => { + const rows = [rowFor('tui')]; + const state = makeState(rows, { cursor: 0 }); + const next = reduce(state, 'up').state; + expect(next.cursor).toBe(0); + }); + + it('down at bottom clamps to last row', () => { + const rows = [rowFor('tui')]; + const state = makeState(rows, { cursor: 0 }); + const next = reduce(state, 'down').state; + expect(next.cursor).toBe(0); + }); + + it('j moves cursor down', () => { + const rows = [rowFor('tui'), rowFor('lsp')]; + const state = makeState(rows, { cursor: 0 }); + expect(reduce(state, 'j').state.cursor).toBe(1); + }); + + it('k moves cursor up', () => { + const rows = [rowFor('tui'), rowFor('lsp')]; + const state = makeState(rows, { cursor: 1 }); + expect(reduce(state, 'k').state.cursor).toBe(0); + }); + + it('viewport clamps when cursor scrolls below viewport', () => { + const rows = [rowFor('tui'), rowFor('lsp'), rowFor('tool-search'), rowFor('brief')]; + const state = makeState(rows, { cursor: 0, viewportOffset: 0, viewportHeight: 2 }); + // Move down past viewport + const s1 = reduce(state, 'down').state; + const s2 = reduce(s1, 'down').state; + const s3 = reduce(s2, 'down').state; + expect(s3.cursor).toBe(3); + // viewportOffset should clamp to keep cursor visible (cursor=3, height=2 → offset=2) + expect(s3.viewportOffset).toBe(2); + }); + + it('viewport clamps when cursor scrolls above viewport', () => { + const rows = [rowFor('tui'), rowFor('lsp'), rowFor('tool-search')]; + const state = makeState(rows, { cursor: 2, viewportOffset: 1, viewportHeight: 2 }); + const s1 = reduce(state, 'up').state; + const s2 = reduce(s1, 'up').state; + expect(s2.cursor).toBe(0); + expect(s2.viewportOffset).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Boolean flag cycling +// --------------------------------------------------------------------------- + +describe('flags-view-state — boolean flag cycling', () => { + it('space toggles boolean from false to true', () => { + const row = rowFor('brief', { brief: false }); // brief=false (disabled) + const state = makeState([row]); + const next = reduce(state, 'space').state; + expect(next.rows[0].configuredValue).toBe(true); + }); + + it('space toggles boolean from true to false', () => { + const row = rowFor('tui', { tui: true }); + const state = makeState([row]); + const next = reduce(state, 'space').state; + expect(next.rows[0].configuredValue).toBe(false); + }); + + it('right arrow also cycles boolean forward', () => { + const row = rowFor('tui', { tui: true }); + const state = makeState([row]); + expect(reduce(state, 'right').state.rows[0].configuredValue).toBe(false); + }); + + it('left arrow cycles boolean backward', () => { + const row = rowFor('tui', { tui: false }); // false → cycles backward → true + const state = makeState([row]); + expect(reduce(state, 'left').state.rows[0].configuredValue).toBe(true); + }); + + it('boolean stops are [true, false] — no null stop', () => { + const row = rowFor('tui'); + expect(row.stops).toEqual([true, false]); + expect(row.allowUnset).toBe(false); + }); + + it('dirty after toggle, not dirty if reverted', () => { + const row = rowFor('tui', { tui: true }); // original = true + const state = makeState([row]); + const s1 = reduce(state, 'space').state; // → false, dirty + expect(s1.rows[0].configuredValue).toBe(false); + expect(s1.rows[0].originalValue).toBe(true); + const s2 = reduce(s1, 'space').state; // → true (back to original) + expect(s2.rows[0].configuredValue).toBe(true); + expect(s2.rows[0].originalValue).toBe(true); + // not dirty + expect(s2.rows[0].configuredValue).toBe(s2.rows[0].originalValue); + }); +}); + +// --------------------------------------------------------------------------- +// Enum cycling +// --------------------------------------------------------------------------- + +describe('flags-view-state — enum cycling', () => { + it('view-mode stops are [null, verbose, focus] — no default stop', () => { + const row = rowFor('view-mode'); + expect(row.stops).toEqual([null, 'verbose', 'focus']); + // 'default' must NOT appear in stops + expect(row.stops).not.toContain('default'); + expect(row.allowUnset).toBe(true); + }); + + it('view-mode cycles null → verbose → focus → null (cycle wrap)', () => { + // Start at default (null) + const row = rowFor('view-mode', {}); // no record entry → null + const state = makeState([row]); + const s1 = reduce(state, 'space').state; + expect(s1.rows[0].configuredValue).toBe('verbose'); + const s2 = reduce(s1, 'space').state; + expect(s2.rows[0].configuredValue).toBe('focus'); + const s3 = reduce(s2, 'space').state; + expect(s3.rows[0].configuredValue).toBe(null); // wraps to null + }); + + it('view-mode dirty-revert: cycle away and back → not dirty', () => { + const row = rowFor('view-mode', {}); // starts at null + const state = makeState([row]); + const s1 = reduce(state, 'space').state; // → verbose, dirty + const s2 = reduce(s1, 'space').state; // → focus + const s3 = reduce(s2, 'space').state; // → null (back to original) + expect(s3.rows[0].configuredValue).toBe(null); + expect(s3.rows[0].originalValue).toBe(null); + // configuredValue === originalValue → not dirty + expect(s3.rows[0].configuredValue).toBe(s3.rows[0].originalValue); + }); + + it('enum cycles left (backward)', () => { + const row = rowFor('view-mode', {}); // null + const state = makeState([row]); + const s1 = reduce(state, 'left').state; // null → focus (backward wrap) + expect(s1.rows[0].configuredValue).toBe('focus'); + }); +}); + +// --------------------------------------------------------------------------- +// Text rows — enter edit mode +// --------------------------------------------------------------------------- + +describe('flags-view-state — text row enter edit mode', () => { + it('space on a number row enters edit mode', () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + expect(state.editing).toBeNull(); + const next = reduce(state, 'space').state; + expect(next.editing).not.toBeNull(); + expect(next.editing?.buffer).toBe('40'); // pre-filled with current value + }); + + it('enter on a number row enters edit mode', () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + const next = reduce(state, 'enter').state; + expect(next.editing).not.toBeNull(); + }); + + it('e on a number row enters edit mode', () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + const next = reduce(state, 'e').state; + expect(next.editing).not.toBeNull(); + }); + + it('edit mode buffer is pre-filled with formatted current value', () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + const next = reduce(state, 'e').state; + // buffer contains the current value as a string + expect(next.editing?.buffer).toBe('40'); + expect(next.editing?.caret).toBe(2); // caret at end + expect(next.editing?.error).toBeNull(); + }); + + it('edit mode buffer is empty when current value is null (unset)', () => { + const row = rowFor('subagent-spawn-depth', {}); // null = not set + const state = makeState([row]); + const next = reduce(state, 'e').state; + expect(next.editing?.buffer).toBe(''); + expect(next.editing?.caret).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Edit mode — commit valid inputs +// --------------------------------------------------------------------------- + +describe('flags-view-state — edit commit valid inputs', () => { + it('entering a valid number and pressing enter commits it', () => { + // Routes through the real reducer (typeInto) so the keyboard→buffer path is + // exercised (applies PF-018 mechanism 7): enter edit mode pre-fills '40', + // clear with backspace, then type the new value. + let s = reduce(makeState([rowFor('max-concurrent-subagents')]), 'e').state; + // buffer = '40', caret = 2; clear with backspace then type '50' + s = reduce(s, 'backspace').state; // '4', caret=1 + s = reduce(s, 'backspace').state; // '', caret=0 + s = reduce(s, '5').state; + s = reduce(s, '0').state; + s = reduce(s, 'enter').state; + expect(s.editing).toBeNull(); // left edit mode + expect(s.rows[0].configuredValue).toBe(50); + }); + + it('valid string commits correctly', () => { + // Routes through the real reducer: default-model starts null → empty buffer. + let s = typeInto('default-model', [...'claude-3-5-sonnet']); + s = reduce(s, 'enter').state; + expect(s.editing).toBeNull(); + expect(s.rows[0].configuredValue).toBe('claude-3-5-sonnet'); + }); +}); + +// --------------------------------------------------------------------------- +// Edit mode — commit invalid inputs → stay editing + error +// --------------------------------------------------------------------------- + +describe('flags-view-state — edit commit invalid inputs', () => { + it("'' (empty) → unset for allowUnset rows", () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + let s = reduce(state, 'e').state; + s = { ...s, editing: { buffer: '', caret: 0, error: null } }; + s = reduce(s, 'enter').state; + // empty on allowUnset row → commit as null (unset) + expect(s.editing).toBeNull(); + expect(s.rows[0].configuredValue).toBeNull(); + }); + + it("'abc' → stay editing + error (not a number)", () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + let s = reduce(state, 'e').state; + s = { ...s, editing: { buffer: 'abc', caret: 3, error: null } }; + s = reduce(s, 'enter').state; + expect(s.editing).not.toBeNull(); + expect(s.editing?.error).not.toBeNull(); + }); + + it("'-1' → stay editing + error (below min=1)", () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + let s = reduce(state, 'e').state; + s = { ...s, editing: { buffer: '-1', caret: 2, error: null } }; + s = reduce(s, 'enter').state; + expect(s.editing).not.toBeNull(); + expect(s.editing?.error).not.toBeNull(); + }); + + it.each([ + ['subagent-spawn-depth'], + ['max-concurrent-subagents'], + ])('leading zeros are rejected for %s', (flagId) => { + const row = rowFor(flagId, {}); + const state = makeState([row]); + let s = reduce(state, 'e').state; + s = { ...s, editing: { buffer: '007', caret: 3, error: null } }; + s = reduce(s, 'enter').state; + expect(s.editing).not.toBeNull(); + expect(s.editing?.error).not.toBeNull(); + }); + + it("' 8' → stay editing + error (leading space)", () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + let s = reduce(state, 'e').state; + s = { ...s, editing: { buffer: ' 8', caret: 2, error: null } }; + s = reduce(s, 'enter').state; + expect(s.editing).not.toBeNull(); + expect(s.editing?.error).not.toBeNull(); + }); + + it('101 (cap+1 for max-concurrent-subagents max=100) → stay editing + error', () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + let s = reduce(state, 'e').state; + s = { ...s, editing: { buffer: '101', caret: 3, error: null } }; + s = reduce(s, 'enter').state; + expect(s.editing).not.toBeNull(); + expect(s.editing?.error).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Edit mode — esc discards edit only +// --------------------------------------------------------------------------- + +describe('flags-view-state — edit esc discards only', () => { + it('esc exits edit mode without changing the value', () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + let s = reduce(state, 'e').state; + s = { ...s, editing: { buffer: '99', caret: 2, error: null } }; + s = reduce(s, 'escape').state; + expect(s.editing).toBeNull(); // left edit mode + expect(s.rows[0].configuredValue).toBe(40); // unchanged + }); + + it('esc in browse mode → cancel intent', () => { + const row = rowFor('tui'); + const state = makeState([row]); + const result = reduce(state, 'escape'); + expect(result.intent).toBe('cancel'); + }); +}); + +// --------------------------------------------------------------------------- +// d = set devflow default +// --------------------------------------------------------------------------- + +describe('flags-view-state — d key (devflow default)', () => { + it('d sets configuredValue to devflowDefault', () => { + // tui has devflowDefault = true + const row = rowFor('tui', { tui: false }); // deviated from default + const state = makeState([row]); + const next = reduce(state, 'd').state; + expect(next.rows[0].configuredValue).toBe(next.rows[0].devflowDefault); + expect(next.rows[0].configuredValue).toBe(true); + }); + + it('d on view-mode sets to devflowDefault (null = default)', () => { + const row = rowFor('view-mode', { 'view-mode': 'verbose' }); + const state = makeState([row]); + const next = reduce(state, 'd').state; + expect(next.rows[0].configuredValue).toBe(next.rows[0].devflowDefault); + // devflowDefault for view-mode is null (mapped from 'default') + expect(next.rows[0].configuredValue).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// u = unset (allowUnset rows only) +// --------------------------------------------------------------------------- + +describe('flags-view-state — u key (unset)', () => { + it('u unsets a number flag (sets to null)', () => { + const row = rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }); + const state = makeState([row]); + const next = reduce(state, 'u').state; + expect(next.rows[0].configuredValue).toBeNull(); + }); + + it('u on a boolean row is a noop (allowUnset=false)', () => { + const row = rowFor('tui', { tui: true }); + const state = makeState([row]); + const next = reduce(state, 'u').state; + // unchanged + expect(next.rows[0].configuredValue).toBe(true); + }); + + it('u on view-mode enum sets to null (neutral)', () => { + const row = rowFor('view-mode', { 'view-mode': 'verbose' }); + const state = makeState([row]); + const next = reduce(state, 'u').state; + expect(next.rows[0].configuredValue).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Intent — save, cancel, abort +// --------------------------------------------------------------------------- + +describe('flags-view-state — intents', () => { + it('enter in browse mode returns save intent', () => { + // Note: enter on a boolean row enters browse save, enter on text enters edit + const row = rowFor('tui'); + const state = makeState([row]); + // tui is boolean, so enter = SAVE + const result = reduce(state, 'enter'); + expect(result.intent).toBe('save'); + }); + + it('q returns cancel intent', () => { + const row = rowFor('tui'); + const state = makeState([row]); + expect(reduce(state, 'q').intent).toBe('cancel'); + }); + + it('ctrl-c returns abort intent', () => { + const row = rowFor('tui'); + const state = makeState([row]); + expect(reduce(state, 'ctrl-c').intent).toBe('abort'); + }); + + it('esc in browse mode returns cancel intent', () => { + const row = rowFor('tui'); + const state = makeState([row]); + expect(reduce(state, 'escape').intent).toBe('cancel'); + }); +}); + +// --------------------------------------------------------------------------- +// up/down ignored while editing +// --------------------------------------------------------------------------- + +describe('flags-view-state — up/down ignored while editing', () => { + it('up is ignored while in edit mode (cursor stays, no navigation)', () => { + const rows = [ + rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }), + rowFor('subagent-spawn-depth', {}), + ]; + const state = makeState(rows, { cursor: 1 }); + let s = reduce(state, 'e').state; // enter edit mode on cursor=1 + const cursorBefore = s.cursor; + s = reduce(s, 'up').state; + expect(s.cursor).toBe(cursorBefore); // cursor unchanged + expect(s.editing).not.toBeNull(); // still editing + }); + + it('down is ignored while in edit mode', () => { + const rows = [ + rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 }), + rowFor('subagent-spawn-depth', {}), + ]; + const state = makeState(rows, { cursor: 0 }); + let s = reduce(state, 'e').state; + const cursorBefore = s.cursor; + s = reduce(s, 'down').state; + expect(s.cursor).toBe(cursorBefore); + expect(s.editing).not.toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Buffer hard-bounded at 64 on paste-like bulk insert +// --------------------------------------------------------------------------- + +describe('flags-view-state — buffer hard-bound at 64', () => { + it('typing 70 chars is clamped to 64', () => { + const row = rowFor('default-model', {}); + const state = makeState([row]); + let s = reduce(state, 'e').state; + // Simulate inserting 70 'a' characters + for (let i = 0; i < 70; i++) { + s = reduce(s, 'a').state; + } + expect(s.editing).not.toBeNull(); + // Exact assertions: a no-op insertChar would give length 0, satisfying ≤ 64 + // (applies PF-018 mechanism 4). Import BUFFER_MAX_LEN so the magic number is + // single-sourced and the test breaks if the constant changes. + expect(s.editing!.buffer.length).toBe(BUFFER_MAX_LEN); + expect(s.editing!.caret).toBe(BUFFER_MAX_LEN); + expect(s.editing!.buffer).toBe('a'.repeat(BUFFER_MAX_LEN)); + }); +}); + +// --------------------------------------------------------------------------- +// collectFlagRecord — view-mode null → 'default' +// --------------------------------------------------------------------------- + +describe('flags-view-state — collectFlagRecord', () => { + it('view-mode null maps back to canonical "default" in the record', () => { + const rows = buildFlagRows({}); + // Set view-mode to null (representing 'default') + const viewModeRow = rows.find(r => r.id === 'view-mode')!; + const modified = rows.map(r => + r.id === 'view-mode' ? { ...r, configuredValue: null } : r, + ); + const record = collectFlagRecord(modified); + expect(record['view-mode']).toBe('default'); + }); + + it('collectFlagRecord preserves boolean true/false correctly', () => { + const rows = buildFlagRows({ tui: true, brief: false }); + const record = collectFlagRecord(rows); + expect(record['tui']).toBe(true); + expect(record['brief']).toBe(false); + }); + + it('collectFlagRecord preserves null for number flags', () => { + const rows = buildFlagRows({}); + const modified = rows.map(r => + r.id === 'max-concurrent-subagents' ? { ...r, configuredValue: null } : r, + ); + const record = collectFlagRecord(modified); + expect(record['max-concurrent-subagents']).toBeNull(); + }); + + it('collectFlagRecord preserves enum set value', () => { + const rows = buildFlagRows({ 'view-mode': 'verbose' }); + const record = collectFlagRecord(rows); + expect(record['view-mode']).toBe('verbose'); + }); +}); + +// --------------------------------------------------------------------------- +// buildFlagRows — row construction +// --------------------------------------------------------------------------- + +describe('flags-view-state — buildFlagRows', () => { + it('view-mode row has correct stops', () => { + const row = rowFor('view-mode'); + expect(row.stops).toEqual([null, 'verbose', 'focus']); + expect(row.stops).not.toContain('default'); + }); + + it('view-mode devflowDefault is null (mapped from "default")', () => { + const row = rowFor('view-mode'); + expect(row.devflowDefault).toBeNull(); + }); + + it('boolean row has stops [true, false]', () => { + const row = rowFor('tui'); + expect(row.stops).toEqual([true, false]); + }); + + it('number row has empty stops (text editing)', () => { + const row = rowFor('max-concurrent-subagents'); + expect(row.stops).toEqual([]); + expect(row.allowUnset).toBe(true); + }); + + it('string row has empty stops (text editing)', () => { + const row = rowFor('default-model'); + expect(row.stops).toEqual([]); + expect(row.allowUnset).toBe(true); + }); + + it('view-mode maps record value "verbose" to TUI "verbose" (no remap needed)', () => { + const row = rowFor('view-mode', { 'view-mode': 'verbose' }); + expect(row.configuredValue).toBe('verbose'); + }); + + it('view-mode maps record value "default" to TUI null', () => { + const row = rowFor('view-mode', { 'view-mode': 'default' }); + expect(row.configuredValue).toBeNull(); + }); + + it('originalValue equals configuredValue at construction', () => { + const row = rowFor('tui', { tui: true }); + expect(row.originalValue).toBe(row.configuredValue); + expect(row.originalValue).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Edit-mode input handling (scrutinize pass) +// +// These pin three defects found by driving the reducer the way a user types, +// rather than by asserting the branches the implementation happens to have. +// --------------------------------------------------------------------------- + +describe('edit mode — typed input', () => { + it('space is inserted into the buffer, not dropped', () => { + // normalizeKey maps the space bar to the NAME 'space' (5 chars), so a + // length===1 test drops it. spellcheck holds a shell command — "aspell list" + // must be typable, and the drop was silent (no error, no visual cue). + const state = typeInto('spellcheck', [...'aspell', 'space', ...'list']); + expect(state.editing?.buffer).toBe('aspell list'); + expect(state.editing?.caret).toBe('aspell list'.length); + }); + + it('j and k are inserted literally in edit mode (not swallowed as navigation)', () => { + // Spec: literal q d u j k must insert in edit mode. 'j' and 'k' were grouped + // with up/down and returned early, so multi-word commands like "aspell check" + // and paths containing 'j'/'k' could not be entered without error or visual cue. + const stateJ = typeInto('spellcheck', [...'as', 'j', ...'ell']); + expect(stateJ.editing?.buffer).toBe('asjell'); + expect(stateJ.editing?.caret).toBe(6); + + const stateK = typeInto('spellcheck', [...'as', 'k', ...'ell']); + expect(stateK.editing?.buffer).toBe('askell'); + expect(stateK.editing?.caret).toBe(6); + }); + + it('a space-containing command commits successfully', () => { + let state = typeInto('spellcheck', [...'aspell', 'space', ...'list']); + state = reduce(state, 'enter').state; + expect(state.editing, 'commit should exit edit mode').toBeNull(); + expect(state.rows[0].configuredValue).toBe('aspell list'); + }); + + it('control characters never enter the buffer', () => { + // normalizeKey passes ctrl-modified keys through as their raw control byte, + // so this is reachable by ordinary typing. coerceFlagValue rejects any string + // containing one, and renderBuffer strips them for display — so a buffered + // control char is both uncommittable and desyncs the caret from what is drawn. + const state = typeInto('spellcheck', [...'aspell', '\x01', '\x1b', '\x7f']); + expect(state.editing?.buffer).toBe('aspell'); + expect(state.editing?.caret).toBe(6); + }); + + it('ctrl-c aborts out of edit mode instead of being swallowed', () => { + // reduceEditMode has no ctrl-c case, so it used to fall through to 'none'. + // Raw mode suppresses the SIGINT that would otherwise rescue the user, so + // ctrl-c was completely dead while editing. + const state = typeInto('spellcheck', [...'asp']); + expect(reduce(state, 'ctrl-c').intent).toBe('abort'); + }); + + it('escape still discards the edit without aborting', () => { + // Guard against over-correcting the ctrl-c fix into escape. + const state = typeInto('spellcheck', [...'asp']); + const result = reduce(state, 'escape'); + expect(result.intent).toBe('none'); + expect(result.state.editing).toBeNull(); + expect(result.state.rows[0].configuredValue).toBe(rowFor('spellcheck').configuredValue); + }); +}); + +// --------------------------------------------------------------------------- +// Edit mode — caret manipulation (TEST-H1) +// +// Six branches in reduceEditMode had zero coverage: +// backspace at caret=0 (no-op), delete-at-caret, delete at end-of-buffer (no-op), +// home, end, left/right clamping at 0 and buffer.length. +// +// E1 already covers backspace at caret>0 and insertChar via the commit-path tests; +// only the remaining boundary/branch cases are added here (applies PF-018: each +// assertion names a concrete post-caret value so a no-op implementation fails RED). +// --------------------------------------------------------------------------- + +describe('edit mode — caret manipulation (TEST-H1)', () => { + it('backspace at caret=0 is a no-op (buffer and caret unchanged)', () => { + // Enter edit mode on '40': buffer='40', caret=2; home → caret=0 + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + s = reduce(s, 'home').state; // caret→0 + expect(s.editing!.caret).toBe(0); + const bufBefore = s.editing!.buffer; + s = reduce(s, 'backspace').state; + expect(s.editing!.buffer).toBe(bufBefore); // buffer unchanged + expect(s.editing!.caret).toBe(0); // caret still 0 + }); + + it('delete at caret removes the character under the caret', () => { + // buffer='40', caret=0; delete removes '4' → buffer='0', caret stays 0 + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + s = reduce(s, 'home').state; // caret=0 + s = reduce(s, 'delete').state; + expect(s.editing!.buffer).toBe('0'); + expect(s.editing!.caret).toBe(0); // caret stays at deletion point + }); + + it('delete at end of buffer is a no-op', () => { + // buffer='40', caret=2 (already at end); delete is a no-op + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + expect(s.editing!.caret).toBe(2); // sanity: at end after entering edit mode on '40' + const bufBefore = s.editing!.buffer; + s = reduce(s, 'delete').state; + expect(s.editing!.buffer).toBe(bufBefore); // buffer unchanged + expect(s.editing!.caret).toBe(2); // caret unchanged + }); + + it('home moves caret to start of buffer', () => { + // buffer='40', caret=2; home → caret=0 + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + expect(s.editing!.caret).toBe(2); + s = reduce(s, 'home').state; + expect(s.editing!.caret).toBe(0); + expect(s.editing!.buffer).toBe('40'); // buffer unchanged + }); + + it('end moves caret to end of buffer', () => { + // Move to start first, then end → caret should reach buffer.length + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + s = reduce(s, 'home').state; // caret=0 + s = reduce(s, 'end').state; + expect(s.editing!.caret).toBe(s.editing!.buffer.length); // end of '40' = 2 + expect(s.editing!.buffer).toBe('40'); // buffer unchanged + }); + + it('left decrements caret by one', () => { + // buffer='40', caret=2; left → caret=1 + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + expect(s.editing!.caret).toBe(2); + s = reduce(s, 'left').state; + expect(s.editing!.caret).toBe(1); + expect(s.editing!.buffer).toBe('40'); // buffer unchanged + }); + + it('left at caret=0 clamps (caret stays 0)', () => { + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + s = reduce(s, 'home').state; // caret=0 + s = reduce(s, 'left').state; + expect(s.editing!.caret).toBe(0); // clamped at 0 + }); + + it('right increments caret by one', () => { + // buffer='40', caret=0 (after home); right → caret=1 + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + s = reduce(s, 'home').state; // caret=0 + s = reduce(s, 'right').state; + expect(s.editing!.caret).toBe(1); + expect(s.editing!.buffer).toBe('40'); // buffer unchanged + }); + + it('right at end of buffer clamps (caret stays at buffer.length)', () => { + // buffer='40', caret=2 (already at end); right clamps + let s = reduce(makeState([rowFor('max-concurrent-subagents', { 'max-concurrent-subagents': 40 })]), 'e').state; + expect(s.editing!.caret).toBe(2); // already at end + s = reduce(s, 'right').state; + expect(s.editing!.caret).toBe(2); // clamped at buffer.length + }); +}); + +describe('resizeViewport', () => { + const rows = buildFlagRows({}); + + it('re-clamps the scroll offset so the cursor stays visible when the terminal shrinks', () => { + // adjustViewport otherwise only runs on up/down, so a resize changed the height + // without moving the offset — leaving the cursor outside the visible slice, where + // renderFrame draws no selection marker at all until the user pressed an arrow key. + const tall = makeState([...rows], { cursor: 15, viewportOffset: 0, viewportHeight: 30 }); + const shrunk = resizeViewport(tall, 5); + + expect(shrunk.viewportHeight).toBe(5); + expect(shrunk.cursor).toBe(15); + // Cursor must lie inside [offset, offset + height) + expect(shrunk.cursor).toBeGreaterThanOrEqual(shrunk.viewportOffset); + expect(shrunk.cursor).toBeLessThan(shrunk.viewportOffset + shrunk.viewportHeight); + }); + + it('does not scroll past the end when the terminal grows', () => { + const small = makeState([...rows], { cursor: 2, viewportOffset: 8, viewportHeight: 3 }); + const grown = resizeViewport(small, rows.length + 10); + + expect(grown.viewportOffset).toBe(0); + expect(grown.cursor).toBe(2); + }); + + it('is a no-op on state when the height is unchanged and the cursor is visible', () => { + const stable = makeState([...rows], { cursor: 1, viewportOffset: 0, viewportHeight: 10 }); + const same = resizeViewport(stable, 10); + expect(same.viewportOffset).toBe(0); + expect(same.viewportHeight).toBe(10); + }); +}); diff --git a/tests/flags-view-terminal.test.ts b/tests/flags-view-terminal.test.ts new file mode 100644 index 00000000..67050a89 --- /dev/null +++ b/tests/flags-view-terminal.test.ts @@ -0,0 +1,401 @@ +/** + * Tests for src/cli/flags-view/terminal.ts — TUI shell adapter. + * + * Tests-first (RED-GREEN): written before the implementation. + * + * Pinned behaviours (per execution plan): + * (a) stdin.pause() called on save, cancel, and abort paths + * (b) MAX_KEYPRESSES flood → resolves with cancel (via shared shell signalAction) + * - Driving with PassThrough: send key bytes → TUI resolves + * - esc → cancel intent, ctrl-c → abort intent + * - edit sequence: enter edit mode, type value, confirm → save with new value + * - Save path: save intent returns the final rows + * + * The tests use the same PassThrough pattern as agents-terminal.test.ts, injecting + * a fake stdout to capture output without a real TTY. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { PassThrough } from 'stream'; +import { runFlagsTui } from '../src/cli/flags-view/terminal.js'; +import { MAX_KEYPRESSES } from '../src/cli/tui/terminal.js'; +import { runTui } from '../src/cli/tui/terminal.js'; +import { buildFlagRows } from '../src/cli/flags-view/state.js'; +import { FLAG_REGISTRY } from '../src/core/flags.js'; +import { reduce } from '../src/cli/flags-view/state.js'; +import { renderFrame } from '../src/cli/flags-view/render.js'; +import type { FlagsRecord } from '../src/core/flags.js'; + +// ENTER_ALT / LEAVE_ALT sequences for inline-mode assertion +const ENTER_ALT = '\x1b[?1049h'; +const LEAVE_ALT = '\x1b[?1049l'; +const ERASE_BELOW = '\x1b[0J'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeStreams() { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + (stdin as unknown as { isTTY: boolean }).isTTY = false; + (stdin as unknown as { setRawMode: (m: boolean) => void }).setRawMode = (_m: boolean) => {}; + (stdout as unknown as { rows: number }).rows = 24; + (stdout as unknown as { columns: number }).columns = 80; + return { stdin, stdout }; +} + +function sendKey(stdin: PassThrough, key: string): void { + stdin.push(key); +} + +/** Build a default record (all flags at devflow defaults) */ +function defaultRecord(): FlagsRecord { + const record: FlagsRecord = {}; + for (const flag of FLAG_REGISTRY) { + record[flag.id] = flag.kind === 'boolean' ? flag.defaultValue : (flag.defaultValue ?? null); + } + return record; +} + +// --------------------------------------------------------------------------- +// (a) stdin.pause() called on all exit paths +// --------------------------------------------------------------------------- + +describe('flags-view-terminal — (a) stdin.pause() on exit', () => { + it('pause() is called when TUI resolves via esc (cancel)', async () => { + const { stdin, stdout } = makeStreams(); + const pauseSpy = vi.spyOn(stdin, 'pause'); + + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + + // Let the first frame render + await new Promise(r => setTimeout(r, 10)); + + // Send esc → cancel + sendKey(stdin, '\x1b'); + const result = await tui; + + expect(result.action).toBe('cancel'); + expect(pauseSpy).toHaveBeenCalled(); + }); + + it('pause() is called when TUI resolves via ctrl-c (abort)', async () => { + const { stdin, stdout } = makeStreams(); + const pauseSpy = vi.spyOn(stdin, 'pause'); + + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + + await new Promise(r => setTimeout(r, 10)); + + // ctrl-c + sendKey(stdin, '\x03'); + const result = await tui; + + expect(result.action).toBe('abort'); + expect(pauseSpy).toHaveBeenCalled(); + }); + + it('pause() is called on save (enter on boolean row)', async () => { + const { stdin, stdout } = makeStreams(); + const pauseSpy = vi.spyOn(stdin, 'pause'); + + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + + await new Promise(r => setTimeout(r, 10)); + + // Enter on first row (boolean) → save + sendKey(stdin, '\r'); + const result = await tui; + + expect(result.action).toBe('save'); + expect(pauseSpy).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// (b) MAX_KEYPRESSES flood → resolves with signalAction (abort) +// --------------------------------------------------------------------------- + +describe('flags-view-terminal — (b) MAX_KEYPRESSES flood resolves', () => { + it(`exhausting ${MAX_KEYPRESSES} keypresses resolves with abort`, async () => { + const { stdin, stdout } = makeStreams(); + + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + + await new Promise(r => setTimeout(r, 10)); + + // Flood with no-op keys (space on first boolean row cycles it but stays running) + // Use 'j' (down) to avoid cycling — it's a navigation key that stays at bottom + for (let i = 0; i <= MAX_KEYPRESSES; i++) { + sendKey(stdin, 'a'); // 'a' is unrecognized in browse mode → noop + } + + const result = await tui; + expect(result.action).toBe('abort'); + }, 30_000); // Allow up to 30s for this test (it's a large loop) +}); + +// --------------------------------------------------------------------------- +// Key routing: esc → cancel +// --------------------------------------------------------------------------- + +describe('flags-view-terminal — key routing', () => { + it('esc resolves with cancel action', async () => { + const { stdin, stdout } = makeStreams(); + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + await new Promise(r => setTimeout(r, 10)); + sendKey(stdin, '\x1b'); + const result = await tui; + expect(result.action).toBe('cancel'); + }); + + it('q resolves with cancel action', async () => { + const { stdin, stdout } = makeStreams(); + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + await new Promise(r => setTimeout(r, 10)); + sendKey(stdin, 'q'); + const result = await tui; + expect(result.action).toBe('cancel'); + }); + + it('ctrl-c resolves with abort action', async () => { + const { stdin, stdout } = makeStreams(); + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + await new Promise(r => setTimeout(r, 10)); + sendKey(stdin, '\x03'); + const result = await tui; + expect(result.action).toBe('abort'); + }); + + it('enter on boolean row resolves with save action', async () => { + const { stdin, stdout } = makeStreams(); + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + await new Promise(r => setTimeout(r, 10)); + sendKey(stdin, '\r'); + const result = await tui; + expect(result.action).toBe('save'); + }); +}); + +// --------------------------------------------------------------------------- +// Result: save returns rows with updated values +// --------------------------------------------------------------------------- + +describe('flags-view-terminal — save result', () => { + it('cancel returns unchanged rows', async () => { + // Applies PF-018 mechanism 4: toBeDefined() is satisfied by any non-null + // value — it cannot observe "unchanged". Replace with toEqual(rowsIn) so + // the test actually checks the "unchanged" claim it is named for. + const { stdin, stdout } = makeStreams(); + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + await new Promise(r => setTimeout(r, 10)); + sendKey(stdin, 'q'); + const result = await tui; + expect(result.action).toBe('cancel'); + expect(result.rows).toEqual(rowsIn); // must be deep-equal (unchanged), not merely defined + }); + + it('space on tui (boolean) toggles value, then enter saves', async () => { + const { stdin, stdout } = makeStreams(); + // tui defaults to enabled (true) in registry — but record may have it set + const record = { tui: true }; // explicitly set tui=true + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + // cursor starts at 0 = tui row + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + await new Promise(r => setTimeout(r, 10)); + // Space toggles tui: true → false + sendKey(stdin, ' '); + await new Promise(r => setTimeout(r, 5)); + // Enter on boolean row = save + sendKey(stdin, '\r'); + const result = await tui; + expect(result.action).toBe('save'); + const tuiRow = result.rows.find(r => r.id === 'tui'); + expect(tuiRow?.configuredValue).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Inline mode driver tests (D-INLINE) +// --------------------------------------------------------------------------- + +describe('runTui — inline mode (D-INLINE)', () => { + function makeInlineSpec(stdin: PassThrough, stdout: PassThrough) { + const record = defaultRecord(); + const rows = buildFlagRows(FLAG_REGISTRY, record); + const initialState = { + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: 10, + editing: null, + }; + return { + initialState, + reduce, + renderFrame, + onResize: (s: typeof initialState, dims: { rows: number; cols: number }) => ({ + ...s, + viewportHeight: Math.max(1, dims.rows - 2), + }), + signalAction: 'abort' as const, + continueIntent: 'none' as const, + screen: 'inline' as const, + io: { stdin, stdout }, + }; + } + + it('inline mode never emits ENTER_ALT (\\x1b[?1049h)', async () => { + const { stdin, stdout } = makeStreams(); + const written: string[] = []; + stdout.on('data', (chunk: Buffer) => written.push(chunk.toString())); + + const spec = makeInlineSpec(stdin, stdout); + const tui = runTui(spec); + await new Promise(r => setTimeout(r, 20)); + + sendKey(stdin, '\x1b'); // esc → cancel + await tui; + + const all = written.join(''); + expect(all).not.toContain(ENTER_ALT); + }); + + it('inline mode never emits LEAVE_ALT (\\x1b[?1049l)', async () => { + const { stdin, stdout } = makeStreams(); + const written: string[] = []; + stdout.on('data', (chunk: Buffer) => written.push(chunk.toString())); + + const spec = makeInlineSpec(stdin, stdout); + const tui = runTui(spec); + await new Promise(r => setTimeout(r, 20)); + + sendKey(stdin, '\x1b'); + await tui; + + const all = written.join(''); + expect(all).not.toContain(LEAVE_ALT); + }); + + it('inline mode repaint uses cursor-up (ESC[nA) after first frame', async () => { + const { stdin, stdout } = makeStreams(); + const written: string[] = []; + stdout.on('data', (chunk: Buffer) => written.push(chunk.toString())); + + const spec = makeInlineSpec(stdin, stdout); + const tui = runTui(spec); + + // Let first frame render + await new Promise(r => setTimeout(r, 20)); + + // Send a navigation key to trigger a repaint + sendKey(stdin, 'j'); // down — noop at bottom but causes a repaint + await new Promise(r => setTimeout(r, 20)); + + sendKey(stdin, '\x1b'); // cancel + await tui; + + // After the first key, at least one cursor-up must have been emitted + const all = written.join(''); + const cursorUpPattern = /\x1b\[\d+A/; + expect(cursorUpPattern.test(all)).toBe(true); + }); + + it('inline mode exit emits ERASE_BELOW to clear widget', async () => { + const { stdin, stdout } = makeStreams(); + const written: string[] = []; + stdout.on('data', (chunk: Buffer) => written.push(chunk.toString())); + + const spec = makeInlineSpec(stdin, stdout); + const tui = runTui(spec); + await new Promise(r => setTimeout(r, 20)); + + sendKey(stdin, '\x1b'); // cancel → cleanup + await tui; + + const all = written.join(''); + expect(all).toContain(ERASE_BELOW); + }); + + it('alt mode still emits ENTER_ALT and LEAVE_ALT (alt mode unchanged)', async () => { + const { stdin, stdout } = makeStreams(); + const written: string[] = []; + stdout.on('data', (chunk: Buffer) => written.push(chunk.toString())); + + const record = defaultRecord(); + const rows = buildFlagRows(FLAG_REGISTRY, record); + const initialState = { + rows, + cursor: 0, + viewportOffset: 0, + viewportHeight: 10, + editing: null, + }; + const spec = { + initialState, + reduce, + renderFrame, + onResize: (s: typeof initialState, dims: { rows: number; cols: number }) => ({ + ...s, + viewportHeight: Math.max(1, dims.rows - 2), + }), + signalAction: 'abort' as const, + continueIntent: 'none' as const, + // screen: 'alt' is the default; not setting it + io: { stdin, stdout }, + }; + + const tui = runTui(spec); + await new Promise(r => setTimeout(r, 20)); + + sendKey(stdin, '\x1b'); // cancel + await tui; + + const all = written.join(''); + expect(all).toContain(ENTER_ALT); + expect(all).toContain(LEAVE_ALT); + }); +}); + +// --------------------------------------------------------------------------- +// runFlagsTui uses inline mode (D-INLINE integration) +// --------------------------------------------------------------------------- + +describe('runFlagsTui — uses inline mode by default', () => { + it('runFlagsTui does not emit ENTER_ALT (inline mode active)', async () => { + const { stdin, stdout } = makeStreams(); + const written: string[] = []; + stdout.on('data', (chunk: Buffer) => written.push(chunk.toString())); + + const record = defaultRecord(); + const rowsIn = buildFlagRows(FLAG_REGISTRY, record); + const tui = runFlagsTui(rowsIn, { stdin, stdout }); + + await new Promise(r => setTimeout(r, 20)); + sendKey(stdin, '\x1b'); // cancel + await tui; + + const all = written.join(''); + expect(all).not.toContain(ENTER_ALT); + expect(all).not.toContain(LEAVE_ALT); + }); +}); diff --git a/tests/flags.test.ts b/tests/flags.test.ts index 636872e0..0b062102 100644 --- a/tests/flags.test.ts +++ b/tests/flags.test.ts @@ -1,143 +1,620 @@ import { describe, it, expect } from 'vitest'; import { FLAG_REGISTRY, - getDefaultFlags, + // New typed exports + getDefaultFlagsRecord, + defaultValueOf, + neutralValueOf, + isNeutral, + coerceFlagValue, + parseFlagValueInput, + formatFlagValue, + effectiveDisplay, + describeFlagKind, + expectedInputFor, + countActiveFlags, + readViewMode, + sanitizeFlagsRecord, + migrateLegacyFlagsToRecord, applyFlags, stripFlags, - applyViewMode, - stripViewMode, + convergeFlagsIntoSettings, + // Kept verbatim + VIEW_MODES, resolveExistingViewMode, resolveFinalViewMode, type ViewMode, + type FlagsRecord, + type ClaudeCodeFlag, + type BooleanFlagDef, + type EnumFlagDef, + type NumberFlagDef, + type StringFlagDef, } from '../src/core/flags.js'; +import { resolveSeedFlags } from '../src/cli/commands/init-seed.js'; -describe('FLAG_REGISTRY', () => { +// ─── Registry invariants ────────────────────────────────────────────────────── + +describe('FLAG_REGISTRY — structural invariants', () => { it('has unique IDs', () => { const ids = FLAG_REGISTRY.map(f => f.id); expect(new Set(ids).size).toBe(ids.length); }); - it('every flag has required fields', () => { + it('has unique env target keys (no duplicate env var keys)', () => { + const envKeys = FLAG_REGISTRY + .filter(f => f.target.type === 'env') + .map(f => f.target.key); + expect(new Set(envKeys).size).toBe(envKeys.length); + }); + + it('has unique setting target keys (no duplicate setting keys)', () => { + const settingKeys = FLAG_REGISTRY + .filter(f => f.target.type === 'setting') + .map(f => f.target.key); + expect(new Set(settingKeys).size).toBe(settingKeys.length); + }); + + it('every flag has required common fields', () => { for (const flag of FLAG_REGISTRY) { - expect(flag.id).toBeTruthy(); - expect(flag.label).toBeTruthy(); - expect(flag.description).toBeTruthy(); - expect(flag.target).toBeDefined(); - expect(typeof flag.defaultEnabled).toBe('boolean'); + expect(flag.id, `${flag.id}: id`).toBeTruthy(); + expect(flag.label, `${flag.id}: label`).toBeTruthy(); + expect(flag.description, `${flag.id}: description`).toBeTruthy(); + expect(flag.hint, `${flag.id}: hint`).toBeTruthy(); + expect(typeof flag.recommended, `${flag.id}: recommended`).toBe('boolean'); + expect(['boolean', 'enum', 'number', 'string'], `${flag.id}: kind`).toContain(flag.kind); + expect(flag.target, `${flag.id}: target`).toBeDefined(); + expect(['env', 'setting'], `${flag.id}: target.type`).toContain(flag.target.type); + expect(typeof flag.target.key, `${flag.id}: target.key`).toBe('string'); } }); - it('target is either env or setting type', () => { - for (const flag of FLAG_REGISTRY) { - expect(['env', 'setting']).toContain(flag.target.type); - if (flag.target.type === 'env') { - expect(typeof flag.target.key).toBe('string'); - expect(typeof flag.target.value).toBe('string'); - } else { - expect(typeof flag.target.key).toBe('string'); - expect(flag.target.value).toBeDefined(); + it('boolean flags have valid onPayload and boolean defaultValue', () => { + const boolFlags = FLAG_REGISTRY.filter((f): f is BooleanFlagDef => f.kind === 'boolean'); + expect(boolFlags.length).toBeGreaterThan(0); + for (const flag of boolFlags) { + expect( + typeof flag.onPayload === 'string' || typeof flag.onPayload === 'boolean', + `${flag.id}: onPayload must be string or boolean`, + ).toBe(true); + expect(typeof flag.defaultValue, `${flag.id}: defaultValue must be boolean`).toBe('boolean'); + } + }); + + it('env boolean flags have string onPayload (env vars are strings)', () => { + const envBoolFlags = FLAG_REGISTRY + .filter((f): f is BooleanFlagDef => f.kind === 'boolean' && f.target.type === 'env'); + for (const flag of envBoolFlags) { + expect( + typeof flag.onPayload, + `${flag.id}: env boolean flag must have string onPayload`, + ).toBe('string'); + } + }); + + it('enum flags have non-empty values array', () => { + const enumFlags = FLAG_REGISTRY.filter((f): f is EnumFlagDef => f.kind === 'enum'); + for (const flag of enumFlags) { + expect(flag.values.length, `${flag.id}: values must be non-empty`).toBeGreaterThan(0); + } + }); + + it('enum flags: neutralValue is a member of values when defined', () => { + const enumFlags = FLAG_REGISTRY.filter((f): f is EnumFlagDef => f.kind === 'enum'); + for (const flag of enumFlags) { + if (flag.neutralValue !== undefined) { + expect( + flag.values, + `${flag.id}: neutralValue '${flag.neutralValue}' must be in values`, + ).toContain(flag.neutralValue); } } }); - it('has unique target keys (no duplicate env var or setting keys)', () => { - const envKeys = FLAG_REGISTRY - .filter(f => f.target.type === 'env') - .map(f => f.target.key); - const settingKeys = FLAG_REGISTRY - .filter(f => f.target.type === 'setting') - .map(f => f.target.key); - expect(new Set(envKeys).size).toBe(envKeys.length); - expect(new Set(settingKeys).size).toBe(settingKeys.length); + it('enum flags: defaultValue is a member of values when defined', () => { + const enumFlags = FLAG_REGISTRY.filter((f): f is EnumFlagDef => f.kind === 'enum'); + for (const flag of enumFlags) { + if (flag.defaultValue !== undefined) { + expect( + flag.values, + `${flag.id}: defaultValue '${flag.defaultValue}' must be in values`, + ).toContain(flag.defaultValue); + } + } + }); + + it('number flags have valid bounds (min <= max when both defined)', () => { + const numFlags = FLAG_REGISTRY.filter((f): f is NumberFlagDef => f.kind === 'number'); + for (const flag of numFlags) { + if (flag.min !== undefined && flag.max !== undefined) { + expect(flag.min, `${flag.id}: min must be <= max`).toBeLessThanOrEqual(flag.max); + } + } + }); + + it('string flags have positive maxLength when defined', () => { + const strFlags = FLAG_REGISTRY.filter((f): f is StringFlagDef => f.kind === 'string'); + for (const flag of strFlags) { + if (flag.maxLength !== undefined) { + expect(flag.maxLength, `${flag.id}: maxLength must be > 0`).toBeGreaterThan(0); + } + } + }); + + it('every flag id is free of whitespace and control chars', () => { + for (const flag of FLAG_REGISTRY) { + expect(flag.id).toMatch(/^[a-z0-9-]+$/); + } + }); +}); + +// ─── getDefaultFlagsRecord ──────────────────────────────────────────────────── + +describe('getDefaultFlagsRecord', () => { + it('includes every registered flag ID', () => { + const record = getDefaultFlagsRecord(); + for (const flag of FLAG_REGISTRY) { + expect(Object.prototype.hasOwnProperty.call(record, flag.id), `missing: ${flag.id}`).toBe(true); + } + expect(Object.keys(record).length).toBe(FLAG_REGISTRY.length); + }); + + it('pinned default record — update intentionally when registry changes', () => { + const record = getDefaultFlagsRecord(); + + // Recommended (default ON) boolean flags + expect(record['tui']).toBe(true); + expect(record['tool-search']).toBe(true); + expect(record['lsp']).toBe(true); + expect(record['prompt-caching-1h']).toBe(true); + expect(record['show-turn-duration']).toBe(true); + expect(record['clear-context-on-plan']).toBe(true); + expect(record['disable-bundled-skills']).toBe(true); + expect(record['pin-sonnet-4-6']).toBe(true); + + // New recommended number flag + expect(record['max-concurrent-subagents']).toBe(40); + + // Optional boolean flags (default OFF = false = neutral) + expect(record['brief']).toBe(false); + expect(record['thinking-summaries']).toBe(false); + expect(record['subprocess-env-scrub']).toBe(false); + expect(record['disable-nonessential-traffic']).toBe(false); + expect(record['forked-subagents']).toBe(false); + expect(record['disable-adaptive-thinking']).toBe(false); + expect(record['always-thinking']).toBe(false); + expect(record['disable-git-instructions']).toBe(false); + expect(record['disable-compact']).toBe(false); + expect(record['disable-1m-context']).toBe(false); + expect(record['disable-autoupdater']).toBe(false); + expect(record['agent-teams']).toBe(false); + + // New optional flags with undefined defaultValue → null + expect(record['subagent-spawn-depth']).toBeNull(); + expect(record['workflow-size-guideline']).toBeNull(); + expect(record['default-model']).toBeNull(); + expect(record['goal-checkin-minutes']).toBeNull(); + expect(record['spellcheck']).toBeNull(); + + // New optional boolean flag + expect(record['enable-todo-tools']).toBe(false); + + // view-mode: default is neutralValue, so entry is 'default' + expect(record['view-mode']).toBe('default'); + }); +}); + +// ─── formatFlagValue — vocabulary table (CONS-H1) ──────────────────────────── + +describe('formatFlagValue — vocabulary table', () => { + const boolFlag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + const enumFlag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; // neutralValue = 'default' + const numFlag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + const strFlag = FLAG_REGISTRY.find(f => f.id === 'spellcheck')!; + + // D-EFFDV: formatFlagValue routes through effectiveDisplay — vocabulary updated + // from enabled/disabled/unset to on/off/ (never 'unset'). + it('boolean true → on', () => { + expect(formatFlagValue(boolFlag, true)).toBe('on'); + }); + it('boolean false → off (not unset; false is neutral but still renders as off)', () => { + expect(formatFlagValue(boolFlag, false)).toBe('off'); + }); + it('boolean null → off (boolean null treated same as false)', () => { + expect(formatFlagValue(boolFlag, null)).toBe('off'); + }); + it('enum neutral value → effective neutral text (default for view-mode)', () => { + expect(formatFlagValue(enumFlag, 'default')).toBe('default'); + }); + it('enum active value → string', () => { + expect(formatFlagValue(enumFlag, 'verbose')).toBe('verbose'); + }); + it('enum null → neutralValue text (default for view-mode)', () => { + expect(formatFlagValue(enumFlag, null)).toBe('default'); + }); + it('number null → devflow defaultValue string (40 for max-concurrent-subagents)', () => { + expect(formatFlagValue(numFlag, null)).toBe('40'); + }); + it('number active value → string', () => { + expect(formatFlagValue(numFlag, 40)).toBe('40'); + }); + it('string null → — (em-dash placeholder)', () => { + expect(formatFlagValue(strFlag, null)).toBe('—'); + }); + it('string active value → string', () => { + expect(formatFlagValue(strFlag, 'aspell')).toBe('aspell'); + }); +}); + +// ─── defaultValueOf ─────────────────────────────────────────────────────────── + +describe('defaultValueOf', () => { + it('boolean flag → flag.defaultValue (boolean)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + expect(defaultValueOf(flag)).toBe(flag.defaultValue); + expect(typeof defaultValueOf(flag)).toBe('boolean'); + }); + it('enum flag with defaultValue → that value', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; + expect(defaultValueOf(flag)).toBe('default'); + }); + it('number flag with defaultValue → that value', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(defaultValueOf(flag)).toBe(40); + }); + it('number flag without defaultValue → null', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'subagent-spawn-depth')!; + expect(defaultValueOf(flag)).toBeNull(); + }); + it('string flag without defaultValue → null', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'spellcheck')!; + expect(defaultValueOf(flag)).toBeNull(); + }); +}); + +// ─── neutralValueOf ─────────────────────────────────────────────────────────── + +describe('neutralValueOf', () => { + it('boolean flag → false', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + expect(neutralValueOf(flag)).toBe(false); + }); + + it('enum flag without neutralValue → null', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline')!; + expect(neutralValueOf(flag)).toBeNull(); + }); + + it('enum flag with neutralValue → that value', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; + expect(neutralValueOf(flag)).toBe('default'); + }); + + it('number flag → null', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(neutralValueOf(flag)).toBeNull(); + }); + + it('string flag → null', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'spellcheck')!; + expect(neutralValueOf(flag)).toBeNull(); + }); +}); + +// ─── isNeutral ──────────────────────────────────────────────────────────────── + +describe('isNeutral', () => { + it('null is always neutral', () => { + for (const flag of FLAG_REGISTRY) { + expect(isNeutral(flag, null), `${flag.id}: null`).toBe(true); + } + }); + + it('false is neutral for boolean flags', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + expect(isNeutral(flag, false)).toBe(true); + }); + + it('true is NOT neutral for boolean flags', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + expect(isNeutral(flag, true)).toBe(false); + }); + + it('0 is NOT neutral for number flags (ACTIVE)', () => { + // Number 0 is an explicit value (e.g. goal-checkin-minutes 0 = off, but still ACTIVE) + const flag = FLAG_REGISTRY.find(f => f.id === 'goal-checkin-minutes')!; + expect(isNeutral(flag, 0)).toBe(false); + }); + + it('neutralValue is neutral for enum flags', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; + expect(isNeutral(flag, 'default')).toBe(true); + }); + + it('non-neutral enum value is not neutral', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; + expect(isNeutral(flag, 'verbose')).toBe(false); + expect(isNeutral(flag, 'focus')).toBe(false); + }); + + it('non-null string is not neutral for string flag', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'spellcheck')!; + expect(isNeutral(flag, 'aspell')).toBe(false); + }); + + it('non-null number is not neutral for number flag', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(isNeutral(flag, 40)).toBe(false); + expect(isNeutral(flag, 1)).toBe(false); }); }); -describe('getDefaultFlags', () => { - it('returns IDs of flags where defaultEnabled is true', () => { - const defaults = getDefaultFlags(); - // Hard-coded to catch unintended additions/removals from the default-on set. - // Update this list intentionally when the registry changes. - const expected = [ - 'tui', - 'tool-search', - 'lsp', - 'prompt-caching-1h', - 'show-turn-duration', - 'clear-context-on-plan', - 'disable-bundled-skills', - 'pin-sonnet-4-6', - ]; - expect(defaults).toEqual(expected); +// ─── coerceFlagValue ────────────────────────────────────────────────────────── + +describe('coerceFlagValue — hostile-value sink cases', () => { + const numFlag = (): NumberFlagDef => + FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents') as NumberFlagDef; + const goalFlag = (): NumberFlagDef => + FLAG_REGISTRY.find(f => f.id === 'goal-checkin-minutes') as NumberFlagDef; + const enumFlag = (): EnumFlagDef => + FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline') as EnumFlagDef; + const strFlag = (): StringFlagDef => + FLAG_REGISTRY.find(f => f.id === 'spellcheck') as StringFlagDef; + const boolFlag = (): BooleanFlagDef => + FLAG_REGISTRY.find(f => f.id === 'tui') as BooleanFlagDef; + + it('null → null (passes through)', () => { + expect(coerceFlagValue(numFlag(), null)).toBeNull(); + }); + + it('Infinity → null (hostile)', () => { + expect(coerceFlagValue(numFlag(), Infinity)).toBeNull(); + }); + + it('NaN → null (hostile)', () => { + expect(coerceFlagValue(numFlag(), NaN)).toBeNull(); + }); + + it('1e309 (overflows to Infinity) → null (hostile)', () => { + expect(coerceFlagValue(numFlag(), 1e309)).toBeNull(); + }); + + it('-Infinity → null (hostile)', () => { + expect(coerceFlagValue(numFlag(), -Infinity)).toBeNull(); + }); + + it('number below min → null (max-concurrent-subagents min: 1)', () => { + expect(coerceFlagValue(numFlag(), 0)).toBeNull(); + }); + + it('number above max → null (max-concurrent-subagents max: 100)', () => { + expect(coerceFlagValue(numFlag(), 101)).toBeNull(); + }); + + it('non-integer when integer required → null', () => { + expect(coerceFlagValue(numFlag(), 1.5)).toBeNull(); + }); + + it('valid finite integer in bounds → passes', () => { + expect(coerceFlagValue(numFlag(), 40)).toBe(40); + expect(coerceFlagValue(numFlag(), 1)).toBe(1); + expect(coerceFlagValue(numFlag(), 100)).toBe(100); + }); + + it('goal-checkin-minutes: 0 passes (min: 0)', () => { + expect(coerceFlagValue(goalFlag(), 0)).toBe(0); + }); + + it('goal-checkin-minutes: 1441 rejected (max: 1440)', () => { + expect(coerceFlagValue(goalFlag(), 1441)).toBeNull(); + }); + + it('valid enum value → passes', () => { + expect(coerceFlagValue(enumFlag(), 'small')).toBe('small'); + expect(coerceFlagValue(enumFlag(), 'unrestricted')).toBe('unrestricted'); + }); + + it('invalid enum value → null', () => { + expect(coerceFlagValue(enumFlag(), 'huge')).toBeNull(); + expect(coerceFlagValue(enumFlag(), '')).toBeNull(); + }); + + it('string within maxLength → passes (spellcheck maxLength: 256)', () => { + expect(coerceFlagValue(strFlag(), 'aspell')).toBe('aspell'); + expect(coerceFlagValue(strFlag(), 'a'.repeat(256))).toBe('a'.repeat(256)); + }); + + it('overlong string → null', () => { + expect(coerceFlagValue(strFlag(), 'a'.repeat(257))).toBeNull(); + }); + + it('control chars in string → null', () => { + expect(coerceFlagValue(strFlag(), 'aspell\x00check')).toBeNull(); + expect(coerceFlagValue(strFlag(), 'aspell\x1fcheck')).toBeNull(); + expect(coerceFlagValue(strFlag(), 'aspell\x7fcheck')).toBeNull(); + }); + + it('LF in string → null (SEC-M1: LF is a shell statement separator)', () => { + // \x0a is LF — rejected so `spellcheck` cannot embed a second shell command + expect(coerceFlagValue(strFlag(), 'aspell\nlist')).toBeNull(); + expect(coerceFlagValue(strFlag(), 'aspell\x0acheck')).toBeNull(); + }); + + it('TAB in string → accepted (the sole documented exception)', () => { + expect(coerceFlagValue(strFlag(), 'aspell\tlist')).toBe('aspell\tlist'); + }); + + it('empty string → null (empty is UNSET, never an active value)', () => { + expect(coerceFlagValue(strFlag(), '')).toBeNull(); + }); + + it('valid boolean → passes', () => { + expect(coerceFlagValue(boolFlag(), true)).toBe(true); + expect(coerceFlagValue(boolFlag(), false)).toBe(false); + }); + + it('non-boolean for boolean flag → null', () => { + expect(coerceFlagValue(boolFlag(), 'true')).toBeNull(); + expect(coerceFlagValue(boolFlag(), 1)).toBeNull(); + }); + + it('non-number for number flag → null', () => { + expect(coerceFlagValue(numFlag(), '40')).toBeNull(); + }); + + it('non-string for enum flag → null', () => { + expect(coerceFlagValue(enumFlag(), 42)).toBeNull(); }); }); -describe('applyFlags', () => { - it('adds env vars for env-type flags', () => { +// ─── applyFlags (FlagsRecord) ───────────────────────────────────────────────── + +describe('applyFlags — FlagsRecord API', () => { + it('boolean true → applies onPayload for env flag', () => { const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(applyFlags(input, ['tool-search'])); + const result = JSON.parse(applyFlags(input, { 'tool-search': true })); expect(result.env.ENABLE_TOOL_SEARCH).toBe('true'); }); - it('adds top-level settings for setting-type flags', () => { + it('boolean true → applies onPayload for setting flag (string value)', () => { const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(applyFlags(input, ['clear-context-on-plan'])); - expect(result.showClearContextOnPlanAccept).toBe(true); + const result = JSON.parse(applyFlags(input, { tui: true })); + expect(result.tui).toBe('fullscreen'); }); - it('applies string-value setting (tui → "fullscreen")', () => { + it('boolean true → applies onPayload for setting flag (boolean value)', () => { const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(applyFlags(input, ['tui'])); - expect(result.tui).toBe('fullscreen'); + const result = JSON.parse(applyFlags(input, { 'show-turn-duration': true })); + expect(result.showTurnDuration).toBe(true); + }); + + it('boolean false (neutral) → deletes env var key', () => { + const input = JSON.stringify({ + env: { ENABLE_TOOL_SEARCH: 'true', OTHER: 'keep' }, + }, null, 2); + const result = JSON.parse(applyFlags(input, { 'tool-search': false })); + expect(result.env?.ENABLE_TOOL_SEARCH).toBeUndefined(); + expect(result.env?.OTHER).toBe('keep'); + }); + + it('boolean false (neutral) → deletes setting key', () => { + const input = JSON.stringify({ tui: 'fullscreen', hooks: {} }, null, 2); + const result = JSON.parse(applyFlags(input, { tui: false })); + expect(result.tui).toBeUndefined(); + expect(result.hooks).toEqual({}); + }); + + it('null (neutral) → deletes env var key', () => { + const input = JSON.stringify({ + env: { CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: '40' }, + }, null, 2); + const result = JSON.parse(applyFlags(input, { 'max-concurrent-subagents': null })); + expect(result.env?.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS).toBeUndefined(); }); - it('applies all registered flags at once', () => { - const allIds = FLAG_REGISTRY.map(f => f.id); + it('number flag → env gets stringified value ("40" not 40)', () => { const input = JSON.stringify({}, null, 2); - const result = JSON.parse(applyFlags(input, allIds)); - for (const flag of FLAG_REGISTRY) { - if (flag.target.type === 'env') { - expect(result.env[flag.target.key]).toBe(flag.target.value); - } else { - expect(result[flag.target.key]).toBe(flag.target.value); - } - } + const result = JSON.parse(applyFlags(input, { 'max-concurrent-subagents': 40 })); + expect(result.env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS).toBe('40'); + expect(typeof result.env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS).toBe('string'); + }); + + it('number 0 → active (writes "0" to env)', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { 'goal-checkin-minutes': 0 })); + expect(result.env.CLAUDE_CODE_GOAL_CHECKIN_MINUTES).toBe('0'); + }); + + it('enum neutralValue → deletes setting key (view-mode: default removes viewMode)', () => { + const input = JSON.stringify({ viewMode: 'verbose', hooks: {} }, null, 2); + const result = JSON.parse(applyFlags(input, { 'view-mode': 'default' })); + expect(result.viewMode).toBeUndefined(); + expect(result.hooks).toEqual({}); + }); + + it('enum non-neutral → applies value (view-mode: verbose)', () => { + const input = JSON.stringify({ hooks: {} }, null, 2); + const result = JSON.parse(applyFlags(input, { 'view-mode': 'verbose' })); + expect(result.viewMode).toBe('verbose'); + }); + + it('enum non-neutral → applies value (view-mode: focus)', () => { + const input = JSON.stringify({ hooks: {} }, null, 2); + const result = JSON.parse(applyFlags(input, { 'view-mode': 'focus' })); + expect(result.viewMode).toBe('focus'); + }); + + it('spellcheck (string wrapKey) → writes {command: value} to setting key', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { spellcheck: 'aspell' })); + expect(result.spellcheck).toEqual({ command: 'aspell' }); + }); + + it('unknown flag IDs are skipped (forward compat)', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { 'nonexistent-future-flag': true })); + // No effect — env or setting not created + expect(result.env).toBeUndefined(); + }); + + it('__proto__ as id is skipped (prototype pollution guard)', () => { + const input = JSON.stringify({}, null, 2); + // Should not throw or pollute __proto__ as an own property + expect(() => applyFlags(input, { __proto__: true } as unknown as FlagsRecord)).not.toThrow(); + const result = JSON.parse(applyFlags(input, { __proto__: true } as unknown as FlagsRecord)); + // result['__proto__'] always resolves to Object.prototype via the prototype chain; + // check OWN-property presence to verify no prototype pollution occurred. + expect(Object.hasOwn(result, '__proto__')).toBe(false); + }); + + it('env object created on demand when first env flag is applied', () => { + const input = JSON.stringify({ hooks: {} }, null, 2); + const result = JSON.parse(applyFlags(input, { 'tool-search': true })); + expect(result.env).toBeDefined(); + expect(result.env.ENABLE_TOOL_SEARCH).toBe('true'); + }); + + it('env object cleaned up when all flags become neutral', () => { + const input = JSON.stringify({ env: { ENABLE_TOOL_SEARCH: 'true' } }, null, 2); + const result = JSON.parse(applyFlags(input, { 'tool-search': false })); + expect(result.env).toBeUndefined(); }); it('applies multiple flags at once', () => { const input = JSON.stringify({}, null, 2); - const result = JSON.parse(applyFlags(input, ['tool-search', 'lsp', 'clear-context-on-plan'])); + const result = JSON.parse(applyFlags(input, { + 'tool-search': true, + lsp: true, + 'clear-context-on-plan': true, + })); expect(result.env.ENABLE_TOOL_SEARCH).toBe('true'); expect(result.env.ENABLE_LSP_TOOL).toBe('true'); expect(result.showClearContextOnPlanAccept).toBe(true); }); - it('preserves existing settings', () => { + it('preserves existing non-flag settings', () => { const input = JSON.stringify({ hooks: { Stop: [] }, statusLine: { type: 'command' }, env: { EXISTING_VAR: 'keep' }, }, null, 2); - const result = JSON.parse(applyFlags(input, ['tool-search'])); + const result = JSON.parse(applyFlags(input, { 'tool-search': true })); expect(result.hooks).toEqual({ Stop: [] }); expect(result.statusLine).toEqual({ type: 'command' }); expect(result.env.EXISTING_VAR).toBe('keep'); expect(result.env.ENABLE_TOOL_SEARCH).toBe('true'); }); - it('ignores unknown flag IDs', () => { - const input = JSON.stringify({}, null, 2); - const result = JSON.parse(applyFlags(input, ['nonexistent-flag'])); - expect(result.env).toBeUndefined(); - }); - - it('returns unchanged JSON when no flags provided', () => { + it('returns unchanged JSON when empty record provided', () => { const input = JSON.stringify({ hooks: {} }, null, 2); - const result = applyFlags(input, []); + const result = applyFlags(input, {}); expect(JSON.parse(result)).toEqual({ hooks: {} }); }); }); -describe('stripFlags', () => { +// ─── stripFlags ─────────────────────────────────────────────────────────────── + +describe('stripFlags — covers viewMode and spellcheck', () => { it('removes env vars managed by flags', () => { const input = JSON.stringify({ env: { @@ -163,10 +640,7 @@ describe('stripFlags', () => { }); it('removes string-valued setting (tui) when stripped', () => { - const input = JSON.stringify({ - tui: 'fullscreen', - hooks: {}, - }, null, 2); + const input = JSON.stringify({ tui: 'fullscreen', hooks: {} }, null, 2); const result = JSON.parse(stripFlags(input)); expect(result.tui).toBeUndefined(); expect(result.hooks).toEqual({}); @@ -181,8 +655,21 @@ describe('stripFlags', () => { expect(result.env).toBeUndefined(); }); + it('removes viewMode (via view-mode registry entry)', () => { + const input = JSON.stringify({ viewMode: 'verbose', hooks: {} }, null, 2); + const result = JSON.parse(stripFlags(input)); + expect(result.viewMode).toBeUndefined(); + expect(result.hooks).toEqual({}); + }); + + it('removes spellcheck setting when present', () => { + const input = JSON.stringify({ spellcheck: { command: 'aspell' }, hooks: {} }, null, 2); + const result = JSON.parse(stripFlags(input)); + expect(result.spellcheck).toBeUndefined(); + expect(result.hooks).toEqual({}); + }); + it('removes CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS when agent-teams flag is registered', () => { - // agent-teams is now a registered flag, so stripFlags removes its env var const input = JSON.stringify({ env: { ENABLE_TOOL_SEARCH: 'true', @@ -208,231 +695,547 @@ describe('stripFlags', () => { expect(result).toEqual({ hooks: {} }); }); - it('is inverse of applyFlags (roundtrip)', () => { + it('"env": [] in settings does not delete user keys (TS-M3: asPlainObject guard)', () => { + // A malformed "env": [] (array, not object) must not match the empty-object + // cleanup guard (Object.keys([]).length === 0 is true) and delete the env key. + // stripFlags should leave an array env unchanged. + const input = JSON.stringify({ env: [], hooks: {} }, null, 2); + const result = JSON.parse(stripFlags(input)); + // Array env is not a valid env block and must survive unchanged + expect(Array.isArray(result.env)).toBe(true); + expect(result.hooks).toEqual({}); + }); + + it('strip-then-apply is idempotent (INV-1): roundtrip preserves only non-flag settings', () => { const base = JSON.stringify({ hooks: { Stop: [] }, env: { CUSTOM: 'value' }, }, null, 2); - const withFlags = applyFlags(base, ['tool-search', 'lsp', 'clear-context-on-plan']); + const withFlags = applyFlags(base, { 'tool-search': true, lsp: true, 'clear-context-on-plan': true }); const stripped = stripFlags(withFlags); const result = JSON.parse(stripped); - expect(result.env.ENABLE_TOOL_SEARCH).toBeUndefined(); - expect(result.env.ENABLE_LSP_TOOL).toBeUndefined(); + expect(result.env?.ENABLE_TOOL_SEARCH).toBeUndefined(); + expect(result.env?.ENABLE_LSP_TOOL).toBeUndefined(); expect(result.showClearContextOnPlanAccept).toBeUndefined(); - expect(result.env.CUSTOM).toBe('value'); + expect(result.viewMode).toBeUndefined(); + expect(result.env?.CUSTOM).toBe('value'); expect(result.hooks).toEqual({ Stop: [] }); }); - it('roundtrip with all registered flags', () => { - const allIds = FLAG_REGISTRY.map(f => f.id); + it('roundtrip with all registered flags (full record)', () => { + const record = getDefaultFlagsRecord(); const base = JSON.stringify({ hooks: { Stop: [] }, env: { CUSTOM: 'value' }, }, null, 2); - const result = JSON.parse(stripFlags(applyFlags(base, allIds))); + const result = JSON.parse(stripFlags(applyFlags(base, record))); for (const flag of FLAG_REGISTRY) { if (flag.target.type === 'env') { - expect(result.env?.[flag.target.key]).toBeUndefined(); + expect(result.env?.[flag.target.key], `${flag.id}: env key`).toBeUndefined(); } else { - expect(result[flag.target.key]).toBeUndefined(); + expect(result[flag.target.key], `${flag.id}: setting key`).toBeUndefined(); } } - expect(result.env.CUSTOM).toBe('value'); + expect(result.env?.CUSTOM).toBe('value'); expect(result.hooks).toEqual({ Stop: [] }); }); }); -describe('agent-teams flag', () => { +// ─── New flag: max-concurrent-subagents ────────────────────────────────────── + +describe('max-concurrent-subagents flag', () => { it('is registered in FLAG_REGISTRY', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'agent-teams'); - expect(flag).toBeDefined(); + expect(FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')).toBeDefined(); }); - it('is defaultEnabled: false (opt-in, not default)', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'agent-teams')!; - expect(flag.defaultEnabled).toBe(false); + it('is kind: number, recommended: true', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(flag.kind).toBe('number'); + expect(flag.recommended).toBe(true); }); - it('maps to CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'agent-teams')!; - expect(flag.target.type).toBe('env'); - if (flag.target.type === 'env') { - expect(flag.target.key).toBe('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'); - expect(flag.target.value).toBe('1'); - } + it('defaultValue: 40, min: 1, max: 100, integer: true, upstreamDefault: 20', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents') as NumberFlagDef; + expect(flag.defaultValue).toBe(40); + expect(flag.min).toBe(1); + expect(flag.max).toBe(100); + expect(flag.integer).toBe(true); + expect(flag.upstreamDefault).toBe(20); }); - it('is NOT in getDefaultFlags() (off by default)', () => { - const defaults = getDefaultFlags(); - expect(defaults).not.toContain('agent-teams'); + it('target is env CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(flag.target.type).toBe('env'); + expect(flag.target.key).toBe('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS'); }); - it('applyFlags adds CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS when agent-teams is enabled', () => { - const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(applyFlags(input, ['agent-teams'])); - expect(result.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1'); + it('applyFlags writes "40" (string) to env', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { 'max-concurrent-subagents': 40 })); + expect(result.env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS).toBe('40'); + expect(typeof result.env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS).toBe('string'); }); - it('stripFlags removes CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', () => { - const input = JSON.stringify({ - env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', CUSTOM: 'keep' }, - }, null, 2); - const result = JSON.parse(stripFlags(input)); - expect(result.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined(); - expect(result.env?.CUSTOM).toBe('keep'); + it('coerceFlagValue rejects 0 (below min: 1)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(coerceFlagValue(flag, 0)).toBeNull(); }); - it('roundtrip: apply then strip is idempotent', () => { - const base = JSON.stringify({ hooks: { Stop: [] } }, null, 2); - const applied = applyFlags(base, ['agent-teams']); - const stripped = stripFlags(applied); - const result = JSON.parse(stripped); - expect(result.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined(); - expect(result.hooks).toEqual({ Stop: [] }); + it('coerceFlagValue rejects 101 (above max: 100)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(coerceFlagValue(flag, 101)).toBeNull(); }); }); -describe('disable-bundled-skills flag', () => { - it('is registered in FLAG_REGISTRY', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'disable-bundled-skills'); - expect(flag).toBeDefined(); - }); +// ─── New flag: subagent-spawn-depth ────────────────────────────────────────── - it('is defaultEnabled: true', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'disable-bundled-skills')!; - expect(flag.defaultEnabled).toBe(true); +describe('subagent-spawn-depth flag', () => { + it('is registered, kind: number, recommended: false', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'subagent-spawn-depth') as NumberFlagDef; + expect(flag).toBeDefined(); + expect(flag.kind).toBe('number'); + expect(flag.recommended).toBe(false); }); - it('maps to disableBundledSkills setting = true', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'disable-bundled-skills')!; - expect(flag.target.type).toBe('setting'); - if (flag.target.type === 'setting') { - expect(flag.target.key).toBe('disableBundledSkills'); - expect(flag.target.value).toBe(true); - } + it('min: 1, max: 10, integer: true, upstreamDefault: 3, defaultValue: undefined', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'subagent-spawn-depth') as NumberFlagDef; + expect(flag.min).toBe(1); + expect(flag.max).toBe(10); + expect(flag.integer).toBe(true); + expect(flag.upstreamDefault).toBe(3); + expect(flag.defaultValue).toBeUndefined(); }); - it('is in getDefaultFlags() (on by default)', () => { - expect(getDefaultFlags()).toContain('disable-bundled-skills'); + it('target is env CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'subagent-spawn-depth')!; + expect(flag.target.key).toBe('CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH'); }); }); -describe('pin-sonnet-4-6 flag', () => { - it('is registered in FLAG_REGISTRY', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'pin-sonnet-4-6'); +// ─── New flag: workflow-size-guideline ─────────────────────────────────────── + +describe('workflow-size-guideline flag', () => { + it('is registered, kind: enum, recommended: false', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline') as EnumFlagDef; expect(flag).toBeDefined(); + expect(flag.kind).toBe('enum'); + expect(flag.recommended).toBe(false); }); - it('is defaultEnabled: true', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'pin-sonnet-4-6')!; - expect(flag.defaultEnabled).toBe(true); + it('values: small | medium | large | unrestricted', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline') as EnumFlagDef; + expect(flag.values).toContain('small'); + expect(flag.values).toContain('medium'); + expect(flag.values).toContain('large'); + expect(flag.values).toContain('unrestricted'); }); - it('maps to ANTHROPIC_DEFAULT_SONNET_MODEL env var = claude-sonnet-4-6', () => { - const flag = FLAG_REGISTRY.find(f => f.id === 'pin-sonnet-4-6')!; - expect(flag.target.type).toBe('env'); - if (flag.target.type === 'env') { - expect(flag.target.key).toBe('ANTHROPIC_DEFAULT_SONNET_MODEL'); - expect(flag.target.value).toBe('claude-sonnet-4-6'); - } + it('target is setting workflowSizeGuideline', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline')!; + expect(flag.target.type).toBe('setting'); + expect(flag.target.key).toBe('workflowSizeGuideline'); }); - it('is in getDefaultFlags() (on by default)', () => { - expect(getDefaultFlags()).toContain('pin-sonnet-4-6'); + it('applyFlags writes enum value to setting', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { 'workflow-size-guideline': 'large' })); + expect(result.workflowSizeGuideline).toBe('large'); + }); + + it('coerceFlagValue rejects invalid value', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline')!; + expect(coerceFlagValue(flag, 'huge')).toBeNull(); }); }); -describe('applyViewMode', () => { - it('sets viewMode to verbose', () => { - const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(applyViewMode(input, 'verbose')); - expect(result.viewMode).toBe('verbose'); +// ─── New flag: default-model ────────────────────────────────────────────────── + +describe('default-model flag', () => { + it('is registered, kind: string, maxLength: 64', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'default-model') as StringFlagDef; + expect(flag).toBeDefined(); + expect(flag.kind).toBe('string'); + expect(flag.maxLength).toBe(64); }); - it('sets viewMode to focus', () => { - const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(applyViewMode(input, 'focus')); - expect(result.viewMode).toBe('focus'); + it('target is env ANTHROPIC_DEFAULT_MODEL', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'default-model')!; + expect(flag.target.type).toBe('env'); + expect(flag.target.key).toBe('ANTHROPIC_DEFAULT_MODEL'); }); - it('removes viewMode key when mode is default', () => { - const input = JSON.stringify({ hooks: {}, viewMode: 'verbose' }, null, 2); - const result = JSON.parse(applyViewMode(input, 'default')); - expect(result.viewMode).toBeUndefined(); + it('applyFlags writes model name to env', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { 'default-model': 'claude-opus-4-5' })); + expect(result.env.ANTHROPIC_DEFAULT_MODEL).toBe('claude-opus-4-5'); }); +}); - it('does not add viewMode key when mode is default and key is absent', () => { - const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(applyViewMode(input, 'default')); - expect(result.viewMode).toBeUndefined(); - expect(Object.keys(result)).not.toContain('viewMode'); +// ─── New flag: enable-todo-tools ───────────────────────────────────────────── + +describe('enable-todo-tools flag', () => { + it('is registered, kind: boolean, recommended: false', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'enable-todo-tools') as BooleanFlagDef; + expect(flag).toBeDefined(); + expect(flag.kind).toBe('boolean'); + expect(flag.recommended).toBe(false); + expect(flag.defaultValue).toBe(false); }); - it('preserves existing settings when applying view mode', () => { - const input = JSON.stringify({ - hooks: { Stop: [] }, - env: { EXISTING: 'keep' }, - }, null, 2); - const result = JSON.parse(applyViewMode(input, 'focus')); - expect(result.hooks).toEqual({ Stop: [] }); - expect(result.env.EXISTING).toBe('keep'); - expect(result.viewMode).toBe('focus'); + it('onPayload is "1" and target is env CLAUDE_CODE_ENABLE_TODO_TOOLS', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'enable-todo-tools') as BooleanFlagDef; + expect(flag.onPayload).toBe('1'); + expect(flag.target.key).toBe('CLAUDE_CODE_ENABLE_TODO_TOOLS'); }); - it('overwrites an existing viewMode value', () => { - const input = JSON.stringify({ viewMode: 'verbose' }, null, 2); - const result = JSON.parse(applyViewMode(input, 'focus')); - expect(result.viewMode).toBe('focus'); + it('applyFlags writes "1" when enabled', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { 'enable-todo-tools': true })); + expect(result.env.CLAUDE_CODE_ENABLE_TODO_TOOLS).toBe('1'); + }); +}); + +// ─── New flag: goal-checkin-minutes ────────────────────────────────────────── + +describe('goal-checkin-minutes flag', () => { + it('is registered, kind: number, min: 0, max: 1440, integer: true', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'goal-checkin-minutes') as NumberFlagDef; + expect(flag).toBeDefined(); + expect(flag.kind).toBe('number'); + expect(flag.min).toBe(0); + expect(flag.max).toBe(1440); + expect(flag.integer).toBe(true); + expect(flag.upstreamDefault).toBe(30); + }); + + it('target is env CLAUDE_CODE_GOAL_CHECKIN_MINUTES', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'goal-checkin-minutes')!; + expect(flag.target.key).toBe('CLAUDE_CODE_GOAL_CHECKIN_MINUTES'); + }); + + it('0 is valid (off-signal, still ACTIVE)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'goal-checkin-minutes')!; + expect(coerceFlagValue(flag, 0)).toBe(0); + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { 'goal-checkin-minutes': 0 })); + expect(result.env.CLAUDE_CODE_GOAL_CHECKIN_MINUTES).toBe('0'); + }); +}); + +// ─── New flag: spellcheck ───────────────────────────────────────────────────── + +describe('spellcheck flag', () => { + it('is registered, kind: string, wrapKey: "command", maxLength: 256', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'spellcheck') as StringFlagDef; + expect(flag).toBeDefined(); + expect(flag.kind).toBe('string'); + expect(flag.wrapKey).toBe('command'); + expect(flag.maxLength).toBe(256); + }); + + it('target is setting spellcheck', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'spellcheck')!; + expect(flag.target.type).toBe('setting'); + expect(flag.target.key).toBe('spellcheck'); + }); + + it('applyFlags writes {command: value} to setting', () => { + const input = JSON.stringify({}, null, 2); + const result = JSON.parse(applyFlags(input, { spellcheck: 'aspell --lang=en' })); + expect(result.spellcheck).toEqual({ command: 'aspell --lang=en' }); + }); + + it('null → spellcheck key deleted', () => { + const input = JSON.stringify({ spellcheck: { command: 'aspell' } }, null, 2); + const result = JSON.parse(applyFlags(input, { spellcheck: null })); + expect(result.spellcheck).toBeUndefined(); }); }); -describe('stripViewMode', () => { - it('removes viewMode key', () => { +// ─── New flag: view-mode (fold-in) ──────────────────────────────────────────── + +describe('view-mode flag (fold-in of viewMode)', () => { + it('is registered, kind: enum, neutralValue: "default"', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode') as EnumFlagDef; + expect(flag).toBeDefined(); + expect(flag.kind).toBe('enum'); + expect(flag.neutralValue).toBe('default'); + expect(flag.defaultValue).toBe('default'); + }); + + it('values: default | verbose | focus', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode') as EnumFlagDef; + expect(flag.values).toContain('default'); + expect(flag.values).toContain('verbose'); + expect(flag.values).toContain('focus'); + }); + + it('target is setting viewMode', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; + expect(flag.target.type).toBe('setting'); + expect(flag.target.key).toBe('viewMode'); + }); + + it('"default" (neutral) → removes viewMode key', () => { const input = JSON.stringify({ viewMode: 'verbose', hooks: {} }, null, 2); - const result = JSON.parse(stripViewMode(input)); + const result = JSON.parse(applyFlags(input, { 'view-mode': 'default' })); expect(result.viewMode).toBeUndefined(); expect(result.hooks).toEqual({}); }); - it('handles missing viewMode key gracefully', () => { + it('"verbose" → sets viewMode: "verbose"', () => { const input = JSON.stringify({ hooks: {} }, null, 2); - const result = JSON.parse(stripViewMode(input)); - expect(result).toEqual({ hooks: {} }); + const result = JSON.parse(applyFlags(input, { 'view-mode': 'verbose' })); + expect(result.viewMode).toBe('verbose'); }); - it('preserves all other settings', () => { - const input = JSON.stringify({ - viewMode: 'focus', - hooks: { Stop: [] }, - env: { CUSTOM: 'value' }, - }, null, 2); - const result = JSON.parse(stripViewMode(input)); + it('"focus" → sets viewMode: "focus"', () => { + const input = JSON.stringify({ hooks: {} }, null, 2); + const result = JSON.parse(applyFlags(input, { 'view-mode': 'focus' })); + expect(result.viewMode).toBe('focus'); + }); + + it('stripFlags removes viewMode', () => { + const input = JSON.stringify({ viewMode: 'focus', hooks: {} }, null, 2); + const result = JSON.parse(stripFlags(input)); expect(result.viewMode).toBeUndefined(); - expect(result.hooks).toEqual({ Stop: [] }); - expect(result.env.CUSTOM).toBe('value'); - }); - - it('roundtrip: applyViewMode then stripViewMode restores original', () => { - const base = JSON.stringify({ hooks: { Stop: [] } }, null, 2); - const modes: ViewMode[] = ['verbose', 'focus', 'default']; - for (const mode of modes) { - const applied = applyViewMode(base, mode); - const stripped = stripViewMode(applied); - const result = JSON.parse(stripped); - expect(result.viewMode).toBeUndefined(); - expect(result.hooks).toEqual({ Stop: [] }); + expect(result.hooks).toEqual({}); + }); +}); + +// ─── parseFlagValueInput ────────────────────────────────────────────────────── + +describe('parseFlagValueInput', () => { + it('"unset" → null for any flag', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(parseFlagValueInput(flag, 'unset')).toBeNull(); + }); + + it('parses number string for number flag', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(parseFlagValueInput(flag, '40')).toBe(40); + }); + + it('parses enum value for enum flag', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline')!; + expect(parseFlagValueInput(flag, 'large')).toBe('large'); + }); + + it('parses "true"/"false" for boolean flag', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + expect(parseFlagValueInput(flag, 'true')).toBe(true); + expect(parseFlagValueInput(flag, 'false')).toBe(false); + }); + + it('invalid number string → null', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(parseFlagValueInput(flag, 'notanumber')).toBeNull(); + }); + + it('empty string for number flag → null (empty is UNSET)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + // Number('') === 0 with bare Number(), but strict grammar rejects empty (TS-H1) + expect(parseFlagValueInput(flag, '')).toBeNull(); + }); + + it('hex literal for number flag → null (strict decimal grammar)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + // Number('0x5') === 5 with bare Number(), but hex is rejected (TS-H1) + expect(parseFlagValueInput(flag, '0x5')).toBeNull(); + expect(parseFlagValueInput(flag, '0x28')).toBeNull(); + }); + + it('exponent notation for number flag → null (strict decimal grammar)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + // Number('1e1') === 10 with bare Number(), but exponent form is rejected (TS-H1) + expect(parseFlagValueInput(flag, '1e1')).toBeNull(); + expect(parseFlagValueInput(flag, '2E2')).toBeNull(); + }); + + it('padded number input → null (strict decimal grammar)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + // Number(' 3 ') === 3 with bare Number(), but whitespace is rejected (TS-H1) + expect(parseFlagValueInput(flag, ' 40 ')).toBeNull(); + expect(parseFlagValueInput(flag, ' 40')).toBeNull(); + expect(parseFlagValueInput(flag, '40 ')).toBeNull(); + }); + + it('empty string for string flag → null (empty is UNSET, not active)', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'default-model')!; + // --set default-model= with MODEL unset should not persist ANTHROPIC_DEFAULT_MODEL='' + expect(parseFlagValueInput(flag, '')).toBeNull(); + }); + + it('valid string value → passes through', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'default-model')!; + expect(parseFlagValueInput(flag, 'claude-3-5-sonnet')).toBe('claude-3-5-sonnet'); + }); +}); + +// ─── countActiveFlags ───────────────────────────────────────────────────────── + +describe('countActiveFlags', () => { + it('counts non-neutral values', () => { + const record: FlagsRecord = { + tui: true, // active + brief: false, // neutral (boolean false) + 'view-mode': 'default', // neutral (enum neutralValue) + 'max-concurrent-subagents': 40, // active + spellcheck: null, // neutral (null) + }; + expect(countActiveFlags(record)).toBe(2); + }); + + it('0 in record is ACTIVE (counts it)', () => { + const record: FlagsRecord = { + 'goal-checkin-minutes': 0, // active (0 is an explicit value) + }; + expect(countActiveFlags(record)).toBe(1); + }); + + it('empty record → 0', () => { + expect(countActiveFlags({})).toBe(0); + }); +}); + +// ─── readViewMode ───────────────────────────────────────────────────────────── + +describe('readViewMode', () => { + it('returns ViewMode from view-mode entry', () => { + expect(readViewMode({ 'view-mode': 'verbose' })).toBe('verbose'); + expect(readViewMode({ 'view-mode': 'focus' })).toBe('focus'); + expect(readViewMode({ 'view-mode': 'default' })).toBe('default'); + }); + + it('returns "default" when view-mode is absent', () => { + expect(readViewMode({})).toBe('default'); + }); + + it('returns "default" when view-mode is null or non-ViewMode', () => { + expect(readViewMode({ 'view-mode': null })).toBe('default'); + }); +}); + +// ─── sanitizeFlagsRecord ───────────────────────────────────────────────────── + +describe('sanitizeFlagsRecord', () => { + it('drops invalid non-null values — key absent (adopt default on next init, REL-S1 + ADR-014)', () => { + // Invalid value (above max) is DROPPED rather than becoming null="deliberately unset" + const record: FlagsRecord = { + 'max-concurrent-subagents': 200 as unknown as number, // above max + }; + const sanitized = sanitizeFlagsRecord(record); + // Key must be absent — not null — so the flag is re-adopted on next init + expect(Object.prototype.hasOwnProperty.call(sanitized, 'max-concurrent-subagents')).toBe(false); + }); + + it('preserves explicit null (deliberately unset — ADR-014 key-presence semantics)', () => { + const record: FlagsRecord = { + 'max-concurrent-subagents': null, // explicit null = user deliberately unset this flag + }; + const sanitized = sanitizeFlagsRecord(record); + expect(sanitized['max-concurrent-subagents']).toBeNull(); + }); + + it('preserves valid values', () => { + const record: FlagsRecord = { + tui: true, + 'max-concurrent-subagents': 40, + }; + const sanitized = sanitizeFlagsRecord(record); + expect(sanitized['tui']).toBe(true); + expect(sanitized['max-concurrent-subagents']).toBe(40); + }); + + it('passes through unknown ids with primitive values (forward-compat)', () => { + const record: FlagsRecord = { + 'future-unknown-flag': true, + 'future-unknown-string': 'some-value', + 'future-unknown-null': null, + }; + const sanitized = sanitizeFlagsRecord(record); + expect(sanitized['future-unknown-flag']).toBe(true); + expect(sanitized['future-unknown-string']).toBe('some-value'); + expect(sanitized['future-unknown-null']).toBeNull(); + }); + + it('drops unknown ids with non-primitive values (TS-M3: no launder of objects into FlagsRecordValue)', () => { + const record = { + 'future-unknown-object': { a: 1 } as unknown as boolean, + 'future-unknown-array': [1, 2] as unknown as boolean, + } as FlagsRecord; + const sanitized = sanitizeFlagsRecord(record); + expect(Object.prototype.hasOwnProperty.call(sanitized, 'future-unknown-object')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(sanitized, 'future-unknown-array')).toBe(false); + }); +}); + +// ─── migrateLegacyFlagsToRecord ─────────────────────────────────────────────── + +describe('migrateLegacyFlagsToRecord', () => { + it('knownIds defined: enabled flag → true', () => { + const knownIds = ['tui', 'tool-search']; + const record = migrateLegacyFlagsToRecord(['tui'], knownIds); + expect(record['tui']).toBe(true); + }); + + it('knownIds defined: disabled flag (in knownIds, NOT in enabledIds) → false', () => { + const knownIds = ['tui', 'tool-search']; + const record = migrateLegacyFlagsToRecord(['tui'], knownIds); + expect(record['tool-search']).toBe(false); // deliberate-disable → false + }); + + it('knownIds defined: new flag not in knownIds → NO entry (adopted on next seed)', () => { + const knownIds = ['tui']; // tool-search is new this install + const record = migrateLegacyFlagsToRecord(['tui'], knownIds); + expect(Object.prototype.hasOwnProperty.call(record, 'tool-search')).toBe(false); + }); + + it('knownIds undefined: all current registry boolean flags get entries', () => { + const record = migrateLegacyFlagsToRecord(['tui']); + // All registry boolean flags should have entries + for (const flag of FLAG_REGISTRY) { + if (flag.kind === 'boolean') { + expect(Object.prototype.hasOwnProperty.call(record, flag.id), flag.id).toBe(true); + } } }); + + it('unknown enabled IDs (not in registry) preserved as true', () => { + const record = migrateLegacyFlagsToRecord(['tui', 'future-unknown-flag'], ['tui', 'future-unknown-flag']); + expect(record['future-unknown-flag']).toBe(true); + }); + + it('viewMode fold: legacyViewMode "focus" → view-mode: "focus"', () => { + const record = migrateLegacyFlagsToRecord([], [], 'focus'); + expect(record['view-mode']).toBe('focus'); + }); + + it('viewMode fold: legacyViewMode "verbose" → view-mode: "verbose"', () => { + const record = migrateLegacyFlagsToRecord([], [], 'verbose'); + expect(record['view-mode']).toBe('verbose'); + }); + + it('viewMode fold: undefined legacyViewMode → view-mode: "default"', () => { + const record = migrateLegacyFlagsToRecord([], []); + expect(record['view-mode']).toBe('default'); + }); + + it('view-mode always has an entry regardless of knownIds', () => { + const record1 = migrateLegacyFlagsToRecord(['tui'], ['tui']); // view-mode not in knownIds + expect(Object.prototype.hasOwnProperty.call(record1, 'view-mode')).toBe(true); + const record2 = migrateLegacyFlagsToRecord(['tui']); + expect(Object.prototype.hasOwnProperty.call(record2, 'view-mode')).toBe(true); + }); }); -describe('resolveExistingViewMode', () => { - // Returns the persisted non-default viewMode so callers can ?? to a fallback: - // viewMode = resolveExistingViewMode(snapshot) ?? manifest?.features.viewMode ?? 'default' +// ─── resolveExistingViewMode ────────────────────────────────────────────────── +describe('resolveExistingViewMode', () => { it('returns "focus" when settings.json has viewMode: "focus"', () => { const input = JSON.stringify({ viewMode: 'focus', hooks: {} }, null, 2); expect(resolveExistingViewMode(input)).toBe('focus'); @@ -443,13 +1246,12 @@ describe('resolveExistingViewMode', () => { expect(resolveExistingViewMode(input)).toBe('verbose'); }); - it('returns undefined when viewMode key is absent (no opinion)', () => { + it('returns undefined when viewMode key is absent', () => { const input = JSON.stringify({ hooks: {} }, null, 2); expect(resolveExistingViewMode(input)).toBeUndefined(); }); - it('returns undefined when viewMode is "default" (no meaningful override to preserve)', () => { - // 'default' means "no preference" — treat as undefined so ?? chains work + it('returns undefined when viewMode is "default"', () => { const input = JSON.stringify({ viewMode: 'default', hooks: {} }, null, 2); expect(resolveExistingViewMode(input)).toBeUndefined(); }); @@ -468,60 +1270,512 @@ describe('resolveExistingViewMode', () => { expect(() => resolveExistingViewMode('')).not.toThrow(); expect(resolveExistingViewMode('')).toBeUndefined(); }); - - it('regression: existing "verbose" mode is preserved for reinstall display', () => { - // Pinned: user has verbose set; reinstall must surface "verbose", not "default" - const existingSettings = JSON.stringify({ viewMode: 'verbose', hooks: {} }, null, 2); - const resolved = resolveExistingViewMode(existingSettings); - expect(resolved).toBe('verbose'); - expect(resolved).not.toBeUndefined(); - }); }); -describe('resolveFinalViewMode', () => { - // Rules: - // 1. explicit=true → selected wins unconditionally - // 2. explicit=false, non-default current → current wins (preserve externally-set mode) - // 3. explicit=false, current=undefined or 'default' → selected +// ─── resolveFinalViewMode (unchanged) ──────────────────────────────────────── - it('explicit=true: selected "default" beats current "focus" (user explicitly chose default)', () => { - const result = resolveFinalViewMode('focus', 'default', true); - expect(result).toBe('default'); +describe('resolveFinalViewMode', () => { + it('explicit=true: selected "default" beats current "focus"', () => { + expect(resolveFinalViewMode('focus', 'default', true)).toBe('default'); }); it('explicit=true: selected "verbose" beats current "focus"', () => { - const result = resolveFinalViewMode('focus', 'verbose', true); - expect(result).toBe('verbose'); + expect(resolveFinalViewMode('focus', 'verbose', true)).toBe('verbose'); }); it('explicit=true: selected "focus" is used even when current is undefined', () => { - const result = resolveFinalViewMode(undefined, 'focus', true); - expect(result).toBe('focus'); + expect(resolveFinalViewMode(undefined, 'focus', true)).toBe('focus'); }); - it('explicit=false: non-default current "focus" preserved (external /focus respected)', () => { - const result = resolveFinalViewMode('focus', 'default', false); - expect(result).toBe('focus'); + it('explicit=false: non-default current "focus" preserved', () => { + expect(resolveFinalViewMode('focus', 'default', false)).toBe('focus'); }); it('explicit=false: non-default current "verbose" preserved', () => { - const result = resolveFinalViewMode('verbose', 'default', false); - expect(result).toBe('verbose'); + expect(resolveFinalViewMode('verbose', 'default', false)).toBe('verbose'); }); it('explicit=false: undefined current → selected is used', () => { - const result = resolveFinalViewMode(undefined, 'verbose', false); - expect(result).toBe('verbose'); + expect(resolveFinalViewMode(undefined, 'verbose', false)).toBe('verbose'); }); it('explicit=false: undefined current + selected "default" → "default"', () => { - const result = resolveFinalViewMode(undefined, 'default', false); - expect(result).toBe('default'); + expect(resolveFinalViewMode(undefined, 'default', false)).toBe('default'); + }); + + it('explicit=false: current "default" → selected wins', () => { + expect(resolveFinalViewMode('default', 'verbose', false)).toBe('verbose'); + }); +}); + +// ─── VIEW_MODES constant (unchanged) ───────────────────────────────────────── + +describe('VIEW_MODES', () => { + it('contains default, verbose, focus', () => { + expect(VIEW_MODES).toContain('default'); + expect(VIEW_MODES).toContain('verbose'); + expect(VIEW_MODES).toContain('focus'); + }); +}); + +// ─── convergeFlagsIntoSettings — SEC-M3 / ARCH-H1 / REG-H1 ────────────────── +// +// Pipeline invariant: valued flags found in settings.json that devflow does NOT +// own (absent from ownedRecord) are folded into the record before strip, so they +// survive the strip+apply pass. Whole-post-state style per PF-015. + +describe('convergeFlagsIntoSettings — view-mode preservation', () => { + const baseSettings = JSON.stringify( + { viewMode: 'focus', hooks: {}, env: {} }, + null, + 2, + ); + + it('/focus survives when viewModeExplicit=false and record says "default"', () => { + // Scenario: user set viewMode:'focus' via /focus (settings.json only, manifest = 'default') + const record: FlagsRecord = { 'view-mode': 'default' }; + const { settings, record: out } = convergeFlagsIntoSettings(baseSettings, record, { + viewModeExplicit: false, + }); + const parsed = JSON.parse(settings) as Record; + // viewMode 'focus' is non-neutral — key must be present + expect(parsed.viewMode, 'viewMode preserved as "focus"').toBe('focus'); + expect(out['view-mode'], 'returned record reflects "focus"').toBe('focus'); + }); + + it('explicit viewModeExplicit=true: record "verbose" wins over settings "focus"', () => { + const record: FlagsRecord = { 'view-mode': 'verbose' }; + const { settings, record: out } = convergeFlagsIntoSettings(baseSettings, record, { + viewModeExplicit: true, + }); + const parsed = JSON.parse(settings) as Record; + expect(parsed.viewMode, 'viewMode overridden to "verbose"').toBe('verbose'); + expect(out['view-mode']).toBe('verbose'); + }); + + it('settings viewMode "default" (neutral) — key absent in output', () => { + const settingsDefault = JSON.stringify({ hooks: {} }, null, 2); + const record: FlagsRecord = { 'view-mode': 'default' }; + const { settings } = convergeFlagsIntoSettings(settingsDefault, record, { + viewModeExplicit: false, + }); + const parsed = JSON.parse(settings) as Record; + expect(parsed.viewMode, 'neutral view-mode must not add viewMode key').toBeUndefined(); + }); +}); + +describe('convergeFlagsIntoSettings — REG-H1: hand-set managed keys survive', () => { + // Settings.json with six hand-set managed keys that devflow now claims in the registry + // but the OLD manifest never wrote (ownedRecord = null, simulating upgrade). + // After convergeFlagsIntoSettings the values must be preserved. + const makeSettings = (): string => + JSON.stringify( + { + hooks: {}, + // setting-target flags: + spellcheck: { command: 'hunspell' }, + workflowSizeGuideline: 'large', + // env-target flags: + env: { + CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: '8', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-4', + CLAUDE_CODE_GOAL_CHECKIN_MINUTES: '15', + CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH: '5', + }, + }, + null, + 2, + ); + + it('whole post-state: all six hand-set keys survive when ownedRecord=null', () => { + // Simulate resolveSeedFlags adopting devflow defaults into the record: + const seededRecord: FlagsRecord = { + 'max-concurrent-subagents': 40, // registry default adopted by resolveSeedFlags + 'spellcheck': null, // absent in old manifest → null (unset) + 'workflow-size-guideline': null, // absent in old manifest → null (unset) + }; + + const { settings, record: out } = convergeFlagsIntoSettings( + makeSettings(), + seededRecord, + { + viewModeExplicit: false, + ownedRecord: null, // nothing previously owned (fresh upgrade — REG-H1 probe) + }, + ); + const parsed = JSON.parse(settings) as { + spellcheck?: unknown; + workflowSizeGuideline?: unknown; + env?: Record; + hooks?: unknown; + }; + + // spellcheck preserved with wrapKey unwrap → re-wrapped on write + expect(parsed.spellcheck, 'spellcheck preserved').toEqual({ command: 'hunspell' }); + + // workflowSizeGuideline preserved + expect(parsed.workflowSizeGuideline, 'workflowSizeGuideline preserved').toBe('large'); + + // env vars preserved + expect(parsed.env?.['CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS'], 'concurrency stays "8"').toBe('8'); + expect(parsed.env?.['ANTHROPIC_DEFAULT_MODEL'], 'default-model preserved').toBe('claude-opus-4'); + expect(parsed.env?.['CLAUDE_CODE_GOAL_CHECKIN_MINUTES'], 'goal-checkin preserved').toBe('15'); + expect(parsed.env?.['CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH'], 'spawn-depth preserved').toBe('5'); + + // returned record also reflects adopted values + expect(out['max-concurrent-subagents'], 'record: concurrency is 8').toBe(8); + expect(out['spellcheck'], 'record: spellcheck is "hunspell"').toBe('hunspell'); + expect(out['workflow-size-guideline'], 'record: workflow-size-guideline is "large"').toBe('large'); + expect(out['default-model'], 'record: default-model is "claude-opus-4"').toBe('claude-opus-4'); + expect(out['goal-checkin-minutes'], 'record: goal-checkin-minutes is 15').toBe(15); + expect(out['subagent-spawn-depth'], 'record: subagent-spawn-depth is 5').toBe(5); + }); + + it('previously-owned value wins over settings value', () => { + // devflow previously wrote max-concurrent-subagents: 40 — settings has '8' + // The owned record takes precedence; fold must NOT override with '8' + const seededRecord: FlagsRecord = { 'max-concurrent-subagents': 40 }; + const ownedRecord: FlagsRecord = { 'max-concurrent-subagents': 40 }; + + const { settings, record: out } = convergeFlagsIntoSettings( + makeSettings(), + seededRecord, + { viewModeExplicit: false, ownedRecord }, + ); + const parsed = JSON.parse(settings) as { env?: Record }; + // devflow's owned value (40) wins — settings '8' is ignored + expect(parsed.env?.['CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS'], 'owned 40 wins').toBe('40'); + expect(out['max-concurrent-subagents'], 'record stays 40').toBe(40); + }); + + it('uninstall full-sweep: stripFlags removes all managed keys regardless of record', () => { + // stripFlags(json) with no second arg — full-sweep semantics must be unchanged + const settings = makeSettings(); + const stripped = JSON.parse(stripFlags(settings)) as { + spellcheck?: unknown; + workflowSizeGuideline?: unknown; + env?: Record; + }; + expect(stripped.spellcheck, 'spellcheck removed on full sweep').toBeUndefined(); + expect(stripped.workflowSizeGuideline, 'workflowSizeGuideline removed on full sweep').toBeUndefined(); + expect(stripped.env?.['CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS'], 'concurrency removed on full sweep').toBeUndefined(); + expect(stripped.env?.['ANTHROPIC_DEFAULT_MODEL'], 'default-model removed on full sweep').toBeUndefined(); + expect(stripped.env?.['CLAUDE_CODE_GOAL_CHECKIN_MINUTES'], 'goal-checkin removed on full sweep').toBeUndefined(); + expect(stripped.env?.['CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH'], 'spawn-depth removed on full sweep').toBeUndefined(); + }); +}); + +// ─── applyFlags / stripFlags — non-object root guard (REL-M2) ──────────────── +// +// applyFlags and stripFlags must throw a clear error (not an opaque TypeError) +// when the settings.json root is not a plain object. This is defence-in-depth +// for callers that bypass readSettingsSafe (init.ts, uninstall.ts). Applies +// PF-023: put the guard at the sink that every caller passes through. + +describe('applyFlags — non-object root guard (REL-M2)', () => { + it('throws on null root', () => { + expect(() => applyFlags('null', {})).toThrow('applyFlags'); + }); + + it('throws on array root', () => { + expect(() => applyFlags('[]', {})).toThrow('applyFlags'); + }); + + it('throws on scalar root (number)', () => { + expect(() => applyFlags('5', {})).toThrow('applyFlags'); + }); + + it('does NOT throw on a valid plain-object root', () => { + expect(() => applyFlags('{}', {})).not.toThrow(); + }); +}); + +describe('stripFlags — non-object root guard (REL-M2)', () => { + it('throws on null root', () => { + expect(() => stripFlags('null')).toThrow('stripFlags'); + }); + + it('throws on array root', () => { + expect(() => stripFlags('[]')).toThrow('stripFlags'); + }); + + it('does NOT throw on a valid plain-object root', () => { + expect(() => stripFlags('{}')).not.toThrow(); + }); +}); + +// ─── describeFlagKind (CPLX-SF3) ───────────────────────────────────────────── +// +// Replaces the 4-level nested ternary in handleList. Exhaustive switch — +// TypeScript narrows each case so no per-kind casts are needed. + +describe('describeFlagKind', () => { + it('boolean flag → "boolean"', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + expect(describeFlagKind(flag)).toBe('boolean'); + }); + + it('enum flag → "enum [small|medium|large|unrestricted]"', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline')!; + expect(describeFlagKind(flag)).toBe('enum [small|medium|large|unrestricted]'); }); - it('explicit=false: current "default" → selected wins (no meaningful current to preserve)', () => { - // If current is 'default', treat as "no opinion" and use selected - const result = resolveFinalViewMode('default', 'verbose', false); - expect(result).toBe('verbose'); + it('enum flag with neutralValue → includes all values', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; + expect(describeFlagKind(flag)).toBe('enum [default|verbose|focus]'); + }); + + it('number flag with min, max, integer → includes all constraints', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(describeFlagKind(flag)).toBe('number min=1 max=100 integer'); + }); + + it('number flag with min=0 → includes min=0', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'goal-checkin-minutes')!; + expect(describeFlagKind(flag)).toBe('number min=0 max=1440 integer'); + }); + + it('number flag with no bounds (subagent-spawn-depth has bounds) → includes them', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'subagent-spawn-depth')!; + expect(describeFlagKind(flag)).toBe('number min=1 max=10 integer'); + }); + + it('string flag with maxLength → includes maxLen=', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'default-model')!; + expect(describeFlagKind(flag)).toBe('string maxLen=64'); + }); + + it('string flag with larger maxLength → correct value', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'spellcheck')!; + expect(describeFlagKind(flag)).toBe('string maxLen=256'); + }); + + it('every registry flag returns a non-empty string without throwing', () => { + for (const flag of FLAG_REGISTRY) { + const label = describeFlagKind(flag); + expect(typeof label, `${flag.id}: returns string`).toBe('string'); + expect(label.length, `${flag.id}: non-empty`).toBeGreaterThan(0); + } + }); + + it('output is byte-identical to the former ternary for all registry flags', () => { + // Reference implementation — the ternary that describeFlagKind replaces — + // preserved here as the ground truth for the regression comparison. + function legacyKindLabel(flag: ClaudeCodeFlag): string { + if (flag.kind === 'boolean') return 'boolean'; + if (flag.kind === 'enum') return `enum [${(flag as EnumFlagDef).values.join('|')}]`; + if (flag.kind === 'number') { + const nf = flag as NumberFlagDef; + const parts: string[] = []; + if (nf.min !== undefined) parts.push(`min=${nf.min}`); + if (nf.max !== undefined) parts.push(`max=${nf.max}`); + if (nf.integer) parts.push('integer'); + return `number${parts.length ? ' ' + parts.join(' ') : ''}`; + } + const sf = flag as StringFlagDef; + return `string${sf.maxLength !== undefined ? ` maxLen=${sf.maxLength}` : ''}`; + } + + for (const flag of FLAG_REGISTRY) { + expect(describeFlagKind(flag), `${flag.id}: matches legacy output`).toBe(legacyKindLabel(flag)); + } + }); +}); + +// ─── expectedInputFor (CPLX-SF4) ───────────────────────────────────────────── +// +// Replaces the triple-nested conditional in the --set Expected: hint. +// Output must match the former inline expression for all flag kinds. + +describe('expectedInputFor', () => { + it('boolean flag → "true|false|unset"', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + expect(expectedInputFor(flag)).toBe('true|false|unset'); + }); + + it('enum flag → values joined by | plus "|unset"', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'workflow-size-guideline')!; + expect(expectedInputFor(flag)).toBe('small|medium|large|unrestricted|unset'); + }); + + it('enum flag with neutralValue → all values included', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; + expect(expectedInputFor(flag)).toBe('default|verbose|focus|unset'); + }); + + it('number flag → "a valid number value or unset"', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; + expect(expectedInputFor(flag)).toBe('a valid number value or unset'); + }); + + it('string flag → "a valid string value or unset"', () => { + const flag = FLAG_REGISTRY.find(f => f.id === 'default-model')!; + expect(expectedInputFor(flag)).toBe('a valid string value or unset'); + }); + + it('every registry flag returns a non-empty string without throwing', () => { + for (const flag of FLAG_REGISTRY) { + const hint = expectedInputFor(flag); + expect(typeof hint, `${flag.id}: returns string`).toBe('string'); + expect(hint.length, `${flag.id}: non-empty`).toBeGreaterThan(0); + } + }); + + it('output is byte-identical to the former ternary for all registry flags', () => { + // Reference — the three-way conditional from handleSet preserved as ground truth. + function legacyExpected(flag: ClaudeCodeFlag): string { + if (flag.kind === 'boolean') return 'true|false|unset'; + if (flag.kind === 'enum') return (flag as EnumFlagDef).values.join('|') + '|unset'; + return `a valid ${flag.kind} value or unset`; + } + + for (const flag of FLAG_REGISTRY) { + expect(expectedInputFor(flag), `${flag.id}: matches legacy output`).toBe(legacyExpected(flag)); + } + }); +}); + +// ─── effectiveDisplay ───────────────────────────────────────────────────────── + +describe('effectiveDisplay — D-EFFDV one-definition seam', () => { + const boolFlag = FLAG_REGISTRY.find(f => f.id === 'tui')!; + const enumFlag = FLAG_REGISTRY.find(f => f.id === 'view-mode')!; // neutralValue='default' + const numFlagD = FLAG_REGISTRY.find(f => f.id === 'max-concurrent-subagents')!; // defaultValue=40 + const numFlagU = FLAG_REGISTRY.find(f => f.id === 'subagent-spawn-depth')!; // upstreamDefault=3, no devflow default + const strFlag = FLAG_REGISTRY.find(f => f.id === 'spellcheck')!; + + it('boolean true → { text: "on", isDefault: false }', () => { + const d = effectiveDisplay(boolFlag, true); + expect(d.text).toBe('on'); + expect(d.isDefault).toBe(false); + }); + + it('boolean false → { text: "off", isDefault: true } (false is neutral but meaningful)', () => { + const d = effectiveDisplay(boolFlag, false); + expect(d.text).toBe('off'); + expect(d.isDefault).toBe(true); + }); + + it('boolean null → { text: "off", isDefault: true } (same as false)', () => { + const d = effectiveDisplay(boolFlag, null); + expect(d.text).toBe('off'); + expect(d.isDefault).toBe(true); + }); + + it('enum active value → { text: value, isDefault: false }', () => { + const d = effectiveDisplay(enumFlag, 'verbose'); + expect(d.text).toBe('verbose'); + expect(d.isDefault).toBe(false); + }); + + it('enum null → { text: neutralValue, isDefault: true }', () => { + const d = effectiveDisplay(enumFlag, null); + expect(d.text).toBe('default'); + expect(d.isDefault).toBe(true); + }); + + it('enum neutralValue → { text: neutralValue, isDefault: true }', () => { + const d = effectiveDisplay(enumFlag, 'default'); + expect(d.text).toBe('default'); + expect(d.isDefault).toBe(true); + }); + + it('number active value → { text: String(value), isDefault: false }', () => { + const d = effectiveDisplay(numFlagD, 20); + expect(d.text).toBe('20'); + expect(d.isDefault).toBe(false); + }); + + it('number null with devflow defaultValue → { text: "40", isDefault: true }', () => { + const d = effectiveDisplay(numFlagD, null); + expect(d.text).toBe('40'); + expect(d.isDefault).toBe(true); + }); + + it('number null with upstreamDefault only → { text: String(upstreamDefault), isDefault: true }', () => { + const d = effectiveDisplay(numFlagU, null); + expect(d.text).toBe('3'); + expect(d.isDefault).toBe(true); + }); + + it('string active value → { text: value, isDefault: false }', () => { + const d = effectiveDisplay(strFlag, 'aspell list'); + expect(d.text).toBe('aspell list'); + expect(d.isDefault).toBe(false); + }); + + it('string null → { text: "—", isDefault: true }', () => { + const d = effectiveDisplay(strFlag, null); + expect(d.text).toBe('—'); + expect(d.isDefault).toBe(true); + }); +}); + +// ─── blurb hard-cap registry test ──────────────────────────────────────────── + +describe('FLAG_REGISTRY — blurb hard-cap (D-BLURB)', () => { + it('every flag has blurb defined and blurb.length ≤ 30', () => { + for (const flag of FLAG_REGISTRY) { + expect( + typeof flag.blurb, + `${flag.id}: blurb must be a string`, + ).toBe('string'); + expect( + flag.blurb.length, + `${flag.id}: blurb "${flag.blurb}" is ${flag.blurb.length} chars (max 30)`, + ).toBeLessThanOrEqual(30); + } + }); + + it('every blurb is non-empty', () => { + for (const flag of FLAG_REGISTRY) { + expect(flag.blurb.length, `${flag.id}: blurb must not be empty`).toBeGreaterThan(0); + } + }); +}); + +// ─── persistence round-trip ─────────────────────────────────────────────────── + +describe('persistence round-trip: manifest write shape → resolveSeedFlags', () => { + it('explicitly set values survive the manifest → resolveSeedFlags round-trip unchanged', () => { + // Simulates what persistFlagConfig writes: manifest.features.flags = record. + // The saved record is then fed to resolveSeedFlags on re-init. + const persistedRecord: FlagsRecord = { + tui: false, // boolean, non-default (default=true) + 'view-mode': 'verbose', // enum, non-neutral + 'max-concurrent-subagents': 20, // number, non-default + spellcheck: 'aspell list', // string active value + }; + + const seeded = resolveSeedFlags(persistedRecord); + + // Explicitly set values must be preserved exactly + expect(seeded['tui']).toBe(false); + expect(seeded['view-mode']).toBe('verbose'); + expect(seeded['max-concurrent-subagents']).toBe(20); + expect(seeded['spellcheck']).toBe('aspell list'); + }); + + it('absent flags in manifest get registry defaults on resolveSeedFlags', () => { + // Only set one flag; all others should resolve to their registry defaults + const persistedRecord: FlagsRecord = { tui: false }; + const seeded = resolveSeedFlags(persistedRecord); + + // lsp.defaultValue = true → seeded as true + expect(seeded['lsp']).toBe(true); + // subagent-spawn-depth.defaultValue = undefined → null via defaultValueOf + expect(seeded['subagent-spawn-depth']).toBeNull(); + // view-mode.defaultValue = 'default' → seeded as 'default' + expect(seeded['view-mode']).toBe('default'); + }); + + it('null values in manifest are preserved (deliberately unset)', () => { + const persistedRecord: FlagsRecord = { + 'subagent-spawn-depth': null, // explicitly set to null (unset) + }; + const seeded = resolveSeedFlags(persistedRecord); + + // null in manifest means "deliberately unset" — must be preserved as null + expect(seeded['subagent-spawn-depth']).toBeNull(); }); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index f225d09d..a32cf9f8 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,5 +1,6 @@ import { readFileSync, readdirSync } from 'fs' import * as path from 'path' +import { type ManifestData } from '../src/core/manifest.js' export const ROOT = path.resolve(import.meta.dirname, '..') @@ -54,6 +55,42 @@ export function extractSection(content: string, startAnchor: string, endAnchor: return content.slice(start, end) } +/** + * Canonical ManifestData factory for tests. + * + * Returns a minimal but structurally complete ManifestData with: + * - flags: FlagsRecord (Phase 2: was string[]) + * - No knownFlags / viewMode fields (deprecated; healed away on readManifest) + * + * Use deep-spread to override individual fields: + * makeManifest({ features: { ...makeManifest().features, proxy: true } }) + * + * This factory is the canonical source for ManifestData test fixtures. + * Tests that write to disk via writeManifest should use this factory so + * readManifest round-trips produce bit-identical results (no heal cycle). + */ +export function makeManifest(overrides: Partial = {}): ManifestData { + return { + version: '2.0.0', + plugins: ['devflow-implement', 'devflow-code-review'], + scope: 'user', + features: { + ambient: true, + memory: true, + hud: true, + knowledge: true, + learning: true, + rules: true, + proxy: false, + compliance: { enabled: false, frameworks: [] }, + flags: { tui: true, lsp: true, 'tool-search': true }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + /** * Pure function mirroring the fp_ratio formula documented in command surfaces. * Denominator = fp_count + fixed_count + deferred_count. diff --git a/tests/init-e2e-flags.test.ts b/tests/init-e2e-flags.test.ts new file mode 100644 index 00000000..dbd88f8f --- /dev/null +++ b/tests/init-e2e-flags.test.ts @@ -0,0 +1,473 @@ +/** + * Subprocess e2e tests for the Phase 6 init integration (flags + view-mode). + * + * These tests drive the REAL `node dist/cli.js init --recommended` command with an + * isolated temp HOME so they never touch the developer's real ~/.claude or ~/.devflow. + * + * Applies PF-018: seeded temp HOME, never empty; vacuous-coverage guard. + * + * Test scenarios: + * 1. OLD-FORMAT manifest (flags: []) + settings with viewMode:'focus' + * → FlagsRecord in manifest, viewMode preserved, adopted flags materialised in + * settings.json, deliberate prior disables preserved, no knownFlags/features.viewMode residue + * 2. Fresh install (no manifest) + empty settings + * → FlagsRecord with all defaults, max-concurrent-subagents env var applied; + * init does NOT open the flags TUI (D40); outcome line present in transcript + * 3. Re-init preserves a modified flag value; adopts defaults only for absent flags + * 4. Idempotency — second run produces byte-stable settings (no thrash) + * + * D-P6-E2E: These tests are the authoritative acceptance gate for the fold-before-strip + * ordering fix and the bridge removal. Unit tests in init-seed.test.ts cover the seed + * computation; these tests cover the full write path including applyFlags. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import { existsSync } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; +import { type ManifestData } from '../src/core/manifest.js'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const ROOT = path.resolve(import.meta.dirname ?? __dirname, '..'); +const CLI_PATH = path.join(ROOT, 'dist', 'cli.js'); +const SUBPROCESS_TIMEOUT_MS = 60_000; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Run `node dist/cli.js init --recommended` in a subprocess with temp HOME. */ +function runInit(tmpHome: string, extraArgs: string[] = []): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync( + process.execPath, + [CLI_PATH, 'init', '--recommended', '--no-ambient', '--no-memory', '--no-learning', '--no-knowledge', '--no-rules', ...extraArgs], + { + cwd: os.tmpdir(), // non-git dir → earlyGitRoot=null → no project discovery + encoding: 'utf-8', + timeout: SUBPROCESS_TIMEOUT_MS, + env: { + ...process.env, + HOME: tmpHome, + // Suppress memory worker spawn (no real claude binary in test env) + DEVFLOW_HOOK_DEBUG: undefined, + // Ensure non-interactive mode + FORCE_COLOR: '0', + }, + }, + ); + if (result.error) throw result.error; + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +/** Read the manifest.json from the temp devflow dir. */ +async function readManifest(tmpHome: string): Promise { + const manifestPath = path.join(tmpHome, '.devflow', 'manifest.json'); + const content = await fs.readFile(manifestPath, 'utf-8'); + return JSON.parse(content) as ManifestData; +} + +/** Read settings.json from the temp claude dir. */ +async function readSettings(tmpHome: string): Promise> { + const settingsPath = path.join(tmpHome, '.claude', 'settings.json'); + const content = await fs.readFile(settingsPath, 'utf-8'); + return JSON.parse(content) as Record; +} + +// ── Test lifecycle ──────────────────────────────────────────────────────────── + +let tmpHome: string; + +beforeEach(async () => { + tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-e2e-flags-')); + await fs.mkdir(path.join(tmpHome, '.claude'), { recursive: true }); + await fs.mkdir(path.join(tmpHome, '.devflow'), { recursive: true }); +}); + +afterEach(async () => { + await fs.rm(tmpHome, { recursive: true, force: true }); +}); + +// ── Guards ──────────────────────────────────────────────────────────────────── + +/** + * PF-018 vacuous-coverage guard: true when dist/cli.js exists. + * + * Uses existsSync (not async access) so it can be used with it.skipIf at + * module evaluation time — it.skipIf requires a synchronous boolean. + * Silent green (early `return`) is the forbidden state; it.skipIf produces an + * explicit SKIP mark in the vitest output instead. + */ +const CLI_BUILT = existsSync(CLI_PATH); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('init e2e — flags Phase 6 integration', () => { + it.skipIf(!CLI_BUILT)('old-format manifest (flags:[]) + viewMode in settings → FlagsRecord + viewMode preserved', async () => { + // PF-018: seed a REAL old-format manifest (flags as string array) and settings with viewMode. + // Non-vacuous: if the bridge removal regressed to string[], flags would be [] in the manifest. + const oldManifest = { + version: '2.0.0', + plugins: ['devflow-implement', 'devflow-code-review'], + scope: 'user', + knownPlugins: ['devflow-implement', 'devflow-code-review'], + features: { + ambient: true, + memory: true, + hud: true, + knowledge: true, + learning: true, + rules: true, + proxy: false, + flags: [], // OLD FORMAT: empty string array (pre-Phase-2) + knownFlags: ['tui', 'lsp'], // deprecated + viewMode: 'focus' as const, // deprecated top-level + security: 'user' as const, + compliance: { enabled: false, frameworks: [] }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await fs.writeFile( + path.join(tmpHome, '.devflow', 'manifest.json'), + JSON.stringify(oldManifest, null, 2) + '\n', + ); + + // Seed settings.json with viewMode + a custom env var + custom hook. + // + // The hook entry MUST use Claude Code's real shape — `{ matcher, hooks: [...] }`. + // A flattened `{ matcher, command }` entry is not just unrealistic, it makes this + // test vacuous: removeCaptureHooks does `entry.hooks.some(...)`, which throws on a + // missing `hooks` array, and init.ts wraps its ENTIRE settings pass (ambient hooks, + // capture hooks, memory hooks, HUD, flags, proxy env) in one try/catch that only + // warns. With a malformed entry the whole pass aborts, settings.json is never + // touched, and every settings assertion below passes because nothing ran — + // the PF-018 shape: a green test that proves nothing. + const seedSettings = { + viewMode: 'focus', + env: { CUSTOM_USER_VAR: 'preserved' }, + hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo custom-hook' }] }] }, + }; + await fs.writeFile( + path.join(tmpHome, '.claude', 'settings.json'), + JSON.stringify(seedSettings, null, 2) + '\n', + ); + + const result = runInit(tmpHome); + expect(result.status, `init failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); + + // PF-018 non-vacuity gate: init.ts swallows any failure in its settings pass with a + // warning and a zero exit code. Assert the warning is ABSENT — otherwise every + // settings assertion below would pass for the wrong reason (the pass never ran). + expect( + result.stdout + result.stderr, + 'init warned that it could not configure settings.json — the settings pass aborted, ' + + 'so the settings assertions in this test would be vacuous', + ).not.toContain('Could not configure settings.json'); + + // ── Manifest assertions ── + + const manifest = await readManifest(tmpHome); + + // FlagsRecord format: flags must be a plain object (not array) + expect(typeof manifest.features.flags).toBe('object'); + expect(Array.isArray(manifest.features.flags)).toBe(false); + + // All registry flags present (key-presence = known; adoption happened) + const flagsRecord = manifest.features.flags as Record; + expect(flagsRecord).toHaveProperty('tui'); + expect(flagsRecord).toHaveProperty('tool-search'); + // New number flag adopted (absent from old manifest → adopt default) + expect(flagsRecord).toHaveProperty('max-concurrent-subagents'); + + // Phase 6 cleanup: no deprecated fields written + expect(manifest.features).not.toHaveProperty('knownFlags'); + expect(manifest.features).not.toHaveProperty('viewMode'); + + // ── Settings assertions ── + + const settings = await readSettings(tmpHome); + + // Fold-before-strip: existing viewMode:'focus' in settings MUST be preserved. + // If the fold-before-strip ordering is wrong, stripFlags runs first and strips + // viewMode before resolveExistingViewMode can read it → viewMode disappears. + expect(settings['viewMode']).toBe('focus'); + + // Custom env var preserved (Devflow only manages its own keys) + expect((settings['env'] as Record)?.CUSTOM_USER_VAR).toBe('preserved'); + // The seeded user hook survives the remove-then-add hook passes + expect(settings['hooks']).toBeDefined(); + + // Manifest ↔ settings convergence — the invariant this whole feature exists to hold. + // An adopted value in the manifest MUST have its payload materialised in settings.json; + // a manifest that says 40 while settings.json says nothing is exactly the desync the + // typed-registry work is meant to prevent. + const env = settings['env'] as Record; + expect(flagsRecord['max-concurrent-subagents']).toBe(40); + expect(env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS).toBe('40'); + + // Other adopted default-ON flags materialise too (proves applyFlags ran over the + // whole adopted record, not just the one flag asserted above). + expect(env.ENABLE_TOOL_SEARCH).toBe('true'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + + // Deliberate prior disables are PRESERVED, not re-adopted (ADR-014): the old manifest + // recorded knownFlags ['tui','lsp'] with an empty enabled list, so both stay off and + // neither writes its payload — while genuinely-new flags above adopt their defaults. + expect(flagsRecord['tui']).toBe(false); + expect(flagsRecord['lsp']).toBe(false); + expect(settings).not.toHaveProperty('tui'); + expect(env.ENABLE_LSP_TOOL).toBeUndefined(); + }, SUBPROCESS_TIMEOUT_MS); + + it.skipIf(!CLI_BUILT)('fresh install (no manifest) → FlagsRecord with all flags + number flag defaults applied; no TUI entered', async () => { + + // PF-018: no manifest means fresh install — all flags adopt their defaults. + // Non-vacuous: if adoption is broken, max-concurrent-subagents env var would be absent. + await fs.writeFile( + path.join(tmpHome, '.claude', 'settings.json'), + JSON.stringify({ env: { EXISTING_VAR: 'keep' } }, null, 2) + '\n', + ); + + const result = runInit(tmpHome); + expect(result.status, `init failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); + + // (c) D40: init must never open the flags TUI — absence of the editor banner confirms this. + const transcript = result.stdout + result.stderr; + expect(transcript, 'flags TUI must not open during init (D40)').not.toContain('Opening the flags editor'); + + // (c) D40: Recommended path emits the flag count in its summary note (non-interactive). + expect(transcript, 'Recommended summary must include the flags count').toContain('Claude Code flags:'); + + const manifest = await readManifest(tmpHome); + const settings = await readSettings(tmpHome); + + // (a) FlagsRecord in manifest — registry defaults written on fresh install + expect(typeof manifest.features.flags).toBe('object'); + expect(Array.isArray(manifest.features.flags)).toBe(false); + + const flagsRecord = manifest.features.flags as Record; + // Default-ON boolean flags are present + expect(flagsRecord['tui']).toBe(true); + expect(flagsRecord['tool-search']).toBe(true); + // Number flag with non-neutral default is present + expect(flagsRecord['max-concurrent-subagents']).toBe(40); + // view-mode default is 'default' (neutral → not written to settings) + expect(flagsRecord['view-mode']).toBe('default'); + + // No deprecated fields + expect(manifest.features).not.toHaveProperty('knownFlags'); + expect(manifest.features).not.toHaveProperty('viewMode'); + + // Settings: max-concurrent-subagents applied + expect((settings['env'] as Record)?.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS).toBe('40'); + // viewMode absent (default → neutral → key deleted) + expect(settings).not.toHaveProperty('viewMode'); + // Custom user var preserved + expect((settings['env'] as Record)?.EXISTING_VAR).toBe('keep'); + }, SUBPROCESS_TIMEOUT_MS); + + it.skipIf(!CLI_BUILT)('(b) re-init preserves a modified flag value; adopts defaults only for absent flags', async () => { + // Regression guard for D40/ADR-014: re-init must not overwrite a flag value the user + // set via `devflow flags`. The manifest already owns the flag; init preserves it and + // adopts registry defaults only for flags absent from the manifest record. + + // Prior manifest: tui deliberately set to false (user disabled it), lsp present, + // max-concurrent-subagents absent (new flag added since the manifest was written). + const priorManifest = { + version: '2.0.0', + plugins: ['devflow-implement'], + scope: 'user', + knownPlugins: ['devflow-implement'], + features: { + ambient: true, + memory: true, + hud: true, + knowledge: true, + learning: true, + rules: true, + proxy: false, + flags: { + tui: false, // deliberately disabled — must survive re-init + lsp: true, + 'tool-search': true, + // max-concurrent-subagents absent → will be adopted with registry default (40) + }, + security: 'user' as const, + compliance: { enabled: false, frameworks: [] }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await fs.writeFile( + path.join(tmpHome, '.devflow', 'manifest.json'), + JSON.stringify(priorManifest, null, 2) + '\n', + ); + await fs.writeFile( + path.join(tmpHome, '.claude', 'settings.json'), + JSON.stringify({}) + '\n', + ); + + const result = runInit(tmpHome); + expect(result.status, `re-init failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); + + // Non-vacuity guard + expect(result.stdout + result.stderr).not.toContain('Could not configure settings.json'); + + const manifest = await readManifest(tmpHome); + const flagsRecord = manifest.features.flags as Record; + + // (b) Modified flag preserved: tui=false was set by user, must not revert to default (true) + expect(flagsRecord['tui'], 'user-set tui=false preserved after re-init').toBe(false); + + // (b) Present flag preserved: lsp=true explicitly written, must not change + expect(flagsRecord['lsp'], 'existing lsp=true preserved').toBe(true); + + // (b) Absent flag adopted: max-concurrent-subagents was absent → adopt registry default 40 + expect(flagsRecord['max-concurrent-subagents'], 'absent flag adopts registry default').toBe(40); + + // (c) Still no TUI opened + expect(result.stdout + result.stderr).not.toContain('Opening the flags editor'); + }, SUBPROCESS_TIMEOUT_MS); + + it.skipIf(!CLI_BUILT)('REG-H1 probe: hand-set managed keys survive init when manifest never owned them', async () => { + // Scenario: user has an existing devflow install that predates the newly-registered flags + // (max-concurrent-subagents, default-model, spellcheck, workflowSizeGuideline). + // The user hand-set these keys in settings.json; on upgrade + reinit they must survive. + // + // Mechanism: ownedRecord = existingManifest.features.flags (no new keys) + // → convergeFlagsIntoSettings folds the settings values into the record + // → the folded record is written to manifest + applied to settings + // Net: concurrency stays '8' (not overridden by registry default 40). + + // Existing manifest: FlagsRecord format, no new flags (pre-upgrade state) + const priorManifest = { + version: '2.0.0', + plugins: ['devflow-implement', 'devflow-code-review'], + scope: 'user', + knownPlugins: ['devflow-implement', 'devflow-code-review'], + features: { + ambient: true, + memory: true, + hud: true, + knowledge: true, + learning: true, + rules: true, + proxy: false, + flags: { + // Only the flags devflow previously wrote — no new valued flags + tui: true, + lsp: true, + 'tool-search': true, + }, + security: 'user' as const, + compliance: { enabled: false, frameworks: [] }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await fs.writeFile( + path.join(tmpHome, '.devflow', 'manifest.json'), + JSON.stringify(priorManifest, null, 2) + '\n', + ); + + // Settings.json with hand-set managed keys that devflow didn't previously own + const seedSettings = { + spellcheck: { command: 'hunspell' }, // string flag with wrapKey + workflowSizeGuideline: 'large', // enum flag + hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi' }] }] }, + env: { + CUSTOM_USER_VAR: 'preserved', + CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: '8', // number flag: must stay '8', not become '40' + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-4', // string flag + CLAUDE_CODE_GOAL_CHECKIN_MINUTES: '15', // number flag + CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH: '5', // number flag + }, + }; + await fs.writeFile( + path.join(tmpHome, '.claude', 'settings.json'), + JSON.stringify(seedSettings, null, 2) + '\n', + ); + + const result = runInit(tmpHome); + expect(result.status, `init failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); + + // Non-vacuity guard: settings pass must not have silently aborted + expect( + result.stdout + result.stderr, + 'settings pass aborted — assertions below would be vacuous', + ).not.toContain('Could not configure settings.json'); + + const manifest = await readManifest(tmpHome); + const settings = await readSettings(tmpHome); + const flagsRecord = manifest.features.flags as Record; + const env = settings['env'] as Record; + + // Whole-post-state: all six hand-set managed keys must survive + // concurrency: hand-set '8' must NOT become '40' (core REG-H1 probe) + expect(env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, 'concurrency hand-set "8" survived').toBe('8'); + expect(flagsRecord['max-concurrent-subagents'], 'manifest concurrency is 8').toBe(8); + + // default-model preserved + expect(env.ANTHROPIC_DEFAULT_MODEL, 'default-model "claude-opus-4" survived').toBe('claude-opus-4'); + expect(flagsRecord['default-model'], 'manifest default-model is "claude-opus-4"').toBe('claude-opus-4'); + + // goal-checkin-minutes preserved + expect(env.CLAUDE_CODE_GOAL_CHECKIN_MINUTES, 'goal-checkin-minutes "15" survived').toBe('15'); + expect(flagsRecord['goal-checkin-minutes'], 'manifest goal-checkin-minutes is 15').toBe(15); + + // subagent-spawn-depth preserved + expect(env.CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH, 'spawn-depth "5" survived').toBe('5'); + expect(flagsRecord['subagent-spawn-depth'], 'manifest subagent-spawn-depth is 5').toBe(5); + + // spellcheck preserved (wrapKey path: { command: 'hunspell' } → 'hunspell' → back to { command: 'hunspell' }) + expect(settings['spellcheck'], 'spellcheck { command: "hunspell" } survived').toEqual({ command: 'hunspell' }); + expect(flagsRecord['spellcheck'], 'manifest spellcheck is "hunspell"').toBe('hunspell'); + + // workflowSizeGuideline preserved + expect(settings['workflowSizeGuideline'], 'workflowSizeGuideline "large" survived').toBe('large'); + expect(flagsRecord['workflow-size-guideline'], 'manifest workflow-size-guideline is "large"').toBe('large'); + + // User keys unrelated to devflow flags must survive too + expect(env.CUSTOM_USER_VAR, 'custom user env var preserved').toBe('preserved'); + }, SUBPROCESS_TIMEOUT_MS); + + it.skipIf(!CLI_BUILT)('idempotency: second run produces content-stable settings (no viewMode thrash)', async () => { + // content-stable = deep-equal parsed objects (not byte-equal strings): stripFlags + // removes managed keys from their original positions and applyFlags re-appends them + // at the end, so key order can legitimately differ between runs while content is identical. + + // PF-018 vacuous guard: this test catches regression where every reinit strips viewMode. + const seedSettings = { viewMode: 'verbose', env: { CUSTOM: 'stable' } }; + await fs.writeFile( + path.join(tmpHome, '.claude', 'settings.json'), + JSON.stringify(seedSettings, null, 2) + '\n', + ); + + // First run + const r1 = runInit(tmpHome); + expect(r1.status, `first run failed: ${r1.stderr}`).toBe(0); + + const settings1 = await readSettings(tmpHome); + const manifest1 = await readManifest(tmpHome); + + // Second run — nothing changed, should be content-stable + const r2 = runInit(tmpHome); + expect(r2.status, `second run failed: ${r2.stderr}`).toBe(0); + + const settings2 = await readSettings(tmpHome); + const manifest2 = await readManifest(tmpHome); + + // Settings content-stable: compare parsed objects, not JSON strings, because + // stripFlags removes managed keys from their original positions and applyFlags + // re-appends them at the end — key order can differ between runs even when content + // is identical (toEqual is correct here; toBe would be spuriously brittle). + expect(settings2).toEqual(settings1); + // Manifest flags stable (viewMode must not thrash — the core assertion of this test) + expect(manifest2.features.flags).toEqual(manifest1.features.flags); + }, SUBPROCESS_TIMEOUT_MS); +}); diff --git a/tests/init-seed.test.ts b/tests/init-seed.test.ts index 8320bb07..691beee6 100644 --- a/tests/init-seed.test.ts +++ b/tests/init-seed.test.ts @@ -10,7 +10,7 @@ import { type FeatureSeed, } from '../src/cli/commands/init-seed.js'; import { DEVFLOW_PLUGINS } from '../src/core/plugins.js'; -import { FLAG_REGISTRY, type ClaudeCodeFlag } from '../src/core/flags.js'; +import { FLAG_REGISTRY, readViewMode, type ClaudeCodeFlag, type FlagsRecord } from '../src/core/flags.js'; import { type ManifestData } from '../src/core/manifest.js'; // ── Test fixtures ───────────────────────────────────────────────────────────── @@ -29,8 +29,8 @@ function makeManifest(overrides: Partial = {}): ManifestData { learning: true, rules: true, proxy: false, - flags: ['tui', 'lsp', 'tool-search'], - viewMode: 'default', + // Phase 2: FlagsRecord (was string[]); no deprecated viewMode field + flags: { tui: true, lsp: true, 'tool-search': true }, }, installedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -38,12 +38,12 @@ function makeManifest(overrides: Partial = {}): ManifestData { }; } -// Synthetic flag registry for isolated flag tests +// Synthetic flag registry for isolated flag tests (BooleanFlagDef shape post Phase 1) const MOCK_FLAGS: ClaudeCodeFlag[] = [ - { id: 'flag-a', label: 'A', description: '', hint: '', target: { type: 'setting', key: 'a', value: true }, defaultEnabled: true }, - { id: 'flag-b', label: 'B', description: '', hint: '', target: { type: 'setting', key: 'b', value: true }, defaultEnabled: true }, - { id: 'flag-c', label: 'C', description: '', hint: '', target: { type: 'setting', key: 'c', value: false }, defaultEnabled: false }, - { id: 'flag-d', label: 'D', description: '', hint: '', target: { type: 'setting', key: 'd', value: true }, defaultEnabled: true }, + { kind: 'boolean', id: 'flag-a', label: 'A', description: '', hint: '', recommended: true, target: { type: 'setting', key: 'a' }, onPayload: true, defaultValue: true }, + { kind: 'boolean', id: 'flag-b', label: 'B', description: '', hint: '', recommended: true, target: { type: 'setting', key: 'b' }, onPayload: true, defaultValue: true }, + { kind: 'boolean', id: 'flag-c', label: 'C', description: '', hint: '', recommended: false, target: { type: 'setting', key: 'c' }, onPayload: false, defaultValue: false }, + { kind: 'boolean', id: 'flag-d', label: 'D', description: '', hint: '', recommended: true, target: { type: 'setting', key: 'd' }, onPayload: true, defaultValue: true }, ]; // ── resolveSeedFeatures ─────────────────────────────────────────────────────── @@ -130,73 +130,86 @@ describe('resolveSeedFeatures', () => { // ── resolveSeedFlags ────────────────────────────────────────────────────────── +// Phase 6: resolveSeedFlags returns FlagsRecord (not string[]). +// ALL registry flags are present with their resolved values. +// FlagsRecord key-presence encodes "known": present key = known, absent = new → adopt default. describe('resolveSeedFlags', () => { - it('fresh (null enabledFlags) → all default-ON flags from registry', () => { - const result = resolveSeedFlags(null, undefined, MOCK_FLAGS); - expect(result.sort()).toEqual(['flag-a', 'flag-b', 'flag-d'].sort()); + it('fresh (null manifestFlags) → all registry flags at their defaults', () => { + const result = resolveSeedFlags(null, MOCK_FLAGS); + // All 4 MOCK_FLAGS present with their default values + expect(result['flag-a']).toBe(true); + expect(result['flag-b']).toBe(true); + expect(result['flag-c']).toBe(false); + expect(result['flag-d']).toBe(true); + expect(Object.keys(result)).toHaveLength(4); }); it('fresh uses real FLAG_REGISTRY when no registry override provided', () => { - const result = resolveSeedFlags(null, undefined); - // Hard-coded: the 8 default-ON flags as of the current registry. - // If this test fails after a registry change, update both the registry - // and this list explicitly — that is the point of pinning it. - const EXPECTED_DEFAULT_ON: string[] = [ - 'tui', - 'tool-search', - 'lsp', - 'prompt-caching-1h', - 'show-turn-duration', - 'clear-context-on-plan', - 'disable-bundled-skills', - 'pin-sonnet-4-6', - ]; - expect(result.sort()).toEqual(EXPECTED_DEFAULT_ON.sort()); - }); - - it('knownFlags === undefined (old manifest) → return enabledFlags as-is, adopt nothing', () => { - const enabled = ['flag-a']; - const result = resolveSeedFlags(enabled, undefined, MOCK_FLAGS); - expect(result).toEqual(['flag-a']); - }); - - it('re-init with knownFlags → union of existing + new default-ON not in knownFlags', () => { - // flag-d is new (not in knownFlags), default-ON → gets adopted - const enabled = ['flag-a', 'flag-b']; - const known = ['flag-a', 'flag-b']; // flag-d was added to registry after last install - const result = resolveSeedFlags(enabled, known, MOCK_FLAGS); - expect(result.sort()).toEqual(['flag-a', 'flag-b', 'flag-d'].sort()); - }); - - it('disabled default-ON flag stays disabled when it is in knownFlags', () => { - // flag-a was known at last install, user disabled it → should NOT be re-added - const enabled = ['flag-b']; // flag-a absent (user disabled it) - const known = ['flag-a', 'flag-b', 'flag-d']; - const result = resolveSeedFlags(enabled, known, MOCK_FLAGS); - expect(result).toEqual(['flag-b']); // flag-a stays disabled - }); - - it('default-OFF flag is never auto-added even when absent from knownFlags', () => { - // flag-c is default-OFF and not in knownFlags → must NOT be added - const enabled = ['flag-a']; - const known = ['flag-a']; // flag-c not in known, flag-b and flag-d are new - const result = resolveSeedFlags(enabled, known, MOCK_FLAGS); - expect(result).not.toContain('flag-c'); - }); - - it('duplicate-safe: existing flag already in result is not duplicated', () => { - // flag-a is in both enabledFlags and would be "newly adopted" — should appear once - const enabled = ['flag-a', 'flag-b']; - const known = []; // all flags are "new" — but enabledFlags already has flag-a - const result = resolveSeedFlags(enabled, known, MOCK_FLAGS); - expect(result.filter(f => f === 'flag-a')).toHaveLength(1); - }); - - it('empty enabledFlags + knownFlags → only new default-ON flags adopted', () => { - const enabled: string[] = []; - const known: string[] = []; - const result = resolveSeedFlags(enabled, known, MOCK_FLAGS); - expect(result.sort()).toEqual(['flag-a', 'flag-b', 'flag-d'].sort()); + const result = resolveSeedFlags(null); + // All registry flags are present in the record + expect(Object.keys(result)).toHaveLength(FLAG_REGISTRY.length); + // Default-ON boolean flags are true + expect(result['tui']).toBe(true); + expect(result['tool-search']).toBe(true); + expect(result['lsp']).toBe(true); + expect(result['prompt-caching-1h']).toBe(true); + expect(result['show-turn-duration']).toBe(true); + expect(result['clear-context-on-plan']).toBe(true); + expect(result['disable-bundled-skills']).toBe(true); + expect(result['pin-sonnet-4-6']).toBe(true); + // Default-OFF boolean flags are false + expect(result['brief']).toBe(false); + // Number flag with non-neutral default is present + expect(result['max-concurrent-subagents']).toBe(40); + // view-mode default is 'default' (neutralValue for the enum) + expect(result['view-mode']).toBe('default'); + }); + + it('all registry flags present in record → existing values kept', () => { + const record: FlagsRecord = { 'flag-a': true, 'flag-b': false, 'flag-c': false, 'flag-d': false }; + const result = resolveSeedFlags(record, MOCK_FLAGS); + expect(result['flag-a']).toBe(true); + expect(result['flag-b']).toBe(false); + expect(result['flag-c']).toBe(false); + expect(result['flag-d']).toBe(false); + }); + + it('partial record (absent flags = new) → existing kept + absent flags adopt defaults', () => { + // flag-c and flag-d absent → adopt defaults (false and true respectively) + const record: FlagsRecord = { 'flag-a': true, 'flag-b': true }; + const result = resolveSeedFlags(record, MOCK_FLAGS); + expect(result['flag-a']).toBe(true); + expect(result['flag-b']).toBe(true); + expect(result['flag-c']).toBe(false); // adopted default-OFF + expect(result['flag-d']).toBe(true); // adopted default-ON + }); + + it('disabled default-ON flag stays disabled when explicitly false in record', () => { + // flag-a was known at last install, user disabled it → stays false + const record: FlagsRecord = { 'flag-a': false, 'flag-b': true, 'flag-c': false, 'flag-d': false }; + const result = resolveSeedFlags(record, MOCK_FLAGS); + expect(result['flag-a']).toBe(false); // stays disabled — PF-023: no resurrection + expect(result['flag-b']).toBe(true); + }); + + it('default-OFF flag present as false stays false when explicitly set', () => { + const record: FlagsRecord = { 'flag-a': true, 'flag-b': true, 'flag-c': false, 'flag-d': true }; + const result = resolveSeedFlags(record, MOCK_FLAGS); + expect(result['flag-c']).toBe(false); + }); + + it('empty record → adopt all registry flags at their defaults (all absent = all new)', () => { + const result = resolveSeedFlags({}, MOCK_FLAGS); + expect(result['flag-a']).toBe(true); + expect(result['flag-b']).toBe(true); + expect(result['flag-c']).toBe(false); + expect(result['flag-d']).toBe(true); + }); + + it('unknown IDs from old manifests pass through unchanged (forward-compat)', () => { + const record: FlagsRecord = { 'flag-a': true, 'future-flag-xyz': true }; + const result = resolveSeedFlags(record, MOCK_FLAGS); + expect(result['future-flag-xyz']).toBe(true); }); }); @@ -284,38 +297,67 @@ describe('resolveInitSeed', () => { const seed = resolveInitSeed(null, null, '{}', DEVFLOW_PLUGINS); // features: FEATURE_DEFAULTS expect(seed.features).toEqual(FEATURE_DEFAULTS); - // flags: all default-ON from real registry - const expectedFlags = FLAG_REGISTRY.filter(f => f.defaultEnabled).map(f => f.id); - expect(seed.flags.sort()).toEqual(expectedFlags.sort()); - // viewMode: 'default' (nothing in settings, no manifest) - expect(seed.viewMode).toBe('default'); + // flags: FlagsRecord with all registry flags at their defaults + expect(typeof seed.flags).toBe('object'); + expect(seed.flags['tui']).toBe(true); + expect(seed.flags['brief']).toBe(false); + expect(seed.flags['max-concurrent-subagents']).toBe(40); + expect(Object.keys(seed.flags)).toHaveLength(FLAG_REGISTRY.length); + // view-mode in flags (not a separate field) + expect(readViewMode(seed.flags)).toBe('default'); // plugins: non-optional workflow plugins, empty language expect(seed.languagePlugins).toEqual([]); expect(seed.workflowPlugins.length).toBeGreaterThan(0); }); - it('viewMode: settings.json non-default wins over manifest', () => { - const manifest = makeManifest({ features: { ...makeManifest().features, viewMode: 'verbose' } }); + it('view-mode: settings.json non-default wins over manifest', () => { + // view-mode lives in flags['view-mode'] (Phase 6 — no deprecated viewMode field) + const manifest = makeManifest({ features: { ...makeManifest().features, flags: { ...makeManifest().features.flags, 'view-mode': 'verbose' } } }); const settings = JSON.stringify({ viewMode: 'focus' }); const seed = resolveInitSeed(manifest, null, settings, DEVFLOW_PLUGINS); - expect(seed.viewMode).toBe('focus'); // settings beats manifest + expect(readViewMode(seed.flags)).toBe('focus'); // settings beats manifest }); - it('viewMode: manifest used when settings.json has no viewMode or "default"', () => { - const manifest = makeManifest({ features: { ...makeManifest().features, viewMode: 'verbose' } }); + it('view-mode: manifest used when settings.json has no viewMode or "default"', () => { + // view-mode lives in flags['view-mode'] (Phase 6) + const manifest = makeManifest({ features: { ...makeManifest().features, flags: { ...makeManifest().features.flags, 'view-mode': 'verbose' } } }); const settings = JSON.stringify({ viewMode: 'default' }); const seed = resolveInitSeed(manifest, null, settings, DEVFLOW_PLUGINS); - expect(seed.viewMode).toBe('verbose'); // settings 'default' → fall through to manifest + expect(readViewMode(seed.flags)).toBe('verbose'); // settings 'default' → fall through to manifest }); - it('viewMode: falls back to "default" when neither settings nor manifest has one', () => { - const manifest = makeManifest(); // viewMode: 'default' in fixture + it('view-mode: falls back to "default" when neither settings nor manifest has one', () => { + const manifest = makeManifest(); // no 'view-mode' in flags → resolves to 'default' const settings = '{}'; const seed = resolveInitSeed(manifest, null, settings, DEVFLOW_PLUGINS); - expect(seed.viewMode).toBe('default'); + expect(readViewMode(seed.flags)).toBe('default'); + }); + + it('does not mutate the manifest flags record (immutability regression — ARCH-S3)', () => { + // Regression guard: resolveInitSeed previously wrote flags['view-mode'] in place, + // which would corrupt manifest.features.flags if it was passed by reference. + const manifestFlags = { tui: true, 'view-mode': 'verbose' as const }; + const manifest = makeManifest({ features: { ...makeManifest().features, flags: manifestFlags } }); + const originalViewMode = manifest.features.flags?.['view-mode']; + + resolveInitSeed(manifest, null, '{}', DEVFLOW_PLUGINS); + + // Manifest flags must be unchanged after the call. + expect(manifest.features.flags?.['view-mode']).toBe(originalViewMode); + }); + + it('returned flags are a fresh copy — mutating them does not affect the manifest', () => { + const manifest = makeManifest({ features: { ...makeManifest().features, flags: { 'view-mode': 'verbose' as const } } }); + const seed = resolveInitSeed(manifest, null, '{}', DEVFLOW_PLUGINS); + + (seed.flags as Record)['view-mode'] = 'focus'; + + // Manifest flags must remain unaffected by the caller mutating the returned record. + expect(manifest.features.flags?.['view-mode']).toBe('verbose'); }); it('re-init round-trip: re-resolving from the same manifest+config produces the same seed', () => { + // Phase 2: FlagsRecord (was string[] + viewMode); view-mode in flags record const manifest = makeManifest({ features: { ambient: false, @@ -324,8 +366,8 @@ describe('resolveInitSeed', () => { knowledge: false, learning: true, rules: false, - flags: ['tui', 'lsp'], - viewMode: 'verbose', + proxy: false, + flags: { tui: true, lsp: true, 'view-mode': 'verbose' }, }, }); const config = { memory: true, learning: true, knowledge: false, reviewPublication: 'auto' as const }; @@ -421,8 +463,8 @@ describe('resolveInitSeed — re-init composability (WS1)', () => { // Features: all FEATURE_DEFAULTS (all true) expect(seed.features).toEqual(FEATURE_DEFAULTS); - // viewMode: 'default' (no settings, no manifest) - expect(seed.viewMode).toBe('default'); + // view-mode: 'default' (no settings, no manifest — encoded in flags) + expect(readViewMode(seed.flags)).toBe('default'); // workflowPlugins: only non-optional workflow plugins (fresh install defaults) for (const name of seed.workflowPlugins) { const plugin = DEVFLOW_PLUGINS.find(p => p.name === name); @@ -484,26 +526,26 @@ describe('resolveResetGatedInputs', () => { expect(seedSettings).toBe(''); }); - it('reset=true forces viewMode "default" even when settings.json has a non-default mode', () => { + it('reset=true forces view-mode "default" even when settings.json has a non-default mode', () => { // Regression guard: --reset must not preserve an externally-set /focus mode. // The bug was passing the REAL settings snapshot to resolveInitSeed under --reset, // which surfaced viewMode:'focus' and (with viewModeExplicit=true) survived the reset. - const manifest = makeManifest({ features: { ...makeManifest().features, viewMode: 'verbose' } }); + const manifest = makeManifest({ features: { ...makeManifest().features } }); const settings = JSON.stringify({ viewMode: 'focus' }); const gated = resolveResetGatedInputs(true, manifest, null, settings); const seed = resolveInitSeed(gated.seedManifest, gated.seedConfig, gated.seedSettings, DEVFLOW_PLUGINS); - expect(seed.viewMode).toBe('default'); + expect(readViewMode(seed.flags)).toBe('default'); }); - it('reset=false preserves a non-default viewMode from the settings snapshot', () => { + it('reset=false preserves a non-default view-mode from the settings snapshot', () => { // Complement to the reset case: without --reset, an externally-set /focus survives seeding. const settings = JSON.stringify({ viewMode: 'focus' }); const gated = resolveResetGatedInputs(false, null, null, settings); const seed = resolveInitSeed(gated.seedManifest, gated.seedConfig, gated.seedSettings, DEVFLOW_PLUGINS); - expect(seed.viewMode).toBe('focus'); + expect(readViewMode(seed.flags)).toBe('focus'); }); }); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 7456cde2..cfa815d7 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; import { readManifest, writeManifest, mergeManifestPlugins, resolvePluginList, detectUpgrade, syncManifestFeature, type ManifestData } from '../src/core/manifest.js'; +import { makeManifest } from './helpers.js'; describe('readManifest', () => { let tmpDir: string; @@ -73,11 +74,13 @@ describe('readManifest', () => { }); it('returns parsed manifest for valid data (without teams)', async () => { + // Phase 2: use FlagsRecord (not string[]) and no deprecated viewMode field + // so the round-trip is heal-free and the result deeply equals the input. const data: ManifestData = { version: '1.4.0', plugins: ['devflow-core-skills', 'devflow-implement'], scope: 'user', - features: { ambient: true, memory: true, hud: false, knowledge: false, learning: false, rules: true, flags: [], viewMode: 'verbose', proxy: false, compliance: { enabled: false, frameworks: [] } }, + features: { ambient: true, memory: true, hud: false, knowledge: false, learning: false, rules: true, flags: {}, proxy: false, compliance: { enabled: false, frameworks: [] } }, installedAt: '2026-03-01T00:00:00.000Z', updatedAt: '2026-03-13T00:00:00.000Z', }; @@ -137,7 +140,8 @@ describe('readManifest', () => { expect(result!.features.knowledge).toBe(false); expect(result!.features.learning).toBe(false); expect(result!.features.rules).toBe(true); - expect(result!.features.flags).toEqual([]); + // Phase 2: flags migrated from absent (no flags in old JSON) → empty FlagsRecord + expect(result!.features.flags).toEqual({}); // learn field no longer exists in manifest expect((result!.features as Record).learn).toBeUndefined(); }); @@ -221,7 +225,9 @@ describe('readManifest', () => { await fs.writeFile(path.join(tmpDir, 'manifest.json'), JSON.stringify(data), 'utf-8'); const result = await readManifest(tmpDir); expect(result).not.toBeNull(); - expect(result!.features.viewMode).toBe(mode); + // Phase 2: viewMode folded into flags['view-mode']; deprecated field stripped + expect(result!.features.flags['view-mode']).toBe(mode); + expect(result!.features.viewMode).toBeUndefined(); } }); @@ -811,7 +817,8 @@ describe('knownFlags / knownPlugins schema', () => { knowledge: false, learning: false, rules: true, - flags: ['tui'], + // Phase 2: FlagsRecord (was string[]) + flags: { tui: true }, }, installedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -825,7 +832,9 @@ describe('knownFlags / knownPlugins schema', () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - it('round-trips knownFlags through write+read', async () => { + it('write manifest with knownFlags — readManifest strips it (Phase 2 heal)', async () => { + // Phase 2: knownFlags semantics are encoded in FlagsRecord key-presence. + // readManifest strips the deprecated knownFlags field on read (needsHeal path). const data: ManifestData = { ...baseManifest(), features: { ...baseManifest().features, knownFlags: ['tui', 'lsp', 'tool-search'] }, @@ -833,7 +842,9 @@ describe('knownFlags / knownPlugins schema', () => { await writeManifest(tmpDir, data); const result = await readManifest(tmpDir); expect(result).not.toBeNull(); - expect(result!.features.knownFlags).toEqual(['tui', 'lsp', 'tool-search']); + expect(result!.features.knownFlags).toBeUndefined(); + // flags preserved (baseManifest has { tui: true }) + expect(result!.features.flags).toEqual(expect.objectContaining({ tui: true })); }); it('round-trips knownPlugins through write+read', async () => { @@ -940,7 +951,9 @@ describe('knownFlags / knownPlugins schema', () => { expect(result!.knownPlugins).toBeUndefined(); }); - it('preserves other features fields alongside knownFlags', async () => { + it('write manifest with knownFlags + viewMode — readManifest strips both, folds viewMode into flags', async () => { + // Phase 2: knownFlags stripped; viewMode folded into flags['view-mode']. + // Other features fields (security) are preserved unchanged. const data: ManifestData = { ...baseManifest(), features: { @@ -953,8 +966,12 @@ describe('knownFlags / knownPlugins schema', () => { await writeManifest(tmpDir, data); const result = await readManifest(tmpDir); expect(result).not.toBeNull(); - expect(result!.features.knownFlags).toEqual(['tui']); - expect(result!.features.viewMode).toBe('verbose'); + // Phase 2: knownFlags stripped (semantics encoded in FlagsRecord key-presence) + expect(result!.features.knownFlags).toBeUndefined(); + // Phase 2: viewMode folded into flags['view-mode']; deprecated field stripped + expect(result!.features.viewMode).toBeUndefined(); + expect(result!.features.flags['view-mode']).toBe('verbose'); + // Non-flags features preserved expect(result!.features.security).toBe('user'); }); }); @@ -1057,3 +1074,139 @@ describe('compliance feature field', () => { expect(result!.features.compliance).toEqual({ enabled: true, frameworks: ['gdpr', 'sox'] }); }); }); + +// ── Phase 2: FlagsRecord heal round-trip guards ─────────────────────────────── + +describe('FlagsRecord heal round-trip (Phase 2)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-manifest-p2-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('canonical makeManifest() round-trips without triggering heal', async () => { + // makeManifest() uses FlagsRecord + no deprecated fields → no heal cycle → deep-equal. + const data = makeManifest(); + await writeManifest(tmpDir, data); + const result = await readManifest(tmpDir); + expect(result).toEqual(data); + }); + + it('FlagsRecord with false values → deliberate-disable preserved on read', async () => { + // A flag explicitly set to false is a deliberate user choice — must NOT be auto-enabled. + const raw = { + version: '2.0.0', + plugins: ['devflow-core-skills'], + scope: 'user', + features: { + ambient: true, memory: true, hud: false, knowledge: false, learning: false, rules: true, + flags: { tui: false, lsp: true, 'tool-search': false }, + proxy: false, compliance: { enabled: false, frameworks: [] }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await fs.writeFile(path.join(tmpDir, 'manifest.json'), JSON.stringify(raw), 'utf-8'); + const result = await readManifest(tmpDir); + expect(result).not.toBeNull(); + // Deliberate-disable (false) must be preserved — PF-023 sink validation + expect(result!.features.flags['tui']).toBe(false); + expect(result!.features.flags['lsp']).toBe(true); + expect(result!.features.flags['tool-search']).toBe(false); + }); + + it('array→FlagsRecord migration: pre-Phase2 string[] heal is idempotent on second read', async () => { + // Write a pre-Phase2 manifest (flags as string array). + // After first read it heals to FlagsRecord; second read must NOT re-trigger heal. + const raw = { + version: '2.0.0', + plugins: ['devflow-core-skills'], + scope: 'user', + features: { + ambient: true, memory: true, hud: false, knowledge: false, learning: false, rules: true, + flags: ['tui', 'lsp'], + proxy: false, compliance: { enabled: false, frameworks: [] }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await fs.writeFile(path.join(tmpDir, 'manifest.json'), JSON.stringify(raw), 'utf-8'); + + // First read: heals array → FlagsRecord, writes healed manifest to disk + const result1 = await readManifest(tmpDir); + expect(result1).not.toBeNull(); + expect(Array.isArray(result1!.features.flags)).toBe(false); + + // Second read: no array → no heal cycle → result is identical + const result2 = await readManifest(tmpDir); + expect(result2).not.toBeNull(); + expect(result2).toEqual(result1); + }); + + it('D39: heal-write failure returns migrated manifest (non-null), does not throw', async () => { + // Write a legacy manifest (array-format flags) that triggers heal-write + const legacy = { + version: '2.0.0', + plugins: ['devflow-core-skills'], + scope: 'user', + features: { + ambient: true, memory: true, hud: false, knowledge: false, learning: false, rules: true, + flags: ['tui', 'lsp'], // array format → needs healing + proxy: false, compliance: { enabled: false, frameworks: [] }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await fs.writeFile(path.join(tmpDir, 'manifest.json'), JSON.stringify(legacy), 'utf-8'); + + // Inject failure at the seam: reject the atomic rename so the heal-write fails. + // This is UID-independent (avoids PF-018): the old chmod approach was vacuous under + // root UID — a container runner's write succeeded and the assertions proved nothing. + // Proof of RED: removing the try/catch in readManifest around writeManifest causes + // the outer catch to return null, which fails expect(result).not.toBeNull(). + const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce( + new Error('ENOSPC: no space left on device'), + ); + + let result: ManifestData | null; + try { + result = await readManifest(tmpDir); + } finally { + renameSpy.mockRestore(); + } + + // D39: heal-write failure must NOT return null — migrated in-memory manifest is returned + expect(result).not.toBeNull(); + // The in-memory manifest has the migrated FlagsRecord (not the legacy array) + expect(Array.isArray(result!.features.flags)).toBe(false); + expect(result!.features.flags['tui']).toBe(true); + expect(result!.features.flags['lsp']).toBe(true); + }); + + it('__proto__ key in flags JSON is stripped by sanitizeFlagsRecord on read', async () => { + // JSON.parse('{"__proto__": true}') creates an own data property on the parsed object. + // sanitizeFlagsRecord must skip it to prevent prototype pollution. + const raw = { + version: '2.0.0', + plugins: ['devflow-core-skills'], + scope: 'user', + features: { + ambient: true, memory: true, hud: false, knowledge: false, learning: false, rules: true, + flags: JSON.parse('{"__proto__": true, "tui": true}'), + proxy: false, compliance: { enabled: false, frameworks: [] }, + }, + installedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + await fs.writeFile(path.join(tmpDir, 'manifest.json'), JSON.stringify(raw), 'utf-8'); + const result = await readManifest(tmpDir); + expect(result).not.toBeNull(); + // tui preserved; __proto__ own-property stripped + expect(result!.features.flags['tui']).toBe(true); + expect(Object.hasOwn(result!.features.flags, '__proto__')).toBe(false); + }); +}); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index 00ff7851..8fc4534f 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -1599,3 +1599,159 @@ describe('terminateRelay — kill path (integration)', () => { await expect(fsAsync.access(lockPath)).rejects.toThrow(); }, 15_000); }); + +// ─── Phase 4: CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT ──────────── +// +// Proxy models trigger surprise context-window compaction because Claude Code +// does not recognise them as Claude models and enforces a conservative limit. +// The fix: pair ANTHROPIC_BASE_URL with CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT +// so the enforcement is lifted for the relay session. +// +// Strip gate: ANTHROPIC_BASE_URL ownership (port match) gates the URL delete only. +// UNKNOWN_MODEL_WINDOW_ENV is always removed — Devflow is its only producer so there +// is no foreign value to protect. (applies PF-015, ADR-003) + +const WINDOW_ENV = 'CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT'; + +describe('Phase 4 / applyProxyEnv: sets UNKNOWN_MODEL_WINDOW_ENV', () => { + it('sets CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT to "1"', () => { + const result = JSON.parse(applyProxyEnv(JSON.stringify({}), DEFAULT_PORT)); + expect((result.env as Record)[WINDOW_ENV]).toBe('1'); + }); + + it('idempotent re-apply: window var stays "1" on second call', () => { + const once = applyProxyEnv(JSON.stringify({}), DEFAULT_PORT); + const twice = applyProxyEnv(once, DEFAULT_PORT); + expect(JSON.parse(twice).env[WINDOW_ENV]).toBe('1'); + }); + + it('preserves unrelated env keys alongside both relay vars', () => { + const input = JSON.stringify({ env: { MY_VAR: 'keep' } }); + const result = JSON.parse(applyProxyEnv(input, DEFAULT_PORT)); + const env = result.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe(OUR_URL); + expect(env[WINDOW_ENV]).toBe('1'); + expect(env.MY_VAR).toBe('keep'); + }); + + it('port-change re-apply: ANTHROPIC_BASE_URL updates; window var stays "1"', () => { + const afterFirst = applyProxyEnv(JSON.stringify({}), 4141); + const afterSecond = applyProxyEnv(afterFirst, 5000); + const env = JSON.parse(afterSecond).env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:5000'); + expect(env[WINDOW_ENV]).toBe('1'); + }); +}); + +describe('Phase 4 / stripProxyEnv: ownership-gated strip of both relay vars', () => { + it('ownership match: removes BOTH ANTHROPIC_BASE_URL and the window var', () => { + const input = JSON.stringify({ env: { ANTHROPIC_BASE_URL: OUR_URL, [WINDOW_ENV]: '1' } }); + const result = JSON.parse(stripProxyEnv(input, DEFAULT_PORT)); + // Both proxy vars removed; env cleaned up entirely + expect(result.env).toBeUndefined(); + }); + + it('ownership match with extra env: removes both vars, preserves unrelated', () => { + const input = JSON.stringify({ + env: { ANTHROPIC_BASE_URL: OUR_URL, [WINDOW_ENV]: '1', EXTRA: 'keep' }, + }); + const result = JSON.parse(stripProxyEnv(input, DEFAULT_PORT)); + const env = result.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env[WINDOW_ENV]).toBeUndefined(); + expect(env.EXTRA).toBe('keep'); + }); + + it('foreign URL: ANTHROPIC_BASE_URL NOT removed; window var IS always removed', () => { + // URL gate protects a foreign url value — but the window var has no foreign value; + // Devflow is its only producer so it is always removed. (applies PF-015, ADR-003) + const input = JSON.stringify({ + env: { ANTHROPIC_BASE_URL: 'https://foreign.example.com', [WINDOW_ENV]: '1' }, + }); + const result = JSON.parse(stripProxyEnv(input, DEFAULT_PORT)); + const env = result.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe('https://foreign.example.com'); + expect(env[WINDOW_ENV]).toBeUndefined(); + }); + + it('absent URL with orphan window var: window var IS removed (Devflow is its only producer)', () => { + // No ANTHROPIC_BASE_URL → URL gate does not fire, but the window var is always ours to remove. + // The env block is cleaned up entirely when the window var was the only key. + const input = JSON.stringify({ env: { [WINDOW_ENV]: '1' } }); + const result = JSON.parse(stripProxyEnv(input, DEFAULT_PORT)); + expect(result.env).toBeUndefined(); + }); + + it('ours-other-port URL with window var: URL NOT removed (different managed port), window var IS removed', () => { + const otherPortUrl = 'http://127.0.0.1:5000'; // not managed by DEFAULT_PORT (4141) + const input = JSON.stringify({ env: { ANTHROPIC_BASE_URL: otherPortUrl, [WINDOW_ENV]: '1' } }); + const result = JSON.parse(stripProxyEnv(input, DEFAULT_PORT)); + const env = result.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe(otherPortUrl); // URL preserved — not our port + expect(env[WINDOW_ENV]).toBeUndefined(); // always removed — Devflow is the only producer + }); + + // SEC-M2 regression: port-drift orphan — UNKNOWN_MODEL_WINDOW_ENV must not survive a + // strip attempt when ANTHROPIC_BASE_URL is present but on a different managed port. + // Reachable via: port drift between enable/disable, hand-edited URL, or uninstall's + // DEFAULT_PROXY_PORT fallback. Both behaviors must be asserted together from a + // fully-enabled starting state (PF-015 stated test rule). + it('SEC-M2: port-mismatch strip — window var removed, mismatched ANTHROPIC_BASE_URL untouched', () => { + const enabledPort = DEFAULT_PORT; // 4141 — the port that was active at enable time + const disablePort = 9999; // different port — simulates port drift between enable and disable + const input = JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${enabledPort}`, + [WINDOW_ENV]: '1', + }, + }); + const result = JSON.parse(stripProxyEnv(input, disablePort)); + const env = result.env as Record; + // (a) window var MUST be gone — Devflow is its only producer; no foreign value to protect + expect(env[WINDOW_ENV]).toBeUndefined(); + // (b) ANTHROPIC_BASE_URL MUST be untouched — port mismatch means it is not ours to remove + expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:${enabledPort}`); + }); +}); + +describe('Phase 4 / T7-extended: fully-enabled state includes UNKNOWN_MODEL_WINDOW_ENV', () => { + /** Fully-enabled settings: hooks + ANTHROPIC_BASE_URL + window-enforcement var. */ + function buildFullyEnabledSettingsP4(extraEnv?: Record): Settings { + const s: Settings = {}; + addProxyHooks(s, DEVFLOW_DIR); + (s as Record).env = { + ANTHROPIC_BASE_URL: OUR_URL, + [WINDOW_ENV]: '1', + ...extraEnv, + }; + return s; + } + + it('PF-015 whole-end-state: applyDisableToSettings removes hooks, relay URL, AND window var', () => { + const s = buildFullyEnabledSettingsP4({ EXTRA: 'keep' }); + const changed = applyDisableToSettings(s, DEFAULT_PORT); + + expect(changed).toBe(true); + expect(hasProxyHooks(s)).toBe(false); + const env = (s as Record).env as Record | undefined; + expect(env?.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env?.[WINDOW_ENV]).toBeUndefined(); + // Unrelated env vars survive + expect(env?.EXTRA).toBe('keep'); + }); + + it('env block removed entirely when both relay vars are the only env keys', () => { + const s = buildFullyEnabledSettingsP4(); // no extras + applyDisableToSettings(s, DEFAULT_PORT); + + expect(hasProxyHooks(s)).toBe(false); + expect((s as Record).env).toBeUndefined(); + }); + + it('applyProxyEnv produces a fully-enabled env containing both relay vars', () => { + const result = JSON.parse(applyProxyEnv(JSON.stringify({}), DEFAULT_PORT)); + const env = result.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe(OUR_URL); + expect(env[WINDOW_ENV]).toBe('1'); + }); +}); diff --git a/tests/tui-cells.test.ts b/tests/tui-cells.test.ts new file mode 100644 index 00000000..ea09241c --- /dev/null +++ b/tests/tui-cells.test.ts @@ -0,0 +1,129 @@ +/** + * Unit tests for src/cli/tui/cells.ts — shared TUI cell helpers. + * + * sanitizeCell is the PF-023 sink for disk-sourced flag values (render.ts:85): + * the point where a persisted value like `spellcheck=$'a\nb'` is stopped from + * breaking the one-string-per-terminal-line frame contract. Tests here pin the + * contract at the sink rather than through two renderers (agents-view and + * flags-view). + * + * avoids PF-018: each assertion names a specific behavior and would fail against + * a no-op or broken implementation of the named function. + */ + +import { describe, it, expect } from 'vitest'; +import { sanitizeCell, padToVisible, truncateVisible } from '../src/cli/tui/cells.js'; +import { stripAnsi } from '../src/core/ansi.js'; + +// --------------------------------------------------------------------------- +// sanitizeCell — PF-023 sink: collapses layout-breaking whitespace, strips ANSI +// --------------------------------------------------------------------------- + +describe('sanitizeCell', () => { + it('collapses TAB to a single space', () => { + expect(sanitizeCell('a\tb')).toBe('a b'); + }); + + it('collapses LF to a single space', () => { + expect(sanitizeCell('a\nb')).toBe('a b'); + }); + + it('collapses mixed TAB and LF — each becomes one space independently', () => { + // Each layout-breaking character collapses to a single space; + // consecutive instances produce consecutive spaces (not folded further). + expect(sanitizeCell('a\tb\nc')).toBe('a b c'); + }); + + it('strips ANSI SGR escape sequences', () => { + expect(sanitizeCell('\x1b[31mred\x1b[0m')).toBe('red'); + }); + + it('strips ANSI then collapses layout-breaking whitespace (ANSI + TAB)', () => { + expect(sanitizeCell('\x1b[31mred\x1b[0m\tvalue')).toBe('red value'); + }); + + it('passes through plain ASCII unchanged', () => { + expect(sanitizeCell('hello world')).toBe('hello world'); + }); + + it('returns empty string for empty input', () => { + expect(sanitizeCell('')).toBe(''); + }); + + it('returns only space when input is a bare LF', () => { + // Regression: a persisted multi-line value that is just a newline + expect(sanitizeCell('\n')).toBe(' '); + }); +}); + +// --------------------------------------------------------------------------- +// padToVisible — measures visible (ANSI-stripped) length for padding +// --------------------------------------------------------------------------- + +describe('padToVisible', () => { + it('pads a plain string to the requested visible width', () => { + expect(padToVisible('ab', 5)).toBe('ab '); + }); + + it('measures ANSI-stripped length so styled text reaches the correct column', () => { + // '\x1b[31mab\x1b[0m' has 2 visible chars; pad to 5 adds 3 spaces after the ANSI reset + const result = padToVisible('\x1b[31mab\x1b[0m', 5); + expect(stripAnsi(result)).toBe('ab '); + expect(stripAnsi(result).length).toBe(5); + }); + + it('adds no padding when the visible length already equals width', () => { + expect(padToVisible('hello', 5)).toBe('hello'); + }); + + it('adds no padding and does NOT truncate when visible length exceeds width', () => { + // padToVisible is a padding function only — no truncation side-effect + expect(padToVisible('toolong', 4)).toBe('toolong'); + }); + + it('pads to width 1 from an empty string', () => { + expect(padToVisible('', 1)).toBe(' '); + }); +}); + +// --------------------------------------------------------------------------- +// truncateVisible — drops styling across truncation boundary; unchanged when fits +// --------------------------------------------------------------------------- + +describe('truncateVisible', () => { + it('returns the original plain string unchanged when visible length fits within maxWidth', () => { + expect(truncateVisible('ab', 5)).toBe('ab'); + }); + + it('preserves ANSI styling when the string fits within maxWidth', () => { + const styled = '\x1b[31mab\x1b[0m'; + // Fits → returns s as-is, styling intact + expect(truncateVisible(styled, 5)).toBe(styled); + }); + + it('truncates a plain string to maxWidth visible characters including the ellipsis', () => { + // truncate(s, 3): slice(0, 2) + '…' → 'he…' (3 visible chars) + expect(truncateVisible('hello', 3)).toBe('he…'); + expect(truncateVisible('hello', 3).length).toBe(3); + }); + + it('drops ANSI styling across the truncation boundary (rebuilds from stripped text)', () => { + // Input: styled 'hello'; truncation discards the ANSI codes and works on raw text + const result = truncateVisible('\x1b[31mhello\x1b[0m', 3); + expect(result).toBe('he…'); + // No escape codes survive the truncation + expect(stripAnsi(result)).toBe(result); + }); + + it('truncated result has exactly maxWidth visible characters', () => { + // maxWidth=4: slice(0, 3) + '…' → 'abc…' (4 chars) + const result = truncateVisible('abcdefgh', 4); + expect(result).toBe('abc…'); + expect(result.length).toBe(4); + }); + + it('handles exactly maxWidth — no truncation, no ellipsis', () => { + // string length equals maxWidth exactly → returned unchanged + expect(truncateVisible('abc', 3)).toBe('abc'); + }); +}); diff --git a/tests/tui-terminal.test.ts b/tests/tui-terminal.test.ts new file mode 100644 index 00000000..46c44ea0 --- /dev/null +++ b/tests/tui-terminal.test.ts @@ -0,0 +1,497 @@ +/** + * Tests for src/cli/tui/terminal.ts — the shared TUI shell driver. + * + * Focus: the cleanup invariant. The shell puts the terminal into alt-screen + + * raw mode + hidden cursor BEFORE it can render anything, so any path that + * leaves without running cleanup() strands the user's shell with no echo and no + * line editing until they run `stty sane`. + * + * The save/cancel/signal paths are covered by flags-view-terminal.test.ts and + * agents-terminal.test.ts. What is pinned here is the path those cannot reach: + * an exception escaping the render or reduce callbacks. A throw inside an + * EventEmitter listener does not reject the enclosing promise — it escapes as an + * uncaughtException — so without an explicit guard the process dies with the + * terminal still in raw mode (the PF-014 failure class: cleanup that does not run). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { PassThrough } from 'stream'; +import { runTui, normalizeKey, type TuiIO } from '../src/cli/tui/terminal.js'; + +// Alias for escape sequences used in bail-guard assertions +const ENTER_ALT = '\x1b[?1049h'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SHOW_CURSOR = '\x1b[?25h'; +const LEAVE_ALT = '\x1b[?1049l'; + +interface Harness { + stdin: PassThrough; + stdout: PassThrough; + io: Partial; + rawModeCalls: boolean[]; + written: () => string; +} + +/** TTY-like fake streams that record setRawMode transitions and all output. */ +function makeHarness(): Harness { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const rawModeCalls: boolean[] = []; + const chunks: string[] = []; + + (stdin as unknown as { isTTY: boolean }).isTTY = true; + (stdin as unknown as { setRawMode: (m: boolean) => void }).setRawMode = (m: boolean) => { + rawModeCalls.push(m); + }; + (stdout as unknown as { rows: number }).rows = 24; + (stdout as unknown as { columns: number }).columns = 80; + + const realWrite = stdout.write.bind(stdout); + stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + return (realWrite as (...a: unknown[]) => boolean)(chunk, ...rest); + }) as PassThrough['write']; + + return { + stdin, + stdout, + io: { stdin, stdout } as Partial, + rawModeCalls, + written: () => chunks.join(''), + }; +} + +/** Assert the terminal was fully restored: raw mode off, cursor shown, alt-screen left. */ +function expectTerminalRestored(h: Harness, pauseSpy: ReturnType): void { + expect(h.rawModeCalls, 'setRawMode(true) then setRawMode(false)').toEqual([true, false]); + expect(pauseSpy, 'stdin.pause() releases the ref\'d TTY handle').toHaveBeenCalled(); + expect(h.written()).toContain(SHOW_CURSOR); + expect(h.written()).toContain(LEAVE_ALT); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// TEST-H1: normalizeKey — complete key table (applies PF-018) +// +// All 12 named entries in the switch table, plus ctrl-c and the default-branch +// fallback, are tested via it.each. The existing TS-M5 tests cover the +// undefined-str surface; this table covers the named-key-to-normalized-name +// mapping for every readline key name the switch knows about. +// --------------------------------------------------------------------------- + +describe('normalizeKey — complete key table (TEST-H1)', () => { + it.each([ + // [key.name as emitted by readline, expected normalized string] + ['backspace', 'backspace'], + ['delete', 'delete'], + ['home', 'home'], + ['end', 'end'], + ['left', 'left'], + ['right', 'right'], + ['up', 'up'], + ['down', 'down'], + ['return', 'enter'], // readline emits 'return', TUI expects 'enter' + ['escape', 'escape'], + ['space', 'space'], + ['tab', 'tab'], + ])('key.name "%s" → normalized "%s"', (keyName, expected) => { + // With str defined: the switch table wins over str (named keys take priority) + expect(normalizeKey('x', { name: keyName })).toBe(expected); + // With str undefined: switch table still resolves correctly + expect(normalizeKey(undefined, { name: keyName })).toBe(expected); + }); + + it('ctrl-c → "ctrl-c" regardless of str', () => { + expect(normalizeKey('c', { ctrl: true, name: 'c' })).toBe('ctrl-c'); + expect(normalizeKey(undefined, { ctrl: true, name: 'c' })).toBe('ctrl-c'); + }); + + it('default branch: returns str when key.name is not in the table', () => { + // For printable single chars, readline emits str='a', name='a' + expect(normalizeKey('a', { name: 'a' })).toBe('a'); + expect(normalizeKey('Z', { name: 'Z' })).toBe('Z'); + }); + + it('default branch: returns key.name when str is undefined and name is not in table', () => { + // str ?? name fallback — no str provided → name is returned + expect(normalizeKey(undefined, { name: 'unknownKey' })).toBe('unknownKey'); + }); +}); + +// --------------------------------------------------------------------------- +// TS-M5: normalizeKey accepts undefined str (readline emits undefined for +// non-printable escape sequences) +// --------------------------------------------------------------------------- + +describe('normalizeKey — undefined str (TS-M5)', () => { + it('returns the key name when str is undefined and key has a name', () => { + // Node readline emits undefined as first arg for non-printable sequences + expect(normalizeKey(undefined, { name: 'up' })).toBe('up'); + expect(normalizeKey(undefined, { name: 'return' })).toBe('enter'); + expect(normalizeKey(undefined, { name: 'escape' })).toBe('escape'); + }); + + it('returns empty string when both str and key.name are absent', () => { + expect(normalizeKey(undefined, null)).toBe(''); + }); + + it('still handles ctrl-c with undefined str', () => { + expect(normalizeKey(undefined, { ctrl: true, name: 'c' })).toBe('ctrl-c'); + }); +}); + +// --------------------------------------------------------------------------- +// SEC-S3: setRawMode(true) runs inside the guarded try block so a throw +// (EIO on a detached TTY) routes through cleanup() before any listener is +// registered. +// --------------------------------------------------------------------------- + +describe('runTui — guarded startup (SEC-S3)', () => { + it('restores the terminal when setRawMode(true) throws before keypress listeners are registered', async () => { + const h = makeHarness(); + let rawModeOffCalled = false; + + // Override: true throws (EIO), false records itself via rawModeOffCalled + (h.stdin as unknown as { setRawMode: (m: boolean) => void }).setRawMode = (m: boolean) => { + if (m) throw new Error('EIO: input/output error'); + rawModeOffCalled = true; // cleanup called setRawMode(false) + }; + + await expect( + runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: s, intent: 'none' }), + renderFrame: () => ['frame'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }), + ).rejects.toThrow('EIO: input/output error'); + + // Cleanup must restore the terminal even though setRawMode(true) threw + // before any keypress listener was registered. + expect(h.written()).toContain(SHOW_CURSOR); + expect(h.written()).toContain(LEAVE_ALT); + expect(rawModeOffCalled, 'cleanup called setRawMode(false) via its own try/catch').toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// REL-M1: renderFrame output is clamped to dims.rows at the single write site +// so every runTui consumer inherits the bound. +// --------------------------------------------------------------------------- + +describe('runTui — frame line clamping (REL-M1)', () => { + it('emits at most dims.rows lines when renderFrame returns more', async () => { + const h = makeHarness(); + // Use 3 rows so the excess is obvious (renderFrame returns 10) + (h.stdout as unknown as { rows: number }).rows = 3; + + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: { n: s.n + 1 }, intent: 'done' }), + // Returns 10 distinctly-named lines — only the first 3 should appear in output + renderFrame: () => ['row0', 'row1', 'row2', 'row3', 'row4', 'row5', 'row6', 'row7', 'row8', 'row9'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + await tui; + + const output = h.written(); + // Lines within dims.rows (0–2) must appear; lines beyond must not + expect(output).toContain('row0'); + expect(output).toContain('row1'); + expect(output).toContain('row2'); + expect(output).not.toContain('row3'); + expect(output).not.toContain('row4'); + }); + + it('preserves at least one line when dims.rows is 1 or less', async () => { + const h = makeHarness(); + (h.stdout as unknown as { rows: number }).rows = 1; + + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: { n: s.n + 1 }, intent: 'done' }), + renderFrame: () => ['only-line', 'hidden-line'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + await tui; + + const output = h.written(); + expect(output).toContain('only-line'); + expect(output).not.toContain('hidden-line'); + }); +}); + +// --------------------------------------------------------------------------- +// TEST-M1 / REG-S3: frame output contract — byte-level assertions +// +// renderToStdout's documented contract: +// HOME + line + ERASE_EOL per line, '\n' between lines but NOT after the last, +// then ERASE_BELOW (\x1b[0J) to clear stale content on terminal shrink. +// +// Both the ERASE_BELOW append and the no-trailing-newline guard are single-line +// fixes that revert silently when deleted. The assertions below are the regression +// guards: each would fail independently against a broken implementation. +// +// Failure modes: +// • "exact composition" assertion — toContain(expectedFrame) fails if ERASE_BELOW +// is deleted (the expected string ends in \x1b[0J which is absent in the output). +// • "no trailing newline" assertion — not.toContain('line-b\x1b[K\n\x1b[0J') fails +// if a '\n' is re-introduced before ERASE_BELOW. +// --------------------------------------------------------------------------- + +describe('renderToStdout — frame output contract (TEST-M1 / REG-S3)', () => { + const HOME_SEQ = '\x1b[H'; + const ERASE_EOL_SEQ = '\x1b[K'; + const ERASE_BELOW_SEQ = '\x1b[0J'; + + it('exact escape-sequence composition for a 2-line frame: HOME + lines + ERASE_EOL + ERASE_BELOW', async () => { + const h = makeHarness(); + + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: { n: s.n + 1 }, intent: 'done' }), + renderFrame: () => ['line-a', 'line-b'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + await tui; + + // Full expected frame bytes: + // HOME + 'line-a' + ERASE_EOL + '\n' + 'line-b' + ERASE_EOL + ERASE_BELOW + // Deleting the ERASE_BELOW append makes toContain fail (ERASE_BELOW absent). + const expectedFrame = + `${HOME_SEQ}line-a${ERASE_EOL_SEQ}\nline-b${ERASE_EOL_SEQ}${ERASE_BELOW_SEQ}`; + expect(h.written()).toContain(expectedFrame); + }); + + it('last frame line has no trailing newline before ERASE_BELOW', async () => { + const h = makeHarness(); + + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: { n: s.n + 1 }, intent: 'done' }), + renderFrame: () => ['line-a', 'line-b'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + await tui; + + // A trailing '\n' before ERASE_BELOW would scroll the alt-screen on every + // redraw. Verify the '\n' is absent: re-introducing it makes this fail. + expect(h.written()).not.toContain(`line-b${ERASE_EOL_SEQ}\n${ERASE_BELOW_SEQ}`); + }); + + it('a 1-line frame has no newline separators', async () => { + const h = makeHarness(); + + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: { n: s.n + 1 }, intent: 'done' }), + renderFrame: () => ['solo'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + await tui; + + // Single line: HOME + 'solo' + ERASE_EOL + ERASE_BELOW, no '\n' at all in the frame. + expect(h.written()).toContain(`${HOME_SEQ}solo${ERASE_EOL_SEQ}${ERASE_BELOW_SEQ}`); + expect(h.written()).not.toContain(`solo${ERASE_EOL_SEQ}\n`); + }); +}); + +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// REL-H1: driver bails before alt-screen when stdin is not a TTY and no +// spec.io.stdin was injected. This pins the guard so a future caller cannot +// silently reintroduce the stdout-only predicate. +// +// In the vitest environment process.stdin.isTTY is falsy (not a real TTY). +// Providing spec.io.stdout but NOT spec.io.stdin exercises the bail path. +// --------------------------------------------------------------------------- + +describe('runTui — non-TTY stdin guard (REL-H1)', () => { + it('rejects before writing ENTER_ALT when no io.stdin and process.stdin is not a TTY', async () => { + // Intercept stdout writes to verify ENTER_ALT is never emitted. + const fakeStdout = new PassThrough(); + const written: string[] = []; + const realWrite = fakeStdout.write.bind(fakeStdout); + fakeStdout.write = ((chunk: unknown, ...rest: unknown[]) => { + written.push(String(chunk)); + return (realWrite as (...a: unknown[]) => boolean)(chunk, ...rest); + }) as PassThrough['write']; + (fakeStdout as unknown as { rows: number }).rows = 24; + (fakeStdout as unknown as { columns: number }).columns = 80; + + await expect( + runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: s, intent: 'none' }), + renderFrame: () => ['frame'], + signalAction: 'done', + continueIntent: 'none', + // spec.io.stdout provided so the guard's "no injected stdin" branch is + // exercised, but spec.io.stdin is intentionally omitted — bail fires when + // process.stdin.isTTY is falsy (the normal vitest environment). + io: { stdout: fakeStdout as unknown as TuiIO['stdout'] }, + }), + ).rejects.toThrow('stdin is not a TTY'); + + // No alt-screen escape must have been written before the guard fired. + expect(written.join('')).not.toContain(ENTER_ALT); + }); + + it('proceeds normally when spec.io.stdin is injected (test-stream path)', async () => { + // When io.stdin is injected, the guard is bypassed even if the stream's + // isTTY would be falsy — the caller owns the stream lifecycle. + const h = makeHarness(); + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: { n: s.n + 1 }, intent: 'done' }), + renderFrame: () => ['frame'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, // io.stdin IS injected → guard bypassed + }); + + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + const result = await tui; + expect(result.intent).toBe('done'); + expect(h.written()).toContain(ENTER_ALT); + }); +}); + +describe('runTui — cleanup always runs', () => { + it('restores the terminal when the INITIAL render throws', async () => { + const h = makeHarness(); + const pauseSpy = vi.spyOn(h.stdin, 'pause'); + const boom = new Error('render exploded'); + + // The initial render happens after alt-screen + raw mode are already set. + await expect( + runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: s, intent: 'none' }), + renderFrame: () => { throw boom; }, + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }), + ).rejects.toThrow('render exploded'); + + expectTerminalRestored(h, pauseSpy); + }); + + it('restores the terminal when onResize throws during startup', async () => { + const h = makeHarness(); + const pauseSpy = vi.spyOn(h.stdin, 'pause'); + + await expect( + runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: s, intent: 'none' }), + renderFrame: () => ['frame'], + onResize: () => { throw new Error('resize exploded'); }, + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }), + ).rejects.toThrow('resize exploded'); + + expectTerminalRestored(h, pauseSpy); + }); + + it('restores the terminal when reduce throws on a keypress', async () => { + const h = makeHarness(); + const pauseSpy = vi.spyOn(h.stdin, 'pause'); + + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: () => { throw new Error('reduce exploded'); }, + renderFrame: () => ['frame'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + // Let the first frame render, then deliver a key that trips the reducer. + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + + await expect(tui).rejects.toThrow('reduce exploded'); + expectTerminalRestored(h, pauseSpy); + }); + + it('a non-Error throw is still surfaced as an Error, with cleanup', async () => { + const h = makeHarness(); + const pauseSpy = vi.spyOn(h.stdin, 'pause'); + + const promise = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: s, intent: 'none' }), + // eslint-disable-next-line @typescript-eslint/only-throw-error + renderFrame: () => { throw 'a bare string'; }, + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + await expect(promise).rejects.toBeInstanceOf(Error); + expectTerminalRestored(h, pauseSpy); + }); + + it('the normal save path still resolves and restores the terminal', async () => { + // Guard against the error handling above regressing the happy path. + const h = makeHarness(); + const pauseSpy = vi.spyOn(h.stdin, 'pause'); + + const tui = runTui<{ n: number }, 'none' | 'done', 'none'>({ + initialState: { n: 0 }, + reduce: s => ({ state: { n: s.n + 1 }, intent: 'done' }), + renderFrame: () => ['frame'], + signalAction: 'done', + continueIntent: 'none', + io: h.io, + }); + + await new Promise(r => setTimeout(r, 10)); + h.stdin.push('x'); + + const result = await tui; + expect(result.intent).toBe('done'); + expect(result.state.n).toBe(1); + expectTerminalRestored(h, pauseSpy); + }); +});