Files
home-assistant-controller/docs/superpowers/plans/2026-08-21-katip-logging.md
T

26 KiB

katip logging Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace all print/putStrLn logging under src/ with katip structured logging, threading a LogEnv through the existing Bus-based runtime.

Architecture: Approach A — Bus gains a busLogEnv :: LogEnv field; defaultMain builds the LogEnv (one stdout scribe, bracketed closeScribes) and passes it to newBus. The HASSEff interpreters (dryRunHassEval, channelHassEval) gain the controller name as a 2nd arg and log via runKatipT. supervised gains a leading LogEnv. The AFRP layer and controller definitions are untouched.

Tech Stack: Haskell (GHC2024), cabal, nix devShell, katip 0.8.8.4 (in nixpkgs), hspec/hedgehog.

Global Constraints

  • Build always via the nix devShell: nix develop -c cabal build.
  • Tests always via the nix devShell: nix develop -c cabal test.
  • After any edit to home-assistant-controller.cabal, regenerate default.nix with nix run nixpkgs#cabal2nix -- ./. > default.nix — never hand-edit default.nix (AGENTS.md).
  • secrets.yaml is sops-encrypted — never read, print, or commit its contents.
  • Code style: vertical modules, minimal comments describing contracts not implementation (AGENTS.md). No comments unless asked.
  • app/Main.hs is outside src/ and is NOT modified.
  • AFRP layer (AFRP.hs) and controller definitions (Controller.hs, Controller/Bedroom.hs) remain logging-free.
  • -Wall is on (cabal common warnings); all code must compile without warnings (no unused bindings).

File Structure

File Responsibility Change
home-assistant-controller.cabal build config add katip to library + test build-depends; add Support to test other-modules
default.nix generated cabal derivation regenerated by cabal2nix (twice)
src/HomeAssistant/Runtime/Bus.hs Bus, newBus, channelHassEval add busLogEnv; newBus :: LogEnv -> Int -> IO Bus; channelHassEval :: Bus -> Text -> ... with katip
src/HomeAssistant/Runtime.hs defaultMain, runController, dryRunHassEval build LogEnv + bracket; thread controller name; dryRunHassEval :: Bus -> Text -> ... with katip
src/HomeAssistant/Runtime/Supervisor.hs supervised supervised :: LogEnv -> Text -> Backoff -> IO Void -> IO Void with katip
src/HomeAssistant/Runtime/Connection.hs reader/writer workers two putStrLnlogMsg
test/Support.hs test helpers (new) silentLogEnv :: IO LogEnv
test/BusSpec.hs bus tests pass silentLogEnv, add name arg
test/RuntimeSpec.hs runtime tests pass silentLogEnv
test/SupervisorSpec.hs supervisor tests pass silentLogEnv, add LogEnv arg (3 sites)

No new modules under src/. No changes to AFRP.hs, Controller.hs, Controller/Bedroom.hs, ConnectionSpec.hs, BackoffProp.hs, app/Main.hs.


Task 1: Add katip dependency

Files:

  • Modify: home-assistant-controller.cabal (library build-depends ~line 77-89, test-suite build-depends ~line 149-160)
  • Regenerate: default.nix

Interfaces:

  • Consumes: nothing

  • Produces: katip available to library and test-suite; default.nix lists katip in libraryHaskellDepends and testHaskellDepends

  • Step 1: Add katip to library build-depends

In home-assistant-controller.cabal, in the library stanza's build-depends (after uuid), add , katip. The library block becomes:

    build-depends:    base ^>=4.20.2.0
                    , websockets
                    , aeson
                    , lens-aeson
                    , lens
                    , text
                    , network
                    , bytestring
                    , time
                    , stm
                    , async
                    , annotated-exception
                    , uuid
                    , katip
  • Step 2: Add katip to test-suite build-depends

In the test-suite home-assistant-controller-test stanza's build-depends (after time), add , katip:

    build-depends:
        base ^>=4.20.2.0,
        home-assistant-controller,
        hspec,
        stm,
        aeson,
        text,
        async,
        hedgehog,
        hspec-hedgehog,
        annotated-exception,
        time,
        katip
  • Step 3: Regenerate default.nix

Run:

nix run nixpkgs#cabal2nix -- ./. > default.nix

Expected: default.nix now lists katip in libraryHaskellDepends and testHaskellDepends. Verify with:

grep katip default.nix

Expected output: two lines mentioning katip.

  • Step 4: Build to verify the dependency resolves

Run:

nix develop -c cabal build

Expected: builds successfully (no code uses katip yet, so no compile errors). If nix needs to build katip first this may take a while on the first run.

  • Step 5: Commit
git add home-assistant-controller.cabal default.nix
git commit -m "Add katip dependency"

Task 2: Add busLogEnv to Bus; newBus signature; silentLogEnv helper; update callers

This task adds the LogEnv field to Bus, changes newBus to take LogEnv, adds a silent test helper, and updates every newBus caller so the build compiles. No logging behavior changes yet (the interpreters still print; they switch in Task 3).

Files:

  • Modify: src/HomeAssistant/Runtime/Bus.hs (full file)
  • Modify: src/HomeAssistant/Runtime.hs (defaultMain ~line 62-72)
  • Modify: home-assistant-controller.cabal (test other-modules ~line 130)
  • Regenerate: default.nix
  • Create: test/Support.hs
  • Modify: test/BusSpec.hs (~line 19)
  • Modify: test/RuntimeSpec.hs (~line 17)

Interfaces:

  • Consumes: katip (LogEnv, initLogEnv) from Task 1

  • Produces:

    • Bus has field busLogEnv :: LogEnv
    • newBus :: LogEnv -> Int -> IO Bus
    • Support.silentLogEnv :: IO LogEnv
  • Step 1: Add busLogEnv field and update newBus in Bus.hs

In src/HomeAssistant/Runtime/Bus.hs, add imports and the field. The full updated file:

{-# LANGUAGE LambdaCase #-}

module HomeAssistant.Runtime.Bus
  ( Bus(..)
  , CallIdGen(..)
  , mkCallIdGen
  , newBus
  , channelHassEval
  ) where

import Control.Concurrent.STM
  ( TChan
  , TVar
  , atomically
  , newBroadcastTChanIO
  , newTChanIO
  , newTVarIO
  , writeTChan
  )
import Data.Aeson (Value)
import Data.IORef (atomicModifyIORef', newIORef)
import HomeAssistant.Controller (HASSEff (..), Service)
import Katip (LogEnv)
import Network.WebSockets (Connection)

-- | Shared runtime state: inbound is a broadcast channel (controllers
-- read from 'dupTChan' copies), outbound queues service calls for the
-- writer, conn holds the current websocket (Nothing before first connect).
data Bus = Bus
  { busInbound  :: TChan Value
  , busOutbound :: TChan Service
  , busConn     :: TVar (Maybe Connection)
  , busGen      :: CallIdGen
  , busLogEnv   :: LogEnv
  }

newBus :: LogEnv -> Int -> IO Bus
newBus le start = Bus
  <$> newBroadcastTChanIO
  <*> newTChanIO
  <*> newTVarIO Nothing
  <*> mkCallIdGen start
  <*> pure le

channelHassEval :: Bus -> HASSEff a -> IO a
channelHassEval bus = \case
  CallService svc -> atomically $ writeTChan (busOutbound bus) svc
  Debug x -> print x
  Trace req x -> print (req, x)

newtype CallIdGen = CallIdGen { generateCallId :: IO Int }

mkCallIdGen :: Int -> IO CallIdGen
mkCallIdGen start = do
  gen <- newIORef start
  pure $ CallIdGen $ atomicModifyIORef' gen (\old -> let new = old + 1 in new `seq` (new, new))

Note: channelHassEval still uses print here — it is converted in Task 3. Keeping it temporarily so this task compiles. Data.Text (Text) is NOT imported yet (it would be unused here, triggering -Wall); Task 3 adds it when the signature gains the Text name parameter.

  • Step 2: Update defaultMain to build a LogEnv and pass to newBus

In src/HomeAssistant/Runtime.hs, update imports and defaultMain. Add these imports near the top (with the other imports):

import Control.Exception (bracket)
import Katip
  ( ColorStrategy (ColorIfTerminal)
  , Severity (DebugS)
  , Verbosity (V2)
  , closeScribes
  , defaultScribeSettings
  , initLogEnv
  , mkHandleScribe
  , permitItem
  , registerScribe
  )
import System.IO (stdout)

Then replace defaultMain with:

defaultMain :: IO ()
defaultMain = withSocketsDo $ do
  scribe <- mkHandleScribe ColorIfTerminal stdout (permitItem DebugS) V2
  le <- registerScribe "stdout" scribe defaultScribeSettings
        =<< initLogEnv "home-assistant-controller" "production"
  bracket (pure le) closeScribes $ \le' -> do
    token <- getEnv "HA_TOKEN"
    bus <- newBus le' 0
    let workers =
          [ ("reader", readerAction "last-resort-redux" 8123 token bus)
          , ("writer", writerAction bus)
          ] ++ [ (name, runController bus c) | c@(Controller name _) <- controllers ]
    as <- mapM (\(name, act) -> async (supervised name defaultBackoff act)) workers
    (_, v) <- waitAny as
    absurd v

Note: supervised still has its 3-arg signature here — it is updated in Task 4. The Controller name _ pattern (was _name) is required now so the workers list can use name; this is safe since Controller's first field is already T.Text. mkHandleScribe, ColorIfTerminal, permitItem, and V2 are all re-exported by the main Katip module, so no separate Katip.Scribes.Handle import is needed.

  • Step 3: Create test/Support.hs with silentLogEnv

Create test/Support.hs:

module Support
  ( silentLogEnv
  ) where

import Katip (LogEnv, initLogEnv)

-- | A LogEnv with no scribes: all log calls are no-ops. For tests that
-- must construct a Bus or call supervised without spewing output.
silentLogEnv :: IO LogEnv
silentLogEnv = initLogEnv "home-assistant-controller" "test"
  • Step 4: Add Support to test-suite other-modules in cabal

In home-assistant-controller.cabal, in the test-suite's other-modules (currently lists BusSpec, ConnectionSpec, RuntimeSpec, SupervisorSpec, BackoffProp), add , Support:

    other-modules:    BusSpec
                    , ConnectionSpec
                    , RuntimeSpec
                    , SupervisorSpec
                    , BackoffProp
                    , Support
  • Step 5: Regenerate default.nix

Run:

nix run nixpkgs#cabal2nix -- ./. > default.nix

Expected: default.nix regenerated; katip still present, Support is not listed (test modules aren't in the derivation) — this is fine.

  • Step 6: Update BusSpec to use silentLogEnv

In test/BusSpec.hs, add the import and update the two newBus 0 calls. Add to imports:

import Support (silentLogEnv)

Replace each bus <- newBus 0 with:

    le <- silentLogEnv
    bus <- newBus le 0

There are two occurrences (lines ~19 and ~30). The channelHassEval bus (CallService svc) call on ~line 32 is unchanged in this task (Task 3 adds the name arg).

  • Step 7: Update RuntimeSpec to use silentLogEnv

In test/RuntimeSpec.hs, add the import:

import Support (silentLogEnv)

Replace bus <- newBus 0 (line ~17) with:

    le <- silentLogEnv
    bus <- newBus le 0
  • Step 8: Build and run tests to verify green

Run:

nix develop -c cabal build && nix develop -c cabal test

Expected: builds with no warnings (the print in channelHassEval is still used, so no unused-binding warning); all tests pass.

  • Step 9: Commit
git add src/HomeAssistant/Runtime/Bus.hs src/HomeAssistant/Runtime.hs test/Support.hs test/BusSpec.hs test/RuntimeSpec.hs home-assistant-controller.cabal default.nix
git commit -m "Add busLogEnv to Bus, silentLogEnv test helper"

Task 3: Convert HASSEff interpreters to katip; thread controller name

Both interpreters gain the controller name (Text) as the 2nd arg and log via runKatipT + logMsg/logF. runController threads the Controller name into the active interpreter call. Debug/Trace use the controller name as the namespace; Trace logs requestTraceId as a structured field.

Files:

  • Modify: src/HomeAssistant/Runtime/Bus.hs (channelHassEval)
  • Modify: src/HomeAssistant/Runtime.hs (dryRunHassEval, runController, imports)
  • Modify: test/BusSpec.hs (channelHassEval call)

Interfaces:

  • Consumes: busLogEnv :: LogEnv from Task 2

  • Produces:

    • channelHassEval :: Bus -> Text -> HASSEff a -> IO a
    • dryRunHassEval :: Bus -> Text -> HASSEff a -> IO a
    • runController passes the controller name to the interpreter
  • Step 1: Convert channelHassEval to katip in Bus.hs

In src/HomeAssistant/Runtime/Bus.hs, update imports and channelHassEval. First add OverloadedStrings to the pragmas at the top of the file (needed so the "traceId" literal in sl is Text):

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

Replace the import Katip (LogEnv) line with:

import AFRP (Request (requestTraceId))
import Data.Text (Text)
import Data.UUID (toText)
import Katip
  ( LogEnv
  , Namespace (Namespace)
  , Severity (DebugS)
  , logF
  , logMsg
  , runKatipT
  , showLS
  , sl
  )

Replace the channelHassEval definition with:

channelHassEval :: Bus -> Text -> HASSEff a -> IO a
channelHassEval bus name = \case
  CallService svc -> atomically $ writeTChan (busOutbound bus) svc
  Debug x -> runKatipT (busLogEnv bus) $
    logMsg (Namespace [name]) DebugS (showLS x)
  Trace req x -> runKatipT (busLogEnv bus) $
    logF (sl "traceId" (toText (requestTraceId req))) (Namespace [name]) DebugS (showLS x)

Trace req x binds req :: Request (from HASSEff's Trace constructor, imported via HASSEff (..)); requestTraceId is the record field brought into scope by import AFRP (Request (requestTraceId)). toText :: UUID -> Text (aeson's ToJSON Text makes sl happy). UUID's ToJSON would also work directly, but toText is used consistently with Runtime.hs per the spec.

  • Step 2: Convert dryRunHassEval to katip in Runtime.hs

In src/HomeAssistant/Runtime.hs, update imports. Extend the Data.UUID import to include toText:

import Data.UUID (UUID, toText)

Add to the Katip import list (from Task 2) the logging functions. The full Katip import for Runtime.hs becomes:

import Katip
  ( ColorStrategy (ColorIfTerminal)
  , Namespace (Namespace)
  , Severity (DebugS)
  , Verbosity (V2)
  , closeScribes
  , defaultScribeSettings
  , initLogEnv
  , logF
  , logMsg
  , mkHandleScribe
  , permitItem
  , registerScribe
  , runKatipT
  , showLS
  , sl
  )

Replace dryRunHassEval with:

dryRunHassEval :: Bus -> T.Text -> HASSEff a -> IO a
dryRunHassEval bus name = \case
  CallService svc -> do
    callId <- generateCallId (busGen bus)
    runKatipT (busLogEnv bus) $
      logF (sl "callId" callId) (Namespace [name]) DebugS (showLS svc)
  Debug x -> runKatipT (busLogEnv bus) $
    logMsg (Namespace [name]) DebugS (showLS x)
  Trace req x -> runKatipT (busLogEnv bus) $
    logF (sl "traceId" (toText (requestTraceId req))) (Namespace [name]) DebugS (showLS x)

Note: the callId is logged as a structured field (keeping generateCallId meaningful under -Wall — a small refinement over the spec's logMsg ... showLS svc, which would have left callId unused). requestTraceId is accessed via the Request record; Runtime.hs already imports AFRP (Event (..), Mealy (..), Request (..)) — confirm Request (..) brings requestTraceId into scope. The current import is import AFRP (Event (..), Mealy (..), Request (..)), so requestTraceId is in scope. Good.

  • Step 3: Thread controller name in runController

In src/HomeAssistant/Runtime.hs, update runController to use the name and pass it to the interpreter. Replace runController with:

runController :: Bus -> Controller -> IO Void
runController bus (Controller name machine) = do
  inbound <- atomically (dupTChan (busInbound bus))
  go inbound machine
  where
    go inbound f = do
      msg <- atomically (readTChan inbound)
      uuid <- UUID.V4.nextRandom
      -- (_, f') <- step (channelHassEval bus name) uuid f (Event msg)
      (_, f') <- step (dryRunHassEval bus name) uuid f (Event msg)
      go inbound f'

The commented channelHassEval line is updated to the new signature too, so enabling it later requires no further edits.

  • Step 4: Update BusSpec channelHassEval call

In test/BusSpec.hs, the test "channelHassEval writes CallService to the outbound channel" calls channelHassEval bus (CallService svc). Update it to pass a name:

    channelHassEval bus "test" (CallService svc)
  • Step 5: Build and run tests to verify green

Run:

nix develop -c cabal build && nix develop -c cabal test

Expected: builds with no warnings; all tests pass. The print calls in channelHassEval/dryRunHassEval are now gone — confirm with:

rg -n "print" src/HomeAssistant/Runtime/Bus.hs src/HomeAssistant/Runtime.hs

Expected: no output (no print remains in those two files).

  • Step 6: Commit
git add src/HomeAssistant/Runtime/Bus.hs src/HomeAssistant/Runtime.hs test/BusSpec.hs
git commit -m "Convert HASSEff interpreters to katip, thread controller name"

Task 4: Convert supervised to take LogEnv and use katip

supervised gains a leading LogEnv argument and replaces its two putStrLn calls with logMsg. The crash message is WarningS, the restart message is InfoS, both under namespace runtime.supervisor.<name>. defaultMain and SupervisorSpec are updated.

Files:

  • Modify: src/HomeAssistant/Runtime/Supervisor.hs (imports, supervised)
  • Modify: src/HomeAssistant/Runtime.hs (defaultMain supervised call)
  • Modify: test/SupervisorSpec.hs (3 supervised call sites + import)

Interfaces:

  • Consumes: LogEnv from katip (Task 1), busLogEnv (Task 2) for the defaultMain call

  • Produces: supervised :: LogEnv -> Text -> Backoff -> IO Void -> IO Void

  • Step 1: Update supervised in Supervisor.hs

In src/HomeAssistant/Runtime/Supervisor.hs, add OverloadedStrings to the pragmas at the top (needed so the message string literals are LogStr):

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

Add imports. Add after the existing imports:

import Katip
  ( LogEnv
  , Namespace (Namespace)
  , Severity (InfoS, WarningS)
  , logMsg
  , ls
  , runKatipT
  , showLS
  )

Also remove the now-unused import qualified Data.Text as T line (the only T. usages were T.unpack/T.pack in the removed putStrLn calls; with -Wall the unused qualified import would warn). Keep import Data.Text (Text) — it is still used by Fatal :: Text and the supervised signature.

Update the export list (the type signature changes but supervised stays exported). Update supervised's type and body. Replace the supervised definition with:

supervised :: LogEnv -> Text -> Backoff -> IO Void -> IO Void
supervised le name backoff action = go 1
  where
    go attempt = do
      start <- getCurrentTime
      outcome <- (action >> pure (Nothing :: Maybe (Either Fatal SomeException))) `catches`
        [ Handler $ \(f :: Fatal) -> pure (Just (Left f))
        , Handler $ \(e :: SomeException) -> pure (Just (Right e))
        ]
      end <- getCurrentTime
      case outcome of
        Nothing -> error "unreachable: supervised action returned"
        Just (Left f) -> throw f
        Just (Right e) -> do
          runKatipT le $
            logMsg (Namespace ["runtime", "supervisor", name]) WarningS
              ("attempt " <> showLS attempt <> " crashed: " <> ls (displayException e))
          let delay = backoffDelay backoff attempt
          runKatipT le $
            logMsg (Namespace ["runtime", "supervisor", name]) InfoS
              ("restarting in " <> showLS delay)
          threadDelay (round (realToFrac delay * 1000000 :: Double))
          go (nextAttempt backoff (diffUTCTime end start) attempt)

Note: logMsg's message is a LogStr; (<>) concatenates LogStr (it has a Semigroup instance). showLS uses show, so it is right for attempt (Int) and delay (NominalDiffTime) but wrong for displayException e (a String) — showLS would double-quote/escape it. Use ls (which converts via StringConv to Text) for raw String values. displayException is already imported from Control.Exception.Annotated. Data.Text (Text) stays for Fatal/the signature; the qualified T import is dropped (see above).

  • Step 2: Update defaultMain supervised call in Runtime.hs

In src/HomeAssistant/Runtime.hs, in defaultMain, the supervised call is currently async (supervised name defaultBackoff act). Update it to pass the LogEnv:

    as <- mapM (\(name, act) -> async (supervised le' name defaultBackoff act)) workers

(le' is the bracketed LogEnv from Task 2.)

  • Step 3: Update SupervisorSpec to pass silentLogEnv

In test/SupervisorSpec.hs, add imports:

import Support (silentLogEnv)

There are three async (supervised "test" tinyBackoff action) calls (lines ~28, ~41, ~56). Each must become:

    le <- silentLogEnv
    sup <- async (supervised le "test" tinyBackoff action)

Insert the le <- silentLogEnv line before each async call. The three sites are in the it blocks: "restarts a crashing action until it stays up", "rethrows Fatal instead of restarting", and "does not restart on async exceptions".

For "rethrows Fatal instead of restarting" the surrounding block becomes:

  it "rethrows Fatal instead of restarting" $ do
    counter <- newIORef (0 :: Int)
    let action = do
          _ <- atomicModifyIORef' counter (\c -> (c + 1, c + 1))
          throw (Fatal "auth_invalid")
    le <- silentLogEnv
    sup <- async (supervised le "test" tinyBackoff action)
    res <- waitCatch sup

Apply the same le <- silentLogEnv insertion to the other two sites.

  • Step 4: Build and run tests to verify green

Run:

nix develop -c cabal build && nix develop -c cabal test

Expected: builds with no warnings; all tests pass. Confirm no putStrLn remains in Supervisor.hs:

rg -n "putStrLn" src/HomeAssistant/Runtime/Supervisor.hs

Expected: no output.

  • Step 5: Commit
git add src/HomeAssistant/Runtime/Supervisor.hs src/HomeAssistant/Runtime.hs test/SupervisorSpec.hs
git commit -m "Convert supervised to katip logging"

Task 5: Convert Connection.hs reader/writer putStrLn to katip

The two putStrLn sites in Connection.hs (reader "connected" and "skipping undecodable message") become logMsg calls under namespace runtime.reader. "connected" is InfoS; "skipping undecodable" is WarningS.

Files:

  • Modify: src/HomeAssistant/Runtime/Connection.hs (imports, two log sites)

Interfaces:

  • Consumes: busLogEnv :: LogEnv from Task 2

  • Produces: no signature changes (reader/writer already take Bus)

  • Step 1: Add katip imports to Connection.hs

In src/HomeAssistant/Runtime/Connection.hs, add after the existing imports:

import Katip
  ( Namespace (Namespace)
  , Severity (InfoS, WarningS)
  , logMsg
  , ls
  , runKatipT
  )

(showLS is not needed here — both messages use string literals / ls for the raw String err.)

  • Step 2: Replace the "connected" putStrLn

In readerAction, replace:

    putStrLn "[reader] connected"

with:

    runKatipT (busLogEnv bus) $
      logMsg (Namespace ["runtime", "reader"]) InfoS "connected"

"connected" is a LogStr via IsString. The onException line that follows is unchanged.

  • Step 3: Replace the "skipping undecodable" putStrLn

In receiveLoop, replace:

    Left err -> putStrLn $ "[reader] skipping undecodable message: " <> err

with:

    Left err -> runKatipT (busLogEnv bus) $
      logMsg (Namespace ["runtime", "reader"]) WarningS
        ("skipping undecodable message: " <> ls err)

err is a String from eitherDecode; ls err converts it to a LogStr without show's quoting (use ls, not showLS, for raw Strings). (<> on LogStr.)

  • Step 4: Build and run tests to verify green

Run:

nix develop -c cabal build && nix develop -c cabal test

Expected: builds with no warnings; all tests pass. Confirm no putStrLn remains in Connection.hs:

rg -n "putStrLn" src/HomeAssistant/Runtime/Connection.hs

Expected: no output.

  • Step 5: Commit
git add src/HomeAssistant/Runtime/Connection.hs
git commit -m "Convert reader/writer logging to katip"

Task 6: Final verification — no manual printing remains under src/

Confirm the whole logging swap is complete: the build and tests are green, and no print/putStrLn/putStr remains under src/.

Files:

  • None modified (verification only)

  • Step 1: Confirm no manual printing remains under src/

Run:

rg -n "print|putStrLn|putStr|hPutStr" src/

Expected: no output. If any match remains, it is a missed site — fix it before committing (revisit the relevant task).

  • Step 2: Full clean build and test

Run:

nix develop -c cabal build && nix develop -c cabal test

Expected: builds with no warnings (-Wall); all tests pass (BusSpec, ConnectionSpec, RuntimeSpec, SupervisorSpec, BackoffProp).

  • Step 3: Confirm default.nix is in sync with the cabal file

Run:

nix run nixpkgs#cabal2nix -- ./. > default.nix
git diff --exit-code default.nix

Expected: git diff --exit-code exits 0 (no changes — default.nix was already regenerated in Tasks 1 and 2). If it does change, the cabal file was edited without regenerating; stage and amend the relevant commit.

  • Step 4: Smoke-check the executable wiring (optional, manual)

If a Home Assistant instance and HA_TOKEN are available, run the executable and confirm katip-formatted lines appear on stdout (e.g. [home-assistant-controller.runtime.reader][Info][...] connected). This is a manual check, not automated.

  • Step 5: Final commit if any fixes were needed

If Steps 1-3 required fixes, commit them:

git add -A
git commit -m "Finish katip logging swap"

Otherwise no commit is needed — the work is already committed across Tasks 1-5.