Skip to content

Latest commit

 

History

History
1378 lines (1076 loc) · 39.8 KB

File metadata and controls

1378 lines (1076 loc) · 39.8 KB

Deferred Apps - Ralph Wiggum Development Plan

DOT'T CHANGE, DELETE OR CREATE ANY FILES OR FOLDERS OUTSIDE OF THE PROJECT REPO!

DON'T RUN COMMANDS THAT USE PATHS FROM OUTSIDE THE PROJECT REPO!

RALPH PHILOSOPHY: When the agent fails, don't just fix the code — fix the prompt. Erect signs so the next loop doesn't have to rediscover fire.

"Ralph is deterministically bad in an undeterministic world."


Table of Contents

  1. Ralph Wiggum Methodology
  2. Project Context
  3. Quality Gates
  4. Requirements
  5. Signs (Lessons Learned)
  6. Completion Checklist

Ralph Wiggum Methodology

Core Principles

  1. Iteration > Perfection: Don't aim for perfect on first try. Let the loop refine the work.
  2. Failures Are Data: Deterministically bad means failures are predictable and informative.
  3. Operator Skill Matters: Success depends on writing good prompts, not just having a good model.
  4. Persistence Wins: Keep trying until success. The loop handles retry logic.

How to Use This Plan

Each requirement (REQ-XXX) is designed to be executed as a single Ralph loop:

/ralph-wiggum:ralph-loop "<requirement prompt>" --completion-promise "COMPLETE" --max-iterations 30

Requirements are structured with:

  • Clear completion criteria (exact conditions for success)
  • Self-correction patterns (what to do when tests fail)
  • Verification commands (automated checks)
  • Signs (guardrails from past failures)

Loop Execution Pattern

1. Read the requirement
2. Write failing test (TDD)
3. Implement minimal code to pass
4. Run verification commands
5. If any fail → fix and retry from step 3
6. If all pass → output <promise>COMPLETE</promise>

Project Context

What Is Deferred Apps?

Deferred Apps creates lightweight wrapper scripts (~5KB) that appear as installed applications in your desktop launcher, but only download the actual package on first launch. Perfect for apps you rarely use but want available.

Current State (Completed)

  • Core library (package.nix) with mkDeferredApp, mkDeferredPackages
  • NixOS and Home Manager modules
  • Two modes: pname (nix shell) and package (nix-store -r)
  • Icon resolution with Papirus theme (icons COPIED, not referenced)
  • Collision detection for terminal commands
  • GC root support
  • Nested package support (python313Packages.numpy)
  • Comprehensive test suite

Community Feedback Addressed

  • @arianvp: Use nix-store --realise → Implemented as package mode
  • Closure optimization → Icons copied (~5KB) not referenced (~1GB)
  • CLI tools support → REQ-001
  • Status tracking → REQ-002
  • Preload for offline use → REQ-004

Quality Gates

Mandatory Verification (Run After EVERY Change)

# ALL of these must pass before marking any task complete
nix flake check           # All tests pass
statix check .            # No linter warnings
deadnix --fail -L .       # No dead code
nix fmt -- --check        # Proper formatting

Quality Gate Protocol

IF any verification fails:
  1. Fix the issue
  2. Re-run ALL verifications
  3. Repeat until ALL pass
  4. Only then proceed to next step

DO NOT skip verification steps.
DO NOT mark tasks complete with failing checks.

Requirements


REQ-001: CLI-Only Mode

Priority: HIGH
Estimated Iterations: 15-25
Dependencies: None

Rationale

Users want deferred apps for CLI tools (ripgrep, fd, jq), not just GUI applications. Currently, all deferred apps create .desktop files and resolve icons, which is unnecessary overhead for terminal-only tools.

User Stories

  1. As a user, I want to defer CLI tools without creating desktop files
  2. As a user, I want faster builds when deferring CLI-only tools (no icon lookup)
  3. As a user, I want a convenient module option for listing CLI apps

Acceptance Criteria

  • mkDeferredApp { package = pkgs.ripgrep; cliOnly = true; } creates wrapper WITHOUT .desktop file
  • mkDeferredApp { pname = "fd"; cliOnly = true; } works the same way
  • CLI-only wrappers have terminal command symlink in $out/bin/
  • CLI-only wrappers do NOT have $out/share/applications/ directory
  • CLI-only wrappers do NOT have $out/share/icons/ directory
  • Build time is measurably faster (no icon theme scanning)
  • Module option programs.deferredApps.cliPackages accepts list of packages
  • Module option programs.deferredApps.cliApps accepts list of pnames
  • All existing tests continue to pass
  • New tests cover all CLI-only scenarios

Test Requirements

Create tests/cli-mode.nix with these test cases:

# Test 1: CLI-only has bin symlink
cli-mode-has-bin = mkBuildCheck "cli-mode-has-bin"
  (deferredAppsLib.mkDeferredApp { package = pkgs.ripgrep; cliOnly = true; })
  ''
    test -L "$out/bin/rg" || { echo "FAIL: missing bin symlink"; exit 1; }
    echo "PASS: bin symlink exists"
  '';

# Test 2: CLI-only has NO desktop file
cli-mode-no-desktop = mkBuildCheck "cli-mode-no-desktop"
  (deferredAppsLib.mkDeferredApp { package = pkgs.ripgrep; cliOnly = true; })
  ''
    if [ -d "$out/share/applications" ]; then
      echo "FAIL: cli-only should not have share/applications"
      exit 1
    fi
    echo "PASS: no desktop directory"
  '';

# Test 3: CLI-only has NO icons
cli-mode-no-icons = mkBuildCheck "cli-mode-no-icons"
  (deferredAppsLib.mkDeferredApp { package = pkgs.ripgrep; cliOnly = true; })
  ''
    if [ -d "$out/share/icons" ]; then
      echo "FAIL: cli-only should not have share/icons"
      exit 1
    fi
    echo "PASS: no icons directory"
  '';

# Test 4: CLI-only with pname mode
cli-mode-pname = mkBuildCheck "cli-mode-pname"
  (deferredAppsLib.mkDeferredApp { pname = "fd"; cliOnly = true; })
  ''
    test -L "$out/bin/fd" || { echo "FAIL: missing bin symlink"; exit 1; }
    test ! -d "$out/share/applications" || { echo "FAIL: has desktop dir"; exit 1; }
    echo "PASS: pname cli-only works"
  '';

# Test 5: Module cliPackages option
cli-mode-module-packages = let
  eval = evalNixosModule {
    programs.deferredApps = {
      enable = true;
      cliPackages = [ pkgs.ripgrep pkgs.fd ];
    };
  };
in mkCheck "cli-mode-module-packages" (
  builtins.length eval.config.environment.systemPackages >= 2
);

# Test 6: Default cliOnly is false (backward compatibility)
cli-mode-default-false = mkBuildCheck "cli-mode-default-false"
  (deferredAppsLib.mkDeferredApp { package = pkgs.hello; })
  ''
    test -d "$out/share/applications" || { echo "FAIL: default should have desktop"; exit 1; }
    echo "PASS: default has desktop file"
  '';

Implementation Steps

  1. Add cliOnly parameter to mkDeferredApp in package.nix:

    • Default: false
    • When true: skip desktop file creation, skip icon resolution
  2. Modify derivation build script to conditionally create directories:

    if [ -z "$cliOnly" ]; then
      # Create desktop file and icons
      mkdir -p "$out/share/applications" "$out/share/icons"
      # ... existing icon/desktop logic
    fi
  3. Add module options in modules/shared.nix:

    cliPackages = lib.mkOption {
      type = lib.types.listOf lib.types.package;
      default = [];
      description = "CLI tools to defer (no desktop files created)";
    };
    
    cliApps = lib.mkOption {
      type = lib.types.listOf lib.types.str;
      default = [];
      description = "CLI tool names to defer (no desktop files created)";
    };
  4. Update buildDeferredPackages to handle CLI apps with cliOnly = true

  5. Add tests to tests/cli-mode.nix

  6. Update README with CLI-only documentation

Verification Commands

# Run after implementation
nix flake check
nix build .#checks.x86_64-linux.cli-mode-has-bin
nix build .#checks.x86_64-linux.cli-mode-no-desktop
nix build .#checks.x86_64-linux.cli-mode-no-icons
nix build .#checks.x86_64-linux.cli-mode-pname
nix build .#checks.x86_64-linux.cli-mode-module-packages
nix build .#checks.x86_64-linux.cli-mode-default-false
statix check .
deadnix --fail -L .
nix fmt -- --check

Signs (Guardrails)

  • DO NOT break backward compatibility - cliOnly defaults to false
  • DO NOT skip icon resolution for non-cliOnly apps
  • DO NOT forget to update collision detection for CLI apps
  • DO test both package mode AND pname mode with cliOnly
  • DO update the module's buildDeferredPackages to pass cliOnly

Completion Promise

When ALL of the following are true:

  • All 6 test cases pass
  • All existing tests still pass
  • nix flake check passes
  • statix check . has no warnings
  • deadnix --fail -L . finds no dead code
  • nix fmt -- --check passes

Output: <promise>COMPLETE</promise>


REQ-002: Status CLI Tool

Priority: HIGH
Estimated Iterations: 20-30
Dependencies: None

Rationale

Users want to know which deferred apps have been downloaded vs which are still pending. This is especially useful for:

  • Checking disk usage of deferred apps
  • Knowing which apps will work offline
  • Debugging download issues

User Stories

  1. As a user, I want to see a list of all my deferred apps
  2. As a user, I want to know which apps are downloaded vs pending
  3. As a user, I want to see how much disk space downloaded apps use
  4. As a user, I want JSON output for scripting

Acceptance Criteria

  • deferred-apps status shows all deferred apps with download status
  • Status shows: app name, status (downloaded/pending), size (if downloaded)
  • deferred-apps status --json outputs machine-readable JSON
  • deferred-apps status <app> shows status of specific app
  • Tool is automatically available when module is enabled
  • Works for both package mode and pname mode apps
  • Exit code 0 on success, non-zero on error

Test Requirements

Create tests in tests/status-cli.nix:

# Test 1: Status CLI exists
status-cli-exists = let
  statusPkg = pkgs.callPackage ../cli/status.nix { 
    inherit (deferredAppsLib) getPnameFromPackage;
  };
in mkBuildCheck "status-cli-exists" statusPkg ''
  test -x "$out/bin/deferred-apps" || { echo "FAIL: missing binary"; exit 1; }
  "$out/bin/deferred-apps" --help | grep -q "status" || { echo "FAIL: no status cmd"; exit 1; }
  echo "PASS: status CLI exists"
'';

# Test 2: Status CLI has subcommands
status-cli-subcommands = let
  statusPkg = pkgs.callPackage ../cli/status.nix {};
in mkBuildCheck "status-cli-subcommands" statusPkg ''
  "$out/bin/deferred-apps" status --help | grep -q "json" || { echo "FAIL: no --json"; exit 1; }
  echo "PASS: has expected options"
'';

# Test 3: Module includes status CLI
status-cli-in-module = let
  eval = evalNixosModule {
    programs.deferredApps = {
      enable = true;
      packages = [ pkgs.hello ];
    };
  };
  hasStatusCli = builtins.any (p: 
    (p.pname or p.name or "") == "deferred-apps-cli"
  ) eval.config.environment.systemPackages;
in mkCheck "status-cli-in-module" hasStatusCli;

# Test 4: JSON output is valid
status-cli-json-valid = let
  statusPkg = pkgs.callPackage ../cli/status.nix {};
in mkBuildCheck "status-cli-json-valid" statusPkg ''
  # Create mock registry for testing
  mkdir -p $TMPDIR/deferred-apps
  echo '{"apps":{"hello":{"outPath":"/nix/store/xxx-hello"}}}' > $TMPDIR/deferred-apps/registry.json
  
  output=$("$out/bin/deferred-apps" status --json --registry "$TMPDIR/deferred-apps/registry.json" 2>/dev/null || true)
  echo "$output" | ${pkgs.jq}/bin/jq . > /dev/null || { echo "FAIL: invalid JSON"; exit 1; }
  echo "PASS: valid JSON output"
'';

Implementation Steps

  1. Create CLI package at cli/status.nix:

    { pkgs, lib, ... }:
    pkgs.writeShellApplication {
      name = "deferred-apps";
      runtimeInputs = with pkgs; [ jq coreutils nix ];
      text = builtins.readFile ./deferred-apps.sh;
    }
  2. Create CLI script at cli/deferred-apps.sh:

    • Subcommand: status [--json] [app-name]
    • Read registry from ~/.local/share/deferred-apps/registry.json
    • Check store paths existence with nix path-info
    • Calculate sizes with nix path-info --size
  3. Create registry mechanism:

    • Module generates registry JSON at build time
    • Contains: app name → store path mapping
    • Registry placed in $out/share/deferred-apps/registry.json
  4. Update module to include CLI and registry:

    environment.systemPackages = [ statusCli ];
    environment.etc."deferred-apps/registry.json".source = registryJson;
  5. Add tests to flake checks

Output Format

Text format (default):

Deferred Apps Status
====================
App             Status      Size
----------------------------------------
spotify         downloaded  245 MB
discord         pending     -
obs-studio      downloaded  189 MB
----------------------------------------
Total downloaded: 2/3 (434 MB)

JSON format (--json):

{
  "apps": [
    {"name": "spotify", "status": "downloaded", "size_bytes": 257000000, "path": "/nix/store/..."},
    {"name": "discord", "status": "pending", "size_bytes": null, "path": "/nix/store/..."},
    {"name": "obs-studio", "status": "downloaded", "size_bytes": 198000000, "path": "/nix/store/..."}
  ],
  "summary": {
    "total": 3,
    "downloaded": 2,
    "pending": 1,
    "total_size_bytes": 455000000
  }
}

Verification Commands

nix flake check
nix build .#checks.x86_64-linux.status-cli-exists
nix build .#checks.x86_64-linux.status-cli-subcommands
nix build .#checks.x86_64-linux.status-cli-in-module
nix build .#checks.x86_64-linux.status-cli-json-valid
statix check .
deadnix --fail -L .
nix fmt -- --check

Signs (Guardrails)

  • DO NOT hardcode paths - use XDG_DATA_HOME with fallback
  • DO NOT assume apps are always in nix store - check existence
  • DO handle missing registry gracefully (empty list, not error)
  • DO use nix path-info not manual store inspection
  • DO make JSON output parseable by jq

Completion Promise

When ALL of the following are true:

  • CLI tool builds and has status subcommand
  • Text and JSON output formats work
  • Module automatically includes the CLI
  • All tests pass
  • All quality gates pass

Output: <promise>COMPLETE</promise>


REQ-003: GC Management CLI

Priority: MEDIUM
Estimated Iterations: 15-25
Dependencies: REQ-002 (shares CLI infrastructure)

Rationale

When gcRoot = true, downloaded packages are protected from garbage collection. Users need a way to:

  • See what's protected
  • Clean up when disk space is needed
  • Understand disk usage impact

User Stories

  1. As a user, I want to list all GC-protected deferred apps
  2. As a user, I want to see total size of GC roots
  3. As a user, I want to remove all GC roots to reclaim space
  4. As a user, I want to remove GC root for specific app

Acceptance Criteria

  • deferred-apps gc list shows all GC roots
  • deferred-apps gc status shows total size
  • deferred-apps gc clean removes all GC roots
  • deferred-apps gc clean <app> removes specific app's GC root
  • deferred-apps gc clean --dry-run shows what would be removed
  • Confirmation prompt before destructive operations (unless --yes)
  • Works with existing GC root directory structure

Test Requirements

# Test 1: GC list command exists
gc-cli-list = let
  cliPkg = pkgs.callPackage ../cli/status.nix {};
in mkBuildCheck "gc-cli-list" cliPkg ''
  "$out/bin/deferred-apps" gc --help | grep -q "list" || { echo "FAIL: no list"; exit 1; }
  echo "PASS: gc list exists"
'';

# Test 2: GC clean has dry-run
gc-cli-dry-run = let
  cliPkg = pkgs.callPackage ../cli/status.nix {};
in mkBuildCheck "gc-cli-dry-run" cliPkg ''
  "$out/bin/deferred-apps" gc clean --help | grep -q "dry-run" || { echo "FAIL: no dry-run"; exit 1; }
  echo "PASS: has dry-run option"
'';

# Test 3: GC clean requires confirmation
gc-cli-confirmation = let
  cliPkg = pkgs.callPackage ../cli/status.nix {};
in mkBuildCheck "gc-cli-confirmation" cliPkg ''
  # Should fail without --yes when no tty
  if echo "" | "$out/bin/deferred-apps" gc clean 2>/dev/null; then
    echo "FAIL: should require confirmation"
    exit 1
  fi
  echo "PASS: requires confirmation"
'';

Implementation Steps

  1. Add gc subcommand to CLI:

    • gc list - enumerate ~/.local/share/deferred-apps/gcroots/
    • gc status - sum sizes of all roots
    • gc clean [--yes] [--dry-run] [app] - remove roots
  2. GC root directory structure:

    ~/.local/share/deferred-apps/gcroots/
    ├── spotify -> /nix/store/xxx-spotify
    ├── discord -> /nix/store/xxx-discord
    └── obs-studio -> /nix/store/xxx-obs-studio
    
  3. Safety features:

    • Confirmation prompt (bypass with --yes)
    • Dry-run mode
    • Clear error messages

Verification Commands

nix flake check
nix build .#checks.x86_64-linux.gc-cli-list
nix build .#checks.x86_64-linux.gc-cli-dry-run
nix build .#checks.x86_64-linux.gc-cli-confirmation
statix check .
deadnix --fail -L .
nix fmt -- --check

Signs (Guardrails)

  • DO NOT delete without confirmation (unless --yes)
  • DO NOT fail if gcroots directory doesn't exist (just show empty)
  • DO use readlink -f to resolve symlinks for size calculation
  • DO handle broken symlinks gracefully

Completion Promise

When ALL tests pass and quality gates pass: Output: <promise>COMPLETE</promise>


REQ-004: Preload CLI Tool

Priority: MEDIUM
Estimated Iterations: 20-30
Dependencies: REQ-002 (shares CLI infrastructure)

Rationale

Users want to pre-download apps before going offline (travel, conferences, etc.). This is a common request from the community.

User Stories

  1. As a user, I want to download all deferred apps at once
  2. As a user, I want to download specific apps by name
  3. As a user, I want to see progress during downloads
  4. As a user, I want downloads to continue if one fails

Acceptance Criteria

  • deferred-apps preload downloads all pending apps
  • deferred-apps preload spotify discord downloads specific apps
  • Shows progress: "Downloading 1/5: spotify..."
  • Continues on failure, reports failures at end
  • --parallel N option for concurrent downloads (default: 1)
  • Exit code indicates success (0) or partial failure (1)

Test Requirements

# Test 1: Preload command exists
preload-cli-exists = let
  cliPkg = pkgs.callPackage ../cli/status.nix {};
in mkBuildCheck "preload-cli-exists" cliPkg ''
  "$out/bin/deferred-apps" preload --help || { echo "FAIL: no preload"; exit 1; }
  echo "PASS: preload exists"
'';

# Test 2: Preload has parallel option
preload-cli-parallel = let
  cliPkg = pkgs.callPackage ../cli/status.nix {};
in mkBuildCheck "preload-cli-parallel" cliPkg ''
  "$out/bin/deferred-apps" preload --help | grep -q "parallel" || { echo "FAIL: no parallel"; exit 1; }
  echo "PASS: has parallel option"
'';

Implementation Steps

  1. Add preload subcommand:

    • Read registry for app → store path mapping
    • For each app: run nix-store -r <path>
    • Track success/failure
    • Report summary
  2. Progress display:

    Preloading deferred apps...
    [1/5] spotify: downloading... done (245 MB)
    [2/5] discord: downloading... done (312 MB)
    [3/5] obs-studio: downloading... FAILED (network error)
    [4/5] blender: downloading... done (1.2 GB)
    [5/5] gimp: downloading... done (89 MB)
    
    Summary: 4/5 succeeded, 1 failed
    Failed: obs-studio
    
  3. Parallel downloads (optional enhancement):

    • Use xargs -P or GNU parallel
    • Default to sequential for safety

Verification Commands

nix flake check
nix build .#checks.x86_64-linux.preload-cli-exists
nix build .#checks.x86_64-linux.preload-cli-parallel
statix check .
deadnix --fail -L .
nix fmt -- --check

Signs (Guardrails)

  • DO NOT exit early on first failure - complete all downloads
  • DO report which apps failed at the end
  • DO use appropriate exit codes (0 = all success, 1 = partial failure)
  • DO NOT require root/sudo for downloads

Completion Promise

When ALL tests pass and quality gates pass: Output: <promise>COMPLETE</promise>


REQ-005: Progress Mode

Priority: MEDIUM
Estimated Iterations: 15-20
Dependencies: None

Rationale

Different users want different feedback during downloads:

  • GUI users: desktop notifications
  • Terminal users: progress bars
  • Scripts: no output (just exit codes)

User Stories

  1. As a terminal user, I want to see download progress in my terminal
  2. As a script author, I want silent operation
  3. As a GUI user, I want notifications (current default)

Acceptance Criteria

  • progressMode = "notification" (default) - uses notify-send
  • progressMode = "terminal" - prints progress to stderr
  • progressMode = "none" - silent operation
  • Per-app override via extraApps
  • Module option programs.deferredApps.progressMode

Test Requirements

# Test 1: Terminal progress mode
progress-mode-terminal = mkBuildCheck "progress-mode-terminal"
  (deferredAppsLib.mkDeferredApp { package = pkgs.hello; progressMode = "terminal"; })
  ''
    grep -q 'PROGRESS_MODE="terminal"' "$out/libexec/deferred-hello" || \
      { echo "FAIL: terminal mode not set"; exit 1; }
    echo "PASS: terminal mode set"
  '';

# Test 2: None progress mode
progress-mode-none = mkBuildCheck "progress-mode-none"
  (deferredAppsLib.mkDeferredApp { package = pkgs.hello; progressMode = "none"; })
  ''
    grep -q 'PROGRESS_MODE="none"' "$out/libexec/deferred-hello" || \
      { echo "FAIL: none mode not set"; exit 1; }
    # Should not have notify-send call when mode is none
    if grep -q 'notify-send' "$out/libexec/deferred-hello" | grep -v 'PROGRESS_MODE'; then
      : # notify-send might still be referenced but guarded
    fi
    echo "PASS: none mode set"
  '';

# Test 3: Default is notification
progress-mode-default = mkBuildCheck "progress-mode-default"
  (deferredAppsLib.mkDeferredApp { package = pkgs.hello; })
  ''
    grep -q 'PROGRESS_MODE="notification"' "$out/libexec/deferred-hello" || \
      { echo "FAIL: default should be notification"; exit 1; }
    echo "PASS: default is notification"
  '';

# Test 4: Module option works
progress-mode-module = let
  eval = evalNixosModule {
    programs.deferredApps = {
      enable = true;
      packages = [ pkgs.hello ];
      progressMode = "terminal";
    };
  };
in mkCheck "progress-mode-module" (eval.config.programs.deferredApps.progressMode == "terminal");

Implementation Steps

  1. Add progressMode parameter to mkDeferredApp:

    • Type: enum ["notification" "terminal" "none"]
    • Default: "notification"
  2. Update wrapper script to respect mode:

    PROGRESS_MODE="@progressMode@"
    
    show_progress() {
      case "$PROGRESS_MODE" in
        notification)
          notify-send --app-name="Deferred Apps" "$1" "$2" &
          ;;
        terminal)
          echo "[deferred-apps] $1: $2" >&2
          ;;
        none)
          : # silent
          ;;
      esac
    }
  3. Add module option:

    progressMode = lib.mkOption {
      type = lib.types.enum [ "notification" "terminal" "none" ];
      default = "notification";
      description = "How to show download progress";
    };
  4. Add per-app override in extraApps submodule

Verification Commands

nix flake check
nix build .#checks.x86_64-linux.progress-mode-terminal
nix build .#checks.x86_64-linux.progress-mode-none
nix build .#checks.x86_64-linux.progress-mode-default
nix build .#checks.x86_64-linux.progress-mode-module
statix check .
deadnix --fail -L .
nix fmt -- --check

Signs (Guardrails)

  • DO NOT break existing notification behavior (it's the default)
  • DO use stderr for terminal output (not stdout)
  • DO keep notify-send optional (graceful degradation)

Completion Promise

When ALL tests pass and quality gates pass: Output: <promise>COMPLETE</promise>


REQ-006: Application Groups

Priority: MEDIUM
Estimated Iterations: 25-35
Dependencies: None

Rationale

Users often want to defer entire categories of apps with shared settings:

  • "Multimedia apps I rarely use" (all with gcRoot=true)
  • "Dev tools" (all from unstable channel)
  • "Games" (all unfree)

User Stories

  1. As a user, I want to group apps by category
  2. As a user, I want to apply shared settings to a group
  3. As a user, I want to enable/disable entire groups

Acceptance Criteria

  • programs.deferredApps.groups.<name>.packages accepts list of packages
  • programs.deferredApps.groups.<name>.apps accepts list of pnames
  • Groups can set: gcRoot, flakeRef, allowUnfree, progressMode
  • programs.deferredApps.groups.<name>.enable defaults to true
  • Setting enable = false disables entire group
  • Group settings are overridable per-app via extraApps
  • Collision detection works across groups

Test Requirements

# Test 1: Basic group with packages
groups-basic-packages = let
  eval = evalNixosModule {
    programs.deferredApps = {
      enable = true;
      groups.multimedia = {
        packages = [ pkgs.vlc pkgs.mpv ];
        gcRoot = true;
      };
    };
  };
in mkCheck "groups-basic-packages" (
  builtins.length eval.config.environment.systemPackages >= 2
);

# Test 2: Group with apps (pnames)
groups-basic-apps = let
  eval = evalNixosModule {
    programs.deferredApps = {
      enable = true;
      groups.tools = {
        apps = [ "hello" "cowsay" ];
      };
    };
  };
in mkCheck "groups-basic-apps" (
  builtins.length eval.config.environment.systemPackages >= 2
);

# Test 3: Disabled group produces no packages
groups-disabled = let
  eval = evalNixosModule {
    programs.deferredApps = {
      enable = true;
      groups.unused = {
        enable = false;
        packages = [ pkgs.hello pkgs.cowsay ];
      };
    };
  };
  # Should only have libnotify, not hello/cowsay wrappers
in mkCheck "groups-disabled" (
  builtins.length eval.config.environment.systemPackages <= 2
);

# Test 4: Group settings inherited
groups-settings-inherited = let
  eval = evalNixosModule {
    programs.deferredApps = {
      enable = true;
      groups.protected = {
        packages = [ pkgs.hello ];
        gcRoot = true;
        progressMode = "terminal";
      };
    };
  };
in mkCheck "groups-settings-inherited" (
  eval.config.programs.deferredApps.groups.protected.gcRoot == true
);

# Test 5: Collision detection across groups
groups-collision = let
  result = builtins.tryEval (evalNixosModule {
    programs.deferredApps = {
      enable = true;
      groups.a = { packages = [ pkgs.hello ]; };
      groups.b = { packages = [ pkgs.hello ]; };  # Collision!
    };
  });
in mkCheck "groups-collision" (!result.success);

Implementation Steps

  1. Define group submodule in shared.nix:

    groupModule = lib.types.submodule {
      options = {
        enable = lib.mkEnableOption "this app group" // { default = true; };
        packages = lib.mkOption { type = lib.types.listOf lib.types.package; default = []; };
        apps = lib.mkOption { type = lib.types.listOf lib.types.str; default = []; };
        gcRoot = lib.mkOption { type = lib.types.bool; default = false; };
        flakeRef = lib.mkOption { type = lib.types.str; default = "nixpkgs"; };
        allowUnfree = lib.mkOption { type = lib.types.bool; default = false; };
        progressMode = lib.mkOption { 
          type = lib.types.enum [ "notification" "terminal" "none" ]; 
          default = "notification"; 
        };
      };
    };
  2. Add groups option:

    groups = lib.mkOption {
      type = lib.types.attrsOf groupModule;
      default = {};
      description = "Named groups of deferred apps with shared settings";
    };
  3. Update buildDeferredPackages to iterate over enabled groups

  4. Extend collision detection to include all group packages

Verification Commands

nix flake check
nix build .#checks.x86_64-linux.groups-basic-packages
nix build .#checks.x86_64-linux.groups-basic-apps
nix build .#checks.x86_64-linux.groups-disabled
nix build .#checks.x86_64-linux.groups-settings-inherited
nix build .#checks.x86_64-linux.groups-collision
statix check .
deadnix --fail -L .
nix fmt -- --check

Signs (Guardrails)

  • DO NOT forget to check collisions across ALL groups
  • DO NOT ignore disabled groups in collision check (they should be excluded)
  • DO propagate group settings to individual apps
  • DO allow extraApps to override group settings

Completion Promise

When ALL tests pass and quality gates pass: Output: <promise>COMPLETE</promise>


REQ-007: Notification Customization

Priority: LOW Estimated Iterations: 10-15 Dependencies: None Status: ✅ COMPLETE

Rationale

Users want to customize notification text for branding or localization.

Acceptance Criteria

  • notificationTitle option overrides "Starting {app}..."
  • notificationBody option overrides "Downloading application..."
  • showNotification = false disables notifications entirely
  • Per-app override via extraApps

Test Requirements

# Test 1: Custom title
notification-custom-title = mkBuildCheck "notification-custom-title"
  (deferredAppsLib.mkDeferredApp {
    package = pkgs.hello;
    notificationTitle = "Loading {app}";
  })
  ''
    grep -q 'Loading {app}' "$out/libexec/deferred-hello" || \
      { echo "FAIL: custom title not used"; exit 1; }
    echo "PASS: custom title works"
  '';

# Test 2: Show notification false
notification-disabled = mkBuildCheck "notification-disabled"
  (deferredAppsLib.mkDeferredApp {
    package = pkgs.hello;
    showNotification = false;
  })
  ''
    grep -q 'SHOW_NOTIFICATION="0"' "$out/libexec/deferred-hello" || \
      { echo "FAIL: notification should be disabled"; exit 1; }
    echo "PASS: notification disabled"
  '';

Implementation Steps

  1. Add options: notificationTitle, notificationBody, showNotification
  2. Update wrapper script template
  3. Add to module options and extraApps submodule

Verification Commands

nix flake check

Signs (Guardrails)

  • DO NOT break existing notification behavior (it's the default)
  • DO use stderr for notifications (not stdout)
  • DO keep notify-send optional (graceful degradation)

Completion Promise

When ALL tests pass and quality gates pass: Output: <promise>COMPLETE</promise>


REQ-008: NixOS VM Integration Tests

Priority: HIGH
Estimated Iterations: 30-40
Dependencies: REQ-001 through REQ-004 (optional, but recommended)

Rationale

Some behaviors can only be verified in a full NixOS system:

  • Desktop files linked into system profile
  • Icons available to desktop environment
  • Wrapper scripts executable with correct paths
  • Module integration with environment.systemPackages

Acceptance Criteria

  • VM test boots NixOS with deferred-apps module
  • Verifies desktop files in /run/current-system/sw/share/applications/
  • Verifies icons in /run/current-system/sw/share/icons/
  • Verifies wrapper in /run/current-system/sw/libexec/
  • Verifies terminal command in /run/current-system/sw/bin/
  • Test runs as part of nix flake check

Test Requirements

Create tests/vm.nix:

{ pkgs, lib, self, system }:

let
  nixosTest = pkgs.nixosTest or pkgs.testers.nixosTest;
in {
  vm-integration = nixosTest {
    name = "deferred-apps-integration";
    
    nodes.machine = { pkgs, ... }: {
      imports = [ self.nixosModules.default ];
      
      programs.deferredApps = {
        enable = true;
        apps = [ "hello" ];
        packages = [ pkgs.cowsay ];
      };
      
      # Minimal VM config
      virtualisation.memorySize = 1024;
    };
    
    testScript = ''
      machine.wait_for_unit("multi-user.target")
      
      # Test 1: Desktop file exists for pname mode
      machine.succeed("test -f /run/current-system/sw/share/applications/hello.desktop")
      
      # Test 2: Desktop file exists for package mode
      machine.succeed("test -f /run/current-system/sw/share/applications/cowsay.desktop")
      
      # Test 3: Wrapper scripts exist
      machine.succeed("test -x /run/current-system/sw/libexec/deferred-hello")
      machine.succeed("test -x /run/current-system/sw/libexec/deferred-cowsay")
      
      # Test 4: Terminal commands exist
      machine.succeed("test -L /run/current-system/sw/bin/hello")
      machine.succeed("test -L /run/current-system/sw/bin/cowsay")
      
      # Test 5: Icons exist (Papirus theme)
      machine.succeed("test -d /run/current-system/sw/share/icons")
      
      # Test 6: Desktop file content is correct
      machine.succeed("grep -q 'Exec=.*/libexec/deferred-hello' /run/current-system/sw/share/applications/hello.desktop")
      
      # Test 7: Wrapper is executable and runs (will fail to download, but should start)
      result = machine.execute("timeout 5 /run/current-system/sw/libexec/deferred-hello --version 2>&1 || true")
      # Should either succeed or fail with network/nix error, not permission error
      assert "Permission denied" not in result[1], f"Permission error: {result[1]}"
    '';
  };
}

Implementation Steps

  1. Create tests/vm.nix with nixosTest definition
  2. Import in tests/default.nix:
    vmTests = import ./vm.nix { inherit pkgs lib self system; };
  3. Add to flake checks (conditional on x86_64-linux due to VM support)
  4. Ensure test runs in CI (may need KVM or QEMU)

Verification Commands

# Build and run VM test
nix build .#checks.x86_64-linux.vm-integration

# Or run interactively for debugging
nix build .#checks.x86_64-linux.vm-integration.driverInteractive
./result/bin/nixos-test-driver

Signs (Guardrails)

  • DO NOT require network access in VM tests (use local store)
  • DO set reasonable timeouts (VMs are slow)
  • DO use minimal VM config (less memory = faster CI)
  • DO test both pname AND package mode

Completion Promise

When VM test passes and all quality gates pass: Output: <promise>COMPLETE</promise>


REQ-009: nixpkgs Submission Readiness

Priority: CRITICAL
Estimated Iterations: 40-60
Dependencies: REQ-001 through REQ-008

Rationale

The ultimate goal is to submit deferred-apps to nixpkgs for community benefit and long-term maintenance.

Acceptance Criteria

  • All functions have documentation comments
  • All module options have type, default, description, example
  • No builtins.currentSystem usage
  • No hardcoded paths
  • License is nixpkgs-compatible (GPL-3.0-or-later is fine)
  • README documents all features
  • ARCHITECTURE.md explains design decisions
  • CONTRIBUTING.md exists
  • Security model documented
  • Benchmarks documented
  • All tests pass on x86_64-linux and aarch64-linux

Checklist

Code Quality:

  • statix check . - zero warnings
  • deadnix --fail -L . - zero findings
  • nix fmt -- --check - all formatted
  • All public functions documented
  • All options have examples

Documentation:

  • README.md complete with all features
  • ARCHITECTURE.md explains:
    • Why wrappers are in /libexec
    • Why icons are copied not referenced
    • Package mode vs pname mode trade-offs
    • Security model
  • CONTRIBUTING.md with:
    • Development setup
    • Test running instructions
    • Code style guidelines

Security:

  • No shell injection vulnerabilities
  • No arbitrary code execution paths
  • --impure usage documented and justified
  • GC root security implications documented

Testing:

  • Unit tests for all library functions
  • Integration tests for modules
  • VM tests for full system
  • Tests pass on both x86_64 and aarch64

Verification Commands

# Full verification suite
nix flake check
statix check .
deadnix --fail -L .
nix fmt -- --check

# Multi-arch (if available)
nix build .#checks.x86_64-linux --all
nix build .#checks.aarch64-linux --all  # Requires aarch64 builder

Completion Promise

When ALL checklist items are complete and ALL verification passes: Output: <promise>COMPLETE</promise>


Signs (Lessons Learned)

RALPH PHILOSOPHY: When Ralph falls off the slide, add a sign saying "SLIDE DOWN, DON'T JUMP"

This section accumulates lessons learned during development. Update after each failure.

Architecture Signs

SIGN: Wrappers go in /libexec, symlinks in /bin
WHY: Prevents PATH pollution, follows FHS conventions

SIGN: Icons are COPIED not referenced
WHY: Referencing pulls ~1GB Papirus theme into closure

SIGN: Use unsafeDiscardStringContext for store paths
WHY: Prevents .drv files from polluting system closure

SIGN: Package mode uses nix-store -r, then --realise fallback
WHY: Cache fetch is fast, local build is fallback

SIGN: Pname mode uses nix shell with flake ref
WHY: Dynamic resolution at runtime, no build-time dependency

Testing Signs

SIGN: Use mkCheck for pure assertions
WHY: No build required, fast evaluation

SIGN: Use mkBuildCheck for derivation inspection
WHY: Need to examine build output

SIGN: Use mkClosureCheck for size verification  
WHY: Closure size is critical metric

SIGN: Test names: prefix with category (lib-, pkg-, module-)
WHY: Easy to identify test scope in output

Common Pitfalls

SIGN: Don't use builtins.currentSystem
WHY: Breaks flake purity, use system from args

SIGN: Don't reference icon theme package in closure
WHY: ~1GB dependency for ~5KB icon

SIGN: Always check terminal command collisions
WHY: Multiple packages can have same mainProgram

SIGN: Default cliOnly to false
WHY: Breaking change if default is true

Module Signs

SIGN: Use lib.mkDefault for environment variables
WHY: Allow user overrides

SIGN: Include libnotify in systemPackages
WHY: Notifications need notify-send

SIGN: pathsToLink includes share/applications and share/icons
WHY: Desktop files and icons must be in system profile

Completion Checklist

Phase 1: CLI Features

  • REQ-001: CLI-Only Mode
  • REQ-002: Status CLI Tool
  • REQ-003: GC Management CLI
  • REQ-004: Preload CLI Tool

Phase 2: UX Improvements

  • REQ-005: Progress Mode
  • REQ-006: Application Groups
  • REQ-007: Notification Customization

Phase 3: Quality & Submission

  • REQ-008: NixOS VM Integration Tests
  • REQ-009: nixpkgs Submission Readiness

Final Verification

# Run this when ALL requirements are complete
nix flake check && \
statix check . && \
deadnix --fail -L . && \
nix fmt -- --check && \
echo "ALL QUALITY GATES PASSED"

When all checkboxes are checked and final verification passes: PROJECT COMPLETE - Ready for nixpkgs submission!


Agent Instructions

When working on any requirement:

  1. READ THE REQUIREMENT COMPLETELY before starting
  2. WRITE TESTS FIRST (TDD) - verify they fail before implementing
  3. IMPLEMENT MINIMAL CODE to pass tests
  4. RUN ALL VERIFICATIONS after every change
  5. FIX FAILURES IMMEDIATELY - do not accumulate debt
  6. UPDATE SIGNS if you discover new pitfalls
  7. MARK CHECKBOXES as complete only when verified
# After EVERY change:
nix flake check && statix check . && deadnix --fail -L . && nix fmt -- --check

When all verifications pass for a requirement:

<promise>COMPLETE</promise>

If stuck after 10+ iterations:

  1. Document what's blocking in Signs section
  2. List attempted approaches
  3. Suggest alternative solutions
  4. Output: <promise>BLOCKED</promise>

Remember: Ralph is deterministically bad in an undeterministic world. Embrace the loop.