Skip to content

feat!: close 7 feature-toggle gaps (user-scope-only, clean-break major version)#244

Merged
dean0x merged 24 commits into
mainfrom
feat/close-feature-toggle-gaps
Jun 16, 2026
Merged

feat!: close 7 feature-toggle gaps (user-scope-only, clean-break major version)#244
dean0x merged 24 commits into
mainfrom
feat/close-feature-toggle-gaps

Conversation

@dean0x

@dean0x dean0x commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

Closes all 7 gaps from the feature-toggle audit where "disabled" didn't fully disable (or there was no off switch). One PR, clean-break major version, user-scope-only — no local-scope writes or <gitRoot>/.devflow resolution anywhere in these changes.

The 7 gaps

Gap 1 — Security deny list: full lifecycle (the careful one)

  • New devflow security command: --status (none/user/managed/both + derived entry count + location), --enable [--user|--managed] (add-to-target-first → verify → strip-the-other; self-heals an interrupted switch), --disable (strips user + removes managed, reports exactly which entries were removed, hard-fails on unparseable settings.json).
  • Fresh user-mode init now actually installs the deny list via a dedicated, atomic, user-dir step that runs after the managed-install resolves. Re-init injects/updates it (fixes the early-return skip).
  • Managed-write failure now really falls back to a user-settings write + warn (the old fallback was a hollow message with no write).
  • Version drift handled by a frozen DEVFLOW_HISTORICAL_DENY superset — detection + removal use the historical set; enable adds the current template. Entry count is always derived, never hardcoded.
  • Manifest↔reality conflict: prompt in TTY / keep detected reality + warn in non-TTY / explicit flag always wins.
  • init --no-security flag; uninstall has a unified security-removal prompt (TTY-confirm default-keep / non-TTY preserve).
  • User-scope-only, machine-global — always targets ~/.claude/settings.json, never a project file.

Gaps 2 & 4 — Decisions/curation runtime gates

  • Shell hooks now gate on config OR sentinel (dual-signal), folded once in dream-evaluate. eval-decisions, eval-curation, dream-capture (usage scanner), and dream-collect-tasks (curation sweep) all honor it. Disabling no longer stamps .curation-last (no 7-day suppression after re-enable).

Gap 3 — HUD hard-disable

  • hud --disable removes the Devflow statusLine from settings.json (surgically — a non-Devflow statusLine survives) AND sets hud.json enabled:false. Self-healing under drift.

Gap 5 — Dead memory sentinel removed

  • getWorkingMemoryDisabledSentinel removed from all copies + the .cjs export; new ENOENT-tolerant per-project migration purge-dead-working-memory-sentinel-v1; CLAUDE.md updated to config-only (per ADR-001).

Gap 6 — Manifest sync + list surfacing

  • ambient/decisions/hud/memory --enable/--disable update manifest.features.* (only when a manifest already exists — never created as a toggle side-effect). devflow list surfaces security + safe-delete as tri-state (on/off/unknown).

Gap 7 — safe-delete toggle + queue drain

  • New devflow safe-delete --enable/--disable/--status (state lives in the shell-profile block; no-trash-CLI warns + writes nothing; upgrade replaces in place — no duplicate blocks; $SHELL unset → "unknown"). init --no-memory now drains the pending-turns queue. .claudeignore unchanged (user-owned scaffold).

Cross-cutting

  • All settings.json writes now go through writeFileAtomicExclusive (temp+rename) — no more truncate-on-crash of the file Claude reads every turn.
  • New syncManifestFeature helper deduplicates toggle→manifest sync across 5 commands.

Breaking / behavioral changes

  • HUD disabled loses the version-update badge (surfaced via devflow list instead).
  • eval-decisions now honors the .disabled sentinel (consistency).
  • Curation now depends on decisions being enabled.
  • SecurityMode gains 'none'.
  • No local-scope handling anywhere (clean break).
  • Aggressive deny-list removal can strip a user entry that exactly matches a historical Devflow entry — the --disable/uninstall removal report lets them re-add.

Testing

  • Full suite green: 1856 tests (excl. the worker integration file) + 42 (worker file, run in isolation) = ~1898 tests, 0 failures. (The worker-test timeouts seen mid-run were pre-existing machine-load flakiness — those tests are byte-identical to main and pass cleanly in isolation.)
  • New coverage: stripUserDenyList / detectDenyState / resolveSecurityAction contracts, manifest security round-trip, safe-delete command, list tri-state, shell-hook dual-signal gating, HUD self-healing.
  • Snyk code scan: 0 issues across src/cli.

Note

  • The standalone security --enable --managed errors (with guidance) on managed-write failure rather than auto-falling-back — honoring "explicit flag wins". The init path auto-falls-back to user mode. This asymmetry is intentional.

🤖 Generated with Claude Code

dean0x and others added 15 commits June 15, 2026 22:23
Dream-Task: decisions
Dream-Session: 29694b18-4416-44a3-a469-d4c8cf769749
Co-Authored-By: Devflow Dream <dream@devflow.local>
Dream-Task: knowledge
Dream-Session: 29694b18-4416-44a3-a469-d4c8cf769749
Co-Authored-By: Devflow Dream <dream@devflow.local>
Add dual-signal gate in dream-evaluate so DECISIONS_ENABLED is folded down
when .devflow/decisions/.disabled sentinel is present, regardless of dream
config. This means eval-decisions and eval-curation both respect config AND
sentinel with zero per-module plumbing.

- dream-evaluate: fold sentinel into DECISIONS_ENABLED after config read
- eval-curation: add DECISIONS_ENABLED guard as FIRST executable statement
  before .curation-last is read/written, preventing 7-day suppression after
  re-enable; use return not exit since file is sourced under set -e
- eval-decisions: drop stale comment claiming sentinel is session-start-context only
- dream-capture: mirror dual-signal for decisions-usage-scan via config + sentinel
- dream-collect-tasks: sweep curation markers when decisions disabled; curation
  depends on decisions data; update header comment accordingly
- tests/shell-hooks.test.ts: update AC-3 to reflect new contract where curation
  is swept when decEnabled=false rather than passed through

Applies PF-007, ADR-001; avoids PF-013.

Co-Authored-By: Claude <noreply@anthropic.com>
The .devflow/memory/.working-memory-disabled sentinel is dead code — no
hook reads it at runtime since memory is config-only gated via dream config
(ADR-001 clean-break). Remove the helper from all three copies of
project-paths and add a per-project migration to clean up stray files.

- src/cli/utils/project-paths.ts: remove getWorkingMemoryDisabledSentinel
- scripts/hooks/lib/project-paths.cjs: remove function and its export entry
- tests/project-paths.test.ts: remove import, assertion, and parity table entry
  (full-export parity test will now pass since both copies no longer export it)
- tests/sentinel.test.ts Part E: repoint 4 .working-memory-disabled fixtures
  onto .devflow/decisions/.disabled (a live sentinel path that actually exists)
- src/cli/utils/migrations.ts: add purge-dead-working-memory-sentinel-v1
  per-project migration that unlinks the stale sentinel; ENOENT is no-op;
  mirrors purge-stale-memory-markers-v1 in shape
- CLAUDE.md: update two approved spots per plan

Applies ADR-001; avoids PF-002/PF-004 (idempotent, ENOENT-tolerant).

Co-Authored-By: Claude <noreply@anthropic.com>
Previously hud --disable only wrote hud.json enabled:false but left the
statusLine in settings.json, meaning the HUD statusLine continued to run
even after "disabling" the HUD. This was a gap between hud.json state and
the actual settings.json runtime configuration.

Now --disable:
1. Writes hud.json enabled:false (existing)
2. Reads settings.json and calls removeHudStatusLine to remove any Devflow
   statusLine (surgical — preserves non-Devflow statusLines)
3. Writes updated settings.json back (only if changed, to avoid unnecessary
   writes)

Also removes the misleading "Version upgrade notifications will still appear"
message which was false once the statusLine is removed.

Co-Authored-By: Claude <noreply@anthropic.com>
…ray bug - Fix mergeDenyList: Array.isArray guard so non-array deny neither throws nor spreads chars - Add DEVFLOW_HISTORICAL_DENY: frozen Set of all 154 ever-shipped deny entries - Add assertHistoricalDenySuperset(): drift detector; throws if template has unlisted entries - Add stripUserDenyList(): pure + idempotent; removes historical entries, preserves user-only - Add detectDenyState(): scans for any historical entry; subset installs read as on - Add resolveSecurityAction(): pure resolver; flag > manifest-agree > conflict > fresh-default-user - Add applyUserSecurityDenyList(): atomic write helper using writeFileAtomicExclusive - Remove deny-list injection from installSettings (moved to dedicated init step in F2) - SecurityMode now includes 'none' (was 'user' or 'managed' only)
…+ --security flag - Add ManifestData.features.security ('user'|'managed'|'none'|undefined) wired into readManifest builder so it round-trips and is never dropped - Replace hardcoded securityMode='user' with resolveSecurityAction() call: detects current deny state, reads manifest mode, resolves action - Add --security <mode> flag (user/managed/none) to initCommand - Add dedicated security step after managed-install that always targets ~/.claude/settings.json for user mode; managed path falls back to user on failure with a real write (not just a warning); none path strips - Assert historical superset at install time to catch template drift - Write resolved securityMode to manifest features on every init - Preserve backward compat: pre-Phase-F manifests with no security field read as undefined (unknown/fresh); readManifest defaults gracefully
… New devflow security command (src/cli/commands/security.ts): --status: reports none/user/managed/both + entry count derived from live deny array length; flags anomalous dual-location installs --enable [--managed|--user]: add-to-target-first, verify, then strip-other; self-heals prior interrupted mode switch; updates manifest --disable: strips from user settings and removes managed settings unconditionally; reports every removed entry; hard-fails on unparseable user settings.json; updates manifest to 'none' Default (no flag) = --status - Uninstall F5: replace separate managed-settings prompt with unified security prompt covering user+managed locations; TTY confirm with initialValue:false (deny list is protective); non-TTY preserves; reports removed entry count on success - Register securityCommand in cli.ts + addHelpText examples
New devflow safe-delete command (src/cli/commands/safe-delete.ts):
- --enable: resolves platform/shell/profile; warns if trash CLI absent
  and writes nothing; upgrades outdated block in-place (remove+install)
  so blocks never stack; no-ops when already at current version
- --disable: surgical marker-delimited removal via removeFromProfile;
  deletes profile file when it becomes empty (fish function files)
- --status (default): tri-state installed/outdated/absent/unknown;
  includes resolved profile path; $SHELL unset => unknown, never crashes
- Re-exports getSafeDeleteStatus() for use by list.ts (no subprocess)
- Registered in cli.ts with examples line in addHelpText block

Tests: tests/safe-delete-command.test.ts covers enable/disable/status,
no-trash-CLI, upgrade-no-duplicate, $SHELL unset, fish file deletion
via tmpdir with HOME+SHELL overrides; no real profile mutation
init --no-memory previously updated dream config to memory:false but
left .pending-turns.jsonl and .pending-turns.processing in place.
On re-enable, the background-memory-update worker would process stale
turns from a prior session, producing incorrect context.

Now mirrors the drain logic in memory.ts --disable (~line 382-385):
Promise.all(unlink pending-turns + unlink pending-turns.processing),
ENOENT-tolerant, runs only when !memoryEnabled and gitRoot is set.
Requires getPendingTurnsPath and getPendingTurnsProcessingPath from
project-paths.ts (newly imported).
…elete in list

Manifest sync (Phase E1):
- ambient --enable/--disable, decisions --enable/--disable,
  hud --enable/--disable, memory --enable/--disable: each now reads
  the existing manifest and updates manifest.features.<name> to match.
  Guards: only updates when manifest already exists (readManifest != null).
  Mirrors the exact pattern in knowledge/toggle.ts. Coder-2 security
  field (manifest.features.security) is not touched here.

list.ts tri-state surfacing (Phase E3):
- Adds TriState type, resolveSecurityTriState() pure function
- Extends formatFeatures() with optional extra param for security +
  safe-delete tri-state values (backward-compatible: existing callers
  still pass only features)
- security: reads manifest.features.security; probes managed settings
  path as live fallback when manifest field is absent (try/catch wraps
  fs.access per spec — throws on unsupported platforms)
- safe-delete: getSafeDeleteStatus() from Phase D (no subprocess)
  installed->on, absent/outdated->off, unknown->unknown
- Old manifests without security field still render fine (unknown)

tests/list-logic.test.ts:
- Order-asserting test for rules->security->safe-delete ordering
- resolveSecurityTriState suite (on/off/unknown cases)
- formatFeatures with extras suite (8 cases covering all tri-states)
…elete in list Manifest sync (Phase E1): - ambient --enable/--disable, decisions --enable/--disable, hud --enable/--disable, memory --enable/--disable: each now reads the existing manifest and updates manifest.features.<name> to match. Guards: only updates when manifest already exists (readManifest != null). Mirrors the exact pattern in knowledge/toggle.ts. Coder-2 security field (manifest.features.security) is not touched here. list.ts tri-state surfacing (Phase E3): - Adds TriState type, resolveSecurityTriState() pure function - Extends formatFeatures() with optional extra param for security + safe-delete tri-state values (backward-compatible: existing callers still pass only features) - security: reads manifest.features.security; probes managed settings path as live fallback when manifest field is absent (try/catch wraps fs.access per spec -- throws on unsupported platforms) - safe-delete: getSafeDeleteStatus() from Phase D (no subprocess) installed->on, absent/outdated->off, unknown->unknown - Old manifests without security field still render fine (unknown) tests/list-logic.test.ts: - Order-asserting test for rules->security->safe-delete ordering - resolveSecurityTriState suite (on/off/unknown cases) - formatFeatures with extras suite (8 cases covering all tri-states)
…ver-truncate-on-crash for the file Claude Code reads every turn): - init.ts security step: the managed-success strip and the 'none' strip paths wrote ~/.claude/settings.json via plain fs.writeFile while the merge paths used the atomic helper. Both strips now go through writeFileAtomicExclusive (temp+rename), matching applyUserSecurityDenyList and the security command. - uninstall.ts section 6: the unified security deny-list strip now uses writeFileAtomicExclusive, mirroring 'devflow security --disable'. Manifest-sync consolidation (Simplifier pass, finalized here): - ambient/decisions/hud/memory/security: replace the inline readManifest -> if-exists -> mutate -> writeManifest blocks with the syncManifestFeature helper. Single consistent pattern across all toggle commands; only updates an existing manifest (never creates one). Verified: tsc clean (zero warnings), npm run build full pipeline, 617 targeted vitest tests green, Snyk code scan 0 issues.
…en when already disabled

Previously, hud --disable early-returned when hud.json already said disabled,
before the statusLine removal ran. If settings.json had a lingering Devflow
statusLine from a partial prior state (crash between config-write and
settings-write), the drift was never cleaned up.

Now: statusLine removal always runs regardless of config.enabled. The
"already disabled" message only prints when BOTH config was already disabled
AND no lingering statusLine was found. A non-Devflow statusLine is never
touched (removeHudStatusLine only removes Devflow-owned entries).
settings.json write remains atomic (writeFileAtomicExclusive) and conditional
(only when content actually changed).

Added 4 drift self-healing contract tests to hud.test.ts covering:
- lingering hud.sh removed when config already disabled
- lingering legacy statusline.sh removed when config already disabled
- non-Devflow statusLine survives --disable
- no-op (no write) when already clean
Dream-Task: knowledge
Dream-Session: 29694b18-4416-44a3-a469-d4c8cf769749
Co-Authored-By: Devflow Dream <dream@devflow.local>
@dean0x

dean0x commented Jun 16, 2026

Copy link
Copy Markdown
Owner Author

BLOCKING: Security deny list not written on declined-sudo fallback

File: src/cli/commands/init.ts:1287-1337
Confidence: 95% (Security)

The Bug

The write step for the managed-mode securityMode uses an if/else if/else if chain with no terminal else:

  • Line 1287: if (securityMode === 'managed' && managedSettingsConfirmed)
  • Line 1315: else if (securityMode === 'user')
  • Line 1325: else if (securityMode === 'none')

When a user selects "Managed settings" but then declines the sudo prompt (line 848), managedSettingsConfirmed = false is set while securityMode stays 'managed'. At the write step:

  • Line 1287 branch: false (managedSettingsConfirmed is false)
  • Line 1315 branch: false (securityMode is still 'managed', not 'user')
  • Line 1325 branch: false (securityMode is 'managed', not 'none')
  • No branch executes → deny list written to neither managed nor user settings

The manifest is then written as security: 'managed' (line 1417), falsely recording managed mode while nothing exists on disk.

Why This Matters

The UI explicitly offers a labeled fallback: "No, fall back to settings.json" (line 839). The user actively selected this, yet receives zero deny-list protection and a manifest/reality mismatch.

Fix

Route the declined-sudo path to the user-settings write. Either:

Option A: Flip the mode at decline time (init.ts:848):

managedSettingsConfirmed = sudoChoice === 'yes';
if (!managedSettingsConfirmed) {
  securityMode = 'user'; // "fall back to settings.json" must actually write to settings.json
}

Option B: Add the terminal branch at the write step:

} else if (securityMode === 'managed' && !managedSettingsConfirmed) {
  // User declined sudo and chose the settings.json fallback — honor it.
  if (templateDeny.length > 0) {
    try {
      await applyUserSecurityDenyList(userSettingsPath, templateDeny);
      securityMode = 'user'; // keep manifest in sync with reality
    } catch (e) {
      p.log.warn(\`Could not write deny list to user settings: ${e instanceof Error ? e.message : e}\`);
    }
  }
}

Either approach fixes the reachable gap and keeps the manifest in sync.

@dean0x

dean0x commented Jun 16, 2026

Copy link
Copy Markdown
Owner Author

Should-Fix Summary

Testing Gaps (HIGH confidence — established patterns not followed)

1. New migration purge-dead-working-memory-sentinel-v1 has no test (migrations.ts:1037, 95% confidence)

  • Every other file-removing migration in migrations.test.ts has a describe block with behavioral .run() tests
  • This migration lacks all three behavioral assertions: success path, ENOENT idempotency, rethrow-on-other-errno
  • Fix: Add a describe('purge-dead-working-memory-sentinel-v1 migration', ...) block following the purge-stale-memory-markers-v1 pattern

2. applyUserSecurityDenyList has zero direct tests (post-install.ts:634, 90% confidence)

  • The function has a read-then-merge-then-atomic-write flow with ENOENT-default-to-{} branch — completely untested
  • This is the only function that writes the user deny list and is exercised in two critical init outcomes (normal user mode + managed-write fallback)
  • Fix: Add a describe('applyUserSecurityDenyList') block with tmpdir tests covering success path, missing-file ENOENT branch, and idempotency

3. security.ts command handler and pure helpers entirely untested (220-line file, 92% confidence)

  • No tests/security-command.test.ts exists
  • countDenyEntries and loadTemplateDenyEntries (two pure, easily-testable helpers) lack any coverage
  • Fix: Add a tests/security-command.test.ts covering the two pure helpers (parse error → 0/[], non-array → 0/[], happy path)

4. syncManifestFeature no-op-when-no-manifest guard is untested (manifest.ts:113, 85% confidence)

  • Documented invariant: "No-op when no manifest exists" — if it regresses to write-anyway, toggles would fabricate partial manifests on un-inited projects
  • Fix: Two-line test pair in manifest.test.ts: (1) no-op when manifest.json absent, (2) updates field when present

Structural/Maintainability Issues

1. TypeScript: SecurityMode union lacks never exhaustiveness guard (init.ts:1287/1315/1325, 88% confidence)

  • The if/else-if chain compiles fine but if a 4th mode is ever added it silently falls through
  • The codebase already establishes the pattern at migrations.ts:1380 (const _exhaustive: never = ...)
  • Fix: Convert to a switch (securityMode) with a default: { const _exhaustive: never = securityMode; } guard

2. Complexity: resolveSecurityAction duplicates mode→target logic 3 times (post-install.ts:340-410, 88% confidence)

  • Lines 380, 386-393, and 407-408 encode the same precedence (both/user → user, managed → managed, none → none/user) but inconsistently
  • Fix: Extract one named helper keepTargetFor(m: DetectedMode) and call from all three sites, dropping function complexity by ~10 lines

3. Reliability: security --disable throws unguarded on corrupt managed JSON (security.ts:210-211, 90% confidence)

  • The disable path carefully guards user-settings parse (try/catch, graceful exit), but managed-settings removal has no guard
  • When managed file is corrupt, removeManagedSettings runs and executes JSON.parse(...) at post-install.ts:534 with no try/catch → raw crash
  • detectDenyState already returns managed=false for corrupt files, but disable ignores that signal and uses raw file existence instead
  • Fix: Gate on the parsed signal or wrap the managed removal to degrade instead of throwing

4. Performance: dream-capture adds jq subprocess before cheap grep gate (scripts/hooks/dream-capture:91-96, 88% confidence)

  • Dual-signal gate calls json_field_file (spawns jq/node) on every turn, before the cheap citation grep
  • Pre-PR the gate was zero subprocesses; this regresses the D29 design intent ("skip Node spawn when no ADR/PF citations present")
  • Fix: Reorder — grep first. Move the in-process citation grep ahead of the config parse so expensive parse only runs when a citation is actually present

5. Consistency: security and safe-delete diverge from toggle-command conventions (security.ts/safe-delete.ts, 90% confidence)

  • New commands treat "no flag" as show-status; all four pre-existing toggles treat it as show-Usage and return
  • New commands hard-exit on operational failures; siblings degrade with warn + return
  • Fix: Pick one convention (safest: make new commands match incumbents on no-flag behavior)

6. Architecture: SecurityMode union declared in 7 places instead of 1 (post-install.ts:624 + 6 others, 90% confidence)

  • Exported canonical type exists but inlined in resolveSecurityAction signature, manifest.ts, and InitOptions
  • Adding a 4th mode would require touching ~7 sites with no compiler enforcement of consistency
  • Fix: Use the single exported SecurityMode type everywhere (import + reuse)

7. Documentation (HIGH): CLI reference missing two new commands and init flag (docs/cli-reference.md, 96% confidence)

  • No ## Security or ## Safe-Delete sections in the canonical reference
  • Init Options table missing --security <user|managed|none> flag
  • Fix: Add two sections with usage examples + one table row for the init flag

Note on PR Description Claim

The PR states "all settings.json writes are now atomic," but this is broader than reality:

  • ✅ All security-path writes use writeFileAtomicExclusive
  • ❌ Five pre-existing plain fs.writeFile remain on HUD/viewMode init+uninstall paths (lines 251, 1173, 279, 382, 399 — not introduced here)

This PR correctly establishes the invariant for the security seam; a follow-up PR should extend it repo-wide.

@dean0x

dean0x commented Jun 16, 2026

Copy link
Copy Markdown
Owner Author

Additional Should-Fix & Lower-Confidence Findings

Additional Type-Safety Issues (MEDIUM, 82-88% confidence)

1. resolveSecurityAction recomputes DetectedMode from same booleans (post-install.ts:358-362, 82% confidence)

  • The 4-way ternary deriving detectedMode embeds boolean logic mid-function that callers already compute
  • Fix: Lift the mapping to a tiny named function classifyDetected(detected): DetectedMode and reuse it

2. TriState unions lack never exhaustiveness (list.ts:38-42, safe-delete.ts:74-80, 82% confidence)

  • triLabel and the SafeDeleteStatus chain use trailing-else defaults instead of checked exhaustiveness
  • If TriState gains a 4th state they'll silently render as unknown with no compiler signal
  • Fix: Use switch with default: { const _exhaustive: never = state; ... } or add const _: never = state assertion

Reliability & Fault Tolerance (MEDIUM, 85-90% confidence)

1. Strip-path duplication: 4 near-identical read-strip-write blocks (init.ts + security.ts, 80% confidence)

  • The sequence read user settings → stripUserDenyList → guard changed → writeFileAtomicExclusive → swallow ENOENT appears verbatim in 4 places
  • The merge side got applyUserSecurityDenyList helper; the strip side wasn't given the same treatment
  • Fix: Extract stripUserSecurityDenyList(settingsPath) helper alongside applyUserSecurityDenyList and reuse everywhere

2. Template deny-list loading duplicated in 4 places (security.ts + init.ts + post-install.ts ×2, 88% confidence)

  • loadTemplateDenyEntries helper exists in security.ts but isn't reused by the 3 other inline copies
  • Fix: Move loadTemplateDenyEntries to post-install.ts alongside other deny-list helpers and import everywhere

3. stripUserDenyList / mergeDenyList throw on corrupt input (latent) (post-install.ts, 85% confidence)

  • Both pure helpers call JSON.parse(...) with no try/catch
  • Documented as "PURE + idempotent" but not as partial functions that throw
  • All current callers pre-validate or wrap in try/catch (safe), but future callers will trust the comment and skip the guard
  • Fix: Either document the throw explicitly in JSDoc or return a Result/null-on-parse-failure shape per the parse-at-boundaries rule

Pre-Existing Issues Surfaced by New Code

1. HUD --enable not hardened against corrupt settings.json (hud.ts, pre-existing but adjacent to hardened disable path, 88% confidence)

  • The NEW hud --disable self-heal correctly wraps its call in try/catch
  • But hud --enable calls hasHudStatusLine(...) outside any try/catch — malformed settings makes it crash with raw stack
  • Inconsistent with this PR's theme of never crashing on settings.json
  • Fix (optional, mirrors disable hardening): wrap the settings parse in try/catch in the enable path

2. Uninstall security comment misleads about hard-fail behavior (uninstall.ts:461 comment vs :464-468 code, 90% confidence)

  • Comment claims "hard fail if unparseable — mirrors security --disable"
  • But code does NOT hard-fail — detectDenyState sets user=false for unparseable input, so strip is skipped (no-op, no error)
  • This is opposite of security --disable (which calls process.exit(1))
  • Fix: Either correct the comment to accurately describe the no-op-on-corrupt behavior, or add explicit hard-fail to match the comment

Documentation Gaps (MEDIUM-HIGH, 85-96% confidence)

1. README omits new public commands (README.md:111-123, 88% confidence)

  • Lists existing toggles like rules --status and decisions --enable but not security or safe-delete
  • Fix: Add two lines to the ## CLI Reference block

2. CLAUDE.md migrations enumeration incomplete (CLAUDE.md:68, 85% confidence)

  • Lists 4 existing migrations but omits the new purge-dead-working-memory-sentinel-v1
  • Fix: Append to the migrations list

Lower-Confidence Observations (60-79% range)

  • Dynamic import of already-static module (init.ts:895, 78%) — imports getManagedSettingsPath from ../utils/paths.js which is already statically imported; unnecessary async hop
  • SecurityMode re-declared in 7 places (90% reported separately above)
  • Template-deny loading not hoisted (88% reported separately above)
  • Dual-signal gate split across two logic branches (session-start-context:47 vs :122-132, 70%) — acceptable in practice (both paths set the signals), but latent inconsistency if a future change sets config-only

Summary of Issues by Category

Type Count Severity
BLOCKING 1 Security deny list not written on declined-sudo
Should-Fix (Testing) 4 Missing test coverage for new paths
Should-Fix (Type-Safety) 3 Missing never exhaustiveness guards + union redeclaration
Should-Fix (Maintainability) 7 Duplication, structural issues, consistency divergences
Should-Fix (Reliability) 3 Unguarded throws, insufficient fault tolerance
Should-Fix (Documentation) 2 CLI reference and CLAUDE.md gaps
Pre-Existing 2 HUD enable, uninstall comment (adjacent to new code)
Low-Priority 3 Style and minor structural nits

No findings block merge once the BLOCKING security issue is fixed. Most should-fixes are low-effort consolidations (extract helpers, add guards, fix comments).


Reviewed with: security.md, reliability.md, performance.md, complexity.md, consistency.md, testing.md, typescript.md, documentation.md, architecture.md, regression.md

dean0x and others added 9 commits June 16, 2026 17:09
Four cases mirroring purge-stale-memory-markers-v1 style:
- removes .working-memory-disabled sentinel when present + returns info
- ENOENT no-ops without throwing (idempotency invariant, avoids PF-004)
- non-ENOENT errors rethrow (ENOTDIR via file-where-dir trick, avoids PF-004)
- registered in MIGRATIONS with per-project scope and ordered after
  purge-stale-memory-markers-v1 (applies ADR-001)

Co-Authored-By: Claude <noreply@anthropic.com>
safe-delete: no-flag now shows p.note('Usage') and returns — matches
ambient/memory/hud/decisions incumbents exactly. --status is its own
branch with a proper exhaustive switch on SafeDeleteStatus (applies
TypeScript rule: exhaustive switch via `never` in default case).

list: triLabel inner function converted from if/if/return to exhaustive
switch on TriState — compiler will catch any future 4th member silently.

Rendered output byte-identical; tsc clean; 47 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
…helper + tests

Extract resolveSecurityRemovalDecision() pure function from section 6 of
the uninstall orchestration, covering the non-interactive-preserve invariant
(isTTY=false → always 'preserve'), the keepDocs skip gate, and the interactive
prompt path. Add 9 behaviour-focused unit tests that lock down all three return
values — including a named regression test for the safety property that
non-interactive mode never strips the deny list (avoids PF-004 half-applied-state
hazard). Also correct the misleading comment at the strip call site: uninstall
does NOT hard-fail on corrupt JSON — detectDenyState catches the parse error
and sets user=false, so the stripUserDenyList call is skipped entirely (unlike
devflow security --disable which process.exit(1)s). tsc clean, 27 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
…h-E-foundation)

SF1: Move canonical SecurityMode to manifest.ts (owns the ManifestData interface
field); post-install.ts re-exports it; init.ts imports it via post-install.ts
re-export. Removes local 'type SecurityMode' alias in manifest.ts:readManifest
and the duplicate inline 'user'|'managed'|'none' in resolveSecurityAction's
signature and InitOptions.security. Single declaration, 7 sites collapsed.
Applies PF-009 (stale references swept in owned files).

SF2: Extract keepTargetFor(DetectedMode): SecurityMode helper — collapses the
three open-coded detectedMode→target ladders to one named function. Add
never exhaustiveness guard in the flag dispatch block. Applies typescript rule
(exhaustive switch/never guards).

SF19: Extract classifyDetected() — lifts the 4-way boolean→DetectedMode ternary
to a named helper, pairs with keepTargetFor so the pipeline reads as two steps.

SF4: Add exported loadTemplateDenyEntries(rootDir): Promise<string[]> in
post-install.ts — single canonical template reader (defensive parse, Array.isArray
guard, String coerce, returns [] on any failure). Replaces inline reads in
installManagedSettings, removeManagedSettings, and init.ts security step.
Downstream: security.ts resolver will adopt it.

SF16: Add @throws JSDoc to mergeDenyList and stripUserDenyList documenting that
SyntaxError propagates on malformed JSON; callers must pre-validate or wrap.

NEW HELPER: Export stripUserSecurityDenyList(settingsPath): Promise<{removed}|null>
— owns the read→strip→guard→writeFileAtomicExclusive→swallow-ENOENT sequence
currently duplicated in init.ts (×2) and security.ts (×1). Downstream resolvers
will adopt it (not wired in yet per spec).

Build: tsc clean, 158 tests green, Snyk code scan 0 issues.

Co-Authored-By: Claude <noreply@anthropic.com>
…d fix no-flag UX

SF4-consumer: delete local loadTemplateDenyEntries and import the canonical one from
post-install.ts (single source of truth).

SF9-consumer: replace open-coded read→strip→guard→atomic-write block in --enable
managed path with stripUserSecurityDenyList (canonical helper from post-install.ts).

SF5: gate managed-settings removal on detected.managed (parse-safe signal from
detectDenyState) rather than raw managedExists — prevents uncaught JSON.parse crash
in removeManagedSettings when the managed file exists but contains corrupt JSON, which
would leave user settings stripped but manifest sync skipped (half-applied state,
applies engineering.md never-throw-in-business-logic rule).

SF6a: bare 'devflow security' now prints a Usage note (mirrors memory.ts incumbent
pattern) instead of silently delegating to --status.

SF11: switch process.exit(1) at operational failure sites (template load failed,
managed write failed) to p.log.error + return; retain process.exit(1) at the
unparseable user settings precondition guard (genuine fail-safe).

test(security): add tests/security-command.test.ts covering countDenyEntries pure
helper (SF12): valid array, parse error, no deny key, non-array deny, absent
permissions, empty array, null deny.

Co-Authored-By: Claude <noreply@anthropic.com>
…batch-F-init)

B1 (Security): when securityMode='managed' but managedSettingsConfirmed=false the
write-step chain had no matching branch — deny list was written nowhere and manifest
falsely recorded 'managed'. Restructured the if/else-if chain so the outer arm is
securityMode==='managed' with an inner split on managedSettingsConfirmed; the false
arm writes to user settings and flips securityMode to 'user' before manifest write.
applies ADR-010 (declined-sudo path is the user-scope write ADR-010 mandates).

SF3 (TypeScript): added terminal `else { const _exhaustive: never = securityMode; }`
after all discriminant arms so the compiler enforces exhaustiveness when SecurityMode
gains new variants. avoids PF-009 (stale references after rename/refactor).

SF9 (Complexity): replaced the two inline read-strip-write-if-changed blocks (managed-
success arm and none arm) with the canonical stripUserSecurityDenyList helper, which
owns atomic temp+rename, ENOENT-swallow, and only-write-if-changed in one place.

SF1 (TypeScript): replaced three inline 'user'|'managed'|'none' union casts (lines
904, 905, 1415) with the canonical SecurityMode type. avoids PF-009.

tsc: 0 errors, 0 warnings. 110 init-logic + 85 security/uninstall/manifest tests green.
Snyk code scan: 0 issues.

Co-Authored-By: Claude <noreply@anthropic.com>
…ateDenyEntries Adds 12 new test cases across init-logic.test.ts and manifest.test.ts to cover the write-path seams introduced by the foundation resolver: init-logic.test.ts new cases: - applyUserSecurityDenyList: merge into existing file with siblings preserved, ENOENT fallback creates from empty, idempotency across two applies - loadTemplateDenyEntries: valid template, non-array deny returns empty, missing file returns empty - resolveSecurityAction: unknownDetected fixture now exercised, two missing non-TTY CONFLICT sub-cases added manifest.test.ts new cases: - syncManifestFeature: no-op when manifest absent, updates field and bumps updatedAt when present, security field update round-trips correctly All 170 tests pass, tsc clean with zero warnings. Co-Authored-By: Claude <noreply@anthropic.com>
…n post-install

list.ts: replace nested ternary for safeDeleteTriState with a flat switch
statement — avoids the banned nested-ternary pattern, aligns with the
SafeDeleteStatus union shape (clearer than combining absent+outdated in a
compound condition), and leaves room for a future exhaustive guard.

post-install.ts: update two stale JSDoc comments — loadTemplateDenyEntries
already serves security.ts (not "will adopt"), and stripUserSecurityDenyList's
"will call" future-tense wording is now "use" since both callers are adopted.
…it flag Closes the SF15/SF17/SF18 documentation review issues for PR #244: - docs/cli-reference.md: add Security (Deny List) + Safe-Delete sections, plus the --security <user|managed|none> Init Options row. - README.md: surface security/safe-delete in the CLI Reference block; note post-init toggling in the Security paragraph. - CLAUDE.md: append purge-dead-working-memory-sentinel-v1 to the Migrations enumeration (applies ADR-001).
@dean0x dean0x merged commit 617f4c9 into main Jun 16, 2026
2 checks passed
@dean0x dean0x deleted the feat/close-feature-toggle-gaps branch June 16, 2026 20:08
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