Skip to content

feat(flags): typed flag registry, settings-page TUI, new upstream flags - #300

Merged
dean0x merged 41 commits into
mainfrom
feat/flags-typed-registry
Aug 25, 2026
Merged

feat(flags): typed flag registry, settings-page TUI, new upstream flags#300
dean0x merged 41 commits into
mainfrom
feat/flags-typed-registry

Conversation

@dean0x

@dean0x dean0x commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • Typed flag registry: replaces string[] flags with a discriminated-union ClaudeCodeFlag registry. Each flag carries kind (boolean|enum|number|string), target (env|setting), default value, and display metadata. 8 new upstream-verified flags added (max-concurrent-subagents default=40, subagent-spawn-depth, workflow-size-guideline, etc.).
  • FlagsRecord throughout: manifest, seed, and init all work with Record<string, FlagValue|null> instead of parallel string[] / knownFlags / viewMode fields. Bridge shims (legacyIdsToRecord, applyViewMode, stripViewMode, getDefaultFlags) deleted.
  • Settings-page TUI: full-keyboard flags editor (runFlagsTui) replaces the multiselect in the Advanced wizard. Agents-view reuses the same generic runTui shell.
  • Fold-before-strip fix (PF-015): resolveExistingViewMode now runs BEFORE stripFlags in the settings apply block. Prior order stripped viewMode from settings before reading it, causing silent viewMode loss on every re-init.
  • Typed flags CLI (devflow flags): --list/--status/--enable/--disable/--set/--unset with input validation and typed persistence.

Changes

Core (src/core/)

  • flags.ts: discriminated-union ClaudeCodeFlag type; 20 flags in typed registry; applyFlags(json, FlagsRecord) new canonical API; sanitizeFlagsRecord, migrateLegacyFlagsToRecord for backward compat; deprecated shims deleted in Phase 6
  • manifest.ts: FlagsRecord in ManifestFeatures; in-reader heal (migrateLegacyFlagsToRecord) writes healed manifest back — no MIGRATIONS entry needed

CLI (src/cli/commands/)

  • init-seed.ts: InitSeed.flags: FlagsRecord; resolveSeedFlags returns FlagsRecord (adopt defaults for absent flags); resolveInitSeed sets flags['view-mode'] in-place
  • init.ts: TUI flags editor in Advanced path; fold-before-strip ordering fix; manifest write uses FlagsRecord directly; no knownFlags/viewMode residue
  • uninstall.ts: stripViewMode removed (covered by stripFlags via view-mode registry entry)
  • flags.ts: new createFlagsCommand() factory with full CRUD
  • proxy.ts: UNKNOWN_MODEL_WINDOW_ENV managed as a pair with ANTHROPIC_BASE_URL

TUI (src/cli/tui/, src/cli/flags-view/, src/cli/agents-view/)

  • Generic runTui<S,A>(spec) driver in src/cli/tui/terminal.ts
  • flags-view/: state, render, terminal, index — full keyboard flags editor
  • agents-view/: thinned to adapter over runTui (removes duplicated TUI driver)

Tests

  • 98 new TUI/flags tests (Phase 5), 34 flags CLI tests (Phase 3), 14 proxy env tests (Phase 4)
  • 3 subprocess e2e tests with seeded temp HOME (PF-018): old-format manifest heal + viewMode preservation, fresh install defaults, idempotency

Breaking Changes

  1. Bare devflow flags on non-TTY: now prints a status table and exits with code 1 (was: usage line, exit 0). Any script invoking bare devflow flags must use --status (exit 0) or handle exit 1. (Already documented in docs/cli-reference.md.)

  2. Seven settings.json/env keys become Devflow-managed: ANTHROPIC_DEFAULT_MODEL, CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH, CLAUDE_CODE_GOAL_CHECKIN_MINUTES, CLAUDE_CODE_ENABLE_TODO_TOOLS (env); workflowSizeGuideline, spellcheck (settings). Hand-set values are PRESERVED by the fold-before-strip pipeline (adopted into the manifest record on first devflow init or flags mutation; explicit devflow flags --set/--unset still wins). max-concurrent-subagents adopts the devflow default of 40 (upstream default: 20) only when the key is absent in settings.json. Full removal still occurs on devflow uninstall.

The manifest in-reader heal is backward-compatible: old flags: string[] manifests are healed on first devflow init after upgrade. All external interfaces (applyFlags, FlagsRecord) are new additions.

Reviewer Focus Areas

  • PF-015 fold-before-strip (src/cli/commands/init.ts ~line 1629): ordering of resolveExistingViewModestripFlagsapplyFlags is critical
  • resolveSeedFlags adoption (src/cli/commands/init-seed.ts): absent key = new flag → adopt registry default (ADR-014)
  • migrateLegacyFlagsToRecord (src/core/flags.ts): knownSet membership gates entry/absence; view-mode handled separately
  • TUI runTui generic shell (src/cli/tui/terminal.ts): shared driver reused by both flags-view and agents-view
  • applyFlags number-flag path (src/core/flags.ts): buildPayload stringifies for env targets (String(40)'40'); 0 is active, null is neutral

Quality Gates

Simplify pass (fca49ff — transition-residue cleanup):

  • Removed dead code references
  • Cleaned up deprecated bridge shim names
  • Eliminated leftover viewMode-only references

Scrutinize 9-pillar pass (3ac14d6 — 6 defects fixed with RED-proven tests):

  • Silent-success flags CLI exit-code bug: getCommand() did not return process.exit() result on programmatic error paths
  • Vacuous e2e test 1: init settings apply pass had no hook-shape validation; added non-vacuity guard to verify hook entry has hooks array
  • TUI raw-mode leak on handler throw: missing try-finally; now restores terminal mode on all exception paths
  • Space untypable in edit buffer: space key was bound to help instead of text insertion; re-mapped correctly
  • Ctrl-C dead while editing: SIGINT handler was not wired during TUI interaction; now properly queues exit signal
  • Resize cursor stranding: cursor position not reclamped after viewport shrink; fixed via normalizeKey backspace/delete/home/end handling

Evaluate alignment audit (3352da7 — 12-item alignment pass, re-checked ALIGNED):

  • ADR-016 cyan/chevrons/dirty-dot vocabulary applied consistently in render modules
  • sanitizeCell on all disk-persisted values (flags display, agent model cache)
  • Literal j/k in edit mode (not interpreted as Vim keybindings)
  • ManifestData residue removal (unused flags: string[] | null field deleted)
  • 5 coverage gaps closed:
    • D39 heal-write-failure: manifest in-reader heal writes back on first load
    • Bare non-TTY tests: subprocess e2e with stdin/stdout capture validates programmatic paths
    • Flags CLI mutation atomicity: --set/--unset use fs-atomic for manifest writes
    • TUI interrupt recovery: Ctrl-C during edit restores terminal state and exits cleanly
    • Settings apply ordering: fold-before-strip verified in integration test

Validate green at every step:

  • 102 test files / 3581 tests PASS
  • Build: 0 errors, 0 warnings
  • QA acceptance: 8/9 scenarios PASS, S9 (proxy convergence via re-init) SKIPPED
    • Reason: proxy preflight requires a live relay binary + ~/.codex/auth.json + doctor endpoint (disproportionate for isolated temp-HOME test)
    • Coverage: the var's apply/strip contract is fully covered in tests/proxy.test.ts

Note on TUI shared shell: The generic shared-shell fixes are:

  • ESC[0J erase-below (stale-frame ghosting on shrink)
  • Missing-trailing-newline handling
  • normalizeKey backspace/delete/home/end

These are shared by flags-view and agents-view. Note: agents-view's viewport re-clamp on resize remains deliberately unchanged (planned as a separate follow-up PR).

Pending Ship Gates

  1. Real-TTY manual checklist (16 items) — comprehensive hands-on verification against the shipped product
  2. Phase 7 docs — DONE (commit 1fcd7f0) — CLAUDE.md, docs/cli-reference.md, and docs/reference/file-organization.md updated
  3. Follow-up (pre-existing on main, out of scope here):
    • removeCaptureHooks crashes the entire init settings pass on a hook entry missing its hooks array (src/cli/commands/capture.ts:93 + the warning-only try/catch at init.ts settings pass). Recommend filing an issue.

dean0x and others added 10 commits August 23, 2026 23:50
… 8 new flags

Rewrites `src/core/flags.ts` from a boolean-only `ClaudeCodeFlag` interface
to a fully typed discriminated-union registry (BooleanFlagDef | EnumFlagDef |
NumberFlagDef | StringFlagDef keyed by `kind`). Adds 8 new upstream flags and
a compile bridge so existing call sites continue to build unchanged.

New types: ClaudeCodeFlag (union), FlagsRecord (Record<string, FlagsRecordValue>),
FlagTarget ({ type:'env'|'setting'; key: string }).

Registry: 28 flags total (20 existing converted + 8 new):
  max-concurrent-subagents (number, recommended, default 40, env)
  subagent-spawn-depth (number, optional, env)
  workflow-size-guideline (enum small|medium|large|unrestricted, optional, setting)
    — domain verified from Claude Code 2.1.241 binary string analysis (Phase 0)
  default-model (string, optional, env ANTHROPIC_DEFAULT_MODEL)
  enable-todo-tools (boolean, optional, env)
  goal-checkin-minutes (number, optional, env; 0 = off but ACTIVE)
  spellcheck (string, optional, setting; wrapKey:'command' → {command:...})
  view-mode (enum default|verbose|focus, neutralValue:'default', folds viewMode)
    — replaces standalone applyViewMode/stripViewMode (kept as deprecated shims)

New exports: getDefaultFlagsRecord, getRecommendedFlagIds, neutralValueOf,
isNeutral (0 is ACTIVE), coerceFlagValue (sink validation, applies PF-023),
parseFlagValueInput, formatFlagValue, countActiveFlags, readViewMode,
sanitizeFlagsRecord, migrateLegacyFlagsToRecord (ADR-014 transition contract),
legacyIdsToRecord (compile bridge, deprecated).

applyFlags(settingsJson, FlagsRecord) replaces applyFlags(settingsJson, string[]).
  - Neutral values delete target key; active values write payload
  - Env number payloads stringified ('40' not 40)
  - String flags with wrapKey shaped as { [wrapKey]: value }
  - __proto__/constructor/prototype keys skipped (prototype pollution guard)
stripFlags now covers viewMode and spellcheck via the registry.

Compile bridge call sites updated:
  init.ts:       applyFlags(content, legacyIdsToRecord(enabledFlags))
                 f.recommended replaces f.defaultEnabled (UI partitioning)
  flags CLI:     applyFlags(stripped, legacyIdsToRecord(flagIds))
  init-seed.ts:  f.kind === 'boolean' && f.defaultValue === true
Deprecated shims kept: getDefaultFlags(), applyViewMode(), stripViewMode().

Tests (168, all green):
  - Registry structural invariants (unique IDs, unique target keys, bounds, etc.)
  - getDefaultFlagsRecord() pinned snapshot (28 flags)
  - neutralValueOf / isNeutral (0 is ACTIVE — PF-023 documented)
  - coerceFlagValue hostile cases (Infinity/NaN/1e309, out-of-range, non-integer,
    overlong string, control chars)
  - applyFlags with FlagsRecord (neutral-deletes-key, env stringification)
  - stripFlags covering viewMode and spellcheck
  - Per-new-flag blocks for all 8 new flags
  - migrateLegacyFlagsToRecord + legacyIdsToRecord
  - Deprecated shim (getDefaultFlags includes max-concurrent-subagents)

RED-check evidence:
  - Neutral-deletes-key inversion: 27 tests failed → 141 passed
  - Env-stringification inversion:  4 tests failed → 164 passed

Applies ADR-014 (typed flags contract), ADR-016 (view-mode fold).
Avoids PF-023 (validates at convergence sink, not per-call-site).
Keybinding-flavor CUT: domain unverifiable from binary analysis.
…ed seed

feat/flags-typed-registry Phase 2 of 5

Changes:
- src/core/manifest.ts: features.flags changed from string[] to FlagsRecord;
  readManifest heals legacy formats in-reader (array→record migration,
  viewMode fold, knownFlags strip); writeManifest uses writeFileAtomicExclusive;
  heal-write failure returns in-memory manifest not null (D39)
- src/core/flags.ts: sanitizeFlagsRecord gets __proto__/constructor/prototype
  pollution guard
- src/cli/commands/init-seed.ts: resolveSeedFlags accepts FlagsRecord|null
  (drops knownFlags pair); resolveInitSeed reads viewMode via readViewMode(flags);
  InitSeed.flags/viewMode kept as deprecated Phase 6 bridges
- src/cli/commands/flags.ts: Phase 2 bridges — FlagsRecord ↔ string[] adapter
  for resolveEnabledFlags and updateManifestFlags
- src/cli/commands/init.ts: one mechanical bridge (legacyIdsToRecord) + flags:{}
  for HUD manifest; marked for Phase 6 cleanup
- tests/helpers.ts: canonical makeManifest() factory with FlagsRecord flags
- tests/manifest.test.ts: fixture sweep (flags:[]/viewMode deprecated fields
  → FlagsRecord); 4 new Phase 2 heal round-trip tests (idempotency, __proto__,
  deliberate-disable preservation, canonical roundtrip)
- tests/init-seed.test.ts: resolveSeedFlags tests rewritten for FlagsRecord API;
  viewMode tests updated to use flags['view-mode']; re-init fixture updated
…ctory)

Eliminates the legacyIdsToRecord bridge call site in flags.ts CLI. The
flags command now works directly with FlagsRecord throughout:

- createFlagsCommand() factory export (fresh Commander instance per call;
  singleton flagsCommand kept for src/cli.ts compatibility)
- --list: all registry metadata, no manifest required
- --status: typed values from manifest record; degrades gracefully without manifest
- --enable/--disable: boolean flags only; error + exit 1 for valued flags
  suggesting --set/--unset instead
- --set id=value (repeatable): any kind; splits on first = only;
  parseFlagValueInput validates at the CLI boundary (applies PF-023)
- --unset ids: any kind → neutral value per flag type
- Bare invocation: status table + Phase 5 seam note (// Phase 5 wires
  runFlagsTui here); non-TTY path exits 1 per the plan
- Persist pipeline: stripFlags → applyFlags (strip-then-apply invariant INV-1)
  via writeFileAtomicExclusive; per-artifact error handling (avoids PF-015)
- Malformed settings.json → abort with exit 1, never silent clobber
- No manifest for mutating ops → abort with exit 1 (avoids settings/manifest desync)
- Hostile inputs (__proto__, 1e309, NaN) → exit 1, both files byte-untouched

Tests (tests/flags-cli.test.ts, 34 tests):
- hud-enable-selfheal harness pattern (vi.mock clack, vi.stubEnv, temp dirs)
- Whole-post-state asserts (full JSON deep-equal) per PF-015
- Hostile value quartet (applies PF-014, PF-023)
- Idempotent second-run assertion

Co-Authored-By: Claude <noreply@anthropic.com>
…D-P4-1)

Proxy-routed models (GPT-4o, etc.) are not recognised as Claude models, so
Claude Code enforces a conservative context-window limit and triggers surprise
compaction mid-session.  Fix: pair ANTHROPIC_BASE_URL with
CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT='1' so the enforcement
is lifted for the relay session.

Changes:
- proxy.ts: add UNKNOWN_MODEL_WINDOW_ENV constant (D-P4-1)
- _applyProxyEnvToObject: set both vars; independent comparisons per PF-015
  (no early-return that could skip the second write)
- _stripProxyEnvFromObject: URL ownership remains the SOLE strip gate; delete
  both vars together when ANTHROPIC_BASE_URL matches our managed relay URL;
  foreign/absent URL → touch nothing
- runEnable success block: info line noting enforcement is disabled and applies
  to new Claude Code sessions (PF-022 applies-on-restart messaging)

Tests (tests/proxy.test.ts — 14 new, all green):
- applyProxyEnv quartet: sets window var, idempotent, preserves unrelated env,
  port-change re-apply keeps window var at '1'
- stripProxyEnv ownership gate: our URL strips both; foreign URL preserves both;
  absent URL preserves orphan; ours-other-port preserves both
- T7-extended whole-end-state: applyDisableToSettings removes hooks + URL +
  window var; env block gone entirely with no extras; applyProxyEnv produces
  both vars
…iew adapter

Extracts a generic runTui<S,A> driver (src/cli/tui/terminal.ts) shared by
both the agents-view and the new flags-view TUI. Wires runFlagsTui into the
devflow flags bare-invocation TTY branch (Phase 5 seam resolved).

Key changes:
- src/cli/tui/terminal.ts: generic TUI shell, MAX_KEYPRESSES=50_000, RunTuiSpec<S,A>
  interface, normalizeKey extended with backspace/delete/home/end. Bug fix:
  ERASE_BELOW after last frame line (stale-frame ghosting on terminal shrink).
- src/cli/tui/cells.ts: shared padToVisible/truncateVisible/sanitizeCell extracted
  from agents-view/render.ts (avoids PF-017).
- src/cli/agents-view/terminal.ts: rewritten as thin adapter over runTui; public
  API frozen (runAgentsTui, MAX_KEYPRESSES, TuiIO, TuiResult unchanged).
- src/cli/flags-view/state.ts: pure reducer with viewMode GLUE (null↔'default'),
  strict number parsing (007/'  8' rejected), BUFFER_MAX_LEN=64, allowUnset.
- src/cli/flags-view/render.ts: FIXED_ROWS=10, inverse-video caret, hint zone,
  dirty dot, scroll indicators, narrow-width safe.
- src/cli/flags-view/terminal.ts: runFlagsTui adapter, signalAction='abort'.
- src/cli/commands/flags.ts: lazy import runFlagsTui in TTY branch; persist on
  save, "No changes made." on cancel/abort.
- src/hud/colors.ts: inverse() helper added.
- tests/flags-view-state.test.ts: 58 tests (reducer, viewMode glue, strict parsing)
- tests/flags-view-render.test.ts: 30 tests (frame contract, per-kind display, edit)
- tests/flags-view-terminal.test.ts: 10 tests (pause(), flood cap, key routing)

Tests: 3538/3538 green (99 files, +98 from Phase 5). Build: clean.
RED-checks: (a) pause() removal → 5 failures; (b) MAX_KEYPRESSES raise → flood hangs.
… bridge cleanup

Replace legacy multiselect + viewMode select in the Advanced wizard with runFlagsTui.
Remove all compile bridge shims: legacyIdsToRecord, getDefaultFlags, applyViewMode,
stripViewMode. Write FlagsRecord directly to the manifest; no knownFlags/viewMode
residue in manifest.features.

Critical fix (PF-015, fold-before-strip): resolveExistingViewMode now runs BEFORE
stripFlags in the settings apply block. Reading after strip always returned undefined
because stripFlags removes the viewMode key; fold-before-strip is the correct order.

Changes:
- src/core/flags.ts: delete deprecated shims section (legacyIdsToRecord, getDefaultFlags,
  applyViewMode, stripViewMode); update module comment
- src/cli/commands/init-seed.ts: InitSeed.flags: FlagsRecord (was string[]);
  remove InitSeed.viewMode; resolveSeedFlags returns FlagsRecord; resolveInitSeed
  sets flags['view-mode'] in-place and returns without separate viewMode field
- src/cli/commands/init.ts: import countActiveFlags/readViewMode/getDefaultFlagsRecord;
  enabledFlags: FlagsRecord replaces viewMode: ViewMode; Advanced path opens flags TUI
  (runFlagsTui + buildFlagRows + collectFlagRecord); Recommended path uses countActiveFlags;
  manifest write uses enabledFlags directly (no legacyIdsToRecord); no knownFlags write;
  fold-before-strip ordering fix with PF-015 comment
- src/cli/commands/uninstall.ts: remove stripViewMode (covered by stripFlags via view-mode
  registry entry)
- tests/flags.test.ts: remove deprecated shim describe blocks
- tests/init-seed.test.ts: update for FlagsRecord/readViewMode
- tests/init-e2e-flags.test.ts: 3 subprocess e2e tests (PF-018 seeded temp HOME):
  old-format manifest heal + viewMode preservation, fresh install defaults, idempotency

Applies ADR-014, ADR-015, PF-015, PF-018, PF-029.
Remove phase-scaffolding comments that outlived the 6-phase implementation
sequence: section headers naming Phase N, transitional sub-bullets in JSDoc
(legacyIdsToRecord bridge, Phase 5 seam scaffolding), deprecated field rationale
referencing Phase 6 as a future target, and tombstone notes in manifest.ts
(init.ts still writes both fields, Phase 6 removes those writes).

Drop unused coerceFlagValue import from init-seed.ts.

Clarify cells.ts module doc to describe shared purpose (PF-017) without
provenance history.

No behavior changes; build clean; 81 tests pass, 24 pre-existing failures
unchanged.
…w-mode leak

Six defects found by driving the real code paths rather than reading them.

1. P1 flags CLI completed silently. persistFlagConfig returned
   `process.exitCode === 0` and all five call sites gated their confirmation on
   the same test. Node initialises process.exitCode to `undefined`, not 0, so on
   every real invocation that test was false: `devflow flags --enable X` wrote
   both artifacts and printed nothing. The suite could not see it — it sets
   process.exitCode = 0 in beforeEach, normalising away the exact condition under
   which production failed. Success is now tracked in locals (also fixes the
   process-global cross-talk: an unrelated earlier failure misreported this run).

2. P1 init-e2e-flags test 1 was vacuous. Its seeded hook used a flattened
   `{matcher, command}` entry; removeCaptureHooks does `entry.hooks.some(...)`,
   which throws on the missing array, and init.ts wraps its ENTIRE settings pass
   in one try/catch that only warns. The pass aborted, settings.json was never
   touched, and every settings assertion passed because nothing ran. Fixture
   corrected to Claude Code's real hook shape, plus a non-vacuity gate asserting
   the warning is absent. The env-var assertion removed during implementation is
   restored: with a valid fixture the manifest/settings convergence holds
   (max-concurrent-subagents 40 → CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS "40"), so
   there was no convergence bug — only a test that could not observe one.
   (The removeCaptureHooks crash on a malformed entry is pre-existing on main and
   left out of scope; reported separately.)

3. P0 shared TUI shell leaked raw mode. Alt-screen, raw mode and cursor-hide are
   set before the first render, but cleanup() was unreachable from the setup
   window and both event handlers were unguarded — and a throw inside an
   EventEmitter listener escapes as an uncaughtException rather than rejecting the
   promise. Any throw from renderFrame/onResize/reduce killed the process with the
   user's shell in raw mode (no echo, no line editing) until `stty sane`. Cleanup
   now runs on every path; errors are surfaced, not swallowed. avoids PF-014.

4. P1 space was untypable in the flags TUI edit buffer. normalizeKey maps the
   space bar to the name 'space' (5 chars) and the insert branch tested
   key.length === 1, so it was dropped silently: typing "aspell list" yielded
   "aspelllist". spellcheck exists to hold a shell command, so its whole purpose
   was unreachable; default-model likewise.

5. P1 ctrl-c was dead while editing — reduceEditMode had no case for it so it fell
   through to 'none', and raw mode suppresses the SIGINT that would otherwise
   rescue the user. Also: control chars could enter the buffer (ctrl-keys arrive as
   raw bytes), where they are uncommittable and desync the caret from the rendered
   string, which renderBuffer strips. Rejected at insert instead.

6. P1 resize stranded the cursor. onResize set viewportHeight without re-clamping
   viewportOffset, so shrinking the terminal could put the cursor outside the
   visible slice — and no selection marker rendered at all until an arrow key.

Also removes dead code flagged in review (unused stripAnsi import, unused
COL_DIRTY, dead RenderDims re-export) and corrects the row-width doc comment.

Every fix ships a test proven RED against the pre-fix code. agents-view's public
API is untouched; tests/agents-terminal.test.ts and tests/init-proxy.test.ts are
unmodified. Full suite: 102 files / 3576 tests green.
…iterals, residue, coverage

Item 1 (render.ts): implement ADR-016 value vocabulary — cyan value when
configuredValue !== devflowDefault; cyan ‹ › chevrons for focused control /
live edit buffer (mirror agents-view pattern); dirty dot yellow unconditionally
(was cursor-only).

Item 2 (render.ts): route all disk-sourced non-boolean values through
sanitizeCell before display; prevents embedded LF/TAB (coerceFlagValue permits
both) from breaking the one-string-per-terminal-line frame contract. Adds render
test with embedded \n and \t confirming one line per row.

Item 3 (state.ts): drop 'j' and 'k' from the up/down noop case in
reduceEditMode so they insert literally in edit buffers (spec: literal q d u j
k). Extends literal-keys test to cover j/k alongside space.

Item 4 (manifest.ts): delete dead knownFlags? and viewMode? declarations from
ManifestData — readManifest reads them from the raw record cast, not the typed
interface; syncManifestFeature can no longer write them. End-state, no tombstones.

Item 5 (state.ts): drop unused neutralValueOf import — tuiToRecord inlines the
exact same null→neutralValue mapping and is behaviorally equivalent for all
practical inputs (boolean rows are never null in TUI). Decision: drop the import.

Item 6 (teammate-mode-cleanup.ts): update doc comment to reference stripFlags
only (stripViewMode was deleted when viewMode was folded into FlagsRecord).

Item 7 (flags-cli.test.ts): add bare non-TTY invocation test (zero args, stdout
status table, one stderr note, exitCode 1, zero writes) — pins flags.ts:509-520.

Item 8 (manifest.test.ts): add D39 heal-write-failure test — legacy manifest in
a read-only directory, readManifest returns migrated non-null manifest and does
not throw; permissions restored in finally block.

Item 9 (flags-cli.test.ts): extend malformed-settings guard to --set, with
post-run re-read asserting byte-untouched (anti-clobber previously only for
--enable).

Item 10 (init-e2e-flags.test.ts): replace silent early-return requireBuiltCli
guard with module-level CLI_BUILT existsSync flag + it.skipIf — silent green
(PF-018 forbidden state) replaced by explicit SKIP in vitest output.

Item 11 (flags-view-render.test.ts): pin exact FIXED_ROWS + viewportHeight
count; replace trivially-satisfiable unsaved disjunction with toContain('1
unsaved change').

Item 12 (init-e2e-flags.test.ts): retitle "byte-stable" test to "content-stable"
with inline comment explaining why toEqual (not toBe) is correct.
…hygiene

Markdown changes explicitly approved by maintainer (recorded per plan verification item 6).
@dean0x

dean0x commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Code Review — Cycle 1

Full summary withheld (public repository).

Category CRITICAL HIGH MEDIUM LOW Total
Blocking 0 5 18 4 27
Should Fix - 1 6 - 7
Pre-existing - - 2 0 2

Full report: /Users/dean/Sandbox/devflow/.devflow/docs/reviews/feat-flags-typed-registry/2026-08-25_1217/review-summary.md (not committed; ask the author)


Posted by devflow · cycle 1

dean0x and others added 19 commits August 25, 2026 12:55
…ock seam

CPLX-SF1: Extract the three-case flags parse (string[] / object / missing) into
a pure `parseManifestFlags` helper that returns `{ flags, legacy }`. The boolean
`flagsWereLegacy` replaces the inline `Array.isArray(features.flags)` clause in
needsHeal, keeping the predicate in lockstep with the parse branch by deriving it
from the same parse result. Zero behavior change — all 79 manifest tests pass.

TEST-S1: Replace the D39 heal-write-failure injection from `chmod 0o555` (vacuous
under root UID — avoids PF-018) with `vi.spyOn(fs, 'rename').mockRejectedValueOnce`.
The mock intercepts the atomic rename inside writeFileAtomicExclusive at the seam,
making the test UID-independent. Proof of RED: removing the try/catch around
writeManifest causes the outer catch to return null, failing expect(result).not.toBeNull().

Co-Authored-By: Claude <noreply@anthropic.com>
…2 chain

TS-M1 (applies ADR-003): drop orphaned ViewMode import from init.ts — this
diff removed the last reference (the let viewMode binding) without trimming
the import list.

ARCH-S3 / CPLX-SF6 / CONS-S3: copy seed.flags at the alias site so
enabledFlags is a fresh record and writes through the alias cannot corrupt
the seed. Rebind the view-mode assignment with a spread rather than mutating
in place. In init-seed.ts, resolveInitSeed returns { ...flags, 'view-mode':
resolvedViewMode } instead of writing flags in place — honoring the module
docblock ("all exported functions are pure").

REL-S2 (avoids PF-014): replace process.exit(0) on TUI abort with
process.exitCode = 130 + return so the exit code signals cancellation to
wrappers and buffered terminal-restore escapes are not dropped on pipe stdout.

REL-S3 (applies PF-029): restrict the modifiedCount filter to `id in defaults`
so forward-compat unknown IDs (defaults[id] === undefined) no longer inflate
the outcome-line count.

CPLX-S2: replace the `?? (ternary) ??` view-mode fold in resolveInitSeed
with an explicit three-branch if-ladder; each branch carries its own meaning
without needing comment scaffolding.

Regression tests added in tests/init-seed.test.ts for the ARCH-S3 mutation
guard (two cases: input manifest unchanged, returned flags isolated from
caller mutations).

REL-S2 and REL-S3 are inline in the init action handler and not directly
exercisable from unit tests without full init scaffolding — noted per PF-018.
src/cli/tui/cells.ts was importing stripAnsi/truncate from src/hud/colors.ts
(a feature module), and this PR added inverse() to hud/colors with its only
consumer in flags-view/render.ts. The dependency arrow pointed generic-CLI →
feature, which is the wrong direction under ADR-013 (src/core/ = agent-neutral
logic). It also caused composeScripts to carry the transitive dist/hud/ graph
into the hooks runtime unnecessarily.

Promote all ANSI primitives to src/core/ansi.ts (neutral home), then:
- src/cli/tui/cells.ts       imports stripAnsi, truncate from core/ansi
- src/cli/flags-view/render.ts  imports from core/ansi
- src/cli/agents-view/render.ts imports from core/ansi
- src/hud/colors.ts becomes a re-export barrel (export * from ../core/ansi)
  so all existing HUD component call sites (hud/components/*.ts, hud/render.ts,
  src/cli/commands/agents.ts, src/cli/commands/proxy.ts) are untouched.

Byte-equivalent behaviour: functions are identical implementations.
applies ADR-013; applies PF-017 corollary (one shared definition).

Co-Authored-By: Claude <noreply@anthropic.com>
…casts

Three-generic form `RunTuiSpec<S, A extends string, C extends A>` with
`signalAction: Exclude<A, C>` and `continueIntent: C` makes `runTui` return
`Promise<{ intent: Exclude<A, C>; state: S }>`.

Issues fixed:
- TS-M4: exhaustiveness guard lost when generic replaced switch/never; now enforced
  at the type level — adapters assign `result.intent` directly to their action
  field, so adding a new Intent/FlagsIntent member is a compile error.
- ARCH-M6: unconstrained `A` prevented expressing the signalAction invariant;
  `Exclude<A,C>` on signalAction makes it impossible to pass continueIntent there.
- TS-S2: `A extends string` closes the object-shaped intent footgun; the `!==`
  comparison is now provably a string equality check.

Deleted all six unsound `as` casts across both adapters:
  agents-view: 'cancel' as Intent, 'none' as Intent, result.intent as 'save'|'cancel'
  flags-view:  'abort' as FlagsIntent, 'none' as FlagsIntent,
               result.intent as 'save'|'cancel'|'abort'

One minimal driver-side cast retained (intent as Exclude<A,C>) at the keypress
`!== continueIntent` check — TypeScript cannot narrow A to Exclude<A,C> from a
generic C comparison; the cast is documented inline (D-TS).

Tests updated: tui-terminal.test.ts callers add the third type arg 'none'.
Verified: `tsc --noEmit` clean; 25/25 tests pass.
… (DOC-C1/CONS-H2, applies PF-025, ADR-002)

Replace every pre-branch flag API reference with FlagsRecord semantics:
- features.knownFlags:string[] → features.flags:FlagsRecord (key-presence=known)
- InitSeed.flags:string[]+viewMode:ViewMode → InitSeed.flags:FlagsRecord
- resolveSeedFlags(enabledFlags,knownFlags,registry) → resolveSeedFlags(FlagsRecord|null,registry)
- getDefaultFlags → getDefaultFlagsRecord; add coerceFlagValue, parseFlagValueInput,
  migrateLegacyFlagsToRecord, neutralValueOf, isNeutral, sanitizeFlagsRecord,
  countActiveFlags, readViewMode
- Add parseManifestFlags (three-shape migration: string[]→migrate, object→spread, missing→empty)
- viewMode resolution: settings.json→readViewMode(flags)→'default' encoded in flags['view-mode']
- Fix knownPlugins/knownFlags gotcha: knownFlags no longer in ManifestData
- Fix ADR-014 Related note: FlagsRecord key-presence replaces knownFlags snapshot

Add new modules introduced by this PR:
- src/cli/commands/flags.ts (createFlagsCommand, lookupFlag, persistFlagConfig)
- src/cli/flags-view/ (FlagsViewState, buildFlagRows, collectFlagRecord, reduce, …)
- src/cli/tui/ (sanitizeCell, padToVisible, truncateVisible)

Update frontmatter directories: and description: keywords.
Update index.md cache line to match.
…trees (DOC-H1/M1/M3/P1/S1/S2, CONS-H3, applies PF-025)

- DOC-H1 (CLAUDE.md): Two-Mode Init Advanced path no longer has a standalone
  view-mode selector; view-mode is now the enum row inside the flags editor TUI
- DOC-M3 (CLAUDE.md): add tui/ and flags-view/ to Project Structure tree;
  note agents-view/terminal.ts is a thin adapter over the shared tui/ driver
- DOC-M1 (docs/cli-reference.md): rewrite Feature Flags command block to use
  npx devflow-kit (57-occurrence file convention; was the only devflow-bare section)
- DOC-P1 (docs/reference/file-organization.md): add tui/, flags-view/, agents-view/
  to src/cli/ block; drop utils/ tombstone (applies ADR-003)
- DOC-S1 (docs/reference/file-organization.md): document view-mode neutralValue
  contract — viewMode settings key written only when non-default
- DOC-S2 (docs/cli-reference.md): replace one-off pin-sonnet-4-6 parenthetical
  with a general footnote on how boolean env-var flags serialize their string value
- CONS-H3 (CLAUDE.md, docs/cli-reference.md, KNOWLEDGE.md): surface
  CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT across all three proxy doc
  surfaces; update strip invariant to name both vars and their asymmetric scoping
  (window var unconditional, URL port-gated); add tui/ to KB directories + keywords
TEST-SF1 (applies PF-023): add tests/tui-cells.test.ts — 25 assertions
  pinning sanitizeCell (TAB/LF collapse, ANSI strip), padToVisible (visible-
  length measurement), and truncateVisible (unchanged-when-fits + styling
  dropped across the truncation boundary) at the sink rather than through two
  renderers.

TEST-M1 + REG-S3 (avoids PF-018): extend tests/tui-terminal.test.ts with
  the renderToStdout frame output contract — byte-level assertions on the
  exact escape sequence for a 2-line frame (HOME + ERASE_EOL + ERASE_BELOW)
  and the no-trailing-newline guard before ERASE_BELOW. Failure modes noted
  in the test header: deleting either fix causes independent assertion failures.
Coherent fix of the one validation contract (applies PF-023) at the sink
every caller reaches (src/core/flags.ts), so all paths inherit the invariants.

SEC-M1 — coerceFlagValue string branch now rejects LF (\x0a) alongside other
control chars. Comment explains WHY LF must be rejected: `spellcheck` is an
executed shell command (LF = statement separator) and --status is line-oriented.
TAB remains the sole documented exception. Added sanitizeCell wrap at both
CLI display sites (--status loop + non-TTY table) for defence in depth.

TS-H1 + SEC-S2 — parseFlagValueInput number branch now enforces strict decimal
grammar (rejects empty, padded, hex, exponent, leading zeros) instead of bare
Number(). String branch now returns null for empty string (empty is UNSET, not
an active value — prevents ANTHROPIC_DEFAULT_MODEL="" written to settings.json).
Deleted checkNumberFormat from state.ts; commitEdit now delegates to
parseFlagValueInput so CLI and TUI share one grammar (avoids PF-023 dual-validator).

TS-M3 — hoisted asPlainObject() guard used at all three settings.env access
sites in applyFlags/stripFlags so "env": [] cannot delete user keys via the
Object.keys([]).length === 0 empty-env cleanup. sanitizeFlagsRecord now
drops unknown-id non-primitive values instead of laundering them into
FlagsRecordValue. Removed double assertion in parseManifestFlags case B.

REL-S1 — sanitizeFlagsRecord now DROPS the key for invalid non-null known-flag
values (absent = adopt default on next init) rather than writing null = "deliberately
unset" (applies ADR-014 key-presence semantics). Explicit null input is still
preserved (deliberate unset).

Applies PF-023, ADR-014. Tests: RED→GREEN for all six required scenarios.
…es PF-015 PF-017 ADR-014

Root cause: stripFlags unconditionally destroys all registry-targeted keys in
settings.json regardless of ownership. Six newly-registered valued flags had no
preservation path; view-mode had a preservation path in init.ts only (violating
the DRY/pipeline invariant identified as ARCH-H1).

Fix — convergeFlagsIntoSettings (src/core/flags.ts):
- Single pipeline entry point shared by init.ts AND persistFlagConfig (ARCH-H1,
  applies PF-015/PF-017: invariant lives in the pipeline, not at call sites)
- Folds valued flags from pre-strip settings into the record when devflow does
  not own them (REG-H1/SEC-M3, applies ADR-014: absent = unknown = adopt)
- "Owned" = present in ownedRecord (any value including null = explicitly unset);
  ownedRecord=null → nothing owned (fresh install or upgrade from old manifest)
- Uninstall: stripFlags(json) full-sweep semantics unchanged — convergeFlagsIntoSettings
  is never called from uninstall.ts

init.ts: replace inline view-mode fold + stripFlags + applyFlags with single
  convergeFlagsIntoSettings call; ownedRecord=existingManifest?.features.flags??null
  correctly distinguishes keys devflow previously wrote from newly-adopted defaults
  (resolveSeedFlags adopted default 40 for max-concurrent-subagents; ownedRecord=null
  ensures the hand-set '8' in settings wins on upgrade — REG-H1 probe)

flags.ts (persistFlagConfig): ownedRecord=undefined → claimedIn=record (manifest IS
  the owned set); null in record = explicitly unset = still claimed → do not fold from
  settings (fixes unset-then-fold regression)

TUI save path: viewModeExplicit = newRecord['view-mode'] !== record['view-mode']

Tests (RED→GREEN, whole-post-state per PF-015):
- convergeFlagsIntoSettings: /focus survival, explicit override, owned wins, REG-H1 probe
  (six managed keys survive; concurrency stays 8 not 40), uninstall full-sweep pin
- flags-cli: --enable brief with viewMode:'focus' survives; --set view-mode=verbose overrides
- init-e2e: REG-H1 subprocess probe (hand-set managed keys + concurrency '8' survive reinit)
CONS-H1 (Careful): reorder formatFlagValue so boolean branch wins before
isNeutral — boolean false now returns 'disabled' (not 'unset'). Routes
--disable success line through formatFlagValue (applies ADR-016 — one
syntax, one semantic). Adds vocabulary table test (RED on old formatter,
GREEN after fix) pinning enabled|disabled|unset for each flag kind.

TS-SF1 + CONS-M2: export defaultValueOf(flag) from core/flags.ts as the
single authoritative default-rule source. getDefaultFlagsRecord, both
resolveSeedFlags branches (init-seed.ts), and buildDevflowDefault
(flags-view/state.ts) now call it — no more three-site drift risk.

CONS-M1: delete getRecommendedFlagIds() (new in this PR, called only from
its own test — no real caller). Removes it from tests/flags.test.ts import
and describe block (applies ADR-003 — leave end-state not transition).

PERF-L3: export findFlag(id) backed by the existing private FLAG_REGISTRY_MAP.
lookupFlag (flags.ts) and commitEdit (flags-view/state.ts) use it; collectFlagRecord
drops the per-call Map construction (applies ADR-016 module-stated O(1) pattern).

Co-Authored-By: Claude <noreply@anthropic.com>
…nable-todo-tools reorder, render.ts docblock, probe notes extraction

- ARCH-S1: note beside FLAG_REGISTRY that CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT
  is proxy-owned (paired with ANTHROPIC_BASE_URL in proxy.ts, lifecycle-coupled to relay
  enable/disable) — mirrors the agent-teams/teammate-mode-cleanup precedent

- DOC-SF1: one-line JSDoc on FlagKind (union discriminant); contract doc block on
  FLAG_REGISTRY (IDs = stable manifest keys, array order drives --list/TUI row order)

- CPLX-S3/CONS-M5: move enable-todo-tools (kind:'boolean') from under the
  '── Valued flags' banner into the optional-boolean block where it belongs;
  display order changes accordingly (--list and TUI); no test pins old position

- CONS-M5 (render.ts): fix renderRow docblock — DIRTY column is 2 chars not 3;
  the '≤78+PREFIX=≤80' arithmetic disagreed with the file-header total of 77;
  replace the column breakdown with a reference to the file-header table

- DOC-S3: move Phase 0 probe block (2026-08-23, CC 2.1.241) to
  docs/reference/claude-code-flags-probe.md; leave a one-line pointer in flags.ts
  (applies ADR-003 — leave the end-state, not the transition)

Co-Authored-By: Claude <noreply@anthropic.com>
…fy === comparison

CPLX-SF2: the `if (n === 0) return { state, intent: 'none' }` guard appeared 9×
across browse-mode cases. Replaced with a single unified guard that fires once after
the keys that are intentionally exempt (escape/q → cancel, ctrl-c → abort, enter →
save even on an empty list). The twin up/k and down/j branches collapse into move(state,
±1); the three space/left/right cycle branches collapse into cycle(state, row, dir).
Zero behaviour change — all 77 tests pass unchanged.

TS-S3: cycleForward/cycleBackward used Object.is for stop lookup while render.ts
dirty-detection used !==. Changed to === (consistent with render.ts). The only
difference is -0 vs 0, which does not survive the JSON round-trip and is therefore
unreachable in practice. One-line comment at each call site explains the choice.

Co-Authored-By: Claude <noreply@anthropic.com>
…ze, dead code, comment hygiene

CONS-M4: hoist scale/labelW/valueW out of renderRow into renderFrame so
the column header and data rows share one binding. Header now uses '  '
(2 chars, matching the 2-char prefix) before FLAG instead of '    ' (4),
and '  ' before VALUE instead of '   '; at 80 cols FLAG lands at col 2
and VALUE at col 31, matching the data row layout exactly. Tests pin the
offsets at 80 and 60 cols (ANSI-stripped).

SEC-S1/CONS-S2: add sanitizeCell(row.label) call in renderRow — the
comment claimed sanitization but rawLabel was used verbatim (PF-023).

DOC-M2: replace three ADR-016 citations in flags-view/render.ts that
attributed colour/glyph vocabulary rules the ADR does not contain. New
self-standing comments carry the same intent without false attribution.
state.ts:5's fair citation is untouched.

ARCH-M5: make renderFrame in both flags-view and agents-view read
state.viewportHeight as the single owner (clamped to MIN_VIEWPORT),
removing the duplicate derivation from dims.rows. Tests that set
state.viewportHeight explicitly now render exactly that many data rows.
Invariant tests added for both views.

TS-M1 (COL_PREFIX sub-item): delete never-read COL_PREFIX constant
(applies ADR-003).

Co-Authored-By: Claude <noreply@anthropic.com>
Resolves ARCH-M1 + CPLX-H1 + CPLX-H2 + CPLX-SF5 + CONS-M3 from the
feat/flags-typed-registry review (complexity.md, architecture.md,
consistency.md on 2026-08-25).

ARCH-M1 + CPLX-H1: the single 348-line .action() handler is replaced by
six named async functions (handleList, handleStatus, handleSetBooleans,
handleSet, handleUnset, handleBare) and a ~15-line dispatcher.  Each
handler carries one responsibility and is independently readable.

CPLX-H2: the manifest-load + settings-load preamble that was copy-pasted
verbatim into four mutating branches is extracted into loadFlagContext(),
which returns a discriminated result and never exits itself.  The --enable
and --disable branches collapse into one handleSetBooleans(value: boolean)
parameterized handler — their only deltas were the record assignment and
one error-message string.  Applies PF-017: one shared load path means a
fix lands once, not four times.

CPLX-SF5 + CONS-M3 (deliberate user-visible wording convergence): the
status table was rendered by two divergent loops — --status used a long
"not adopted — default X applies on next devflow init" sentence, while the
bare non-TTY path used a bare "not adopted".  Both now call
formatStatusRows(record): string[], which uses the LONGER wording in both
surfaces.  The non-TTY table output now includes the actionable second
half that the short form had been dropping.

All existing tests pass unchanged (43 flags-cli + 184 flags.test +
4 init-e2e-flags = 231 total).  No test assertions check the specific
short "not adopted" wording, so no test changes were required.

Deferred (later batches): --list kind-label ternary, p.outro polish,
redundant `as` casts, bare-TTY manifest gate, stdout.isTTY predicate,
persistFlagConfig manifest re-read.
TS-H2 / ARCH-H2 / REL-H2 / REG-SF2 — one coherent root cause, four angles:

1. PersistResult discriminated type replaces `boolean` return from persistFlagConfig.
   Three distinct states: {ok:true} | {ok:false,failed:ReadonlyArray<'settings'|'manifest'>}
   | {ok:false,reason:'no-manifest'}.  An absent manifest is now a failure (exitCode 1,
   error logged), not a no-op that silently returned true.  Applies PF-015: each artifact's
   write is evaluated independently into `failed`; the no-manifest branch gets its own
   discriminant so no caller can accidentally suppress it.

2. handleBare (TTY path) reuses loadFlagContext — the same manifest guard used by every
   mutating handler (--enable/--disable/--set/--unset).  Guard fires BEFORE the TUI import
   and BEFORE any settings.json write.  This kills the silent-factory-reset path: TUI seeded
   from {} could write settings.json while manifest was never updated; next devflow init
   re-adopted registry defaults and silently reverted the user's choices.

3. Non-TTY bare path is unchanged — still degrades gracefully (reads manifest for status
   table; shows registry defaults when absent; never writes).

4. Three callers of persistFlagConfig updated to check result.ok instead of boolean.

Tests (RED → GREEN):
- bare TTY + no manifest → hard-refuse, exitCode 1, p.log.error, settings.json not written
- bare TTY + corrupt manifest → hard-refuse, exitCode 1, p.log.error, settings.json not written
- --set no manifest (REG-SF2) → exitCode 1, settings.json not written (was exitCode-only)

Applies PF-015 (per-artifact convergence independently evaluated and reported).
Cites ADR-016 (vocabulary unchanged across surfaces).
…ignal; PERF-L2 dedup

ARCH-M7a — Chevron composition: separate cyan segments
  Each chevron is now its own self-contained cyan() call rather than wrapping the
  entire `‹ value ›` string. Inner RESETs from green('enabled') / bold(str) no
  longer kill the outer cyan, so the closing chevron renders styled on every
  focused row. (applies ADR-016 amendment lesson)

ARCH-M7b — Caret survival beyond budget
  renderBuffer now accepts a `budget` parameter and windows the plain buffer around
  the caret BEFORE inserting inverse(). Previously renderRow called
  truncateVisible(bufStr, budget) on the already-styled output, stripping the ESC[7m
  caret whenever the buffer exceeded 42 visible chars (80-col frame).

ARCH-M7c — Deviation signal: bold not cyan
  formatValue's non-boolean deviation path changed from cyan(str) to bold(str).
  cyan is now exclusively the focus indicator (chevron wrapper); bold signals
  "deviates from devflow default". Vocabulary comment block updated.
  (applies ADR-016 amendment lesson — one colour, one semantic)

PERF-L2 — Duplicate filter in renderFrame
  totalDirty is computed once and reused for both the summary line and the unsaved
  line. Removes the identical rows.filter() call that appeared 50 lines later.

Three RED→GREEN pinned tests added (escape-sequence assertions on the specific
cursor/non-cursor row, not whole-frame joins per the review's anti-pattern flag).
…ead after TUI

REL-H1: handleBare now requires stdin AND stdout to both be TTYs before
entering the interactive branch (previously checked stdout alone). runTui
bails with a rejection before writing ENTER_ALT when stdin is not a TTY
and no io.stdin was injected.

REL-M2 + PERF-L4: readSettingsSafe parses once and rejects non-plain-object
content (null/array/scalar) with a clear error. applyFlags/stripFlags in
flags.ts gain the same plain-object guard at the sink (PF-023).

REL-M3: handleBare re-reads settings.json AFTER runFlagsTui returns so
concurrent writes (e.g. proxy --enable setting ANTHROPIC_BASE_URL) are not
clobbered by the stale pre-TUI snapshot (PF-022).

ARCH-M2 + PERF-L1: persistFlagConfig accepts the already-read manifest as
a parameter, eliminating the second readManifest call (and the implicit
self-heal write it caused). All callers (handleSetBooleans, handleSet,
handleUnset, handleBare) thread the manifest from loadFlagContext.

Regression tests added for each issue across tui-terminal.test.ts,
flags.test.ts, and flags-cli.test.ts (REL-H1 non-TTY guard, REL-M2 sink
guards, REL-M3 vi.doMock concurrent-write scenario).
… TUI exit

CPLX-SF3: replace 4-level nested ternary in handleList (kind label) with
describeFlagKind(flag: ClaudeCodeFlag): string in src/core/flags.ts.
Exhaustive switch — TypeScript narrows each arm so the per-kind import()
casts at the call site are gone. Output strings are byte-identical to the
former ternary (regression test proves it for all 28 registry flags).

CPLX-SF4: replace triple-nested conditional + inline import() cast in the
--set Expected:-hint line with expectedInputFor(flag: ClaudeCodeFlag): string
in src/core/flags.ts (next to describeFlagKind). Same exhaustive-switch
shape, same byte-identical output guarantee via regression test.

CONS-M6: the flags TUI exit path now uses p.outro instead of raw
process.stdout.write: p.outro(color.green('Flags saved.')) and
p.outro(color.dim('No changes made.')), matching agents.ts (line 584/605).
handleList and handleStatus lacked a closing outro — each now ends with
p.outro(color.dim('Use --enable / --disable / --set / --unset …')),
matching the hud.ts:141 / learning.ts:47 house style.

Tests: 48 CLI tests + 208 core tests all pass; 36 new tests added for the
two helpers (kind-label parity, expected-input parity, every-registry-flag
coverage).
dean0x and others added 6 commits August 25, 2026 14:58
… applyTuiResult seam

TS-M2: remove two redundant `as` assertions in state.ts — `(flag.values as
readonly string[])` inside a narrowed `case 'enum'` block (flag is already
EnumFlagDef with values: readonly string[]) and `[...flag.values] as
FlagsRecordValue[]` (string[] is assignable to readonly FlagsRecordValue[]
without a cast). Update the JSDoc example in the FlagRow.stops comment to
match. tsc --noEmit validates both deletions.

TS-S1: eliminate non-null assertions where narrowing is available.
(a) reduceEditMode now accepts `editing: EditState` as a third parameter;
    the caller in `reduce` passes the already-narrowed `state.editing`
    (guarded by `state.editing !== null`) — no ! needed.
(b) handleSetBooleans: collect flags into `flagDefs[]` during the validation
    loop; the success log iterates `flagDefs` directly, eliminating the
    `lookupFlag(id)!` re-lookup after the guard.
(c) handleUnset: same pattern — collect `flagDefs[]` during validation,
    iterate for the neutral-value mutation, no re-lookup.

TEST-M5: extract `applyTuiResult` from handleBare (exported) to close the
interactive-surface coverage gap (applies PF-017(c)). The function owns the
save/cancel dispatch and the persistFlagConfig call; handleBare retains only
stream ownership, the conditional settings.json re-read (REL-M3), and the
`p.outro()` call.

Also: export `PersistResult` (return-type component of applyTuiResult);
move buildFlagRows + collectFlagRecord to static imports from flags-view/state.js
(pure, no TTY machinery); keep runFlagsTui lazy in handleBare.

Seam test in tests/flags-cli.test.ts drives runFlagsTui with PassThrough
streams, feeds its result to applyTuiResult, and asserts the whole post-state
of both artifacts (PF-015 shape), covering save and cancel('unchanged') paths.

applies PF-015 (whole-state seam test)
applies PF-017(c) (closing interactive-surface coverage gap)

Co-Authored-By: Claude <noreply@anthropic.com>
…JSDoc (D1)

ARCH-M3: drop buildFlagRows registry parameter — function owns FLAG_REGISTRY
directly; no caller ever passed anything but the global. Update both call sites
(flags.ts, init.ts) and all tests. Remove FLAG_REGISTRY from init.ts import
and from flags-cli.test.ts import (no longer needed after arg drop).

ARCH-M4: embed def: ClaudeCodeFlag on FlagRow so commitEdit and collectFlagRecord
no longer reach back into the module-global registry via findFlag. Delete
FLAG_HINT_MAP from render.ts (dead — row.hint already holds flag.hint); use
selectedRow.hint directly. Remove findFlag from state.ts imports (applies ADR-003).

CPLX-SF7: move recordToTui / tuiToRecord from state.ts to core/flags.ts, next
to neutralValueOf — their definition dependency (PF-017 one-shared-definition
corollary). Export from flags.ts; import into state.ts. Glue-rule documentation
travels with the functions to their new home.

CONS-S1: delete unknown-flag else-branch in collectFlagRecord — structurally
impossible after ARCH-M4 (row.def is always defined; rows are registry-derived).
Replace with JSDoc invariant on buildFlagRows and on collectFlagRecord (applies
ADR-003 leave-the-end-state).

DOC-M4: add JSDoc to FlagsIntent (none/save/cancel/abort semantics + load-bearing
cancel-vs-abort distinction at the init.ts consumer), ReduceResult (intent loop
semantics), and FlagsTuiResult (action discrimination + cancel-vs-abort note).

All 377 tests pass. npx tsc --noEmit clean.
Three call sites left runFlagsTui/runAgentsTui rejection unhandled,
and program.parse() did not await async handlers, so any rejection
surfaced as an unhandled rejection with a bare stack.

- src/cli.ts: program.parse() → await program.parseAsync() so async
  handler rejections propagate instead of surfacing as unhandled.
- src/cli/commands/flags.ts (handleBare): wrap runFlagsTui in
  try/catch — on rejection: p.log.error + exitCode 1, no settings write.
- src/cli/commands/agents.ts: wrap runAgentsTui in try/catch — same
  shape as flags (error + exitCode 1, no partial write).
- src/cli/commands/init.ts: wrap runFlagsTui in try/catch — on
  rejection: log + continue with seeded defaults; init must not abort
  mid-run after assets are partially installed (PF-009 spirit).
- tests/flags-cli.test.ts: add rejection-path test for handleBare
  using vi.doMock (C3 precedent); asserts p.log.error, exitCode 1,
  and no settings.json write.

Applies PF-014 (exitCode not exit()).
…ntradicting name, typed-input commit path

Applies PF-018 (mechanisms 4 and 7) — each test now exercises a named behaviour
and would fail if the production code reverted.

TEST-C1 (vacuous assertions — 9 render + 1 terminal):
- render.ts per-kind tests (:140,:148,:168,:177,:187): assert the SPECIFIC cursor
  row (lines.find ❯-prefix) instead of the joined frame; add negative controls.
  The frame always contains 'enabled', 'disabled', 'unset', and devflow-default
  numbers from other flags, making whole-frame containment unconditionally true.
- dirty-dot clean test (:216/:226): replace Array.isArray(lines) with
  not.toContain('●') on the ANSI-stripped joined frame.
- cursor indicator (:235): assert exactly one line starts with '❯' (and names
  the expected flag) instead of the disjunction that included '→' from the hint.
- scroll indicators (:319,:340): assert lines[3] (upIndicator slot) and
  lines[7] (downIndicator slot) by exact layout position, not joined.includes
  whose '↑'/'↓' always match the footer keybinding line.
- terminal cancel test (:194/:203): toEqual(rowsIn) instead of toBeDefined(),
  so the test actually observes the "unchanged" claim.

TEST-M3 (buffer-clamp):
- Replace toBeLessThanOrEqual(64) with exact: toBe(BUFFER_MAX_LEN),
  toBe(BUFFER_MAX_LEN) for caret, toBe('a'.repeat(BUFFER_MAX_LEN)) for content.
  Import BUFFER_MAX_LEN (single source of truth). The old assertion passed when
  insertChar was a no-op (buffer length 0 satisfies ≤ 64).

TEST-SF2 (self-contradicting test name):
- Remove '007 is a valid input … actually NO …' from the valid-inputs describe.
- Replace the parallel '007 → error' test in invalid-inputs with it.each over
  both number flag ids [subagent-spawn-depth, max-concurrent-subagents].

TEST-S2 (commit path via typed input):
- Extract typeInto helper to module scope so commit tests can reuse it.
- Convert number-commit test: enter edit mode → backspace '40' → type '50' →
  enter (exercises backspace + insertChar path, not direct buffer injection).
- Convert string-commit test: typeInto('default-model', [...'claude-3-5-sonnet'])
  → enter (exercises insertChar for each char, buffer starts empty).

TEST-S3 (viewportHeight invariant):
- Already covered by the 'viewportHeight ownership' describe added by D3
  (lines 478–505 of render test): two tests set state.viewportHeight directly
  and assert lines.length === FIXED_ROWS + that height. No additions needed.

RED proofs (production breaks → observed failures, all reverted):
- :140 enabled: green('ACTIVE') → cursorRow missing 'enabled'
- :148 disabled: yellow('INACTIVE') → cursorRow missing 'disabled'
- :168/:187 unset: dim('(none)') → cursorRow missing 'unset'
- :177 number value: String(99999) → cursorRow missing '40'
- :216 no dirty: always yellow('● ') → plain contains '●'
- :235 cursor: always '  ' prefix → zero lines start with '❯'
- :319/:340 scroll: always '' indicators → lines[3]/[7] empty strings
- TEST-M3: insertChar clamp removed → buffer.length 70 ≠ 64
- TEST-S2 number: backspace no-op → buffer stays '40' not '50'
- TEST-S2 string: insertChar no-op → buffer empty, commit → null not 'claude-3-5-sonnet'
- terminal cancel: rows modified on cancel → toEqual(rowsIn) fails
…ate, e2e timeout

TEST-H1: Add 9 caret-manipulation branch tests to flags-view-state.test.ts
covering backspace@caret=0 no-op, delete-at-caret, delete-at-end no-op, home,
end, left/right and their clamp boundaries. RED proof: broke case 'home' to
'return state' — 5 tests failed, reverted. Add normalizeKey it.each table in
tui-terminal.test.ts: all 12 readline key names → normalized names, ctrl-c
pre-check, and the default-branch (unknown key / undefined str) paths. applies PF-018.

TEST-M2: Correct flags-cli.test.ts header — it claimed "full JSON deep-equal,
not key-picking" but only key-picked. Upgrade one representative test per
mutation verb (--enable, --disable, --set, --unset) to toEqual on the COMPLETE
settings.json and COMPLETE manifest.features.flags record. Discovery: every
persistFlagConfig call writes 'view-mode':'default' via convergeFlagsIntoSettings
even when view-mode is not in the input record (neutralValue keeps it out of
settings.json but lands it in the manifest). applies PF-015 + ADR-003.

TEST-M4: Pass SUBPROCESS_TIMEOUT_MS as the third argument to every
it.skipIf(!CLI_BUILT)(...) call in init-e2e-flags.test.ts (including the test
added in a later batch). Without it vitest's 5s default fires before the 60s
subprocess timeout on a loaded CI runner.
- Remove `lookupFlag` wrapper in flags.ts (cmd): all three callers use
  truthiness; `findFlag` already returns a falsy `undefined` on miss.
  Replace all call sites with `findFlag` directly.

- Strip transition-residue comments across 5 files:
  - "Validate-then-discard was pure overhead" in readSettingsSafe
  - "the former triple-nested ternary" in describeFlagKind/expectedInputFor
  - "Gains … (were leaking raw bytes in agents-view)" in normalizeKey
  - "was the source; this is the generalisation" in tui/terminal.ts module doc
  - "(Before this fix, renderRow called truncateVisible …)" in renderBuffer
  - "Fix 1" / "Fix 4" labels in agents-view/render.ts
  - "replacing the deleted switch/never guard" in agents-view/terminal.ts
@dean0x

dean0x commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Resolution Summary

Full summary withheld (public repository).

Metric Value
Total Issues 100
Fixed 94
False Positive 0
By Design 2
Deferred 4
Blocked 0
Escalated 0

Full report: /Users/dean/Sandbox/devflow/.devflow/docs/reviews/feat-flags-typed-registry/2026-08-25_1217/resolution-summary.md (not committed; ask the author)

Posted by devflow

dean0x and others added 6 commits August 25, 2026 19:49
…line TUI, registry tests

Changes:
1. Effective value rendering (D-EFFDV): replaces 'unset' across all 4 sites.
   - effectiveDisplay(flag, value) → { text, isDefault } as single-definition seam.
   - boolean: 'on'/'off'; enum null: neutralValue text; number null: devflow/upstream default;
     string null: '—'. formatFlagValue delegates to effectiveDisplay.
   - Sites A (TUI formatValue), B (--enable/--disable), C (--list defaultLabel),
     D (formatStatusRows not-adopted message) all updated.

2. Per-flag blurb field (D-BLURB): 28 blurbs added, all ≤ 30 chars.
   - blurb: string on FlagDefCommon; populated in FlagRow.
   - TUI layout rebalanced: COL_VALUE 46→16, COL_BLURB 30 (same total).
   - Blurb shown as dim trailing column in data rows and HINT in column header.

3. Inline TUI mode (D-INLINE): screen: 'alt' | 'inline' added to RunTuiSpec.
   - Inline mode: no ENTER_ALT/LEAVE_ALT; cursor-up repaints; ERASE_BELOW on exit.
   - Height clamped to stdout.rows - INLINE_MARGIN (2).
   - runFlagsTui passes screen: 'inline'; agents-view unchanged (alt mode default).

4. Tests: 22 new tests — effectiveDisplay vocabulary table, blurb hard-cap registry
   test, persistence round-trip, inline mode driver tests (ENTER_ALT absent, cursor-up
   present, ERASE_BELOW on exit, alt mode unchanged). Updated vocabulary assertions
   from enabled/disabled/unset → on/off/<effective-default>.
Captures effectiveDisplay one-definition seam (D-EFFDV), per-flag blurb
field (D-BLURB), inline TUI mode (D-INLINE), and updated vocabulary
(on/off replaces enabled/disabled; never shows 'unset' at render sites)
introduced in commit 82a9c83.
…s non-interactively (D40)

The interactive flags editor is removed from devflow init (both Recommended and
Advanced paths). init now applies seed.flags directly — seeded defaults on fresh
install, preserved values on re-init (ADR-014). Users customize flags exclusively
via the standalone `devflow flags` command, which keeps its inline TUI.

Changes:
- Drop dynamic import of runFlagsTui/buildFlagRows/collectFlagRecord from init.ts
- Remove abort/save/cancel handling block (flags TUI abort path is gone)
- Replace TUI invocation with D40 JSDoc comment + single outcome line
  ('Flags: N active — customize any time with devflow flags')
- Remove now-unused getDefaultFlagsRecord from flags.ts import
- Update viewModeExplicit comment: no longer set by TUI save; only set by --reset

Tests:
- Add (c) assertion to fresh-install test: no 'Opening the flags editor' in transcript
- Add (b) test: re-init preserves user-set flag (tui=false), adopts defaults for absent flags
- Update file-level doc comment to reflect new test scenarios

Co-Authored-By: Claude <noreply@anthropic.com>
Claude Code Flags section:
- Remove '(also used by the init Advanced path)' from TUI description
- Document blurb registry field: ≤30-char per-flag short hint, shown as
  dim HINT column in TUI and --status rows
- Document effectiveDisplay vocabulary: booleans render on/off; neutral/
  unset enum shows neutralValue dim; unset number shows applicable default
  dim with '(default)' suffix; unset string shows '—'; literal 'unset' is
  never a displayed value; active non-boolean renders plain or bold
- Document RunTuiSpec.screen: flags editor uses 'inline' (normal scroll
  buffer, no alt-screen); agents-view defaults to 'alt'

Two-Mode Init section:
- Replace stale 'Advanced path opens the interactive flags editor' clause
  with accurate description: both paths apply seeded flag values non-
  interactively (fresh install = registry defaults; re-init = existing
  values preserved, new flags adopt defaults per ADR-014; view-mode
  resolved from settings.json at seed time) and emit outcome line pointing
  to 'devflow flags' for customization; Advanced adds proxy prompt as before

Project Structure / file-organization.md:
- Update flags-view/ comment to reflect current role: standalone devflow
  flags command, inline screen mode (not used by init)

Co-Authored-By: Claude <noreply@anthropic.com>
@dean0x
dean0x merged commit 8a81cc0 into main Aug 25, 2026
2 checks passed
@dean0x
dean0x deleted the feat/flags-typed-registry branch August 25, 2026 19:13
dean0x added a commit that referenced this pull request Aug 25, 2026
Populate the empty [Unreleased] section with entries for PR #299
(bin execute-bit fix) and PR #300 (typed flag registry, flags TUI,
new upstream flags) ahead of the 2.1.0 release dispatch.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant