diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -1,16 +1,19 @@
 module Main where
 
-import Data.Text (Text)
+import Control.Monad (when, forM_)
 import qualified Data.Text as T
 import qualified Data.Text.IO as TIO
 import System.Exit (exitFailure, exitSuccess)
-import System.IO (stdout, stderr, hSetEncoding, utf8)
+import System.IO (stdout, stderr, hSetEncoding, hSetBuffering, utf8, BufferMode(..))
 
 import SynapseCC.CLI
+import SynapseCC.Config (initSynapseConfig, loadSynapseConfig, synapseConfigPath)
+import SynapseCC.Detect (ProjectHint(..), defaultDetectors)
 import SynapseCC.Discover
 import SynapseCC.Logging
 import SynapseCC.Pipeline
 import SynapseCC.Types
+import SynapseCC.Watch (runWatch)
 
 -- ============================================================================
 -- Main Entry Point
@@ -21,45 +24,117 @@
   -- Set UTF-8 encoding for stdout/stderr to handle Unicode characters
   hSetEncoding stdout utf8
   hSetEncoding stderr utf8
+  hSetBuffering stdout LineBuffering
+  hSetBuffering stderr LineBuffering
 
-  -- Parse command-line arguments
-  config <- parseArgs
+  cmd <- parseCommand
 
-  let debug = optDebug (cfgOptions config)
+  case cmd of
+    -- ------------------------------------------------------------------
+    -- synapse-cc init
+    -- ------------------------------------------------------------------
+    CmdInit -> do
+      result <- initSynapseConfig synapseConfigPath defaultDetectors
+      case result of
+        Left err -> do
+          TIO.putStrLn $ "[!] " <> err
+          exitFailure
+        Right hint -> do
+          logSuccess $ "Created " <> T.pack synapseConfigPath
+          -- If a detector gave a reason, surface it so the user knows
+          -- why the generated config has a particular transport.
+          case phReason hint of
+            Just reason -> logInfo reason
+            Nothing     -> pure ()
+          TIO.putStrLn "Edit it to configure your targets, then run:"
+          TIO.putStrLn "  synapse-cc build"
+          exitSuccess
 
-  -- Print version
-  when debug $ TIO.putStrLn versionInfo
+    -- ------------------------------------------------------------------
+    -- synapse-cc build <target> <backend> [opts]
+    -- ------------------------------------------------------------------
+    CmdBuild config -> do
+      let debug = optDebug (cfgOptions config)
+      when debug $ TIO.putStrLn versionInfo
 
-  -- Discover tools
-  logStep "Discovering tools..."
-  toolsResult <- discoverTools debug
-  case toolsResult of
-    Left err -> do
-      TIO.putStrLn $ formatError err
-      exitFailure
+      logStep "Discovering tools..."
+      toolsResult <- discoverTools (cfgOptions config)
+      case toolsResult of
+        Left err -> do
+          TIO.putStrLn $ formatError err
+          exitFailure
 
-    Right tools -> do
-      logSuccess "Found all required tools"
+        Right tools -> do
+          logSuccess "Found all required tools"
+          logStep $ "Connecting to " <> backendName (cfgBackend config)
+                 <> " at ws://" <> cfgHost config <> ":" <> cfgPort config <> "..."
 
-      -- Connect to backend
-      logStep $ "Connecting to " <> backendName (cfgBackend config) <> " via registry at " <> cfgHost config <> ":" <> cfgPort config <> "..."
+          pipelineResult <- runPipeline config tools
+          case pipelineResult of
+            Left err -> do
+              TIO.putStrLn $ formatError err
+              exitFailure
 
-      -- Run the pipeline
-      pipelineResult <- runPipeline config tools
-      case pipelineResult of
+            Right (CompiledPath outputPath) -> do
+              TIO.putStrLn ""
+              logSuccess $ "Client → " <> T.pack outputPath <> "/"
+              exitSuccess
+
+    -- ------------------------------------------------------------------
+    -- synapse-cc build   (reads synapse.config.json, all targets)
+    -- ------------------------------------------------------------------
+    CmdBuildFromConfig opts -> do
+      let debug = optDebug opts
+      when debug $ TIO.putStrLn versionInfo
+
+      cfgResult <- loadSynapseConfig
+      case cfgResult of
         Left err -> do
-          TIO.putStrLn $ formatError err
+          TIO.putStrLn $ "[!] " <> err
+          TIO.putStrLn "Hint: run 'synapse-cc init' to create synapse.config.json"
+          TIO.putStrLn "      or provide target/backend: synapse-cc build typescript substrate"
           exitFailure
 
-        Right (CompiledPath outputPath) -> do
-          TIO.putStrLn ""
-          logSuccess $ "Client ready at " <> T.pack outputPath
-          exitSuccess
+        Right sc -> do
+          logStep "Discovering tools..."
+          toolsResult <- discoverTools opts
+          case toolsResult of
+            Left err -> do
+              TIO.putStrLn $ formatError err
+              exitFailure
 
--- ============================================================================
--- Helpers
--- ============================================================================
+            Right tools -> do
+              logSuccess "Found all required tools"
+              results <- runBuildFromConfig sc opts tools
+              forM_ results $ \(targetName, result) ->
+                case result of
+                  Left err ->
+                    -- formatError includes its own "[!] Error: " prefix — strip it
+                    -- to avoid "[!] target: [!] Error: ..." doubling
+                    let msg = T.strip (formatError err)
+                        bare = maybe msg T.strip (T.stripPrefix "[!] Error:" msg
+                                               >>= Just . T.stripStart)
+                    in logError $ targetName <> ": " <> bare
+                  Right (CompiledPath outPath) ->
+                    logSuccess $ targetName <> " → " <> T.pack outPath <> "/"
+              if all (either (const False) (const True) . snd) results
+                then exitSuccess
+                else exitFailure
 
-when :: Applicative f => Bool -> f () -> f ()
-when True action = action
-when False _ = pure ()
+    -- ------------------------------------------------------------------
+    -- synapse-cc watch <backend> [plugins...] [--target name]
+    -- ------------------------------------------------------------------
+    CmdWatch watchArgs -> do
+      let debug = optDebug (waOptions watchArgs)
+      when debug $ TIO.putStrLn versionInfo
+
+      logStep "Discovering tools..."
+      toolsResult <- discoverTools (waOptions watchArgs)
+      case toolsResult of
+        Left err -> do
+          TIO.putStrLn $ formatError err
+          exitFailure
+
+        Right tools -> do
+          logSuccess "Found all required tools"
+          runWatch watchArgs tools
diff --git a/src/SynapseCC/Benchmark.hs b/src/SynapseCC/Benchmark.hs
new file mode 100644
--- /dev/null
+++ b/src/SynapseCC/Benchmark.hs
@@ -0,0 +1,208 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+
+-- | Pipeline step benchmarking with regression detection.
+--
+-- After the first successful run, step timings are written to a JSON baseline
+-- file inside the cache directory. Every subsequent run compares against that
+-- baseline and emits a warning for any step that regresses by more than the
+-- configured threshold.
+module SynapseCC.Benchmark
+  ( -- * Timing
+    timeStep
+
+    -- * Baseline management
+  , loadBaseline
+  , saveBaseline
+  , baselinePath
+
+    -- * Regression reporting
+  , reportBenchmarks
+
+    -- * Types
+  , Baseline(..)
+  , StepTiming(..)
+  ) where
+
+import Data.Aeson (FromJSON, ToJSON)
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as BL
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Time.Clock
+import GHC.Generics (Generic)
+import System.Directory (createDirectoryIfMissing, doesFileExist)
+import System.FilePath ((</>), takeDirectory)
+
+-- ============================================================================
+-- Types
+-- ============================================================================
+
+data StepTiming = StepTiming
+  { stName :: !Text
+  , stMs   :: !Int
+  } deriving stock (Show, Eq, Generic)
+    deriving anyclass (FromJSON, ToJSON)
+
+data Baseline = Baseline
+  { blVersion    :: !Text
+  , blBackend    :: !Text
+  , blTarget     :: !Text
+  , blRecordedAt :: !Text
+  , blSteps      :: !(Map Text Int)  -- step name -> baseline ms
+  , blTotalMs    :: !Int
+  } deriving stock (Show, Eq, Generic)
+    deriving anyclass (FromJSON, ToJSON)
+
+-- ============================================================================
+-- Timing
+-- ============================================================================
+
+-- | Run an action and return its result paired with elapsed milliseconds.
+timeStep :: IO a -> IO (a, Int)
+timeStep action = do
+  t0 <- getCurrentTime
+  result <- action
+  t1 <- getCurrentTime
+  let ms = round (diffUTCTime t1 t0 * 1000) :: Int
+  pure (result, ms)
+
+-- ============================================================================
+-- Baseline I/O
+-- ============================================================================
+
+-- | Canonical path for the baseline file.
+baselinePath :: FilePath  -- ^ cache dir (optCacheDir)
+             -> Text      -- ^ target  (e.g. "typescript")
+             -> Text      -- ^ backend (e.g. "substrate")
+             -> FilePath
+baselinePath cacheDir target backend =
+  cacheDir </> "benchmarks" </> T.unpack target </> T.unpack backend <> ".json"
+
+-- | Load a baseline from disk.  Returns Nothing if the file doesn't exist or
+-- can't be parsed.
+loadBaseline :: FilePath -> IO (Maybe Baseline)
+loadBaseline path = do
+  exists <- doesFileExist path
+  if not exists
+    then pure Nothing
+    else do
+      bytes <- BL.readFile path
+      case Aeson.eitherDecode bytes of
+        Left  _  -> pure Nothing   -- corrupted — treat as missing
+        Right bl -> pure (Just bl)
+
+-- | Write a baseline to disk, creating parent directories as needed.
+saveBaseline :: FilePath -> Baseline -> IO ()
+saveBaseline path bl = do
+  createDirectoryIfMissing True (takeDirectory path)
+  BL.writeFile path (Aeson.encode bl)
+
+-- ============================================================================
+-- Regression detection
+-- ============================================================================
+
+-- | Regression threshold: warn when a step is this many percent slower than
+-- baseline AND at least this many milliseconds slower (avoids noise on fast
+-- steps).
+regressionPct :: Int
+regressionPct = 25
+
+regressionMinMs :: Int
+regressionMinMs = 200
+
+-- | Compare a run's step timings against a baseline.
+-- Returns (regressions, improvements) as lists of human-readable lines.
+checkRegressions :: Map Text Int  -- ^ baseline step times
+                 -> Map Text Int  -- ^ actual step times
+                 -> ([Text], [Text])
+checkRegressions baseline actual =
+  Map.foldlWithKey' classify ([], []) actual
+  where
+    classify (regs, imps) step ms =
+      case Map.lookup step baseline of
+        Nothing -> (regs, imps)  -- new step, no baseline yet
+        Just baseMs ->
+          let delta    = ms - baseMs
+              pct      = (delta * 100) `div` max 1 baseMs
+              label    = padStep step <> T.pack (show ms) <> "ms"
+                       <> " (baseline " <> T.pack (show baseMs) <> "ms"
+                       <> ", " <> showDelta delta <> ")"
+          in if delta > regressionMinMs && pct > regressionPct
+               then (label : regs, imps)
+               else if delta < negate regressionMinMs
+                      then (regs, label : imps)
+                      else (regs, imps)
+
+    showDelta d
+      | d > 0    = "+" <> T.pack (show d) <> "ms"
+      | otherwise = T.pack (show d) <> "ms"
+
+    padStep s = s <> T.replicate (max 1 (24 - T.length s)) " "
+
+-- ============================================================================
+-- Public reporting
+-- ============================================================================
+
+-- | Called at the end of a successful pipeline run.
+--
+-- Behaviour:
+--  * If no baseline exists: save current timings as the new baseline and
+--    print a "baseline recorded" notice.
+--  * If a baseline exists: compare, print a timing table, warn on regressions,
+--    and update the baseline for any step that improved.
+reportBenchmarks
+  :: FilePath       -- ^ baseline file path
+  -> Text           -- ^ backend name
+  -> Text           -- ^ target name
+  -> Text           -- ^ ISO-8601 timestamp
+  -> Map Text Int   -- ^ step timings (ms)
+  -> Int            -- ^ total pipeline ms
+  -> IO ()
+reportBenchmarks path backend target ts steps totalMs = do
+  mbBaseline <- loadBaseline path
+
+  case mbBaseline of
+    Nothing -> do
+      -- First run — record baseline
+      let bl = Baseline
+            { blVersion    = "1"
+            , blBackend    = backend
+            , blTarget     = target
+            , blRecordedAt = ts
+            , blSteps      = steps
+            , blTotalMs    = totalMs
+            }
+      saveBaseline path bl
+      putStrLn $ "\n[bench] Baseline recorded (" <> show totalMs <> "ms total)"
+      putStrLn   "[bench] Future runs will compare against this baseline."
+
+    Just bl -> do
+      let (regs, imps) = checkRegressions (blSteps bl) steps
+
+      -- Always print the timing table
+      putStrLn "\n[bench] Step timings:"
+      mapM_ (\(k, v) -> putStrLn $ "  " <> T.unpack (padStep k) <> show v <> "ms") (Map.toAscList steps)
+      putStrLn $ "  " <> T.unpack (padStep "total") <> show totalMs <> "ms"
+      putStrLn $ "  baseline total: " <> show (blTotalMs bl) <> "ms"
+
+      -- Report regressions
+      if null regs
+        then putStrLn "[bench] No regressions."
+        else do
+          putStrLn "\n[bench] ⚠  REGRESSIONS DETECTED:"
+          mapM_ (\r -> putStrLn $ "  " <> T.unpack r) regs
+
+      -- Report improvements
+      mapM_ (\i -> putStrLn $ "[bench] ✓ faster: " <> T.unpack i) imps
+
+      -- Update baseline: keep the minimum (best) time per step, update total
+      -- if this run was faster overall.
+      let mergedSteps = Map.unionWith min steps (blSteps bl)
+          newTotal    = min totalMs (blTotalMs bl)
+          bl'         = bl { blSteps = mergedSteps, blTotalMs = newTotal, blRecordedAt = ts }
+      saveBaseline path bl'
+  where
+    padStep s = s <> T.replicate (max 1 (20 - T.length s)) " "
diff --git a/src/SynapseCC/CLI.hs b/src/SynapseCC/CLI.hs
--- a/src/SynapseCC/CLI.hs
+++ b/src/SynapseCC/CLI.hs
@@ -1,29 +1,38 @@
 -- | Command-line interface parsing
 module SynapseCC.CLI
-  ( parseArgs
+  ( -- * Legacy build-only parser (for tests)
+    parseArgs
+  , synapseCCParserInfo
+
+    -- * Command parser (with subcommands)
+  , parseCommand
+  , synapseCCCommandParserInfo
+
+    -- * Version
   , versionInfo
   ) where
 
 import Data.Text (Text)
 import qualified Data.Text as T
 import Options.Applicative
-import System.FilePath ((</>))
 
 import SynapseCC.Types
 
 -- ============================================================================
--- CLI Parser
+-- Legacy Build Parser (backward-compatible, used by tests)
 -- ============================================================================
 
--- | Parse command-line arguments into Config
+-- | Full parser info (usable with execParserPure for in-process testing)
+synapseCCParserInfo :: ParserInfo Config
+synapseCCParserInfo = info (configParser <**> helper <**> simpleVersioner (T.unpack versionInfo))
+  ( fullDesc
+ <> progDesc "Unified compiler toolchain for Plexus backends"
+ <> header "synapse-cc - from schema to compiled client in one command"
+  )
+
+-- | Parse command-line arguments into Config (legacy build-only)
 parseArgs :: IO Config
-parseArgs = execParser opts
-  where
-    opts = info (configParser <**> helper)
-      ( fullDesc
-     <> progDesc "Unified compiler toolchain for Plexus backends"
-     <> header "synapse-cc - from schema to compiled client in one command"
-      )
+parseArgs = execParser synapseCCParserInfo
 
 -- | Main config parser
 configParser :: Parser Config
@@ -34,6 +43,98 @@
   <*> portParser
   <*> optionsParser
 
+-- ============================================================================
+-- Command Parser (with subcommands: build, watch, init)
+-- ============================================================================
+
+-- | Full command parser info with subcommands
+synapseCCCommandParserInfo :: ParserInfo Command
+synapseCCCommandParserInfo = info (commandParser <**> helper <**> simpleVersioner (T.unpack versionInfo))
+  ( fullDesc
+ <> progDesc "Unified compiler toolchain for Plexus backends"
+ <> header "synapse-cc - from schema to compiled client in one command"
+  )
+
+-- | Parse a top-level Command
+parseCommand :: IO Command
+parseCommand = execParser synapseCCCommandParserInfo
+
+commandParser :: Parser Command
+commandParser = subparser
+  ( command "build" (info buildCommandParser
+      (progDesc "Build client from backend schema (default)"))
+ <> command "watch" (info watchCommandParser
+      (progDesc "Watch backend for changes and incrementally rebuild"))
+ <> command "init"  (info initCommandParser
+      (progDesc "Scaffold a synapse.config.json in the current directory"))
+  )
+
+buildCommandParser :: Parser Command
+buildCommandParser = mkCmd
+  <$> optional targetParser
+  <*> optional backendParser
+  <*> hostParser
+  <*> portParser
+  <*> optionsParser
+  where
+    mkCmd (Just t) (Just b) host port opts = CmdBuild (Config t b host port opts)
+    mkCmd _        _        _    _    opts = CmdBuildFromConfig opts
+
+watchCommandParser :: Parser Command
+watchCommandParser = fmap CmdWatch $ WatchArgs
+  <$> (T.pack <$> argument str
+        ( metavar "BACKEND"
+       <> help "Backend identifier (substrate, plexus, etc.)"
+        ))
+  <*> many (T.pack <$> argument str
+        ( metavar "PLUGIN..."
+       <> help "Plugin prefix filters (e.g. echo health). Empty = all plugins."
+        ))
+  <*> optional (T.pack <$> option str
+        ( long "target"
+       <> short 't'
+       <> metavar "NAME"
+       <> help "Pin to a single named target from synapse.config.json"
+        ))
+  <*> optional (option auto
+        ( long "interval"
+       <> short 'i'
+       <> metavar "MS"
+       <> help "Poll interval in milliseconds (overrides synapse.config.json)"
+        ))
+  <*> watchOptionsParser
+
+initCommandParser :: Parser Command
+initCommandParser = pure CmdInit
+
+-- | Options relevant to watch mode.
+-- host/port come from synapse.config.json (not CLI flags for watch).
+watchOptionsParser :: Parser Options
+watchOptionsParser = (\cacheDir debug synapse hub ->
+  defaultOptions
+    { optInstallDeps    = False
+    , optBuild          = False
+    , optRunTests       = False
+    , optCacheDir       = cacheDir
+    , optDebug          = debug
+    , optSynapsePath    = synapse
+    , optHubCodegenPath = hub
+    })
+  <$> option str
+      ( long "cache-dir"
+     <> metavar "DIR"
+     <> value (optCacheDir defaultOptions)
+     <> showDefault
+     <> help "Cache directory"
+      )
+  <*> switch (long "debug" <> help "Enable debug logging")
+  <*> optional (option str (long "synapse"     <> metavar "PATH" <> help "synapse binary path"))
+  <*> optional (option str (long "hub-codegen" <> metavar "PATH" <> help "hub-codegen binary path"))
+
+-- ============================================================================
+-- Shared Parsers
+-- ============================================================================
+
 -- | Parse target language
 targetParser :: Parser Target
 targetParser = argument readTarget
@@ -79,6 +180,11 @@
  <> help "Registry/discovery port"
   )
 
+parseTransport :: String -> Either String TransportType
+parseTransport "ws"      = Right WsTransport
+parseTransport "browser" = Right BrowserTransport
+parseTransport s         = Left $ "Unknown transport: " <> s
+
 -- | Parse options
 optionsParser :: Parser Options
 optionsParser = Options
@@ -90,12 +196,11 @@
      <> showDefault
      <> help "Output directory"
       )
-  <*> option auto
-      ( long "bundle-transport"
-     <> metavar "BOOL"
-     <> value (optBundleTransport defaultOptions)
-     <> showDefault
-     <> help "Bundle transport code (true/false)"
+  <*> option (eitherReader parseTransport)
+      ( long "transport"
+     <> metavar "ws|browser"
+     <> value WsTransport
+     <> help "Transport: ws (Node.js, default) or browser (native WebSocket, for Tauri/WebView)"
       )
   <*> flag True False
       ( long "no-install"
@@ -121,13 +226,19 @@
      <> help "Force regeneration (ignore cache)"
       )
   <*> switch
-      ( long "watch"
-     <> help "Watch backend and regenerate on changes"
-      )
-  <*> switch
       ( long "debug"
      <> help "Enable debug logging"
       )
+  <*> optional (option str
+      ( long "synapse"
+     <> metavar "PATH"
+     <> help "Path to synapse binary (overrides discovery)"
+      ))
+  <*> optional (option str
+      ( long "hub-codegen"
+     <> metavar "PATH"
+     <> help "Path to hub-codegen binary (overrides discovery)"
+      ))
 
 -- ============================================================================
 -- Version Info
@@ -135,4 +246,4 @@
 
 -- | Version information
 versionInfo :: Text
-versionInfo = "synapse-cc v0.1.0"
+versionInfo = "synapse-cc v" <> synapseCCVersion
diff --git a/src/SynapseCC/Cache.hs b/src/SynapseCC/Cache.hs
--- a/src/SynapseCC/Cache.hs
+++ b/src/SynapseCC/Cache.hs
@@ -11,6 +11,9 @@
   , getCacheDir
   , clearCache
 
+    -- * Shared utilities
+  , expandTilde
+
     -- * V2 Validation (internal, exposed for testing)
   , validatePluginCaches
   , validatePluginCache
@@ -18,7 +21,7 @@
 
 import Control.Exception (catch, SomeException)
 import Control.Monad (when, forM_)
-import Data.Aeson (encode, eitherDecodeFileStrict)
+import Data.Aeson (FromJSON, encode, eitherDecodeFileStrict)
 import qualified Data.ByteString.Lazy as BL
 import Data.List (isPrefixOf)
 import Data.Map.Strict (Map)
@@ -32,6 +35,7 @@
 import System.Directory (createDirectoryIfMissing, doesFileExist, removeDirectoryRecursive, getHomeDirectory)
 import System.FilePath ((</>))
 
+import SynapseCC.Logging (logDebug)
 import SynapseCC.Types
 import qualified SynapseCC.Dependency as Dep
 
@@ -76,43 +80,36 @@
 getCodeCacheDir :: Options -> Backend -> Target -> IO FilePath
 getCodeCacheDir opts backend target = do
   baseDir <- getCacheDir opts
-  pure $ baseDir </> "hub-codegen" </> targetName target </> T.unpack (backendName backend)
-  where
-    targetName TypeScript = "typescript"
-    targetName Python = "python"
-    targetName Rust = "rust"
+  pure $ baseDir </> "synapse-cc" </> "code" </> T.unpack (targetToText target) </> T.unpack (backendName backend)
 
 -- ============================================================================
 -- Cache Reading
 -- ============================================================================
 
--- | Read IR cache manifest
-readIRCacheManifest :: Options -> Backend -> IO (Either SynapseCCError IRCacheManifest)
-readIRCacheManifest opts backend = do
-  cacheDir <- getIRCacheDir opts backend
+-- | Generic manifest reader: look for manifest.json in cacheDir and decode it.
+readManifest :: FromJSON a => FilePath -> String -> IO (Either SynapseCCError a)
+readManifest cacheDir label = do
   let manifestPath = cacheDir </> "manifest.json"
   exists <- doesFileExist manifestPath
   if not exists
-    then pure $ Left $ CacheError "IR cache manifest not found"
+    then pure $ Left $ CacheError $ T.pack label <> " manifest not found"
     else do
       result <- eitherDecodeFileStrict manifestPath
       case result of
-        Left err -> pure $ Left $ CacheError $ "Failed to parse IR cache manifest: " <> T.pack err
+        Left err     -> pure $ Left $ CacheError $ "Failed to parse " <> T.pack label <> " manifest: " <> T.pack err
         Right manifest -> pure $ Right manifest
 
+-- | Read IR cache manifest
+readIRCacheManifest :: Options -> Backend -> IO (Either SynapseCCError IRCacheManifest)
+readIRCacheManifest opts backend = do
+  cacheDir <- getIRCacheDir opts backend
+  readManifest cacheDir "IR cache"
+
 -- | Read code cache manifest
 readCodeCacheManifest :: Options -> Backend -> Target -> IO (Either SynapseCCError CodeCacheManifest)
 readCodeCacheManifest opts backend target = do
   cacheDir <- getCodeCacheDir opts backend target
-  let manifestPath = cacheDir </> "manifest.json"
-  exists <- doesFileExist manifestPath
-  if not exists
-    then pure $ Left $ CacheError "Code cache manifest not found"
-    else do
-      result <- eitherDecodeFileStrict manifestPath
-      case result of
-        Left err -> pure $ Left $ CacheError $ "Failed to parse code cache manifest: " <> T.pack err
-        Right manifest -> pure $ Right manifest
+  readManifest cacheDir "code cache"
 
 -- ============================================================================
 -- Cache Validation
@@ -120,40 +117,42 @@
 
 -- | Validate cache with version-aware checking
 -- Returns CacheResult indicating what needs regeneration
-validateCache :: Config -> IO CacheResult
-validateCache config = do
+validateCache :: Config -> ToolLocations -> IO CacheResult
+validateCache config tools = do
   let opts = cfgOptions config
       backend = cfgBackend config
       target = cfgTarget config
       debug = optDebug opts
+      synapseVer    = toolSynapseVersion tools
+      hubCodegenVer = toolHubCodegenVersion tools
 
   -- If --force flag is set, skip cache entirely
   if optForce opts
     then do
-      when debug $ putStrLn "[!] --force flag set, skipping cache"
+      logDebug debug "--force flag set, skipping cache"
       pure $ CacheMiss ManifestNotFound
     else do
       -- Try to read IR cache manifest
       irManifestResult <- readIRCacheManifest opts backend
       case irManifestResult of
         Left err -> do
-          when debug $ putStrLn $ "[!] IR cache miss: " ++ T.unpack (formatError err)
+          logDebug debug $ "IR cache miss: " <> formatError err
           pure $ CacheMiss ManifestNotFound
 
         Right irManifest -> do
           -- Check tool versions against IR cache
           let irToolchain = ircmToolchain irManifest
           if tvSynapseCC irToolchain /= synapseCCVersion ||
-             tvSynapse irToolchain /= "0.2.0.0"  -- TODO: Get from synapse somehow
+             tvSynapse irToolchain /= synapseVer
             then do
-              when debug $ putStrLn "[!] Tool versions changed, invalidating IR cache"
+              logDebug debug "Tool versions changed, invalidating IR cache"
               pure $ CacheMiss ToolVersionChanged
             else do
               -- IR cache valid, check code cache
               codeManifestResult <- readCodeCacheManifest opts backend target
               case codeManifestResult of
                 Left err -> do
-                  when debug $ putStrLn $ "[!] Code cache miss: " ++ T.unpack (formatError err)
+                  logDebug debug $ "Code cache miss: " <> formatError err
                   -- IR is cached, but code is not
                   pure $ CacheMiss ManifestNotFound
 
@@ -161,14 +160,14 @@
                   -- Check tool versions against code cache
                   let codeToolchain = ccmToolchain codeManifest
                   if tvSynapseCC codeToolchain /= synapseCCVersion ||
-                     tvSynapse codeToolchain /= "0.2.0.0" ||  -- TODO: Get from synapse
-                     isJust (tvHubCodegen codeToolchain) && tvHubCodegen codeToolchain /= Just "0.1.0"  -- TODO: Get from hub-codegen
+                     tvSynapse codeToolchain /= synapseVer ||
+                     isJust (tvHubCodegen codeToolchain) && tvHubCodegen codeToolchain /= Just hubCodegenVer
                     then do
-                      when debug $ putStrLn "[!] Tool versions changed, invalidating code cache"
+                      logDebug debug "Tool versions changed, invalidating code cache"
                       pure $ CacheMiss ToolVersionChanged
                     else do
                       -- V2: Check plugin hashes for granular invalidation
-                      when debug $ putStrLn "[*] Checking plugin hashes for granular invalidation..."
+                      logDebug debug "Checking plugin hashes for granular invalidation..."
                       validatePluginCaches irManifest codeManifest debug
 
 -- ============================================================================
@@ -180,44 +179,54 @@
 validatePluginCaches irManifest codeManifest debug = do
   let irPlugins = ircmPlugins irManifest
       codePlugins = ccmPlugins codeManifest
-      allPluginNames = Set.toList $ Set.union (Map.keysSet irPlugins) (Map.keysSet codePlugins)
 
-  -- Check each plugin for direct invalidation (schema/IR hash changes)
-  results <- mapM (validatePluginCache irManifest codeManifest debug) allPluginNames
+  -- Monolithic code cache: a single "default" entry means the last run was a
+  -- full-output pass with no per-plugin breakdown.  Tool versions already match
+  -- (checked before we get here), so treat this as a full cache hit.
+  -- Output-directory existence is verified by runPipeline's FullCacheHit handler.
+  if Map.size codePlugins == 1 && Map.member "default" codePlugins
+    then do
+      logDebug debug $ "Full cache hit (" <> T.pack (show (Map.size irPlugins)) <> " plugins)"
+      pure FullCacheHit
+    else do
+      let allPluginNames = Set.toList $ Set.union (Map.keysSet irPlugins) (Map.keysSet codePlugins)
 
-  -- Collect directly invalid plugins
-  let directlyInvalidPlugins = Set.fromList [name | (name, False) <- results]
-      directlyValidPlugins = [name | (name, True) <- results]
+      -- Check each plugin for direct invalidation (schema/IR hash changes)
+      results <- mapM (validatePluginCache irManifest codeManifest debug) allPluginNames
 
-  -- Build dependency graph from IR cache
-  let depGraph = Map.map ipcDependencies irPlugins
+      -- Collect directly invalid plugins
+      let directlyInvalidPlugins = Set.fromList [name | (name, False) <- results]
+          directlyValidPlugins = [name | (name, True) <- results]
 
-  -- Find transitively invalid plugins using dependency resolution
-  let allInvalidPlugins = Dep.findInvalidPlugins directlyInvalidPlugins depGraph
-      invalidPluginsList = Set.toList allInvalidPlugins
-      validPluginsList = filter (`Set.notMember` allInvalidPlugins) directlyValidPlugins
+      -- Build dependency graph from IR cache
+      let depGraph = Map.map ipcDependencies irPlugins
 
-  -- Report transitive invalidation
-  when debug $ do
-    let transitivelyInvalid = Set.difference allInvalidPlugins directlyInvalidPlugins
-    when (not $ Set.null transitivelyInvalid) $ do
-      putStrLn "[!] Transitive invalidation due to dependencies:"
-      forM_ (Set.toList transitivelyInvalid) $ \name ->
-        putStrLn $ "    - " ++ T.unpack name ++ " (depends on invalid plugin)"
+      -- Find transitively invalid plugins using dependency resolution
+      let allInvalidPlugins = Dep.findInvalidPlugins directlyInvalidPlugins depGraph
+          invalidPluginsList = Set.toList allInvalidPlugins
+          validPluginsList = filter (`Set.notMember` allInvalidPlugins) directlyValidPlugins
 
-  if null invalidPluginsList
-    then do
-      when debug $ putStrLn $ "[+] Full cache hit - all " ++ show (length validPluginsList) ++ " plugins valid"
-      pure FullCacheHit
-    else do
+      -- Report transitive invalidation
       when debug $ do
-        putStrLn $ "[!] Partial cache hit:"
-        putStrLn $ "    Valid: " ++ show (length validPluginsList) ++ " plugins"
-        putStrLn $ "    Invalid: " ++ show (length invalidPluginsList) ++ " plugins (including transitive)"
-        forM_ invalidPluginsList $ \name ->
-          putStrLn $ "      - " ++ T.unpack name
-      pure $ PartialCacheHit validPluginsList invalidPluginsList
+        let transitivelyInvalid = Set.difference allInvalidPlugins directlyInvalidPlugins
+        when (not $ Set.null transitivelyInvalid) $ do
+          logDebug debug "Transitive invalidation due to dependencies:"
+          forM_ (Set.toList transitivelyInvalid) $ \name ->
+            logDebug debug $ "    - " <> name <> " (depends on invalid plugin)"
 
+      if null invalidPluginsList
+        then do
+          logDebug debug $ "Full cache hit - all " <> T.pack (show (length validPluginsList)) <> " plugins valid"
+          pure FullCacheHit
+        else do
+          when debug $ do
+            logDebug debug "Partial cache hit:"
+            logDebug debug $ "    Valid: " <> T.pack (show (length validPluginsList)) <> " plugins"
+            logDebug debug $ "    Invalid: " <> T.pack (show (length invalidPluginsList)) <> " plugins (including transitive)"
+            forM_ invalidPluginsList $ \name ->
+              logDebug debug $ "      - " <> name
+          pure $ PartialCacheHit validPluginsList invalidPluginsList
+
 -- | Validate a single plugin's cache
 -- Returns (plugin name, is valid)
 validatePluginCache :: IRCacheManifest -> CodeCacheManifest -> Bool -> Text -> IO (Text, Bool)
@@ -228,17 +237,17 @@
   case (Map.lookup pluginName irPlugins, Map.lookup pluginName codePlugins) of
     (Nothing, Nothing) -> do
       -- Plugin not in either cache - invalid
-      when debug $ putStrLn $ "[!] Plugin '" ++ T.unpack pluginName ++ "' not found in cache"
+      logDebug debug $ "Plugin '" <> pluginName <> "' not found in cache"
       pure (pluginName, False)
 
     (Just _irCache, Nothing) -> do
       -- IR cached but code not generated - invalid
-      when debug $ putStrLn $ "[!] Plugin '" ++ T.unpack pluginName ++ "' has IR cache but no code cache"
+      logDebug debug $ "Plugin '" <> pluginName <> "' has IR cache but no code cache"
       pure (pluginName, False)
 
     (Nothing, Just _codeCache) -> do
       -- Code exists but no IR cache - invalid (shouldn't happen)
-      when debug $ putStrLn $ "[!] Plugin '" ++ T.unpack pluginName ++ "' has code cache but no IR cache"
+      logDebug debug $ "Plugin '" <> pluginName <> "' has code cache but no IR cache"
       pure (pluginName, False)
 
     (Just irCache, Just codeCache) -> do
@@ -272,19 +281,17 @@
 -- | Report cache hit with V2 hash details
 reportCacheHit :: Text -> Text -> Text -> Bool -> IO ()
 reportCacheHit pluginName selfHash childrenHash debug = do
-  when debug $ do
-    putStrLn $ "[+] Cache hit: '" ++ T.unpack pluginName ++ "'"
-    when (not $ T.null selfHash) $
-      putStrLn $ "    self_hash:     " ++ T.unpack (T.take 8 selfHash) ++ "..."
-    when (not $ T.null childrenHash) $
-      putStrLn $ "    children_hash: " ++ T.unpack (T.take 8 childrenHash) ++ "..."
+  logDebug debug $ "Cache hit: '" <> pluginName <> "'"
+  when (not $ T.null selfHash) $
+    logDebug debug $ "    self_hash:     " <> T.take 8 selfHash <> "..."
+  when (not $ T.null childrenHash) $
+    logDebug debug $ "    children_hash: " <> T.take 8 childrenHash <> "..."
 
 -- | Report cache invalidation with detailed reason
 reportInvalidation :: Text -> String -> Bool -> IO ()
 reportInvalidation pluginName reason debug = do
-  when debug $ do
-    putStrLn $ "[!] Cache miss: '" ++ T.unpack pluginName ++ "'"
-    putStrLn $ "    Reason: " ++ reason
+  logDebug debug $ "Cache miss: '" <> pluginName <> "'"
+  logDebug debug $ "    Reason: " <> T.pack reason
 
 -- | Analyze what changed using V2 granular hashes
 analyzeHashChange :: Text -> IRPluginCache -> Text -> Bool -> IO ()
@@ -293,33 +300,32 @@
       cachedSelfHash = ipcSelfHash irCache
       cachedChildrenHash = ipcChildrenHash irCache
 
-  when debug $ do
-    putStrLn $ "[!] Cache miss: '" ++ T.unpack pluginName ++ "'"
-    putStrLn $ "    IR hash changed:"
-    putStrLn $ "      cached: " ++ T.unpack (T.take 8 cachedIRHash) ++ "..."
-    putStrLn $ "      new:    " ++ T.unpack (T.take 8 newCodeHash) ++ "..."
+  logDebug debug $ "Cache miss: '" <> pluginName <> "'"
+  logDebug debug "    IR hash changed:"
+  logDebug debug $ "      cached: " <> T.take 8 cachedIRHash <> "..."
+  logDebug debug $ "      new:    " <> T.take 8 newCodeHash <> "..."
 
-    -- V2 granular analysis (when fresh hashes are available)
-    if T.null cachedSelfHash && T.null cachedChildrenHash
-      then do
-        putStrLn "    V2 hashes unavailable - using V1 validation"
-        putStrLn "    Reason: Schema or IR changed (full regeneration needed)"
-      else do
-        putStrLn "    V2 granular analysis:"
-        when (not $ T.null cachedSelfHash) $
-          putStrLn $ "      self_hash:     " ++ T.unpack (T.take 8 cachedSelfHash) ++ "... (methods)"
-        when (not $ T.null cachedChildrenHash) $
-          putStrLn $ "      children_hash: " ++ T.unpack (T.take 8 cachedChildrenHash) ++ "... (dependencies)"
-        putStrLn "    Note: Fresh schema comparison needed for granular invalidation"
-        putStrLn "          (requires fetching current schema from backend)"
+  -- V2 granular analysis (when fresh hashes are available)
+  if T.null cachedSelfHash && T.null cachedChildrenHash
+    then do
+      logDebug debug "    V2 hashes unavailable - using V1 validation"
+      logDebug debug "    Reason: Schema or IR changed (full regeneration needed)"
+    else do
+      logDebug debug "    V2 granular analysis:"
+      when (not $ T.null cachedSelfHash) $
+        logDebug debug $ "      self_hash:     " <> T.take 8 cachedSelfHash <> "... (methods)"
+      when (not $ T.null cachedChildrenHash) $
+        logDebug debug $ "      children_hash: " <> T.take 8 cachedChildrenHash <> "... (dependencies)"
+      logDebug debug "    Note: Fresh schema comparison needed for granular invalidation"
+      logDebug debug "          (requires fetching current schema from backend)"
 
 -- ============================================================================
 -- Cache Writing
 -- ============================================================================
 
 -- | Write IR cache manifest
-writeIRCacheManifest :: Options -> Backend -> Map Text IRPluginCache -> IO ()
-writeIRCacheManifest opts backend plugins = do
+writeIRCacheManifest :: Options -> Backend -> Map Text IRPluginCache -> Text -> IO ()
+writeIRCacheManifest opts backend plugins synapseVer = do
   cacheDir <- getIRCacheDir opts backend
   createDirectoryIfMissing True cacheDir
 
@@ -331,7 +337,7 @@
         , ircmIRVersion = "2.0"
         , ircmToolchain = ToolchainVersions
             { tvSynapseCC = synapseCCVersion
-            , tvSynapse = "0.2.0.0"  -- TODO: Get from synapse
+            , tvSynapse = synapseVer
             , tvHubCodegen = Nothing
             }
         , ircmUpdatedAt = timestamp
@@ -342,8 +348,8 @@
   BL.writeFile manifestPath (encode manifest)
 
 -- | Write code cache manifest
-writeCodeCacheManifest :: Options -> Backend -> Target -> Map Text CodePluginCache -> IO ()
-writeCodeCacheManifest opts backend target plugins = do
+writeCodeCacheManifest :: Options -> Backend -> Target -> Map Text CodePluginCache -> Text -> Text -> IO ()
+writeCodeCacheManifest opts backend target plugins synapseVer hubCodegenVer = do
   cacheDir <- getCodeCacheDir opts backend target
   createDirectoryIfMissing True cacheDir
 
@@ -352,14 +358,11 @@
 
   let manifest = CodeCacheManifest
         { ccmVersion = "1.0"
-        , ccmTarget = T.pack $ case target of
-            TypeScript -> "typescript"
-            Python -> "python"
-            Rust -> "rust"
+        , ccmTarget = targetToText target
         , ccmToolchain = ToolchainVersions
             { tvSynapseCC = synapseCCVersion
-            , tvSynapse = "0.2.0.0"  -- TODO: Get from synapse
-            , tvHubCodegen = Just "0.1.0"  -- TODO: Get from hub-codegen
+            , tvSynapse = synapseVer
+            , tvHubCodegen = Just hubCodegenVer
             }
         , ccmUpdatedAt = timestamp
         , ccmPlugins = plugins
@@ -380,4 +383,4 @@
   when exists $ do
     catch
       (removeDirectoryRecursive cacheDir)
-      (\(e :: SomeException) -> putStrLn $ "Warning: Failed to clear cache: " ++ show e)
+      (\(e :: SomeException) -> logDebug True $ "Warning: Failed to clear cache: " <> T.pack (show e))
diff --git a/src/SynapseCC/Config.hs b/src/SynapseCC/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/SynapseCC/Config.hs
@@ -0,0 +1,155 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | synapse.config.json loader and validator
+module SynapseCC.Config
+  ( loadSynapseConfig
+  , validateSynapseConfig
+  , initSynapseConfig
+  , synapseConfigPath
+
+    -- * Shared config-to-runtime conversions
+  , parseWsUrl
+  , buildConfigFromTarget
+  ) where
+
+import Data.Aeson (eitherDecodeFileStrict)
+import qualified Data.Aeson.Encode.Pretty as Pretty
+import qualified Data.ByteString.Lazy as BL
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import qualified Data.Text as T
+import System.Directory (doesFileExist)
+
+import SynapseCC.Detect (Detector, ProjectHint(..), runDetectors)
+import SynapseCC.Types
+
+-- | Standard config file name (looked up from CWD)
+synapseConfigPath :: FilePath
+synapseConfigPath = "synapse.config.json"
+
+-- | Load synapse.config.json from the current directory.
+-- Returns Left if the file doesn't exist or fails to parse.
+loadSynapseConfig :: IO (Either Text SynapseConfig)
+loadSynapseConfig = loadSynapseConfigFrom synapseConfigPath
+
+-- | Load from an explicit path (for hot-reload)
+loadSynapseConfigFrom :: FilePath -> IO (Either Text SynapseConfig)
+loadSynapseConfigFrom path = do
+  exists <- doesFileExist path
+  if not exists
+    then pure $ Left $ "synapse.config.json not found at " <> T.pack path
+                    <> " — run 'synapse-cc init' to create one"
+    else do
+      result <- eitherDecodeFileStrict path
+      case result of
+        Left err  -> pure $ Left $ T.pack err
+        Right cfg -> case validateSynapseConfig cfg of
+          Left verr -> pure $ Left verr
+          Right ()  -> pure $ Right cfg
+
+-- | Validate a loaded SynapseConfig, returning the first error found.
+validateSynapseConfig :: SynapseConfig -> Either Text ()
+validateSynapseConfig cfg = do
+  requireNonEmpty "language" (scLanguage cfg)
+  -- backend and url are required only when at least one target needs a backend.
+  -- A target with generate: ["transport"] is backend-free.
+  let needsBackend = any ((/= ["transport"]) . tcGenerate) (Map.elems (scTargets cfg))
+  if needsBackend
+    then case scBackend cfg of
+           Nothing -> Left "\"backend\" is required when targets need a backend connection"
+           Just b  -> requireNonEmpty "backend" b
+    else Right ()
+  if Map.null (scTargets cfg)
+    then Left "At least one entry under \"targets\" is required"
+    else mapM_ (validateTargetConfig (scTargets cfg)) (Map.toList (scTargets cfg))
+  where
+    requireNonEmpty field val =
+      if T.null (T.strip val)
+        then Left $ "\"" <> field <> "\" must not be empty"
+        else Right ()
+
+validateTargetConfig :: Map Text TargetConfig -> (Text, TargetConfig) -> Either Text ()
+validateTargetConfig _all (name, tc) = do
+  if null (tcGenerate tc)
+    then Left $ "target \"" <> name <> "\": \"generate\" list must not be empty"
+    else Right ()
+  if null (tcOutputDir tc)
+    then Left $ "target \"" <> name <> "\": \"outputDir\" must not be empty"
+    else Right ()
+
+-- ============================================================================
+-- Config-to-Runtime Conversions
+-- ============================================================================
+
+-- | Parse host and port from a ws:// or wss:// URL.
+-- Defaults port to "4444" when absent.
+parseWsUrl :: Text -> (Text, Text)
+parseWsUrl url =
+  let stripped = case T.stripPrefix "wss://" url of
+        Just s  -> s
+        Nothing -> case T.stripPrefix "ws://" url of
+          Just s  -> s
+          Nothing -> url
+      (host, rest) = T.breakOn ":" stripped
+      port         = T.takeWhile (/= '/') (T.drop 1 rest)
+  in (host, if T.null port then "4444" else port)
+
+-- | Build a runtime Config from a set of CLI options, a SynapseConfig, and one target.
+-- The target's outputDir and transport override the options; all other options
+-- (--debug, --force, --no-install, etc.) are inherited as-is.
+buildConfigFromTarget :: Options -> SynapseConfig -> TargetConfig -> Config
+buildConfigFromTarget opts sc tc =
+  let url            = fromMaybe "ws://127.0.0.1:4444" (scUrl sc)
+      (host, port)   = parseWsUrl url
+      backendName    = fromMaybe "" (scBackend sc)
+  in Config
+       { cfgTarget  = parseLanguage (scLanguage sc)
+       , cfgBackend = Backend backendName
+       , cfgHost    = host
+       , cfgPort    = port
+       , cfgOptions = opts
+           { optOutput    = tcOutputDir tc
+           , optTransport = tcTransport tc
+           }
+       }
+
+-- | Scaffold a synapse.config.json, using 'detectors' to infer the right
+-- transport and other settings for the current project.
+--
+-- Returns @Left@ if the file already exists.
+-- Returns @Right hint@ on success — the caller can inspect 'phReason' to
+-- print a message explaining what was detected.
+--
+-- Pass 'SynapseCC.Detect.defaultDetectors' for the standard set, or prepend
+-- additional detectors for project-specific overrides.
+initSynapseConfig :: FilePath -> [Detector] -> IO (Either Text ProjectHint)
+initSynapseConfig path detectors = do
+  exists <- doesFileExist path
+  if exists
+    then pure $ Left $ T.pack path <> " already exists"
+    else do
+      hint <- runDetectors detectors
+      let config = applyHint hint defaultSynapseConfig
+      BL.writeFile path (Pretty.encodePretty' prettyConfig config)
+      pure $ Right hint
+
+-- | Apply detected hints to the default config.
+applyHint :: ProjectHint -> SynapseConfig -> SynapseConfig
+applyHint hint cfg =
+  case phTransport hint of
+    Nothing -> cfg
+    Just t  -> cfg
+      { scTargets = Map.map (\tc -> tc { tcTransport = t }) (scTargets cfg)
+      }
+
+-- | aeson-pretty config: 2-space indent, semantic field ordering.
+prettyConfig :: Pretty.Config
+prettyConfig = Pretty.defConfig
+  { Pretty.confCompare = Pretty.keyOrder
+      [ "schema", "language", "backend", "url", "packageManager"
+      , "watch", "pollInterval", "hotReload"
+      , "targets", "generate", "transport", "outputDir", "smokePath"
+      ]
+  }
diff --git a/src/SynapseCC/Detect.hs b/src/SynapseCC/Detect.hs
new file mode 100644
--- /dev/null
+++ b/src/SynapseCC/Detect.hs
@@ -0,0 +1,194 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Project detection layer for synapse-cc init.
+--
+-- Inspects the current working directory to infer appropriate config values
+-- (transport mode, output directory, etc.) so that generated synapse.config.json
+-- is correct out of the box for common project structures.
+--
+-- Detectors are plain values — extend by prepending to the list passed to
+-- 'runDetectors'. Earlier detectors win on a per-field basis.
+module SynapseCC.Detect
+  ( -- * Types
+    ProjectHint(..)
+  , Detector(..)
+  , emptyHint
+
+    -- * Running detectors
+  , runDetectors
+
+    -- * Default detector set
+  , defaultDetectors
+
+    -- * Individual detectors (for composition and testing)
+  , detectTauri
+  , detectVite
+  , detectNextJS
+  , detectNodeProject
+  ) where
+
+import Control.Applicative ((<|>))
+import Control.Monad (foldM)
+import qualified Data.Text as T
+import System.Directory (doesDirectoryExist, doesFileExist)
+
+import SynapseCC.Types (TransportType(..))
+
+-- ============================================================================
+-- Hint type
+-- ============================================================================
+
+-- | Inferred preferences for a new synapse.config.json.
+-- 'Nothing' means "no opinion" — another detector or the hardcoded default wins.
+data ProjectHint = ProjectHint
+  { phTransport :: Maybe TransportType
+    -- ^ 'WsTransport' (Node.js) or 'BrowserTransport' (native WebSocket)
+  , phReason    :: Maybe T.Text
+    -- ^ Human-readable explanation of why transport was chosen
+  } deriving (Show, Eq)
+
+-- | Hint with no opinions on any field.
+emptyHint :: ProjectHint
+emptyHint = ProjectHint Nothing Nothing
+
+-- | Merge two hints: earlier/left values win per field.
+-- Uses 'Control.Applicative.<|>' which picks the first 'Just'.
+mergeHints :: ProjectHint -> ProjectHint -> ProjectHint
+mergeHints a b = ProjectHint
+  { phTransport = phTransport a <|> phTransport b
+  , phReason    = phReason a    <|> phReason b
+  }
+
+-- ============================================================================
+-- Detector type
+-- ============================================================================
+
+-- | A named detector that inspects the project and returns a hint.
+--
+-- Detectors are run in order; the first non-Nothing value wins per field.
+-- Add custom detectors by prepending them to the list passed to 'runDetectors'.
+data Detector = Detector
+  { detectorName :: T.Text
+    -- ^ Short label for debug output (e.g. "tauri", "vite")
+  , runDetector  :: IO ProjectHint
+    -- ^ IO action: inspect CWD, return hints (or 'emptyHint' if not applicable)
+  }
+
+-- ============================================================================
+-- Running detectors
+-- ============================================================================
+
+-- | Run all detectors in order, merging results.
+-- The first non-Nothing value per field wins (leftmost detector takes priority).
+runDetectors :: [Detector] -> IO ProjectHint
+runDetectors = foldM step emptyHint
+  where
+    step acc det = mergeHints acc <$> runDetector det
+
+-- ============================================================================
+-- Default detector set
+-- ============================================================================
+
+-- | Default detector list, in priority order:
+--
+-- @
+-- detectTauri      -- src-tauri/          → browser
+-- detectVite       -- vite.config.*       → browser
+-- detectNextJS     -- next.config.*       → browser
+-- detectNodeProject-- package.json        → ws (fallback)
+-- @
+--
+-- Inject additional detectors by prepending to this list:
+--
+-- @
+-- myDetector : defaultDetectors
+-- @
+defaultDetectors :: [Detector]
+defaultDetectors =
+  [ detectTauri
+  , detectVite
+  , detectNextJS
+  , detectNodeProject
+  ]
+
+-- ============================================================================
+-- Individual detectors
+-- ============================================================================
+
+-- | Tauri desktop-app projects.
+-- Looks for 'src-tauri/' directory or 'tauri.conf.json' (v1 or v2).
+-- Sets transport to 'BrowserTransport' — Tauri's WebView exposes a native
+-- 'WebSocket' global, so the @ws@ npm package must not be imported.
+detectTauri :: Detector
+detectTauri = Detector "tauri" $ do
+  hasSrcTauri  <- doesDirectoryExist "src-tauri"
+  hasTauriConf <- anyExist
+    [ "tauri.conf.json"
+    , "src-tauri/tauri.conf.json"
+    , "src-tauri/tauri.conf.json5"
+    ]
+  if hasSrcTauri || hasTauriConf
+    then pure emptyHint
+      { phTransport = Just BrowserTransport
+      , phReason    = Just "Tauri project detected (src-tauri/) — using browser WebSocket"
+      }
+    else pure emptyHint
+
+-- | Vite projects (browser-native bundler context).
+-- Looks for 'vite.config.ts', 'vite.config.js', or 'vite.config.mts'.
+-- Sets transport to 'BrowserTransport'.
+detectVite :: Detector
+detectVite = Detector "vite" $ do
+  found <- anyExist
+    [ "vite.config.ts"
+    , "vite.config.js"
+    , "vite.config.mts"
+    , "vite.config.mjs"
+    ]
+  if found
+    then pure emptyHint
+      { phTransport = Just BrowserTransport
+      , phReason    = Just "Vite project detected — using browser WebSocket"
+      }
+    else pure emptyHint
+
+-- | Next.js projects.
+-- Looks for 'next.config.js', 'next.config.ts', or 'next.config.mjs'.
+-- Sets transport to 'BrowserTransport' (client components use native WebSocket).
+detectNextJS :: Detector
+detectNextJS = Detector "nextjs" $ do
+  found <- anyExist
+    [ "next.config.js"
+    , "next.config.ts"
+    , "next.config.mjs"
+    ]
+  if found
+    then pure emptyHint
+      { phTransport = Just BrowserTransport
+      , phReason    = Just "Next.js project detected — using browser WebSocket"
+      }
+    else pure emptyHint
+
+-- | Bare Node.js projects (package.json present, no framework marker).
+-- Sets transport to 'WsTransport' — the @ws@ npm package is available in Node.
+-- This is the fallback: runs last, so framework detectors override it.
+detectNodeProject :: Detector
+detectNodeProject = Detector "node" $ do
+  found <- doesFileExist "package.json"
+  if found
+    then pure emptyHint
+      { phTransport = Just WsTransport
+      , phReason    = Just "Node.js project detected — using ws WebSocket"
+      }
+    else pure emptyHint
+
+-- ============================================================================
+-- Helpers
+-- ============================================================================
+
+-- | Return True if any of the given paths exists as a file.
+anyExist :: [FilePath] -> IO Bool
+anyExist paths = foldM step False paths
+  where
+    step True  _    = pure True
+    step False path = doesFileExist path
diff --git a/src/SynapseCC/Discover.hs b/src/SynapseCC/Discover.hs
--- a/src/SynapseCC/Discover.hs
+++ b/src/SynapseCC/Discover.hs
@@ -3,67 +3,91 @@
   ( discoverTools
   , findTool
   , toolPathToFilePath
+  , findInDistNewstyle
   ) where
 
-import Control.Monad (filterM)
+import Control.Exception (catch, SomeException, try)
+import System.Exit (ExitCode)
+import Control.Monad (forM, when)
+import Data.List (sortBy, maximumBy)
+import Data.Maybe (catMaybes)
+import Data.Ord (comparing, Down(..))
 import Data.Text (Text)
 import qualified Data.Text as T
-import System.Directory (doesFileExist, findExecutable, getHomeDirectory)
+import System.Directory (doesFileExist, doesDirectoryExist, findExecutable, getHomeDirectory, listDirectory, getModificationTime)
 import System.FilePath ((</>))
+import System.Process (readProcessWithExitCode)
 
+import SynapseCC.Logging (logDebug, logInfo)
 import SynapseCC.Types
 
 -- ============================================================================
 -- Tool Discovery
 -- ============================================================================
 
--- | Discover all required tools
-discoverTools :: Bool -> IO (Either SynapseCCError ToolLocations)
-discoverTools debug = do
-  when debug $ putStrLn "[*] Discovering tools..."
+-- | Discover all required tools, respecting any explicit paths in Options
+discoverTools :: Options -> IO (Either SynapseCCError ToolLocations)
+discoverTools opts = do
+  let debug = optDebug opts
 
-  synapsePath <- findTool debug "synapse" synapsePaths
+  synapsePath <- case optSynapsePath opts of
+    Just explicit -> resolveExplicit "synapse" explicit debug
+    Nothing       -> findTool debug "synapse" synapseFallbackPaths
+
   case synapsePath of
     Nothing -> pure $ Left $ ToolNotFound "synapse" synapseSuggestions
     Just synapseToolPath -> do
-      when debug $ putStrLn $ "  [+] Found synapse at " ++ toolPathToFilePath synapseToolPath
+      hubCodegenPath <- case optHubCodegenPath opts of
+        Just explicit -> resolveExplicit "hub-codegen" explicit debug
+        Nothing       -> findTool debug "hub-codegen" hubCodegenFallbackPaths
 
-      hubCodegenPath <- findTool debug "hub-codegen" hubCodegenPaths
       case hubCodegenPath of
         Nothing -> pure $ Left $ ToolNotFound "hub-codegen" hubCodegenSuggestions
         Just hubCodegenToolPath -> do
-          when debug $ putStrLn $ "  [+] Found hub-codegen at " ++ toolPathToFilePath hubCodegenToolPath
+          -- Run --version for each discovered tool
+          synapseVer    <- getToolVersion (toolPathToFilePath synapseToolPath)
+          hubCodegenVer <- getToolVersion (toolPathToFilePath hubCodegenToolPath)
 
+          logDebug debug $ "  synapse     " <> T.pack (toolPathToFilePath synapseToolPath) <> "  (" <> synapseVer <> ")"
+          logDebug debug $ "  hub-codegen " <> T.pack (toolPathToFilePath hubCodegenToolPath) <> "  (" <> hubCodegenVer <> ")"
+
           pure $ Right $ ToolLocations
-            { toolSynapse = synapseToolPath
-            , toolHubCodegen = hubCodegenToolPath
+            { toolSynapse           = synapseToolPath
+            , toolHubCodegen        = hubCodegenToolPath
+            , toolSynapseVersion    = synapseVer
+            , toolHubCodegenVersion = hubCodegenVer
             }
 
--- | Find a tool by checking multiple locations
-findTool :: Bool -> String -> (FilePath -> [FilePath]) -> IO (Maybe ToolPath)
-findTool debug name pathsF = do
-  home <- getHomeDirectory
-  let paths = pathsF home
-
-  when debug $ putStrLn $ "  Searching for " ++ name ++ "..."
+-- | Resolve an explicitly-provided path, failing clearly if it doesn't exist
+resolveExplicit :: String -> FilePath -> Bool -> IO (Maybe ToolPath)
+resolveExplicit name path debug = do
+  exists <- doesFileExist path
+  if exists
+    then pure $ Just $ classifyPath path
+    else do
+      logDebug debug $ "  [!] " <> T.pack name <> ": explicit path not found: " <> T.pack path
+      pure Nothing
 
-  -- Try to find the tool in priority order
-  tryPaths paths
+-- | Find a tool: check PATH first, then fallback paths
+findTool :: Bool -> String -> IO [FilePath] -> IO (Maybe ToolPath)
+findTool debug name fallbacksIO = do
+  -- Check PATH first (i.e. `which <name>`)
+  mbWhich <- findExecutable name
+  case mbWhich of
+    Just path -> do
+      logDebug debug $ "  " <> T.pack name <> ": found via PATH at " <> T.pack path
+      pure $ Just $ SystemPath path
+    Nothing -> do
+      logDebug debug $ "  " <> T.pack name <> ": not in PATH, searching fallback locations..."
+      paths <- fallbacksIO
+      tryPaths paths
   where
-    tryPaths [] = do
-      -- Last resort: check system PATH
-      mbSystemPath <- findExecutable name
-      case mbSystemPath of
-        Just path -> do
-          when debug $ putStrLn $ "    Found in PATH: " ++ path
-          pure $ Just $ SystemPath path
-        Nothing -> pure Nothing
-
+    tryPaths [] = pure Nothing
     tryPaths (path:rest) = do
       exists <- doesFileExist path
       if exists
         then do
-          when debug $ putStrLn $ "    Found: " ++ path
+          logDebug debug $ "    Found: " <> T.pack path
           pure $ Just $ classifyPath path
         else tryPaths rest
 
@@ -71,9 +95,9 @@
 classifyPath :: FilePath -> ToolPath
 classifyPath path
   | "dist-newstyle" `elem` splitPath path = LocalDev path
-  | "target" `elem` splitPath path = LocalDev path
-  | ".plexus/bin" `elem` splitPath path = PlexusBin path
-  | otherwise = SystemPath path
+  | "target"        `elem` splitPath path = LocalDev path
+  | ".plexus/bin"   `elem` splitPath path = PlexusBin path
+  | otherwise                             = SystemPath path
   where
     splitPath = filter (not . null) . wordsBy (== '/')
     wordsBy p s = case dropWhile p s of
@@ -87,62 +111,133 @@
   SystemPath path -> path
   PlexusBin path  -> path
 
+-- | Run a tool with @--version@ and return the version string.
+-- Strips a leading "v" and takes only the first line.
+-- Returns @"unknown"@ if the call fails for any reason.
+getToolVersion :: FilePath -> IO Text
+getToolVersion exe = do
+  result <- try (readProcessWithExitCode exe ["--version"] "") :: IO (Either SomeException (ExitCode, String, String))
+  case result of
+    Left _             -> pure "unknown"
+    Right (_, out, _) ->
+      let firstLine = T.strip $ T.pack $ head (lines out ++ [""])
+          -- Take last word (handles "hub-codegen 0.1.0" and "3.5.0")
+          lastWord  = case reverse (T.words firstLine) of
+                        (w:_) -> w
+                        []    -> ""
+          ver = case T.unpack lastWord of
+                  ('v':rest) -> T.pack rest
+                  _          -> lastWord
+      in pure $ if T.null ver then "unknown" else ver
+
 -- ============================================================================
--- Search Paths
+-- dist-newstyle Glob Search
 -- ============================================================================
 
--- | Search paths for synapse (in priority order)
-synapsePaths :: FilePath -> [FilePath]
-synapsePaths home =
-  [ -- Local development builds
-    "../synapse/dist-newstyle/build/aarch64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"
-  , "../synapse/dist-newstyle/build/x86_64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"
-  , "../../synapse/dist-newstyle/build/aarch64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"
-  , "../../synapse/dist-newstyle/build/x86_64-linux/ghc-9.4.8/plexus-synapse-0.2.0.0/x/synapse/build/synapse/synapse"
-    -- Plexus bin directory
-  , home </> ".plexus/bin/synapse"
-    -- Cabal install location
-  , home </> ".cabal/bin/synapse"
-  ]
+-- | Search dist-newstyle for a built executable, returning the most recently
+-- modified match (newest build wins).
+--
+-- Walks the pattern:
+--   <root>/dist-newstyle/build/*\/ghc-*\/<pkg>-*\/x\/<exe>\/build\/<exe>\/<exe>
+findInDistNewstyle :: FilePath  -- ^ project root (e.g. "../synapse")
+                   -> String    -- ^ executable name (e.g. "synapse")
+                   -> IO (Maybe FilePath)
+findInDistNewstyle root exe = do
+  let buildDir = root </> "dist-newstyle" </> "build"
+  exists <- doesDirectoryExist buildDir
+  if not exists
+    then pure Nothing
+    else do
+      -- Level 1: arch dirs (e.g. aarch64-linux, x86_64-darwin)
+      archDirs <- safeListDirectory buildDir
+      candidates <- fmap concat $ forM archDirs $ \arch -> do
+        let archPath = buildDir </> arch
+        isDir <- doesDirectoryExist archPath
+        if not isDir then pure [] else do
 
--- | Search paths for hub-codegen (in priority order)
-hubCodegenPaths :: FilePath -> [FilePath]
-hubCodegenPaths home =
-  [ -- Local development builds
-    "../hub-codegen/target/release/hub-codegen"
-  , "../hub-codegen/target/debug/hub-codegen"
-  , "../../hub-codegen/target/release/hub-codegen"
-  , "../../hub-codegen/target/debug/hub-codegen"
-    -- Plexus bin directory
-  , home </> ".plexus/bin/hub-codegen"
-    -- Cargo install location
-  , home </> ".cargo/bin/hub-codegen"
-  ]
+          -- Level 2: ghc-* dirs
+          ghcDirs <- safeListDirectory archPath
+          fmap concat $ forM ghcDirs $ \ghcDir -> do
+            if not ("ghc-" `isPrefixOfStr` ghcDir) then pure [] else do
+              let ghcPath = archPath </> ghcDir
 
+              -- Level 3: <pkg>-* dirs
+              pkgDirs <- safeListDirectory ghcPath
+              fmap concat $ forM pkgDirs $ \pkgDir -> do
+                let pkgPath = ghcPath </> pkgDir
+                isDir2 <- doesDirectoryExist pkgPath
+                if not isDir2 then pure [] else do
+
+                  -- Level 4: x/<exe>/build/<exe>/<exe>
+                  let candidate = pkgPath </> "x" </> exe </> "build" </> exe </> exe
+                  exists2 <- doesFileExist candidate
+                  if exists2 then pure [candidate] else pure []
+
+      case candidates of
+        []  -> pure Nothing
+        [c] -> pure $ Just c
+        cs  -> do
+          -- Pick the most recently modified binary
+          withTimes <- forM cs $ \c -> do
+            t <- getModificationTime c
+            pure (t, c)
+          pure $ Just $ snd $ maximumBy (comparing fst) withTimes
+  where
+    isPrefixOfStr prefix str = take (length prefix) str == prefix
+
+    safeListDirectory dir =
+      listDirectory dir `catch` \(_ :: SomeException) -> pure []
+
 -- ============================================================================
+-- Fallback Search Paths (used only when not found in PATH)
+-- ============================================================================
+
+-- | Fallback paths for synapse (checked if not in PATH).
+-- Uses glob-based dist-newstyle search to handle any arch/GHC version.
+synapseFallbackPaths :: IO [FilePath]
+synapseFallbackPaths = do
+  home <- getHomeDirectory
+  -- Search dist-newstyle trees relative to common project layouts
+  mb1 <- findInDistNewstyle "../synapse" "synapse"
+  mb2 <- findInDistNewstyle "../../synapse" "synapse"
+  let devPaths = catMaybes [mb1, mb2]
+  let installPaths =
+        [ home </> ".plexus/bin/synapse"
+        , home </> ".local/bin/synapse"
+        , home </> ".cabal/bin/synapse"
+        ]
+  pure $ devPaths ++ installPaths
+
+-- | Fallback paths for hub-codegen (checked if not in PATH)
+hubCodegenFallbackPaths :: IO [FilePath]
+hubCodegenFallbackPaths = do
+  home <- getHomeDirectory
+  let devPaths =
+        [ "../hub-codegen/target/release/hub-codegen"
+        , "../hub-codegen/target/debug/hub-codegen"
+        , "../../hub-codegen/target/release/hub-codegen"
+        , "../../hub-codegen/target/debug/hub-codegen"
+        ]
+      installPaths =
+        [ home </> ".plexus/bin/hub-codegen"
+        , home </> ".cargo/bin/hub-codegen"
+        ]
+  pure $ devPaths ++ installPaths
+
+-- ============================================================================
 -- Suggestions
 -- ============================================================================
 
--- | Suggestions for installing synapse
 synapseSuggestions :: [Text]
 synapseSuggestions =
-  [ "Build synapse: cd ../synapse && cabal build"
-  , "Install synapse: cabal install synapse"
-  , "Add to PATH: export PATH=\"$PATH:~/.plexus/bin\""
+  [ "Install synapse: cd ../synapse && cabal install exe:synapse"
+  , "Or specify path: --synapse /path/to/synapse"
+  , "Add to PATH: export PATH=\"$HOME/.local/bin:$PATH\""
   ]
 
--- | Suggestions for installing hub-codegen
 hubCodegenSuggestions :: [Text]
 hubCodegenSuggestions =
-  [ "Build hub-codegen: cd ../hub-codegen && cargo build --release"
-  , "Install hub-codegen: cargo install --path ../hub-codegen"
-  , "Add to PATH: export PATH=\"$PATH:~/.plexus/bin\""
+  [ "Install hub-codegen: cd ../hub-codegen && cargo install --path ."
+  , "Or specify path: --hub-codegen /path/to/hub-codegen"
+  , "Add to PATH: export PATH=\"$HOME/.cargo/bin:$PATH\""
   ]
-
--- ============================================================================
--- Helpers
--- ============================================================================
-
-when :: Applicative f => Bool -> f () -> f ()
-when True action = action
-when False _ = pure ()
diff --git a/src/SynapseCC/Language.hs b/src/SynapseCC/Language.hs
--- a/src/SynapseCC/Language.hs
+++ b/src/SynapseCC/Language.hs
@@ -1,15 +1,20 @@
 -- | Language-specific integrations (dependency install, build, etc.)
 module SynapseCC.Language
   ( installDependencies
+  , addDependencies
   , buildProject
   , runTests
   ) where
 
 import Control.Monad (when)
+import Data.Aeson (eitherDecodeFileStrict, Value(..))
+import qualified Data.Aeson.Key as AKey
+import qualified Data.Aeson.KeyMap as KM
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
 import Data.Maybe (isJust)
 import Data.Text (Text)
 import qualified Data.Text as T
-import qualified Data.Text.IO as TIO
 import System.Directory (doesFileExist, findExecutable)
 import System.Exit (ExitCode(..))
 import System.FilePath ((</>))
@@ -29,7 +34,7 @@
   | Bun
   deriving (Show, Eq)
 
--- | Get command name for package manager
+-- | Get install command name for package manager
 packageManagerCommand :: PackageManager -> String
 packageManagerCommand = \case
   Npm  -> "npm"
@@ -37,56 +42,75 @@
   Yarn -> "yarn"
   Bun  -> "bun"
 
+-- | Get (command, args-prefix) for running an exec via the package manager
+-- e.g. bun x tsc, pnpm exec tsc, npx tsc
+packageManagerExec :: PackageManager -> (String, [String])
+packageManagerExec = \case
+  Bun  -> ("bun", ["x"])
+  Pnpm -> ("pnpm", ["exec"])
+  Yarn -> ("yarn", [])
+  Npm  -> ("npx", [])
+
+-- | Get (command, args) for running tests via the package manager
+packageManagerTestCmd :: PackageManager -> (String, [String])
+packageManagerTestCmd = \case
+  Bun  -> ("bun", ["test"])
+  Pnpm -> ("pnpm", ["test"])
+  Yarn -> ("yarn", ["test"])
+  Npm  -> ("npm", ["test"])
+
 -- | Detect which package manager to use for TypeScript project
 detectPackageManager :: GeneratedPath -> Bool -> IO PackageManager
 detectPackageManager (GeneratedPath path) debug = do
   when debug $ putStrLn "[*] Detecting package manager..."
 
-  -- Priority 1: Check for lockfiles
-  hasPnpmLock <- doesFileExist (path </> "pnpm-lock.yaml")
-  if hasPnpmLock
+  -- Priority 1: Check for lockfiles (most reliable signal)
+  hasBunLock  <- (||) <$> doesFileExist (path </> "bun.lock")
+                          <*> doesFileExist (path </> "bun.lockb")
+  if hasBunLock
     then do
-      when debug $ putStrLn "  [+] Found pnpm-lock.yaml, using pnpm"
-      pure Pnpm
+      when debug $ putStrLn "  [+] Found bun.lock, using bun"
+      pure Bun
     else do
-      hasYarnLock <- doesFileExist (path </> "yarn.lock")
-      if hasYarnLock
+      hasPnpmLock <- doesFileExist (path </> "pnpm-lock.yaml")
+      if hasPnpmLock
         then do
-          when debug $ putStrLn "  [+] Found yarn.lock, using yarn"
-          pure Yarn
+          when debug $ putStrLn "  [+] Found pnpm-lock.yaml, using pnpm"
+          pure Pnpm
         else do
-          -- Priority 2: Check available commands (prefer pnpm > yarn > bun > npm)
-          hasPnpm <- isJust <$> findExecutable "pnpm"
-          if hasPnpm
+          hasYarnLock <- doesFileExist (path </> "yarn.lock")
+          if hasYarnLock
             then do
-              when debug $ putStrLn "  [+] pnpm available, using pnpm"
-              pure Pnpm
+              when debug $ putStrLn "  [+] Found yarn.lock, using yarn"
+              pure Yarn
             else do
-              hasYarn <- isJust <$> findExecutable "yarn"
-              if hasYarn
+              hasNpmLock <- doesFileExist (path </> "package-lock.json")
+              if hasNpmLock
                 then do
-                  when debug $ putStrLn "  [+] yarn available, using yarn"
-                  pure Yarn
+                  when debug $ putStrLn "  [+] Found package-lock.json, using npm"
+                  pure Npm
                 else do
+                  -- Priority 2: Check available commands (prefer bun > pnpm > yarn > npm)
                   hasBun <- isJust <$> findExecutable "bun"
                   if hasBun
                     then do
                       when debug $ putStrLn "  [+] bun available, using bun"
                       pure Bun
                     else do
-                      when debug $ putStrLn "  [+] Defaulting to npm"
-                      pure Npm
-
--- | Check if package.json contains workspace protocol
-checkForWorkspaceProtocol :: GeneratedPath -> IO Bool
-checkForWorkspaceProtocol (GeneratedPath path) = do
-  let packageJson = path </> "package.json"
-  exists <- doesFileExist packageJson
-  if exists
-    then do
-      content <- TIO.readFile packageJson
-      pure $ "workspace:" `T.isInfixOf` content
-    else pure False
+                      hasPnpm <- isJust <$> findExecutable "pnpm"
+                      if hasPnpm
+                        then do
+                          when debug $ putStrLn "  [+] pnpm available, using pnpm"
+                          pure Pnpm
+                        else do
+                          hasYarn <- isJust <$> findExecutable "yarn"
+                          if hasYarn
+                            then do
+                              when debug $ putStrLn "  [+] yarn available, using yarn"
+                              pure Yarn
+                            else do
+                              when debug $ putStrLn "  [+] Defaulting to npm"
+                              pure Npm
 
 -- | Provide helpful suggestions based on error patterns
 checkInstallError :: Text -> [Text]
@@ -97,10 +121,6 @@
       ["Network error - check your internet connection"]
   | "ENETUNREACH" `T.isInfixOf` stderr =
       ["Network unreachable - check firewall/proxy settings"]
-  | "workspace:" `T.isInfixOf` stderr =
-      ["npm doesn't support workspace: protocol"
-      ,"Use pnpm, yarn, or bun: npm install -g pnpm && pnpm install"
-      ,"Or enable bundle-transport: --bundle-transport=true"]
   | otherwise = []
 
 -- ============================================================================
@@ -116,17 +136,6 @@
   pm <- detectPackageManager genPath debug
   let pmCmd = packageManagerCommand pm
 
-  -- Check for workspace protocol with npm (warn user)
-  when (pm == Npm) $ do
-    hasWorkspace <- checkForWorkspaceProtocol genPath
-    when hasWorkspace $ do
-      putStrLn "\nWarning: This project uses the 'workspace:*' protocol"
-      putStrLn "npm does not support workspace protocol."
-      putStrLn "\nOptions:"
-      putStrLn "  1. Use pnpm: npm install -g pnpm && pnpm install"
-      putStrLn "  2. Use yarn: npm install -g yarn && yarn install"
-      putStrLn "  3. Set --bundle-transport=true to avoid workspace dependency"
-
   -- Verify package manager is actually available
   mbPmPath <- findExecutable pmCmd
   case mbPmPath of
@@ -159,6 +168,64 @@
   when debug $ putStrLn "[*] Rust dependency installation not yet implemented (Phase 2 TODO)"
   pure $ Right ()
 
+-- | Add specific packages to the project using the detected package manager.
+-- Calls @pm add \<pkgs\>@ for runtime deps and @pm add -D \<pkgs\>@ for dev deps.
+-- Skips packages already present in package.json (no-op on subsequent builds).
+-- Returns True if packages were actually added, False if all were already present.
+addDependencies
+  :: GeneratedPath
+  -> Map Text Text   -- ^ Runtime dependencies (name -> version)
+  -> Map Text Text   -- ^ Dev dependencies (name -> version)
+  -> Bool            -- ^ Debug
+  -> IO (Either SynapseCCError Bool)
+addDependencies _       deps devDeps _ | Map.null deps && Map.null devDeps = pure (Right False)
+addDependencies genPath deps devDeps debug = do
+  existing <- readExistingDeps (unGeneratedPath genPath)
+  let newDeps    = Map.filterWithKey (\k _ -> k `Map.notMember` existing) deps
+      newDevDeps = Map.filterWithKey (\k _ -> k `Map.notMember` existing) devDeps
+  if Map.null newDeps && Map.null newDevDeps
+    then pure (Right False)
+    else do
+      pm <- detectPackageManager genPath debug
+      let pmCmd = packageManagerCommand pm
+          dir   = Just (unGeneratedPath genPath)
+      -- Add runtime deps
+      rtResult <- if Map.null newDeps then pure (Right ()) else do
+        let pkgs = Map.keys newDeps
+        when debug $ putStrLn $ "  [+] " <> pmCmd <> " add " <> unwords (map T.unpack pkgs)
+        r <- runProcess pmCmd ("add" : map T.unpack pkgs) dir debug
+        pure $ case prExitCode r of
+          ExitSuccess   -> Right ()
+          ExitFailure c -> Left $ LanguageToolError (T.pack pmCmd <> " add") (prStderr r) c
+      -- Add dev deps
+      devResult <- if Map.null newDevDeps then pure (Right ()) else do
+        let pkgs = Map.keys newDevDeps
+        when debug $ putStrLn $ "  [+] " <> pmCmd <> " add -D " <> unwords (map T.unpack pkgs)
+        r <- runProcess pmCmd (["add", "-D"] <> map T.unpack pkgs) dir debug
+        pure $ case prExitCode r of
+          ExitSuccess   -> Right ()
+          ExitFailure c -> Left $ LanguageToolError (T.pack pmCmd <> " add -D") (prStderr r) c
+      pure $ fmap (const True) (rtResult >> devResult)
+
+-- | Read all declared dependencies (runtime + dev) from an existing package.json.
+-- Returns an empty map if the file doesn't exist or can't be parsed.
+readExistingDeps :: FilePath -> IO (Map Text Text)
+readExistingDeps dir = do
+  let pkgPath = dir </> "package.json"
+  exists <- doesFileExist pkgPath
+  if not exists then pure Map.empty else do
+    result <- eitherDecodeFileStrict pkgPath :: IO (Either String Value)
+    case result of
+      Left _           -> pure Map.empty
+      Right (Object o) ->
+        let extract key = case KM.lookup (AKey.fromText key) o of
+              Just (Object deps) -> Map.fromList
+                [ (AKey.toText k, v')
+                | (k, String v') <- KM.toList deps ]
+              _ -> Map.empty
+        in pure $ Map.union (extract "dependencies") (extract "devDependencies")
+      Right _ -> pure Map.empty
+
 -- ============================================================================
 -- Building/Type-checking
 -- ============================================================================
@@ -168,15 +235,19 @@
 buildProject TypeScript genPath debug = do
   when debug $ putStrLn $ "[*] Type-checking TypeScript in " ++ unGeneratedPath genPath
 
-  -- Run tsc --noEmit for type-checking
-  result <- runProcess "npx" ["tsc", "--noEmit"] (Just $ unGeneratedPath genPath) debug
+  pm <- detectPackageManager genPath debug
+  let (cmd, prefix) = packageManagerExec pm
+      args = prefix ++ ["tsc", "--noEmit"]
 
+  result <- runProcess cmd args (Just $ unGeneratedPath genPath) debug
+
   case prExitCode result of
     ExitSuccess -> do
       when debug $ putStrLn "  [+] TypeScript type-check passed"
       pure $ Right $ CompiledPath $ unGeneratedPath genPath
     ExitFailure code -> do
-      pure $ Left $ LanguageToolError "tsc" (prStderr result) code
+      let combined = T.unlines $ filter (not . T.null . T.strip) [prStdout result, prStderr result]
+      pure $ Left $ LanguageToolError "tsc" combined code
 
 buildProject Python genPath debug = do
   when debug $ putStrLn "[*] Python build not yet implemented (Phase 2 TODO)"
@@ -197,18 +268,21 @@
   Python -> pure $ Right ()  -- TODO: Implement Python tests
   Rust -> pure $ Right ()  -- TODO: Implement Rust tests
 
--- | Run TypeScript smoke tests using npm
+-- | Run TypeScript smoke tests
 runTypeScriptTests :: GeneratedPath -> Bool -> IO (Either SynapseCCError ())
-runTypeScriptTests (GeneratedPath path) debug = do
-  when debug $ putStrLn $ "[*] Running smoke tests in " ++ path
+runTypeScriptTests genPath debug = do
+  when debug $ putStrLn $ "[*] Running smoke tests in " ++ unGeneratedPath genPath
 
-  -- Check if npm is available
-  result <- runProcess "npm" ["test"] (Just path) debug
+  pm <- detectPackageManager genPath debug
+  let (cmd, args) = packageManagerTestCmd pm
+      toolLabel = T.pack cmd <> " test"
 
+  result <- runProcess cmd args (Just $ unGeneratedPath genPath) debug
+
   case prExitCode result of
     ExitSuccess -> do
       when debug $ putStrLn "  [+] Tests passed"
       pure $ Right ()
     ExitFailure code -> do
-      let stderr = prStderr result
-      pure $ Left $ LanguageToolError "npm test" stderr code
+      let combined = T.unlines $ filter (not . T.null . T.strip) [prStdout result, prStderr result]
+      pure $ Left $ LanguageToolError toolLabel combined code
diff --git a/src/SynapseCC/Lock.hs b/src/SynapseCC/Lock.hs
new file mode 100644
--- /dev/null
+++ b/src/SynapseCC/Lock.hs
@@ -0,0 +1,127 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | synapse.lock — project-level lockfile for reproducible builds.
+-- Committed to git; makes backend changes visible as a diff.
+module SynapseCC.Lock
+  ( SynapseLock(..), LockTarget(..)
+  , readSynapseLock, writeSynapseLock
+  , lookupLockTarget, synapseLockPath
+  , emptySynapseLock
+  ) where
+
+import Data.Aeson (FromJSON, ToJSON, eitherDecodeFileStrict)
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Encode.Pretty as Pretty
+import qualified Data.ByteString.Lazy as BL
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as T
+import System.Directory (doesFileExist)
+
+import SynapseCC.Types (synapseCCVersion)
+
+-- | Standard lock file name (lives next to synapse.config.json in CWD)
+synapseLockPath :: FilePath
+synapseLockPath = "synapse.lock"
+
+-- ============================================================================
+-- Types
+-- ============================================================================
+
+-- | Per-target entry in synapse.lock, keyed by outputDir
+data LockTarget = LockTarget
+  { ltBackend   :: !Text
+  , ltIrHash    :: !Text
+  , ltTransport :: !Text
+  , ltFiles     :: !(Map Text Text)  -- ^ relPath → content hash
+  } deriving (Show, Eq)
+
+instance FromJSON LockTarget where
+  parseJSON = Aeson.withObject "LockTarget" $ \o -> LockTarget
+    <$> o Aeson..: "backend"
+    <*> o Aeson..: "irHash"
+    <*> o Aeson..: "transport"
+    <*> o Aeson..: "files"
+
+instance ToJSON LockTarget where
+  toJSON lt = Aeson.object
+    [ "backend"   Aeson..= ltBackend lt
+    , "irHash"    Aeson..= ltIrHash lt
+    , "transport" Aeson..= ltTransport lt
+    , "files"     Aeson..= ltFiles lt
+    ]
+
+-- | Top-level synapse.lock structure
+data SynapseLock = SynapseLock
+  { slVersion    :: !Text
+  , slSynapseCC  :: !Text
+  , slHubCodegen :: !Text
+  , slUpdatedAt  :: !Text
+  , slTargets    :: !(Map Text LockTarget)  -- ^ outputDir → LockTarget
+  } deriving (Show, Eq)
+
+instance FromJSON SynapseLock where
+  parseJSON = Aeson.withObject "SynapseLock" $ \o -> SynapseLock
+    <$> o Aeson..: "version"
+    <*> o Aeson..: "synapseCC"
+    <*> o Aeson..: "hubCodegen"
+    <*> o Aeson..: "updatedAt"
+    <*> o Aeson..: "targets"
+
+instance ToJSON SynapseLock where
+  toJSON sl = Aeson.object
+    [ "version"    Aeson..= slVersion sl
+    , "synapseCC"  Aeson..= slSynapseCC sl
+    , "hubCodegen" Aeson..= slHubCodegen sl
+    , "updatedAt"  Aeson..= slUpdatedAt sl
+    , "targets"    Aeson..= slTargets sl
+    ]
+
+-- | Empty lock, used as a base when creating a fresh synapse.lock
+emptySynapseLock :: SynapseLock
+emptySynapseLock = SynapseLock
+  { slVersion    = "1"
+  , slSynapseCC  = synapseCCVersion
+  , slHubCodegen = ""
+  , slUpdatedAt  = ""
+  , slTargets    = Map.empty
+  }
+
+-- ============================================================================
+-- Pretty-print config
+-- ============================================================================
+
+lockPrettyConfig :: Pretty.Config
+lockPrettyConfig = Pretty.defConfig
+  { Pretty.confCompare = Pretty.keyOrder
+      [ "version", "synapseCC", "hubCodegen", "updatedAt", "targets"
+      , "backend", "irHash", "transport", "files"
+      ]
+  }
+
+-- ============================================================================
+-- Read / Write
+-- ============================================================================
+
+-- | Read synapse.lock from CWD. Returns Nothing on missing or parse error.
+readSynapseLock :: IO (Maybe SynapseLock)
+readSynapseLock = do
+  exists <- doesFileExist synapseLockPath
+  if not exists
+    then pure Nothing
+    else do
+      result <- eitherDecodeFileStrict synapseLockPath
+      case result of
+        Left  _ -> pure Nothing
+        Right sl -> pure (Just sl)
+
+-- | Write synapse.lock to CWD (pretty-printed JSON).
+writeSynapseLock :: SynapseLock -> IO ()
+writeSynapseLock sl =
+  BL.writeFile synapseLockPath (Pretty.encodePretty' lockPrettyConfig sl)
+
+-- | Look up the LockTarget for a given outputDir.
+lookupLockTarget :: FilePath -> SynapseLock -> Maybe LockTarget
+lookupLockTarget outputDir sl =
+  Map.lookup (T.pack outputDir) (slTargets sl)
diff --git a/src/SynapseCC/Logging.hs b/src/SynapseCC/Logging.hs
--- a/src/SynapseCC/Logging.hs
+++ b/src/SynapseCC/Logging.hs
@@ -5,6 +5,7 @@
   , logError
   , logDebug
   , logStep
+  , logSubStep
   ) where
 
 import Data.Text (Text)
@@ -48,11 +49,20 @@
   setSGR [Reset]
 logDebug False _ = pure ()
 
--- | Log a pipeline step
+-- | Log a top-level pipeline step (blank line before, bold yellow ==>)
 logStep :: Text -> IO ()
 logStep msg = do
   TIO.putStrLn ""
   setSGR [SetColor Foreground Vivid Yellow, SetConsoleIntensity BoldIntensity]
   TIO.putStr "==> "
+  TIO.putStrLn msg
+  setSGR [Reset]
+
+-- | Log a sub-step within a target build (no leading blank line, indented)
+-- Used for steps that are visually nested under a logStep target header.
+logSubStep :: Text -> IO ()
+logSubStep msg = do
+  setSGR [SetColor Foreground Vivid Yellow, SetConsoleIntensity BoldIntensity]
+  TIO.putStr "    ==> "
   TIO.putStrLn msg
   setSGR [Reset]
diff --git a/src/SynapseCC/Merge.hs b/src/SynapseCC/Merge.hs
new file mode 100644
--- /dev/null
+++ b/src/SynapseCC/Merge.hs
@@ -0,0 +1,157 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Three-way merge for generated files.
+-- Preserves user modifications while applying generator updates safely.
+module SynapseCC.Merge
+  ( computeFileHash
+  , applyMerge
+  , cleanRemovedFiles
+  , MergeResult(..)
+  ) where
+
+import qualified Crypto.Hash.SHA256 as SHA256
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base16 as Base16
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import qualified Data.Text.Encoding.Error as TEE
+import Control.Exception (try, SomeException)
+import Data.Maybe (catMaybes)
+import System.Directory ( createDirectoryIfMissing, doesFileExist
+                        , removeFile, listDirectory, removeDirectory )
+import System.FilePath ((</>), takeDirectory)
+
+-- | Compute SHA-256 hash of text content, returning first 16 hex characters.
+-- Algorithm matches hub-codegen hash.rs: SHA-256(utf8_bytes)[..16] as lowercase hex.
+computeFileHash :: Text -> Text
+computeFileHash content =
+  T.take 16 $ TE.decodeUtf8 $ Base16.encode $ SHA256.hash $ TE.encodeUtf8 content
+
+data FileStatus
+  = NewFile
+  | SafeToUpdate
+  | Unchanged
+  | UserModified
+  deriving (Show, Eq)
+
+-- | Three-way merge decision table:
+--
+--   cached  | current | new  | status
+--   --------+---------+------+---------------
+--   ∅       | ∅       | any  | NewFile
+--   any     | ∅       | any  | SafeToUpdate (restore deleted file)
+--   ∅       | H       | H    | Unchanged
+--   ∅       | H       | H2   | NewFile
+--   H1==H1  | same    | same | Unchanged
+--   H1==H1  | same    | H2   | SafeToUpdate
+--   H1      | H2≠H1   | any  | UserModified (skip)
+determineStatus :: Maybe Text -> Maybe Text -> Text -> FileStatus
+determineStatus Nothing   Nothing      _new = NewFile
+determineStatus (Just _)  Nothing      _new = SafeToUpdate
+determineStatus Nothing   (Just curr)  new
+  | curr == new = Unchanged
+  | otherwise   = NewFile
+determineStatus (Just cached) (Just curr) new
+  | cached == curr = if curr == new then Unchanged else SafeToUpdate
+  | otherwise      = UserModified
+
+-- | Result of applying a merge
+data MergeResult = MergeResult
+  { mrNew       :: [Text]  -- ^ New files written
+  , mrUpdated   :: [Text]  -- ^ Existing files safely updated
+  , mrUnchanged :: [Text]  -- ^ Files unchanged (write skipped)
+  , mrSkipped   :: [Text]  -- ^ Files skipped due to user modifications
+  , mrDeleted   :: [Text]  -- ^ Stale generated files removed
+  } deriving (Show, Eq)
+
+-- | Apply three-way merge: write generated files to disk, skipping user-modified ones.
+applyMerge
+  :: Map Text Text  -- ^ Generated file contents (relPath -> content)
+  -> Map Text Text  -- ^ Generated file hashes   (relPath -> hash, unused but kept for API symmetry)
+  -> Map Text Text  -- ^ Cached file hashes       (relPath -> hash)
+  -> FilePath       -- ^ Output directory
+  -> IO MergeResult
+applyMerge generatedFiles _generatedHashes cachedHashes outputDir = do
+  results <- mapM processFile (Map.toList generatedFiles)
+  pure $ foldr addResult emptyResult results
+  where
+    emptyResult = MergeResult [] [] [] [] []
+
+    processFile (relPath, content) = do
+      let fullPath   = outputDir </> T.unpack relPath
+          newHash    = computeFileHash content
+          cachedHash = Map.lookup relPath cachedHashes
+      currentHash <- readCurrentHash fullPath
+      let status = determineStatus cachedHash currentHash newHash
+      case status of
+        NewFile      -> writeFile' fullPath content >> pure (relPath, NewFile)
+        SafeToUpdate -> writeFile' fullPath content >> pure (relPath, SafeToUpdate)
+        Unchanged    -> pure (relPath, Unchanged)
+        UserModified -> pure (relPath, UserModified)
+
+    readCurrentHash path = do
+      exists <- doesFileExist path
+      if exists
+        then do
+          bytes <- BS.readFile path
+          pure $ Just $ computeFileHash $ TE.decodeUtf8With TEE.lenientDecode bytes
+        else pure Nothing
+
+    writeFile' path content = do
+      createDirectoryIfMissing True (takeDirectory path)
+      BS.writeFile path (TE.encodeUtf8 content)
+
+    addResult (p, NewFile)      mr = mr { mrNew       = p : mrNew       mr }
+    addResult (p, SafeToUpdate) mr = mr { mrUpdated   = p : mrUpdated   mr }
+    addResult (p, Unchanged)    mr = mr { mrUnchanged = p : mrUnchanged mr }
+    addResult (p, UserModified) mr = mr { mrSkipped   = p : mrSkipped   mr }
+
+-- | Delete stale generated files: those present in the previous generation's
+-- cached hashes but absent from the new generation.
+--
+-- A file is only deleted if it hasn't been modified since the last generation
+-- (current hash == cached hash). User-modified files are left untouched.
+--
+-- After file deletion, empty parent directories are pruned up to (but not
+-- including) the output root.
+cleanRemovedFiles
+  :: Map Text Text  -- ^ Old cached file hashes (previous generation)
+  -> Map Text Text  -- ^ New generated file hashes (current generation)
+  -> FilePath       -- ^ Output directory
+  -> IO [Text]      -- ^ Paths of deleted files (relative)
+cleanRemovedFiles oldHashes newHashes outputDir = do
+  let stale = Map.keys $ Map.difference oldHashes newHashes
+  deleted <- mapM tryDelete stale
+  pure (catMaybes deleted)
+  where
+    tryDelete relPath = do
+      let fullPath   = outputDir </> T.unpack relPath
+          cachedHash = Map.findWithDefault "" relPath oldHashes
+      exists <- doesFileExist fullPath
+      if not exists
+        then pure Nothing  -- already gone
+        else do
+          bytes <- BS.readFile fullPath
+          let current = computeFileHash $ TE.decodeUtf8With TEE.lenientDecode bytes
+          if current /= cachedHash
+            then pure Nothing  -- user-modified: leave it
+            else do
+              removeFile fullPath
+              pruneEmptyDirs (takeDirectory fullPath) outputDir
+              pure (Just relPath)
+
+-- | Remove empty directories walking upward, stopping at the output root.
+pruneEmptyDirs :: FilePath -> FilePath -> IO ()
+pruneEmptyDirs dir root
+  | dir == root || length dir <= length root = pure ()
+  | otherwise = do
+      contentsResult <- try (listDirectory dir) :: IO (Either SomeException [FilePath])
+      case contentsResult of
+        Left _         -> pure ()
+        Right []       -> do
+          _ <- try (removeDirectory dir) :: IO (Either SomeException ())
+          pruneEmptyDirs (takeDirectory dir) root
+        Right _nonempty -> pure ()
diff --git a/src/SynapseCC/Pipeline.hs b/src/SynapseCC/Pipeline.hs
--- a/src/SynapseCC/Pipeline.hs
+++ b/src/SynapseCC/Pipeline.hs
@@ -1,30 +1,45 @@
 -- | Pipeline orchestration - running the full toolchain
 module SynapseCC.Pipeline
   ( runPipeline
+  , runBuildFromConfig
   , generateIR
   , generateCode
+  , formatSynapseError
   ) where
 
-import Control.Monad (unless, when)
+import Control.Exception (try, SomeException)
+import Control.Monad (when, unless, forM_)
 import Data.Aeson (FromJSON, eitherDecodeStrict, eitherDecodeFileStrict)
 import qualified Data.Aeson as Aeson
 import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as BL
 import qualified Data.Map.Strict as Map
 import Data.Maybe (fromMaybe)
-import Data.Monoid (mempty)
 import Data.Text (Text)
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
+import qualified Data.Text.IO as TIO
+import Data.Time.Clock (UTCTime, getCurrentTime, diffUTCTime)
+import Data.Time.Format (formatTime, defaultTimeLocale)
 import GHC.Generics (Generic)
-import System.Directory (createDirectoryIfMissing, doesFileExist)
+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist, getCurrentDirectory, listDirectory)
 import System.Exit (ExitCode(..))
-import System.FilePath ((</>), takeDirectory)
+import System.FilePath ((</>))
 
+import Synapse.Monad (initEnv, runSynapseM, SynapseError(..))
+import Synapse.IR.Builder (buildIR)
+
+import SynapseCC.Benchmark (timeStep, baselinePath, reportBenchmarks)
+import SynapseCC.Config (buildConfigFromTarget)
+import SynapseCC.Logging (logDebug, logInfo, logStep, logSuccess)
 import SynapseCC.Types
 import SynapseCC.Process
 import SynapseCC.Discover
 import qualified SynapseCC.Language as Language
 import qualified SynapseCC.Cache as Cache
+import SynapseCC.Cache (getCacheDir)
+import qualified SynapseCC.Merge as Merge
+import qualified SynapseCC.Lock as Lock
 
 -- ============================================================================
 -- Pipeline Orchestration
@@ -36,152 +51,396 @@
   let debug = optDebug (cfgOptions config)
 
   -- Step 0: Check cache (unless --force is set)
-  cacheResult <- Cache.validateCache config
+  cacheResult <- Cache.validateCache config tools
   case cacheResult of
     FullCacheHit -> do
-      when debug $ putStrLn "\n[+] Full cache hit (versions match)"
-      when debug $ putStrLn "  [*] Using cached output"
-      -- TODO: Copy cached code to output directory if needed
+      logDebug debug "Full cache hit (versions match)"
       let outputPath = optOutput (cfgOptions config)
-      pure $ Right $ CompiledPath outputPath
+      -- If synapse.lock has an entry for this target, verify the live irHash
+      -- before skipping.  This catches dynamic backends (e.g. fidget-spinner)
+      -- where the schema can change without a toolchain version bump.
+      lockResult <- Lock.readSynapseLock
+      case lockResult >>= Lock.lookupLockTarget outputPath of
+        Nothing ->
+          -- No lock entry — fall through to directory existence check
+          useCachedOutput debug outputPath
+        Just lt -> do
+          logDebug debug "  Checking live irHash against synapse.lock..."
+          irCheckResult <- generateIR config tools
+          case irCheckResult of
+            Left _ -> do
+              -- Backend unreachable — use cached output gracefully (offline mode)
+              logDebug debug "  Cannot reach backend — using cached output"
+              useCachedOutput debug outputPath
+            Right (IRPath irFile) -> do
+              irBytes <- BS.readFile irFile
+              -- Use the IR's own irHash field (content-stable, excludes timestamps).
+              let liveHash = case eitherDecodeStrict irBytes of
+                    Right (irData :: IRData) -> fromMaybe "" (irdIrHash irData)
+                    Left _                   -> Merge.computeFileHash (TE.decodeUtf8 irBytes)
+              if liveHash == Lock.ltIrHash lt
+                then do
+                  logDebug debug $ "  irHash unchanged (" <> T.take 8 liveHash <> "...) — using cache"
+                  useCachedOutput debug outputPath
+                else do
+                  logDebug debug $ "  irHash changed: " <> T.take 8 (Lock.ltIrHash lt)
+                                <> " → " <> T.take 8 liveHash
+                  runFullPipeline config tools
+      where
+        useCachedOutput dbg outPath = do
+          dirExists <- doesDirectoryExist outPath
+          files <- if dirExists then listDirectory outPath else pure []
+          if dirExists && not (null files)
+            then do
+              logDebug dbg "  Using cached output"
+              pure $ Right $ CompiledPath outPath
+            else do
+              logDebug dbg "  Cache hit but output directory missing or empty — regenerating"
+              runFullPipeline config tools
 
     CacheMiss reason -> do
-      when debug $ putStrLn $ "\n[!] Cache miss: " ++ show reason
-      when debug $ putStrLn "  [*] Regenerating..."
+      logDebug debug $ "Cache miss: " <> T.pack (show reason)
+      logDebug debug "  Regenerating..."
       runFullPipeline config tools
 
     PartialCacheHit valid invalid -> do
-      when debug $ putStrLn "\n[*] Partial cache hit"
-      when debug $ putStrLn $ "  [+] Valid plugins: " ++ show valid
-      when debug $ putStrLn $ "  [!] Invalid plugins: " ++ show invalid
-      when debug $ putStrLn "  [*] Regenerating invalid plugins..."
-      -- TODO: Implement partial regeneration
-      -- For now, do full regeneration
+      let totalPlugins = length valid + length invalid
+      logInfo $ T.pack (show (length invalid)) <> " of " <> T.pack (show totalPlugins)
+              <> " plugins changed — regenerating"
       runFullPipeline config tools
 
+-- | Minimal starter package.json written when none exists in the output directory.
+-- Includes standard scripts for the generated client; users own the name/version.
+-- Dependencies are NOT listed here — they are added via `pm add` by addDependencies.
+-- | Marker field written into the starter package.json.
+-- Presence of this field in the cwd package.json tells synapse-cc that IT
+-- created the file (standalone mode), so scaffolding files (tsconfig, test/)
+-- continue to be regenerated on subsequent runs.  Users who remove this field
+-- opt in to integration mode — synapse-cc will stop managing those files.
+synapseCCMarker :: Text
+synapseCCMarker = "\"_generatedBy\": \"synapse-cc\""
+
+-- | Marker present in package.json files generated by hub-codegen.
+-- Used alongside synapseCCMarker to detect generated (standalone) output directories.
+generatedByMarker :: Text
+generatedByMarker = "\"_generatedBy\""
+
+-- | Generate tsconfig.json for the synapse-cc managed output directory.
+-- Excludes test/ so bun:test imports don't cause tsc errors — bun handles
+-- test files natively and doesn't need tsconfig to know about bun:test.
+generateTsconfig :: TransportType -> Text
+generateTsconfig transport =
+  let typeConfig = case transport of
+        BrowserTransport -> "\"lib\": [\"ES2022\", \"DOM\"]"
+        WsTransport      -> "\"types\": [\"node\"]"
+  in T.unlines
+    [ "{"
+    , "  \"compilerOptions\": {"
+    , "    \"target\": \"ES2022\","
+    , "    \"module\": \"ESNext\","
+    , "    \"moduleResolution\": \"bundler\","
+    , "    \"strict\": true,"
+    , "    \"skipLibCheck\": true,"
+    , "    \"noEmit\": true,"
+    , "    " <> typeConfig
+    , "  },"
+    , "  \"include\": [\"*.ts\"]"
+    , "}"
+    ]
+
 -- | Run the full pipeline without cache
 runFullPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath)
 runFullPipeline config tools = do
-  let debug = optDebug (cfgOptions config)
+  let debug  = optDebug (cfgOptions config)
+      opts   = cfgOptions config
+      Backend backendName = cfgBackend config
+      targetName = targetToText (cfgTarget config)
+      outputDir = optOutput opts
 
-  -- Step 1: Generate IR
-  when debug $ putStrLn "\n[*] Generating IR..."
-  irResult <- generateIR config tools
+  pipelineStart <- getCurrentTime
+  cacheDir <- getCacheDir opts
+
+  -- Step 1: Read schema from backend
+  logStep "Reading schema..."
+  (irResult, irMs) <- timeStep $ generateIR config tools
   case irResult of
     Left err -> pure $ Left err
     Right irPath -> do
-      when debug $ putStrLn $ "  [+] IR generated at " ++ unIRPath irPath
+      irBytes <- BS.readFile (unIRPath irPath)
+      let pluginCount = case eitherDecodeStrict irBytes of
+            Right (ir :: IRData) -> Map.size (irdPlugins ir)
+            Left _               -> 0
+      logSuccess $ "Schema ready (" <> T.pack (show pluginCount) <> " plugins)"
+      logDebug debug $ "  IR at " <> T.pack (unIRPath irPath)
 
       -- Step 2: Generate code
-      when debug $ putStrLn "\n[*] Generating code..."
-      codeResult <- generateCode config tools irPath
+      logStep "Generating code..."
+      (codeResult, codeMs) <- timeStep $ generateCode config tools irPath Nothing
       case codeResult of
         Left err -> pure $ Left err
-        Right genPath -> do
-          when debug $ putStrLn $ "  [+] Code generated at " ++ unGeneratedPath genPath
+        Right out -> do
+          logSuccess $ "Code generated (" <> T.pack (show (Map.size (coFiles out))) <> " files)"
 
+          -- Detect standalone vs integration mode.
+          --
+          -- Check the OUTPUT directory first: if it already has a package.json with a
+          -- "_generatedBy" field, hub-codegen (or a prior synapse-cc run) wrote it
+          -- → standalone, regardless of what the CWD contains.
+          --
+          -- If outputDir has no package.json, fall back to checking the CWD: if the CWD
+          -- has a package.json without our marker, the user ran synapse-cc from inside a
+          -- host project → integration mode (Tauri, monorepo, etc.).
+          --
+          -- Integration mode strips test/ scaffolding, skips devDeps, and skips
+          -- build/test steps — the host project owns those concerns.
+          cwd <- getCurrentDirectory
+          let pmPath    = GeneratedPath cwd
+              cwdPkgPath = cwd </> "package.json"
+              outPkgJsonPath = outputDir </> "package.json"
+          outPkgExists <- doesFileExist outPkgJsonPath
+          isIntegration <-
+            if outPkgExists
+              then do
+                -- outputDir has a package.json — if it has _generatedBy it's ours (standalone)
+                content <- TIO.readFile outPkgJsonPath
+                pure $ not (generatedByMarker `T.isInfixOf` content)
+              else do
+                -- outputDir has no package.json — check CWD for host project
+                cwdPkgExists <- doesFileExist cwdPkgPath
+                if not cwdPkgExists
+                  then pure False
+                  else do
+                    cwdContent <- TIO.readFile cwdPkgPath
+                    pure $ not (synapseCCMarker `T.isInfixOf` cwdContent)
+          when isIntegration $
+            logInfo "  Integration mode — build and test steps skipped"
+          let scaffolding k _ = "test/" `T.isPrefixOf` k
+
+          -- Apply three-way merge: write safe files, skip user-modified ones.
+          -- tsconfig.json is always excluded: synapse-cc writes its own (see below).
+          -- package.json is excluded in integration mode (host project owns it);
+          --   in standalone mode it goes through the merge so script changes are applied.
+          -- In integration mode, also exclude test/* (host project owns those).
+          -- With --force, skip cached hashes so all files are written fresh.
+          cachedHashes <- if optForce opts then pure Map.empty else getCachedFileHashes config
+          let filesToMerge = (if isIntegration then Map.filterWithKey (fmap not . scaffolding) else id)
+                           $ (if isIntegration then Map.delete "package.json" else id)
+                           $ Map.delete "tsconfig.json"
+                           $ coFiles out
+          mergeResult  <- Merge.applyMerge filesToMerge (coFileHashes out) cachedHashes outputDir
+
+          -- Remove stale generated files (plugins removed from backend).
+          -- Only deletes files whose on-disk hash still matches the cached hash
+          -- (i.e. the user hasn't modified them). Empty directories are pruned.
+          deleted <- Merge.cleanRemovedFiles cachedHashes (coFileHashes out) outputDir
+          unless (null deleted) $ do
+            logInfo $ "  🗑  " <> T.pack (show (length deleted))
+                    <> " stale file(s) removed (plugin removed from backend)"
+            when debug $ forM_ deleted $ \f ->
+              logDebug debug $ "      - " <> f
+
+          -- Write ir.json to output dir as a reference artifact
+          irContent <- BS.readFile (unIRPath irPath)
+          BS.writeFile (outputDir </> "ir.json") irContent
+
+          -- Log skipped files (user modifications preserved)
+          let skipped = Merge.mrSkipped mergeResult
+          unless (null skipped) $ do
+            logInfo $ "  ⚠  " <> T.pack (show (length skipped))
+                    <> " file(s) skipped (user modifications preserved)"
+            when debug $ forM_ skipped $ \f ->
+              logDebug debug $ "      - " <> f
+
+          let genPath = GeneratedPath outputDir
+
+          -- Write synapse-cc's tsconfig to the output dir (standalone mode only).
+          -- Integration mode: host project owns tsconfig at its root; we don't touch it.
+          -- The tsconfig only covers *.ts (not test/) so tsc never sees bun:test imports.
+          unless isIntegration $ do
+            logDebug debug "  Writing tsconfig.json"
+            TIO.writeFile (outputDir </> "tsconfig.json") (generateTsconfig (optTransport opts))
+
           -- Step 3: Install dependencies (if enabled)
-          installResult <- if optInstallDeps (cfgOptions config)
+          -- Standalone: deps go into the generated dir (genPath) — it owns its own package.json.
+          -- Integration: deps go into the host project root (pmPath) since there's no generated package.json.
+          let depsToAdd    = coDependencies out
+              devDepsToAdd = if isIntegration then Map.empty else coDevDependencies out
+              depPath      = if isIntegration then pmPath else genPath
+          (installResult, installMs) <- if optInstallDeps opts
             then do
-              when debug $ putStrLn "\n[*] Installing dependencies..."
-              Language.installDependencies (cfgTarget config) genPath debug
+              (addResult, addMs) <- timeStep $ Language.addDependencies
+                depPath
+                depsToAdd
+                devDepsToAdd
+                debug
+              case addResult of
+                Left err -> pure (Left err, addMs)
+                Right added -> do
+                  -- Run installDependencies if new packages were added OR node_modules is absent
+                  nodeModulesExists <- doesDirectoryExist
+                    (unGeneratedPath depPath </> "node_modules")
+                  if not added && nodeModulesExists
+                    then pure (Right (), addMs)
+                    else do
+                      when added $ logSuccess "Dependencies updated"
+                      logStep "Installing dependencies..."
+                      (result, ms) <- timeStep $ Language.installDependencies (cfgTarget config) depPath debug
+                      case result of
+                        Right () -> logSuccess "Dependencies installed"
+                        Left _   -> pure ()
+                      pure (result, addMs + ms)
             else do
-              when debug $ putStrLn "\n[*] Skipping dependency installation (--no-install)"
-              pure $ Right ()
+              logDebug debug "Skipping dependency installation (--no-install)"
+              pure (Right (), 0)
 
           case installResult of
             Left err -> pure $ Left err
             Right () -> do
 
               -- Step 4: Build project (if enabled)
-              buildResult <- if optBuild (cfgOptions config)
+              -- In integration mode tsconfig.json is not written to the output dir;
+              -- the host project's own build system handles compilation.
+              (buildResult, buildMs) <- if optBuild opts && not isIntegration
                 then do
-                  when debug $ putStrLn "\n[*] Building project..."
-                  Language.buildProject (cfgTarget config) genPath debug
+                  logStep "Building..."
+                  (result, ms) <- timeStep $ Language.buildProject (cfgTarget config) genPath debug
+                  case result of
+                    Right _ -> logSuccess "Build passed"
+                    Left _  -> pure ()
+                  pure (result, ms)
                 else do
-                  when debug $ putStrLn "\n[*] Skipping build (--no-build)"
-                  pure $ Right $ CompiledPath $ unGeneratedPath genPath
+                  if isIntegration
+                    then logDebug debug "Skipping build (integration mode — compile via your project's build system)"
+                    else logDebug debug "Skipping build (--no-build)"
+                  pure (Right $ CompiledPath $ unGeneratedPath genPath, 0)
 
               case buildResult of
                 Left err -> pure $ Left err
                 Right compiledPath -> do
 
                   -- Step 5: Run tests (if enabled)
-                  if optRunTests (cfgOptions config)
+                  -- In integration mode test/ is not written to the output dir.
+                  (testResult, testMs) <- if optRunTests opts && not isIntegration
                     then do
-                      when debug $ putStrLn "\n[*] Running smoke tests..."
-                      testResult <- Language.runTests (cfgTarget config) genPath debug
-                      case testResult of
-                        Left err -> pure $ Left err
-                        Right () -> do
-                          when debug $ putStrLn "  [+] All tests passed"
-                          writeCache config compiledPath
-                          pure $ Right compiledPath
-                    else do
-                      writeCache config compiledPath
+                      logStep "Running tests..."
+                      (result, ms) <- timeStep $ Language.runTests (cfgTarget config) genPath debug
+                      case result of
+                        Right () -> logSuccess "Tests passed"
+                        Left _   -> pure ()
+                      pure (result, ms)
+                    else pure (Right (), 0)
+
+                  case testResult of
+                    Left err -> pure $ Left err
+                    Right () -> do
+                      writeCache config tools irPath compiledPath out
+
+                      -- Benchmarks
+                      pipelineEnd <- getCurrentTime
+                      let totalMs  = round (pipelineEnd `diffUTCTime'` pipelineStart * 1000) :: Int
+                          steps    = Map.fromList $ filter (\(_, v) -> v > 0)
+                                       [ ("ir_generation",       irMs)
+                                       , ("code_generation",     codeMs)
+                                       , ("install_dependencies", installMs)
+                                       , ("build",               buildMs)
+                                       , ("tests",               testMs)
+                                       ]
+                          ts       = T.pack $ formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" pipelineEnd
+                          bPath    = baselinePath cacheDir targetName backendName
+
+                      when debug $ reportBenchmarks bPath backendName targetName ts steps totalMs
+
                       pure $ Right compiledPath
 
--- | Write cache manifests after successful generation
-writeCache :: Config -> CompiledPath -> IO ()
-writeCache config _compiledPath = do
-  let debug = optDebug (cfgOptions config)
-      outputDir = optOutput (cfgOptions config)
-  when debug $ putStrLn "\n[*] Writing cache manifests..."
+diffUTCTime' :: UTCTime -> UTCTime -> Double
+diffUTCTime' t1 t0 = realToFrac (t1 `diffUTCTime` t0)
 
-  -- Read file hashes from .codegen-metadata.json
-  fileHashesMap <- readFileHashes outputDir debug
+-- | Write cache manifests after successful generation
+writeCache :: Config -> ToolLocations -> IRPath -> CompiledPath -> CodegenOutput -> IO ()
+writeCache config tools irPath _compiledPath codegenOutput = do
+  let debug         = optDebug (cfgOptions config)
+      synapseVer    = toolSynapseVersion tools
+      hubCodegenVer = coHubCodegenVersion codegenOutput
+  logDebug debug "Writing cache manifests..."
 
   -- Read IR to extract plugin hashes
-  irPluginCaches <- readIRPluginHashes outputDir debug
+  irPluginCaches <- readIRPluginHashes irPath debug
 
   -- Write IR cache manifest with plugin hashes
-  Cache.writeIRCacheManifest (cfgOptions config) (cfgBackend config) irPluginCaches
+  Cache.writeIRCacheManifest (cfgOptions config) (cfgBackend config) irPluginCaches synapseVer
 
-  -- Write code cache with file hashes
-  -- Convert flat file hash map to per-plugin structure
-  let pluginCaches = buildPluginCaches fileHashesMap
-  Cache.writeCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config) pluginCaches
+  -- Write code cache manifest using file hashes from CodegenOutput
+  let pluginCaches = Map.singleton "default" CodePluginCache
+        { cpcIRHash     = ""
+        , cpcFileHashes = coFileHashes codegenOutput
+        , cpcCachedAt   = ""
+        }
+  Cache.writeCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config)
+    pluginCaches synapseVer hubCodegenVer
 
-  when debug $ putStrLn "  [+] Cache manifests written"
+  -- Write synapse.lock (project-level, committed to git)
+  irBytes <- BS.readFile (unIRPath irPath)
+  -- Use the IR's own irHash field (content-stable, no timestamps).
+  -- Fall back to hashing the raw bytes only if the field is absent.
+  let parsedIrHash = case eitherDecodeStrict irBytes of
+        Right (irData :: IRData) -> fromMaybe "" (irdIrHash irData)
+        Left _                   -> ""
+      irHash    = if T.null parsedIrHash
+                    then Merge.computeFileHash (TE.decodeUtf8 irBytes)
+                    else parsedIrHash
+      outputDir = optOutput (cfgOptions config)
+      transport = case optTransport (cfgOptions config) of
+                    WsTransport      -> "ws"
+                    BrowserTransport -> "browser"
+      Backend bkName = cfgBackend config
+      lt = Lock.LockTarget
+             { Lock.ltBackend   = bkName
+             , Lock.ltIrHash    = irHash
+             , Lock.ltTransport = transport
+             , Lock.ltFiles     = coFileHashes codegenOutput
+             }
+  lock <- fromMaybe Lock.emptySynapseLock <$> Lock.readSynapseLock
+  now  <- getCurrentTime
+  Lock.writeSynapseLock $ lock
+    { Lock.slTargets    = Map.insert (T.pack outputDir) lt (Lock.slTargets lock)
+    , Lock.slUpdatedAt  = T.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" now)
+    , Lock.slHubCodegen = hubCodegenVer
+    }
 
--- | Read file hashes from .codegen-metadata.json
-readFileHashes :: FilePath -> Bool -> IO (Map.Map Text Text)
-readFileHashes outputDir debug = do
-  let metadataPath = outputDir </> ".codegen-metadata.json"
-  exists <- doesFileExist metadataPath
-  if not exists
-    then do
-      when debug $ putStrLn "  [!] .codegen-metadata.json not found, skipping file hashes"
-      pure Map.empty
-    else do
-      result <- eitherDecodeFileStrict metadataPath
-      case result of
-        Left err -> do
-          when debug $ putStrLn $ "  [!] Failed to parse metadata: " ++ err
-          pure Map.empty
-        Right (metadata :: CodegenMetadata) -> do
-          when debug $ putStrLn $ "  [+] Read " ++ show (Map.size (ciFileHashes (cmCache metadata))) ++ " file hashes"
-          pure $ ciFileHashes (cmCache metadata)
+  logDebug debug "  Cache manifests written"
 
--- | Read IR plugin hashes from ir.json
-readIRPluginHashes :: FilePath -> Bool -> IO (Map.Map Text IRPluginCache)
-readIRPluginHashes outputDir debug = do
-  let irPath = outputDir </> "ir.json"
-  exists <- doesFileExist irPath
+-- | Read the cached file hashes for three-way merge.
+-- synapse.lock (project-level, committed to git) takes priority over ~/.cache.
+getCachedFileHashes :: Config -> IO (Map.Map Text Text)
+getCachedFileHashes config = do
+  let outputDir = optOutput (cfgOptions config)
+  lockResult <- Lock.readSynapseLock
+  case lockResult >>= Lock.lookupLockTarget outputDir of
+    Just lt -> pure (Lock.ltFiles lt)
+    Nothing -> do
+      result <- Cache.readCodeCacheManifest (cfgOptions config) (cfgBackend config) (cfgTarget config)
+      pure $ case result of
+        Left _         -> Map.empty
+        Right manifest -> maybe Map.empty cpcFileHashes $ Map.lookup "default" (ccmPlugins manifest)
+
+-- | Read IR plugin hashes from ir.json (now at cache path)
+readIRPluginHashes :: IRPath -> Bool -> IO (Map.Map Text IRPluginCache)
+readIRPluginHashes (IRPath irFilePath) debug = do
+  exists <- doesFileExist irFilePath
   if not exists
     then do
-      when debug $ putStrLn "  [!] ir.json not found, skipping IR plugin hashes"
+      logDebug debug "  ir.json not found, skipping IR plugin hashes"
       pure Map.empty
     else do
-      result <- eitherDecodeFileStrict irPath
+      result <- eitherDecodeFileStrict irFilePath
       case result of
         Left err -> do
-          when debug $ putStrLn $ "  [!] Failed to parse IR: " ++ err
+          logDebug debug $ "  Failed to parse IR: " <> T.pack err
           pure Map.empty
         Right (irData :: IRData) -> do
           let pluginHashes = fromMaybe Map.empty (irdPluginHashes irData)
               pluginCaches = Map.mapWithKey (buildIRPluginCache pluginHashes) (irdPlugins irData)
-          when debug $ putStrLn $ "  [+] Extracted hashes for " ++ show (Map.size pluginCaches) ++ " plugins"
+          logDebug debug $ "  Extracted hashes for " <> T.pack (show (Map.size pluginCaches)) <> " plugins"
           pure pluginCaches
 
 -- | Build an IRPluginCache entry from plugin info and hashes
@@ -189,83 +448,179 @@
 buildIRPluginCache hashMap pluginName _methods =
   case Map.lookup pluginName hashMap of
     Just hashes -> IRPluginCache
-      { ipcIRHash = ""  -- TODO: Compute IR hash (WS2)
-      , ipcSchemaHash = phiHash hashes
-      , ipcSelfHash = phiSelfHash hashes
+      { ipcIRHash       = ""
+      , ipcSchemaHash   = phiHash hashes
+      , ipcSelfHash     = phiSelfHash hashes
       , ipcChildrenHash = phiChildrenHash hashes
-      , ipcDependencies = []  -- TODO: Extract dependencies from IR
-      , ipcCachedAt = ""  -- Will be set by writeIRCacheManifest
+      , ipcDependencies = []
+      , ipcCachedAt     = ""
       }
     Nothing -> IRPluginCache
-      { ipcIRHash = ""
-      , ipcSchemaHash = ""  -- No hash info available
-      , ipcSelfHash = ""
+      { ipcIRHash       = ""
+      , ipcSchemaHash   = ""
+      , ipcSelfHash     = ""
       , ipcChildrenHash = ""
       , ipcDependencies = []
-      , ipcCachedAt = ""
+      , ipcCachedAt     = ""
       }
 
--- | Build per-plugin cache entries from flat file hash map
--- For now, group all files into a single "default" plugin entry
--- This will be improved when we implement per-plugin hash tracking
-buildPluginCaches :: Map.Map Text Text -> Map.Map Text CodePluginCache
-buildPluginCaches fileHashes =
-  if Map.null fileHashes
-    then Map.empty
-    else Map.singleton "default" CodePluginCache
-      { cpcIRHash = ""  -- Will be populated when WS1 is complete
-      , cpcFileHashes = fileHashes
-      , cpcCachedAt = ""  -- Will be set by writeCodeCacheManifest
-      }
+-- ============================================================================
+-- Config-file build
+-- ============================================================================
 
+-- | Run a build pass for every target in a SynapseConfig.
+-- CLI options override per-target transport/outputDir only when explicitly
+-- different from their defaults; targets own those fields.
+-- Returns one result per target (name, outcome).
+runBuildFromConfig
+  :: SynapseConfig
+  -> Options       -- ^ CLI flags (--debug, --force, --no-install, etc.)
+  -> ToolLocations
+  -> IO [(T.Text, Either SynapseCCError CompiledPath)]
+runBuildFromConfig sc opts tools =
+  mapM buildTarget (Map.toList (scTargets sc))
+  where
+    buildTarget (name, tc) = do
+      let config = buildConfigFromTarget opts sc tc
+      logInfo $ "Building \"" <> name <> "\" → " <> T.pack (optOutput (cfgOptions config))
+      logDebug (optDebug opts) $ "  target \"" <> name <> "\" is defined in synapse.config.json under .targets"
+      result <- if tcGenerate tc == ["transport"]
+                   then runTransportOnlyPipeline config tools
+                   else runPipeline config tools
+      pure (name, result)
+
 -- ============================================================================
+-- Transport-Only Pipeline (no backend connection required)
+-- ============================================================================
+
+-- | Run the transport-only pipeline.
+-- transport.ts is a static template parameterised only by --transport mode,
+-- so no backend connection or IR generation is needed.
+runTransportOnlyPipeline :: Config -> ToolLocations -> IO (Either SynapseCCError CompiledPath)
+runTransportOnlyPipeline config tools = do
+  let opts      = cfgOptions config
+      debug     = optDebug opts
+      outputDir = optOutput opts
+
+  createDirectoryIfMissing True outputDir
+
+  logStep "Generating transport layer..."
+  codeResult <- generateTransportCode config tools
+  case codeResult of
+    Left err -> pure $ Left err
+    Right out -> do
+      logSuccess "Transport generated"
+      -- Three-way merge (transport.ts is static — merge handles unchanged correctly
+      -- even without a cache entry: if content matches what's on disk, skip write).
+      cachedHashes <- if optForce opts then pure Map.empty else getCachedFileHashes config
+      mergeResult  <- Merge.applyMerge (coFiles out) (coFileHashes out) cachedHashes outputDir
+      let skipped = Merge.mrSkipped mergeResult
+      unless (null skipped) $
+        logInfo $ "  ⚠  " <> T.pack (show (length skipped))
+                <> " file(s) skipped (user modifications preserved)"
+      logDebug debug "  No backend, deps, build, or tests for transport-only target"
+      pure $ Right $ CompiledPath outputDir
+
+-- | Call hub-codegen --generate transport (no IR argument needed).
+generateTransportCode :: Config -> ToolLocations -> IO (Either SynapseCCError CodegenOutput)
+generateTransportCode config tools = do
+  let debug          = optDebug (cfgOptions config)
+      hubCodegenPath = toolPathToFilePath (toolHubCodegen tools)
+      opts           = cfgOptions config
+      targetArg      = T.unpack (targetToText (cfgTarget config))
+      args =
+        [ "--target",        targetArg
+        , "--output-format", "json"
+        , "--generate",      "transport"
+        , "--transport",     case optTransport opts of
+                               WsTransport      -> "ws"
+                               BrowserTransport -> "browser"
+        ]
+  result <- runProcess hubCodegenPath args Nothing debug
+  case prExitCode result of
+    ExitSuccess ->
+      case eitherDecodeStrict (TE.encodeUtf8 (prStdout result)) of
+        Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0
+        Right out     -> pure $ Right out
+    ExitFailure code ->
+      pure $ Left $ HubCodegenError (prStderr result) code
+
+-- ============================================================================
 -- IR Generation
 -- ============================================================================
 
--- | Generate IR using synapse
+-- | Generate IR using plexus-synapse library (replaces subprocess call)
 generateIR :: Config -> ToolLocations -> IO (Either SynapseCCError IRPath)
-generateIR config tools = do
-  let debug = optDebug (cfgOptions config)
-      synapsePath = toolPathToFilePath (toolSynapse tools)
-      Backend backendName = cfgBackend config
+generateIR config _tools = do
+  let debug  = optDebug (cfgOptions config)
+      Backend bkName = cfgBackend config
       outputDir = optOutput (cfgOptions config)
-      irFile = outputDir </> "ir.json"
+      opts = cfgOptions config
+      host = cfgHost config
+      port = read (T.unpack (cfgPort config)) :: Int
+      generatorInfo = ["synapse-cc:" <> synapseCCVersion]
 
-  -- Ensure output directory exists
+  -- Compute IR file path in the cache directory:
+  --   <cacheDir>/synapse/ir/<backend>/ir.json
+  cacheDir <- getCacheDir opts
+  let irDir  = cacheDir </> "synapse" </> "ir" </> T.unpack bkName
+      irFile = irDir </> "ir.json"
+
+  -- Ensure output and IR cache directories exist
   createDirectoryIfMissing True outputDir
+  createDirectoryIfMissing True irDir
 
-  -- Build synapse command: synapse -H <host> -P <port> -i <backend> --generator-info synapse-cc:version
-  let host = cfgHost config
-      port = cfgPort config
-      generatorInfo = "synapse-cc:" <> synapseCCVersion
-      args = [ "-H", T.unpack host
-             , "-P", T.unpack port
-             , "--generator-info", T.unpack generatorInfo
-             , "-i"
-             , T.unpack backendName
-             ]
+  -- Call plexus-synapse library directly (no subprocess).
+  -- Pass [] as path: the backend is encoded in the env; [] = walk from root.
+  logDebug debug $ "  Connecting to " <> host <> ":" <> T.pack (show port)
+  env <- initEnv host port bkName
+  -- Wrap in try to catch hard exceptions from child-plugin fetch errors
+  rawResult <- try (runSynapseM env (buildIR generatorInfo []))
+  result <- case rawResult of
+    Left (ex :: SomeException) ->
+      let raw = T.pack (show ex)
+          msg = if "Connection refused" `T.isInfixOf` raw || "does not exist" `T.isInfixOf` raw
+                  then "Cannot connect to backend — is it running?"
+                  else T.takeWhile (/= '\n') raw
+      in pure $ Left msg
+    Right (Left synapseErr)    -> pure $ Left $ formatSynapseError synapseErr
+    Right (Right ir)           -> pure $ Right ir
 
-  -- Run synapse
-  result <- runProcess synapsePath args Nothing debug
+  case result of
+    Left msg -> do
+      logDebug debug $ "  Synapse error: " <> msg
+      pure $ Left $ SynapseError msg 1
 
-  case prExitCode result of
-    ExitSuccess -> do
-      -- Write IR to file
-      BS.writeFile irFile (TE.encodeUtf8 $ prStdout result)
+    Right ir -> do
+      -- Encode IR to JSON and write to cache path
+      let irBytes = BL.toStrict (Aeson.encode ir)
+      BS.writeFile irFile irBytes
 
-      -- Validate IR by trying to parse it
-      irBytes <- BS.readFile irFile
+      -- Validate by round-tripping (ensures we can parse what we wrote)
       case eitherDecodeStrict irBytes of
         Left parseErr -> pure $ Left $ InvalidIR $ T.pack parseErr
         Right (_ :: IRData) -> pure $ Right $ IRPath irFile
 
-    ExitFailure code -> do
-      pure $ Left $ SynapseError (prStderr result) code
+-- | Format a SynapseError (from plexus-synapse) for display
+formatSynapseError :: SynapseError -> Text
+formatSynapseError err = case err of
+  NavError ne ->
+    let raw = T.pack (show ne)
+    in if "Connection refused" `T.isInfixOf` raw || "does not exist" `T.isInfixOf` raw
+         then "Cannot connect to backend — is it running?"
+         else "Connection error: " <> T.takeWhile (/= '\n') raw
+  TransportError t  -> t
+  TransportErrorContext ctx ->
+    "Cannot connect to " <> T.pack (show ctx)
+  ParseError t      -> "Parse error: " <> t
+  ValidationError t -> "Validation error: " <> t
+  BackendError bt _ -> "Backend error: " <> T.pack (show bt)
 
 -- | IR structure for reading plugin hashes
 -- We only need the fields relevant for caching
 data IRData = IRData
   { irdVersion      :: !Text
+  , irdIrHash       :: !(Maybe Text)   -- ^ Content-stable hash of schema (no timestamps)
   , irdPlugins      :: !(Map.Map Text [Text])
   , irdPluginHashes :: !(Maybe (Map.Map Text PluginHashInfo))
   } deriving stock (Show, Generic)
@@ -273,6 +628,7 @@
 instance FromJSON IRData where
   parseJSON = Aeson.withObject "IRData" $ \o -> IRData
     <$> o Aeson..: "irVersion"
+    <*> o Aeson..:? "irHash"
     <*> o Aeson..: "irPlugins"
     <*> o Aeson..:? "irPluginHashes"
 
@@ -288,34 +644,39 @@
 -- Code Generation
 -- ============================================================================
 
--- | Generate code using hub-codegen
-generateCode :: Config -> ToolLocations -> IRPath -> IO (Either SynapseCCError GeneratedPath)
-generateCode config tools irPath = do
-  let debug = optDebug (cfgOptions config)
+-- | Generate code using hub-codegen, returning parsed JSON output.
+-- Pass @Just namespaces@ to restrict generation to specific plugin namespaces
+-- (equivalent to @--generate plugins --plugins ns1,ns2@).
+-- Pass @Nothing@ for a full generation pass.
+generateCode :: Config -> ToolLocations -> IRPath -> Maybe [Text] -> IO (Either SynapseCCError CodegenOutput)
+generateCode config tools irPath nsFilter = do
+  let debug          = optDebug (cfgOptions config)
       hubCodegenPath = toolPathToFilePath (toolHubCodegen tools)
-      outputDir = optOutput (cfgOptions config)
-      target = cfgTarget config
-      opts = cfgOptions config
-
-  -- Build hub-codegen command
-  let targetArg = case target of
-        TypeScript -> "typescript"
-        Python -> "python"
-        Rust -> "rust"
-
+      opts           = cfgOptions config
+      target         = cfgTarget config
+      targetArg      = T.unpack (targetToText target)
+      backendUrl     = "ws://" <> T.unpack (cfgHost config) <> ":" <> T.unpack (cfgPort config)
+      filterArgs = case nsFilter of
+        Nothing  -> []
+        Just nss -> ["--generate", "plugins", "--plugins", T.unpack (T.intercalate "," nss)]
       args =
-        [ "--target", targetArg
-        , "--output", outputDir
-        , unIRPath irPath
+        [ "--target",           targetArg
+        , "--output-format",    "json"
+        , "--transport", case optTransport opts of
+            WsTransport      -> "ws"
+            BrowserTransport -> "browser"
+        , "--backend-url",      backendUrl
         ]
+        ++ filterArgs
+        ++ [ unIRPath irPath ]
 
-  -- Run hub-codegen
   result <- runProcess hubCodegenPath args Nothing debug
 
   case prExitCode result of
     ExitSuccess -> do
-      pure $ Right $ GeneratedPath outputDir
-
-    ExitFailure code -> do
+      let stdoutBytes = TE.encodeUtf8 (prStdout result)
+      case eitherDecodeStrict stdoutBytes of
+        Left parseErr -> pure $ Left $ HubCodegenError (T.pack parseErr) 0
+        Right out     -> pure $ Right out
+    ExitFailure code ->
       pure $ Left $ HubCodegenError (prStderr result) code
-
diff --git a/src/SynapseCC/Process.hs b/src/SynapseCC/Process.hs
--- a/src/SynapseCC/Process.hs
+++ b/src/SynapseCC/Process.hs
@@ -5,16 +5,18 @@
   , ProcessResult(..)
   ) where
 
+import Control.Monad (unless, when)
 import qualified Data.ByteString as BS
 import Data.Text (Text)
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
 import qualified Data.Text.Encoding.Error as TE
-import qualified Data.Text.IO as TIO
 import System.Exit (ExitCode(..))
 import System.IO (Handle, hClose, hGetContents, hPutStr)
 import System.Process hiding (runProcess)
 
+import SynapseCC.Logging (logDebug)
+
 -- ============================================================================
 -- Process Execution
 -- ============================================================================
@@ -34,26 +36,14 @@
   -> Bool          -- ^ Debug mode
   -> IO ProcessResult
 runProcess exe args mbCwd debug = do
-  when debug $ do
-    putStrLn $ "Running: " ++ exe ++ " " ++ unwords args
-    case mbCwd of
-      Just cwd -> putStrLn $ "  Working directory: " ++ cwd
-      Nothing -> pure ()
+  logDebug debug $ "Running: " <> T.pack exe <> " " <> T.pack (unwords args)
+  case mbCwd of
+    Just cwd -> logDebug debug $ "  Working directory: " <> T.pack cwd
+    Nothing  -> pure ()
 
   (exitCode, stdout, stderr) <- readProcessWithExitCode' exe args mbCwd ""
 
-  when debug $ do
-    unless (T.null stdout) $ do
-      let len = T.length stdout
-      if len > 1000
-        then putStrLn $ "  Stdout: <large output, " ++ show len ++ " chars, truncated>"
-        else do
-          putStrLn "  Stdout:"
-          putStrLn $ "    " ++ T.unpack stdout
-    unless (T.null stderr) $ do
-      putStrLn "  Stderr:"
-      putStrLn $ "    " ++ T.unpack stderr
-
+  logProcessOutput debug stdout stderr
   pure $ ProcessResult exitCode stdout stderr
 
 -- | Run a process with stdin input
@@ -65,33 +55,35 @@
   -> Bool          -- ^ Debug mode
   -> IO ProcessResult
 runProcessWithInput exe args mbCwd input debug = do
-  when debug $ do
-    putStrLn $ "Running: " ++ exe ++ " " ++ unwords args
-    case mbCwd of
-      Just cwd -> putStrLn $ "  Working directory: " ++ cwd
-      Nothing -> pure ()
-    putStrLn $ "  With input: " ++ T.unpack (T.take 100 input) ++ "..."
+  logDebug debug $ "Running: " <> T.pack exe <> " " <> T.pack (unwords args)
+  case mbCwd of
+    Just cwd -> logDebug debug $ "  Working directory: " <> T.pack cwd
+    Nothing  -> pure ()
+  logDebug debug $ "  With input: " <> T.take 100 input <> "..."
 
   (exitCode, stdout, stderr) <- readProcessWithExitCode' exe args mbCwd (T.unpack input)
 
-  when debug $ do
-    unless (T.null stdout) $ do
-      let len = T.length stdout
-      if len > 1000
-        then putStrLn $ "  Stdout: <large output, " ++ show len ++ " chars, truncated>"
-        else do
-          putStrLn "  Stdout:"
-          putStrLn $ "    " ++ T.unpack stdout
-    unless (T.null stderr) $ do
-      putStrLn "  Stderr:"
-      putStrLn $ "    " ++ T.unpack stderr
-
+  logProcessOutput debug stdout stderr
   pure $ ProcessResult exitCode stdout stderr
 
 -- ============================================================================
 -- Helpers
 -- ============================================================================
 
+-- | Log process stdout/stderr when in debug mode.
+logProcessOutput :: Bool -> Text -> Text -> IO ()
+logProcessOutput debug stdout stderr = when debug $ do
+  unless (T.null stdout) $ do
+    let len = T.length stdout
+    if len > 5000
+      then logDebug debug $ "  Stdout: <large output, " <> T.pack (show len) <> " chars, truncated>"
+      else do
+        logDebug debug "  Stdout:"
+        logDebug debug $ "    " <> stdout
+  unless (T.null stderr) $ do
+    logDebug debug "  Stderr:"
+    logDebug debug $ "    " <> stderr
+
 -- | Like readProcessWithExitCode but with working directory support
 readProcessWithExitCode'
   :: FilePath
@@ -124,11 +116,3 @@
   exitCode <- waitForProcess ph
 
   pure (exitCode, stdoutText, stderrText)
-
-when :: Applicative f => Bool -> f () -> f ()
-when True action = action
-when False _ = pure ()
-
-unless :: Applicative f => Bool -> f () -> f ()
-unless False action = action
-unless True _ = pure ()
diff --git a/src/SynapseCC/Types.hs b/src/SynapseCC/Types.hs
--- a/src/SynapseCC/Types.hs
+++ b/src/SynapseCC/Types.hs
@@ -9,10 +9,24 @@
     -- * Configuration
   , Config(..)
   , Target(..)
+  , targetToText
+  , parseLanguage
   , Backend(..)
+  , TransportType(..)
   , Options(..)
   , defaultOptions
 
+    -- * Synapse Config File
+  , SynapseConfig(..)
+  , TargetConfig(..)
+  , WatchConfig(..)
+  , defaultSynapseConfig
+  , defaultWatchConfig
+
+    -- * Commands (CLI subcommands)
+  , Command(..)
+  , WatchArgs(..)
+
     -- * Tool Locations
   , ToolLocations(..)
   , ToolPath(..)
@@ -31,21 +45,27 @@
   , CacheResult(..)
   , CacheMissReason(..)
 
-    -- * Metadata Types
-  , CodegenMetadata(..)
-  , CacheInfo(..)
+    -- * Codegen Output
+  , CodegenOutput(..)
+  , CodegenWarning(..)
 
     -- * Errors
   , SynapseCCError(..)
   , formatError
+  , summarizeStderr
   ) where
 
 import Data.Aeson (FromJSON, ToJSON, fieldLabelModifier)
 import qualified Data.Aeson as Aeson
+import Data.Aeson.Types (Parser)
 import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (catMaybes)
 import Data.Text (Text)
 import qualified Data.Text as T
+import Data.Version (showVersion)
 import GHC.Generics (Generic)
+import Paths_synapse_cc (version)
 import System.FilePath (FilePath)
 
 -- ============================================================================
@@ -54,7 +74,7 @@
 
 -- | synapse-cc version (from cabal file: synapse-cc.cabal)
 synapseCCVersion :: Text
-synapseCCVersion = "0.1.0.0"
+synapseCCVersion = T.pack (showVersion version)
 
 -- ============================================================================
 -- Configuration
@@ -77,47 +97,201 @@
   deriving stock (Show, Eq, Ord, Generic)
   deriving anyclass (FromJSON, ToJSON)
 
+-- | Canonical Target → lowercase string (used in cache paths, manifest fields, etc.)
+targetToText :: Target -> Text
+targetToText TypeScript = "typescript"
+targetToText Python     = "python"
+targetToText Rust       = "rust"
+
+-- | Parse a language string from synapse.config.json into a Target.
+-- Unrecognised values default to TypeScript.
+parseLanguage :: Text -> Target
+parseLanguage "typescript" = TypeScript
+parseLanguage "python"     = Python
+parseLanguage "rust"       = Rust
+parseLanguage _            = TypeScript
+
 -- | Backend identifier
 data Backend = Backend
   { backendName :: !Text  -- ^ Backend name (e.g., "substrate", "plexus")
   } deriving stock (Show, Eq, Generic)
   deriving anyclass (FromJSON, ToJSON)
 
+-- | Transport environment for generated TypeScript code
+data TransportType = WsTransport | BrowserTransport
+  deriving stock (Show, Eq, Generic)
+
 -- | Options for code generation and compilation
 data Options = Options
   { optOutput          :: !FilePath
-  , optBundleTransport :: !Bool
+  , optTransport       :: !TransportType
   , optInstallDeps     :: !Bool
   , optBuild           :: !Bool
   , optRunTests        :: !Bool
   , optCacheDir        :: !FilePath
   , optForce           :: !Bool
-  , optWatch           :: !Bool
   , optDebug           :: !Bool
+  , optSynapsePath     :: !(Maybe FilePath)  -- ^ Override synapse binary path
+  , optHubCodegenPath  :: !(Maybe FilePath)  -- ^ Override hub-codegen binary path
   } deriving stock (Show, Eq, Generic)
 
 -- | Default options
 defaultOptions :: Options
 defaultOptions = Options
   { optOutput          = "./generated"
-  , optBundleTransport = True
+  , optTransport       = WsTransport
   , optInstallDeps     = True
   , optBuild           = True
   , optRunTests        = False
   , optCacheDir        = "~/.cache/plexus-codegen"
   , optForce           = False
-  , optWatch           = False
   , optDebug           = False
+  , optSynapsePath     = Nothing
+  , optHubCodegenPath  = Nothing
   }
 
 -- ============================================================================
+-- Synapse Config File (synapse.config.json)
+-- ============================================================================
+
+-- | Watch-mode configuration
+data WatchConfig = WatchConfig
+  { wcPollInterval :: !Int   -- ^ Milliseconds between hash polls (default: 1000)
+  , wcHotReload    :: !Bool  -- ^ Reload config on file change if valid (default: False)
+  } deriving stock (Show, Eq, Generic)
+
+instance FromJSON WatchConfig where
+  parseJSON = Aeson.withObject "WatchConfig" $ \o -> WatchConfig
+    <$> o Aeson..:? "pollInterval" Aeson..!= 1000
+    <*> o Aeson..:? "hotReload"    Aeson..!= False
+
+instance ToJSON WatchConfig where
+  toJSON wc = Aeson.object
+    [ "pollInterval" Aeson..= wcPollInterval wc
+    , "hotReload"    Aeson..= wcHotReload wc
+    ]
+
+defaultWatchConfig :: WatchConfig
+defaultWatchConfig = WatchConfig
+  { wcPollInterval = 1000
+  , wcHotReload    = False
+  }
+
+-- | Per-target configuration entry in synapse.config.json
+data TargetConfig = TargetConfig
+  { tcGenerate   :: ![Text]           -- ^ Selectors: ["transport","rpc","plugins"] or ["all"]
+  , tcTransport  :: !TransportType    -- ^ Transport for this target
+  , tcOutputDir  :: !FilePath         -- ^ Where to write generated files
+  , tcSmokePath  :: !(Maybe Text)     -- ^ Relative path to transport for smoke targets
+  } deriving stock (Show, Eq, Generic)
+
+instance FromJSON TargetConfig where
+  parseJSON = Aeson.withObject "TargetConfig" $ \o -> TargetConfig
+    <$> o Aeson..: "generate"
+    <*> (parseTransport =<< o Aeson..: "transport")
+    <*> o Aeson..: "outputDir"
+    <*> o Aeson..:? "smokePath"
+    where
+      parseTransport :: Text -> Parser TransportType
+      parseTransport "ws"      = pure WsTransport
+      parseTransport "browser" = pure BrowserTransport
+      parseTransport t         = fail $ "Unknown transport: " <> show t
+
+instance ToJSON TargetConfig where
+  toJSON tc = Aeson.object $
+    [ "generate"  Aeson..= tcGenerate tc
+    , "transport" Aeson..= (case tcTransport tc of WsTransport -> "ws" :: Text; BrowserTransport -> "browser")
+    , "outputDir" Aeson..= tcOutputDir tc
+    ] ++ case tcSmokePath tc of
+           Nothing -> []
+           Just p  -> ["smokePath" Aeson..= p]
+
+-- | Top-level synapse.config.json configuration.
+-- \"backend\" and \"url\" are optional when every target has generate: [\"transport\"]
+-- (transport.ts is a static template that needs no backend connection).
+data SynapseConfig = SynapseConfig
+  { scSchema         :: !Text                -- ^ Config schema version (e.g. "1.0")
+  , scLanguage       :: !Text                -- ^ Target language (e.g. "typescript")
+  , scBackend        :: !(Maybe Text)        -- ^ Backend identifier; omit for transport-only configs
+  , scUrl            :: !(Maybe Text)        -- ^ Backend WebSocket URL; omit for transport-only configs
+  , scPackageManager :: !Text                -- ^ Package manager (e.g. "bun")
+  , scWatch          :: !WatchConfig         -- ^ Watch mode settings
+  , scTargets        :: !(Map Text TargetConfig)  -- ^ Named build targets
+  } deriving stock (Show, Eq, Generic)
+
+instance FromJSON SynapseConfig where
+  parseJSON = Aeson.withObject "SynapseConfig" $ \o -> SynapseConfig
+    <$> o Aeson..: "schema"
+    <*> o Aeson..: "language"
+    <*> o Aeson..:? "backend"
+    <*> o Aeson..:? "url"
+    <*> o Aeson..:? "packageManager" Aeson..!= "bun"
+    <*> o Aeson..:? "watch"          Aeson..!= defaultWatchConfig
+    <*> o Aeson..: "targets"
+
+instance ToJSON SynapseConfig where
+  toJSON sc = Aeson.object $ catMaybes
+    [ Just $ "schema"         Aeson..= scSchema sc
+    , Just $ "language"       Aeson..= scLanguage sc
+    , ("backend" Aeson..=) <$> scBackend sc
+    , ("url"     Aeson..=) <$> scUrl sc
+    , Just $ "packageManager" Aeson..= scPackageManager sc
+    , Just $ "watch"          Aeson..= scWatch sc
+    , Just $ "targets"        Aeson..= scTargets sc
+    ]
+
+defaultSynapseConfig :: SynapseConfig
+defaultSynapseConfig = SynapseConfig
+  { scSchema         = "1.0"
+  , scLanguage       = "typescript"
+  , scBackend        = Just "substrate"
+  , scUrl            = Just "ws://127.0.0.1:4444"
+  , scPackageManager = "bun"
+  , scWatch          = defaultWatchConfig
+  , scTargets        = Map.fromList
+      [ ( "client"
+        , TargetConfig
+            { tcGenerate  = ["transport", "rpc", "plugins"]
+            , tcTransport = WsTransport
+            , tcOutputDir = "src/lib/plexus"
+            , tcSmokePath = Nothing
+            }
+        )
+      ]
+  }
+
+-- ============================================================================
+-- Commands (CLI Subcommands)
+-- ============================================================================
+
+-- | Top-level CLI command
+data Command
+  = CmdBuild Config         -- ^ Build with explicit target/backend (CLI args)
+  | CmdBuildFromConfig      -- ^ Build all targets from synapse.config.json
+      Options               -- ^ CLI flags that override per-target config
+  | CmdWatch WatchArgs      -- ^ Watch mode
+  | CmdInit                 -- ^ Scaffold synapse.config.json
+  deriving stock (Show, Eq)
+
+-- | Arguments for the watch subcommand
+data WatchArgs = WatchArgs
+  { waBackend   :: !Text         -- ^ Backend identifier (e.g. "substrate")
+  , waPlugins   :: ![Text]       -- ^ Plugin prefix filters (empty = all)
+  , waTarget    :: !(Maybe Text) -- ^ Pin to a single named config target
+  , waInterval  :: !(Maybe Int)  -- ^ Poll interval override in ms (overrides config)
+  , waOptions   :: !Options      -- ^ Inherited options (host, port, cache, debug, etc.)
+  } deriving stock (Show, Eq)
+
+-- ============================================================================
 -- Tool Locations
 -- ============================================================================
 
 -- | Discovered locations of required tools
 data ToolLocations = ToolLocations
-  { toolSynapse    :: !ToolPath
-  , toolHubCodegen :: !ToolPath
+  { toolSynapse            :: !ToolPath
+  , toolHubCodegen         :: !ToolPath
+  , toolSynapseVersion     :: !Text   -- ^ Result of @synapse --version@
+  , toolHubCodegenVersion  :: !Text   -- ^ Result of @hub-codegen --version@
   } deriving stock (Show, Eq)
 
 -- | Path to a discovered tool
@@ -215,47 +389,42 @@
   deriving stock (Show, Eq)
 
 -- ============================================================================
--- Metadata Types (for parsing .codegen-metadata.json)
+-- Codegen Output Types (JSON response from hub-codegen --output-format json)
 -- ============================================================================
 
--- | Cache information from .codegen-metadata.json
-data CacheInfo = CacheInfo
-  { ciFileHashes :: !(Map Text Text)  -- file path -> hash
+-- | JSON output from hub-codegen when invoked with --output-format json
+data CodegenOutput = CodegenOutput
+  { coFiles             :: !(Map Text Text)   -- ^ Generated file contents (relPath -> content)
+  , coFileHashes        :: !(Map Text Text)   -- ^ Per-file content hashes (relPath -> hash)
+  , coWarnings          :: ![CodegenWarning]  -- ^ Warnings from generation
+  , coHubCodegenVersion :: !Text             -- ^ hub-codegen version string
+  , coDependencies      :: !(Map Text Text)   -- ^ Runtime dependencies (name -> version)
+  , coDevDependencies   :: !(Map Text Text)   -- ^ Dev dependencies (name -> version)
   } deriving stock (Show, Eq, Generic)
 
-instance FromJSON CacheInfo where
+instance FromJSON CodegenOutput where
   parseJSON = Aeson.genericParseJSON Aeson.defaultOptions
     { fieldLabelModifier = \case
-        "ciFileHashes" -> "file_hashes"
-        other -> other
-    }
-
-instance ToJSON CacheInfo where
-  toJSON = Aeson.genericToJSON Aeson.defaultOptions
-    { fieldLabelModifier = \case
-        "ciFileHashes" -> "file_hashes"
+        "coFiles"             -> "files"
+        "coFileHashes"        -> "fileHashes"
+        "coWarnings"          -> "warnings"
+        "coHubCodegenVersion" -> "hubCodegenVersion"
+        "coDependencies"      -> "dependencies"
+        "coDevDependencies"   -> "devDependencies"
         other -> other
     }
 
--- | Metadata file generated by hub-codegen
-data CodegenMetadata = CodegenMetadata
-  { cmFormatVersion :: !Text
-  , cmCache         :: !CacheInfo
+-- | A warning emitted during code generation
+data CodegenWarning = CodegenWarning
+  { cwLocation :: !Text
+  , cwMessage  :: !Text
   } deriving stock (Show, Eq, Generic)
 
-instance FromJSON CodegenMetadata where
+instance FromJSON CodegenWarning where
   parseJSON = Aeson.genericParseJSON Aeson.defaultOptions
     { fieldLabelModifier = \case
-        "cmFormatVersion" -> "format_version"
-        "cmCache" -> "cache"
-        other -> other
-    }
-
-instance ToJSON CodegenMetadata where
-  toJSON = Aeson.genericToJSON Aeson.defaultOptions
-    { fieldLabelModifier = \case
-        "cmFormatVersion" -> "format_version"
-        "cmCache" -> "cache"
+        "cwLocation" -> "location"
+        "cwMessage"  -> "message"
         other -> other
     }
 
@@ -283,6 +452,37 @@
     -- ^ Invalid configuration
   deriving stock (Show, Eq)
 
+-- | Extract the first meaningful non-empty line from stderr output.
+-- Filters out Node.js stack frames and internal paths.
+-- Falls back to the first non-empty line if nothing more useful is found.
+-- Trims to 120 chars if very long.
+summarizeStderr :: Text -> Text
+summarizeStderr stderr =
+  let ls = T.lines stderr
+      isUseful l =
+        not (T.null (T.strip l)) &&
+        not ("    at " `T.isPrefixOf` l) &&
+        not ("at " `T.isPrefixOf` T.strip l) &&
+        not ("node:internal" `T.isInfixOf` l) &&
+        not ("node_modules" `T.isInfixOf` l)
+      firstUseful = case filter isUseful ls of
+        (x:_) -> T.take 120 (T.strip x)
+        []    -> case filter (not . T.null . T.strip) ls of
+          (x:_) -> T.take 120 (T.strip x)
+          []    -> "(no output)"
+  in firstUseful
+
+-- | Extract the key part of an aeson parse error, stripping the path prefix.
+-- E.g. "Error in $['irPlugins']: key \"foo\" not found"
+--   -> "key \"foo\" not found"
+summarizeAesonError :: Text -> Text
+summarizeAesonError msg =
+  -- aeson errors look like "Error in $...path...: actual message"
+  let afterColon = case T.breakOn ": " msg of
+        (_, rest) | not (T.null rest) -> T.drop 2 rest
+        _                             -> msg
+  in T.take 120 afterColon
+
 -- | Format error for display to user
 formatError :: SynapseCCError -> Text
 formatError = \case
@@ -298,13 +498,8 @@
       , "For more info: https://github.com/hypermemetic/synapse-cc"
       ]
 
-  SynapseError stderr exitCode ->
-    T.unlines
-      [ "[!] Error: synapse failed (exit code " <> T.pack (show exitCode) <> ")"
-      , ""
-      , "Output:"
-      , stderr
-      ]
+  SynapseError msg _ ->
+    "[!] Error: " <> msg <> "\n"
 
   HubCodegenError stderr exitCode ->
     T.unlines
@@ -314,12 +509,11 @@
       , stderr
       ]
 
-  LanguageToolError tool stderr exitCode ->
+  LanguageToolError tool output exitCode ->
     T.unlines
       [ "[!] Error: " <> tool <> " failed (exit code " <> T.pack (show exitCode) <> ")"
       , ""
-      , "Output:"
-      , stderr
+      , output
       ]
 
   CacheError msg ->
@@ -327,9 +521,14 @@
 
   InvalidIR msg ->
     T.unlines
-      [ "[!] Error: Invalid IR"
+      [ "[!] Failed to parse IR output from synapse"
       , ""
-      , msg
+      , "  The IR JSON was not in the expected format."
+      , "  This usually means synapse and synapse-cc are out of sync."
+      , ""
+      , "  Detail: " <> summarizeAesonError msg
+      , ""
+      , "  Try: rebuild synapse from source, or check synapse --version"
       ]
 
   BackendUnreachable url msg ->
diff --git a/src/SynapseCC/Watch.hs b/src/SynapseCC/Watch.hs
new file mode 100644
--- /dev/null
+++ b/src/SynapseCC/Watch.hs
@@ -0,0 +1,389 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- | Watch mode: poll backend for changes, incrementally rebuild affected plugins
+module SynapseCC.Watch
+  ( -- * Entry point
+    runWatch
+
+    -- * Pure utilities (exported for testing)
+  , matchesAnyPrefix
+  , parseUrl
+  , extractPluginHashesFromBytes
+  , filterTargets
+  , buildConfigFromTarget
+
+    -- * IO utilities (exported for testing)
+  , fetchBackendHash
+  , makeSubstrateConfig
+  ) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception (try, SomeException)
+import Control.Monad (forM_, when, unless)
+import Data.Aeson (Value)
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Key as AKey
+import qualified Data.Aeson.KeyMap as KM
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as BL
+import Data.IORef
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (fromMaybe, mapMaybe, isJust)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Time.Clock (getCurrentTime, diffUTCTime)
+import System.Directory (createDirectoryIfMissing, doesFileExist)
+import System.FilePath ((</>))
+
+import Plexus.Client (SubstrateConfig(..), defaultConfig)
+import qualified Plexus.Transport as Transport
+import Plexus.Types (PlexusStreamItem(..))
+import Synapse.Monad (initEnv, runSynapseM)
+import Synapse.IR.Builder (buildIR)
+
+import SynapseCC.Cache (expandTilde)
+import qualified SynapseCC.Config as Config
+import SynapseCC.Config (loadSynapseConfig)
+import SynapseCC.Logging
+import SynapseCC.Merge (applyMerge)
+import SynapseCC.Pipeline (formatSynapseError, generateCode, runPipeline)
+import SynapseCC.Types
+
+-- ============================================================================
+-- Public Entry Point
+-- ============================================================================
+
+-- | Run the watch loop.
+-- 1. Load synapse.config.json (falls back to defaults from CLI args)
+-- 2. Full initial build across all active targets
+-- 3. Poll {backend}.hash every pollInterval ms:
+--    - unchanged → no-op
+--    - changed   → fetch IR, diff plugin hashes, partial-rebuild changed plugins
+runWatch :: WatchArgs -> ToolLocations -> IO ()
+runWatch watchArgs tools = do
+  let debug  = optDebug (waOptions watchArgs)
+      bkName = waBackend watchArgs
+
+  -- Load synapse.config.json
+  cfgResult <- loadSynapseConfig
+  synapseConfig <- case cfgResult of
+    Left err -> do
+      logInfo $ "[!] Config: " <> err
+      logInfo   "    Falling back to CLI defaults"
+      pure (synapseConfigFromArgs watchArgs)
+    Right cfg -> pure cfg
+
+  let allTargets    = scTargets synapseConfig
+      activeTargets = filterTargets watchArgs allTargets
+
+  when (Map.null activeTargets) $
+    logInfo "[!] No targets with \"plugins\" in their generate list — nothing to watch"
+
+  -- Full initial build for each active target
+  logStep "Running initial build..."
+  forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) -> do
+    let config = buildConfigFromTarget watchArgs synapseConfig targetCfg
+    result <- runPipeline config tools
+    case result of
+      Left err -> let msg  = T.strip (formatError err)
+                      bare = maybe msg T.stripStart (T.stripPrefix "[!] Error:" msg)
+                  in logError $ targetName <> ": " <> bare
+      Right _  -> logSuccess $ targetName <> " built"
+
+  -- State: last seen backend hash
+  lastHashRef <- newIORef (Nothing :: Maybe Text)
+
+  -- Config hot-reload state
+  let hotReload = wcHotReload (scWatch synapseConfig)
+      pollMs    = maybe (wcPollInterval (scWatch synapseConfig)) id (waInterval watchArgs)
+      pollUs    = pollMs * 1000
+  configRef <- newIORef synapseConfig
+
+  when hotReload $
+    logInfo "  Config hot-reload enabled (watching synapse.config.json)"
+
+  logInfo $ "Watching " <> bkName <> " (polling every " <> T.pack (show pollMs) <> "ms)..."
+
+  let loop = do
+        -- Hot-reload config if enabled
+        when hotReload $ do
+          newCfgResult <- loadSynapseConfig
+          case newCfgResult of
+            Right newCfg -> writeIORef configRef newCfg
+            Left _       -> pure ()  -- keep old config on error
+
+        currentCfg <- readIORef configRef
+        let subCfg  = makeSubstrateConfig watchArgs currentCfg
+
+        hashResult <- (try (fetchBackendHash subCfg bkName) :: IO (Either SomeException (Either Text Text)))
+
+        case hashResult of
+          Left ex -> logInfo $ "[!] Backend unreachable: " <> T.pack (show ex)
+
+          Right (Left err) -> logInfo $ "[!] Hash poll error: " <> err
+
+          Right (Right newHash) -> do
+            lastHash <- readIORef lastHashRef
+            when (Just newHash /= lastHash) $ do
+              -- Skip partial rebuild on first poll — initial build covered it
+              when (isJust lastHash) $
+                handleHashChange watchArgs currentCfg tools debug newHash
+
+              writeIORef lastHashRef (Just newHash)
+
+        threadDelay pollUs
+        loop
+
+  loop
+
+-- ============================================================================
+-- Hash Polling
+-- ============================================================================
+
+-- | Build a SubstrateConfig from watch args + synapse config URL
+makeSubstrateConfig :: WatchArgs -> SynapseConfig -> SubstrateConfig
+makeSubstrateConfig watchArgs synapseConfig =
+  let (host, portTxt) = parseUrl (fromMaybe "ws://127.0.0.1:4444" (scUrl synapseConfig))
+      port = read (T.unpack portTxt) :: Int
+      bk   = waBackend watchArgs
+  in (defaultConfig bk)
+       { substrateHost = T.unpack host
+       , substratePort = port
+       }
+
+-- | Call {backend}.hash and return the composite hash string.
+fetchBackendHash :: SubstrateConfig -> Text -> IO (Either Text Text)
+fetchBackendHash cfg bkName = do
+  let method = bkName <> ".hash"
+  result <- Transport.rpcCallWith cfg method Aeson.Null
+  case result of
+    Left err   -> pure $ Left $ T.pack (show err)
+    Right items ->
+      case mapMaybe extractHashValue items of
+        (Right h : _) -> pure $ Right h
+        (Left e  : _) -> pure $ Left e
+        []             -> pure $ Left "No hash in response"
+
+-- | Extract a hash string from a Plexus stream item for {backend}.hash calls.
+-- Substrate sends content_type "{ns}.hash" with body {"event":"hash","value":"..."}.
+extractHashValue :: PlexusStreamItem -> Maybe (Either Text Text)
+extractHashValue (StreamData _ _ ct dat)
+  | ".hash" `T.isSuffixOf` ct =
+      case dat of
+        Aeson.Object o ->
+          case ( KM.lookup (AKey.fromText "event") o
+               , KM.lookup (AKey.fromText "value") o ) of
+            (Just (Aeson.String "hash"), Just (Aeson.String v)) -> Just (Right v)
+            _ -> Nothing
+        _ -> Nothing
+  | otherwise = Nothing
+extractHashValue (StreamError _ _ err _) = Just (Left err)
+extractHashValue _                        = Nothing
+
+-- ============================================================================
+-- Change Handling
+-- ============================================================================
+
+-- | Handle a backend hash change: fetch full IR, diff plugin hashes, rebuild changed plugins.
+handleHashChange
+  :: WatchArgs
+  -> SynapseConfig
+  -> ToolLocations
+  -> Bool    -- ^ debug
+  -> Text    -- ^ new backend hash (informational)
+  -> IO ()
+handleHashChange watchArgs synapseConfig tools debug _newHash = do
+  let bkName = waBackend watchArgs
+      opts   = waOptions watchArgs
+      (host, portTxt) = parseUrl (fromMaybe "ws://127.0.0.1:4444" (scUrl synapseConfig))
+      port = read (T.unpack portTxt) :: Int
+      pluginFilter = waPlugins watchArgs
+      generatorInfo = ["synapse-cc:" <> synapseCCVersion]
+
+  logInfo "Backend changed — fetching IR..."
+  t0 <- getCurrentTime
+
+  -- Fetch fresh IR via plexus-synapse library
+  env <- initEnv host port bkName
+  irResult <- runSynapseM env (buildIR generatorInfo [])
+
+  case irResult of
+    Left synapseErr ->
+      logInfo $ "[!] IR fetch failed: " <> formatSynapseError synapseErr
+
+    Right ir -> do
+      -- Write IR to cache path
+      cacheDir <- expandTilde (optCacheDir opts)
+      let irDir  = cacheDir </> "synapse" </> "ir" </> T.unpack bkName
+          irFile = irDir </> "ir.json"
+      createDirectoryIfMissing True irDir
+      let irBytes = BL.toStrict (Aeson.encode ir)
+      BS.writeFile irFile irBytes
+
+      t1 <- getCurrentTime
+      let fetchMs = round (diffUTCTime t1 t0 * 1000) :: Int
+
+      -- Extract per-plugin hashes from fresh IR JSON
+      let freshHashes = extractPluginHashesFromBytes irBytes
+
+      -- Diff against cache to find changed/removed plugin namespaces
+      (changedNs, removedNs) <- diffPluginHashes opts bkName freshHashes
+
+      -- Apply prefix filter from CLI
+      let filteredNs = if null pluginFilter
+            then changedNs
+            else filter (matchesAnyPrefix pluginFilter) changedNs
+
+      let activeTargets = filterTargets watchArgs (scTargets synapseConfig)
+
+      -- If plugins were removed from the backend, trigger a full rebuild per target.
+      -- A partial rebuild cannot clean up stale files or fix index.ts exports.
+      unless (null removedNs) $ do
+        logInfo $ "  " <> T.pack (show (length removedNs))
+               <> " plugin(s) removed — triggering full rebuild"
+        when debug $ forM_ removedNs $ \ns ->
+          logInfo $ "    - " <> ns
+        forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) -> do
+          let config = buildConfigFromTarget watchArgs synapseConfig targetCfg
+          result <- runPipeline config tools
+          case result of
+            Left err ->
+              let msg  = T.strip (formatError err)
+                  bare = maybe msg T.stripStart (T.stripPrefix "[!] Error:" msg)
+              in logError $ targetName <> ": " <> bare
+            Right _  -> logSuccess $ targetName <> " rebuilt (structural change)"
+
+      if null filteredNs
+        then when (null removedNs) $
+               logInfo $ "  IR fetched in " <> T.pack (show fetchMs) <> "ms — no plugin changes"
+        else do
+          logInfo $ "  IR fetched in " <> T.pack (show fetchMs) <> "ms — "
+                 <> T.pack (show (length filteredNs)) <> " plugin(s) changed"
+
+          -- Rebuild each changed plugin across affected targets
+          forM_ filteredNs $ \ns -> do
+            forM_ (Map.toList activeTargets) $ \(targetName, targetCfg) ->
+              when ("plugins" `elem` tcGenerate targetCfg || "all" `elem` tcGenerate targetCfg) $ do
+                tStart <- getCurrentTime
+                rebuildResult <- rebuildPlugin watchArgs synapseConfig tools (IRPath irFile) ns targetCfg debug
+                tEnd <- getCurrentTime
+                let ms = round (diffUTCTime tEnd tStart * 1000) :: Int
+                case rebuildResult of
+                  Left err -> logInfo $ "  [!] " <> ns <> " (" <> targetName <> "): " <> err
+                  Right () -> logSuccess $ ns <> " (" <> targetName <> ") rebuilt in " <> T.pack (show ms) <> "ms"
+
+-- | Rebuild a single plugin namespace for a single target using hub-codegen.
+rebuildPlugin
+  :: WatchArgs
+  -> SynapseConfig
+  -> ToolLocations
+  -> IRPath
+  -> Text         -- ^ plugin namespace
+  -> TargetConfig
+  -> Bool         -- ^ debug
+  -> IO (Either Text ())
+rebuildPlugin watchArgs synapseConfig tools irPath ns targetCfg _debug = do
+  let config    = buildConfigFromTarget watchArgs synapseConfig targetCfg
+      outputDir = tcOutputDir targetCfg
+
+  codeResult <- generateCode config tools irPath (Just [ns])
+  case codeResult of
+    Left err -> pure $ Left $ formatError err
+    Right out -> do
+      let files      = coFiles out
+          fileHashes = coFileHashes out
+      _mergeResult <- applyMerge files fileHashes Map.empty outputDir
+      pure $ Right ()
+
+-- ============================================================================
+-- Plugin Hash Helpers
+-- ============================================================================
+
+-- | Extract per-plugin composite hashes from raw IR JSON bytes.
+-- Returns a Map of plugin namespace → composite hash.
+extractPluginHashesFromBytes :: BS.ByteString -> Map Text Text
+extractPluginHashesFromBytes bytes =
+  case Aeson.decodeStrict bytes of
+    Nothing -> Map.empty
+    Just v  -> extractPluginHashesFromValue v
+
+-- | Extract plugin hashes from a parsed IR JSON Value.
+extractPluginHashesFromValue :: Value -> Map Text Text
+extractPluginHashesFromValue v =
+  case v of
+    Aeson.Object obj ->
+      case KM.lookup (AKey.fromText "irPluginHashes") obj of
+        Just (Aeson.Object hashesObj) ->
+          Map.fromList
+            [ (AKey.toText k, extractCompositeHash hv)
+            | (k, hv) <- KM.toList hashesObj
+            ]
+        _ -> Map.empty
+    _ -> Map.empty
+
+-- | Pull the "hash" field from a PluginHashInfo JSON object.
+extractCompositeHash :: Value -> Text
+extractCompositeHash (Aeson.Object o) =
+  case KM.lookup (AKey.fromText "hash") o of
+    Just (Aeson.String h) -> h
+    _                     -> ""
+extractCompositeHash _ = ""
+
+-- | Compute which plugin namespaces have changed or been removed vs the cached IR manifest.
+-- Returns (changed, removed).
+-- If no cache exists, treats all current plugins as changed and none as removed.
+diffPluginHashes :: Options -> Text -> Map Text Text -> IO ([Text], [Text])
+diffPluginHashes opts bkName freshHashes = do
+  cacheDir <- expandTilde (optCacheDir opts)
+  let manifestPath = cacheDir </> "synapse" </> "ir" </> T.unpack bkName </> "manifest.json"
+  exists <- doesFileExist manifestPath
+  if not exists
+    then pure (Map.keys freshHashes, [])
+    else do
+      result <- Aeson.eitherDecodeFileStrict manifestPath :: IO (Either String IRCacheManifest)
+      case result of
+        Left _         -> pure (Map.keys freshHashes, [])
+        Right manifest -> do
+          let changed = Map.keys $ Map.filterWithKey (isChanged manifest) freshHashes
+              removed = Map.keys $ Map.difference (ircmPlugins manifest) freshHashes
+          pure (changed, removed)
+  where
+    isChanged manifest ns newHash =
+      case Map.lookup ns (ircmPlugins manifest) of
+        Nothing     -> True  -- plugin is new
+        Just cached -> ipcSchemaHash cached /= newHash
+
+-- ============================================================================
+-- Utilities
+-- ============================================================================
+
+-- | Does a plugin namespace match any of the given prefix filters?
+-- A prefix "echo" matches "echo", "echo.health", "echo.workspace.repos", etc.
+matchesAnyPrefix :: [Text] -> Text -> Bool
+matchesAnyPrefix prefixes ns =
+  any (\p -> p == ns || (p <> ".") `T.isPrefixOf` ns) prefixes
+
+-- | Build a Config from WatchArgs and a TargetConfig entry.
+-- Thin wrapper around 'Config.buildConfigFromTarget' extracting Options from WatchArgs.
+buildConfigFromTarget :: WatchArgs -> SynapseConfig -> TargetConfig -> Config
+buildConfigFromTarget wa = Config.buildConfigFromTarget (waOptions wa)
+
+-- | Filter targets to those with "plugins" or "all" in generate list,
+-- optionally pinning to a single named target (--target flag).
+filterTargets :: WatchArgs -> Map Text TargetConfig -> Map Text TargetConfig
+filterTargets watchArgs allTargets =
+  let hasPlugins tc = "plugins" `elem` tcGenerate tc || "all" `elem` tcGenerate tc
+  in case waTarget watchArgs of
+    Just name -> maybe Map.empty (Map.singleton name) (Map.lookup name allTargets)
+    Nothing   -> Map.filter hasPlugins allTargets
+
+-- | Synthesize a SynapseConfig from CLI args alone (no config file)
+synapseConfigFromArgs :: WatchArgs -> SynapseConfig
+synapseConfigFromArgs watchArgs =
+  defaultSynapseConfig { scBackend = Just (waBackend watchArgs) }
+
+-- | Parse host and port from a WebSocket URL. Alias for 'Config.parseWsUrl'.
+parseUrl :: Text -> (Text, Text)
+parseUrl = Config.parseWsUrl
diff --git a/synapse-cc.cabal b/synapse-cc.cabal
--- a/synapse-cc.cabal
+++ b/synapse-cc.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.0
 name:               synapse-cc
-version:            0.1.0.0
+version:            0.2.0
 synopsis:           Unified compiler toolchain for Plexus backends
 description:
   synapse-cc orchestrates the complete pipeline from backend schema
@@ -19,17 +19,24 @@
     import:           warnings
     exposed-modules:  SynapseCC.Types
                     , SynapseCC.CLI
+                    , SynapseCC.Config
+                    , SynapseCC.Detect
                     , SynapseCC.Discover
+                    , SynapseCC.Lock
+                    , SynapseCC.Merge
                     , SynapseCC.Pipeline
                     , SynapseCC.Process
                     , SynapseCC.Cache
                     , SynapseCC.Dependency
                     , SynapseCC.Language
                     , SynapseCC.Logging
+                    , SynapseCC.Benchmark
+                    , SynapseCC.Watch
     build-depends:    base >= 4.14 && < 5
                     , optparse-applicative >= 0.18
                     , process >= 1.6
                     , aeson >= 2.0
+                    , aeson-pretty >= 0.8
                     , bytestring >= 0.11
                     , text >= 1.2
                     , filepath >= 1.4
@@ -38,7 +45,17 @@
                     , ansi-terminal >= 0.11
                     , time >= 1.9
                     , cryptohash-sha256 >= 0.11
+                    , base16-bytestring >= 1.0
                     , containers >= 0.6
+                    , stm >= 2.5
+                    , mtl >= 2.3
+                    , transformers >= 0.6
+                    , unordered-containers >= 0.2
+                    , fsnotify >= 0.4
+                    , plexus-protocol
+                    , plexus-synapse
+    autogen-modules:  Paths_synapse_cc
+    other-modules:    Paths_synapse_cc
     hs-source-dirs:   src
     default-language: Haskell2010
     default-extensions: OverloadedStrings
@@ -71,3 +88,17 @@
                     , synapse-cc
                     , hspec >= 2.0
                     , QuickCheck >= 2.14
+                    , text >= 1.2
+                    , directory >= 1.3
+                    , filepath >= 1.4
+                    , process >= 1.6
+                    , aeson >= 2.0
+                    , aeson-pretty >= 0.8
+                    , bytestring >= 0.11
+                    , containers >= 0.6
+                    , optparse-applicative >= 0.18
+                    , plexus-protocol
+                    , stm >= 2.5
+    default-extensions: OverloadedStrings
+                      , LambdaCase
+                      , ScopedTypeVariables
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -2,8 +2,1581 @@
 
 import Test.Hspec
 
-main :: IO ()
-main = hspec $ do
-  describe "SynapseCC" $ do
-    it "placeholder" $ do
-      True `shouldBe` True
+import Control.Concurrent (threadDelay)
+import Control.Exception (try, SomeException)
+import Control.Monad (forM_, when, unless, void)
+import Data.Aeson (Value(..), decode)
+import qualified Data.Aeson as Aeson
+import Data.Aeson ((.=))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KM
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BSC8
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.ByteString.Lazy.Char8 as LBS8
+import Data.Char (isAlpha, isDigit)
+import Data.Either (isLeft, isRight)
+import Data.List (isInfixOf, isPrefixOf, find)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+import System.Directory
+  ( doesDirectoryExist, doesFileExist, findExecutable
+  , getTemporaryDirectory, listDirectory
+  , createDirectoryIfMissing, removeDirectoryRecursive
+  , withCurrentDirectory
+  )
+import System.Exit (ExitCode(..))
+import System.FilePath ((</>), takeExtension)
+import System.Process
+  ( readProcessWithExitCode, CreateProcess(..), proc
+  , readCreateProcessWithExitCode, createProcess, terminateProcess
+  , waitForProcess, std_out, std_err, StdStream(..)
+  )
+import GHC.IO.Encoding (setLocaleEncoding, utf8)
+import Options.Applicative (execParserPure, defaultPrefs, getParseResult)
+import Plexus.Client (SubstrateConfig(..), defaultConfig)
+import qualified Plexus.Transport as Transport
+import Plexus.Types (TransportError(..), PlexusStreamItem)
+import SynapseCC.CLI (synapseCCParserInfo, synapseCCCommandParserInfo)
+import SynapseCC.Config (loadSynapseConfig, validateSynapseConfig, initSynapseConfig, synapseConfigPath)
+import SynapseCC.Detect
+  ( ProjectHint(..), Detector(..), emptyHint
+  , runDetectors, defaultDetectors
+  , detectTauri, detectVite, detectNextJS, detectNodeProject
+  )
+import SynapseCC.Discover (discoverTools)
+import SynapseCC.Pipeline (runPipeline)
+import SynapseCC.Types
+  ( Config(..), Backend(..), Target(..), Options(..), defaultOptions, formatError
+  , SynapseConfig(..), TargetConfig(..), WatchConfig(..)
+  , WatchArgs(..), Command(..)
+  , defaultSynapseConfig
+  , TransportType(..)
+  )
+import SynapseCC.Watch
+  ( matchesAnyPrefix, parseUrl, extractPluginHashesFromBytes
+  , filterTargets, buildConfigFromTarget
+  , fetchBackendHash
+  )
+
+-- | Shared test context: the path to generated output
+data TestEnv = TestEnv
+  { teOutputDir :: !FilePath
+  , tePipelineRan :: !Bool  -- True if we ran synapse-cc, False if using existing generated/
+  , tePipelineExit :: !ExitCode
+  , tePipelineError :: !String  -- Captured error output when pipeline fails
+  }
+
+-- | Test context for Tauri integration: output goes into a plexus/ subdir
+data TauriTestEnv = TauriTestEnv
+  { taOutputDir :: !FilePath  -- the generated output dir (proj </> "plexus")
+  , taProjDir   :: !FilePath  -- the project root (has user-owned package.json)
+  }
+
+-- | Recursively find all files with a given extension
+findFilesWithExt :: FilePath -> String -> IO [FilePath]
+findFilesWithExt root ext = go root
+  where
+    go dir = do
+      entries <- listDirectory dir
+      paths <- concat <$> mapM (process dir) entries
+      return paths
+    process dir name = do
+      let path = dir </> name
+      isDir <- doesDirectoryExist path
+      if isDir
+        then go path
+        else return [path | takeExtension name == ext]
+
+-- | Check if a file contains a substring (case-sensitive)
+fileContains :: FilePath -> String -> IO Bool
+fileContains path needle = do
+  content <- readFile path
+  return $ needle `isInfixOf` content
+
+-- | Count top-level namespace directories (those containing index.ts)
+countNamespaceDirs :: FilePath -> IO Int
+countNamespaceDirs root = do
+  entries <- listDirectory root
+  let candidates = filter (\e -> not ("." `isPrefixOf` e) && e /= "test" && e /= "node_modules") entries
+  dirs <- mapM (\e -> doesDirectoryExist (root </> e)) candidates
+  let dirNames = [e | (e, True) <- zip candidates dirs]
+  -- Count dirs that have index.ts (are namespace modules)
+  counts <- mapM (\d -> doesFileExist (root </> d </> "index.ts")) dirNames
+  return $ length [() | True <- counts]
+
+-- | Count "export * as" lines in a file
+countNamespaceExports :: FilePath -> IO Int
+countNamespaceExports path = do
+  content <- readFile path
+  return $ length $ filter ("export * as " `isPrefixOf`) $ lines content
+
+-- | Run a process and return (exitcode, stdout, stderr)
+runProc :: Maybe FilePath -> String -> [String] -> IO (ExitCode, String, String)
+runProc _mCwd cmd args = readProcessWithExitCode cmd args ""
+
+-- | Discover substrate URL by querying the registry through synapse CLI.
+--   Returns the ws:// URL if substrate is found, Nothing otherwise.
+discoverSubstrateUrl :: String -> IO (Maybe String)
+discoverSubstrateUrl registryPort = do
+  mSynapse <- findExecutable "synapse"
+  case mSynapse of
+    Nothing -> do
+      putStrLn "  synapse not found, skipping registry discovery"
+      return Nothing
+    Just synapse -> do
+      (exit, stdout, _) <- readProcessWithExitCode synapse
+        ["-P", registryPort, "--json", "registry-hub", "registry", "list"] ""
+      case exit of
+        ExitSuccess -> return $ parseSubstrateUrl stdout
+        _ -> return Nothing
+
+-- | Parse synapse --json output to find substrate's URL.
+--   The output contains JSON stream items, one per line.
+--   We look for a "backends" data item containing a substrate entry.
+parseSubstrateUrl :: String -> Maybe String
+parseSubstrateUrl output =
+  let jsonLines = mapMaybe (decode . LBS8.pack) (lines output) :: [Value]
+      -- Find the data item with backends list
+      backends = concatMap extractBackends jsonLines
+      substrate = find isSubstrate backends
+  in fmap backendToUrl substrate
+  where
+    extractBackends :: Value -> [Value]
+    extractBackends (Object obj) =
+      case KM.lookup "content" obj of
+        Just (Object content) ->
+          case KM.lookup "backends" content of
+            Just (Array arr) -> foldr (:) [] arr
+            _ -> []
+        _ -> []
+    extractBackends _ = []
+
+    isSubstrate :: Value -> Bool
+    isSubstrate (Object obj) =
+      case KM.lookup "name" obj of
+        Just (String name) -> name == "substrate"
+        _ -> False
+    isSubstrate _ = False
+
+    backendToUrl :: Value -> String
+    backendToUrl (Object obj) =
+      let proto = case KM.lookup "protocol" obj of
+            Just (String p) -> T.unpack p
+            _ -> "ws"
+          host = case KM.lookup "host" obj of
+            Just (String h) -> T.unpack h
+            _ -> "localhost"
+          port = case KM.lookup "port" obj of
+            Just (Number n) -> show (round n :: Int)
+            _ -> "4445"
+      in proto ++ "://" ++ host ++ ":" ++ port
+    backendToUrl _ = "ws://localhost:4445"
+
+-- | Patch the smoke test URL if it points to the registry instead of substrate.
+--   Reads the smoke test, checks if URL differs from discovered substrate URL,
+--   and rewrites the file if needed.
+patchSmokeTestUrl :: FilePath -> String -> IO ()
+patchSmokeTestUrl smokeTestPath substrateUrl = do
+  content <- readFile smokeTestPath
+  let patched = replaceWsUrls content substrateUrl
+  when (patched /= content) $ do
+    putStrLn $ "  Patching smoke test URL -> " ++ substrateUrl
+    length patched `seq` writeFile smokeTestPath patched
+
+-- | Replace ws:// URLs in the smoke test content with the given URL
+replaceWsUrls :: String -> String -> String
+replaceWsUrls [] _ = []
+replaceWsUrls content@(c:cs) newUrl
+  | "ws://localhost:" `isPrefixOf` content =
+      let rest = drop (length ("ws://localhost:" :: String)) content
+          (_port, after) = span isDigit rest
+      in newUrl ++ after
+  | "ws://127.0.0.1:" `isPrefixOf` content =
+      let rest = drop (length ("ws://127.0.0.1:" :: String)) content
+          (_port, after) = span isDigit rest
+      in newUrl ++ after
+  | otherwise = c : replaceWsUrls cs newUrl
+
+-- | Set up the test environment by running the pipeline in-process
+setupTestEnv :: IO TestEnv
+setupTestEnv = do
+  tmpBase <- getTemporaryDirectory
+  let tmpDir = tmpBase </> "synapse-cc-test-output"
+  tmpExists <- doesDirectoryExist tmpDir
+  when tmpExists $ removeDirectoryRecursive tmpDir
+  createDirectoryIfMissing True tmpDir
+
+  -- Parse args exactly as the CLI would, using the real parser
+  let args = [ "typescript", "substrate"
+             , "-P", "4444"
+             , "-o", tmpDir
+             , "--force"
+             , "--no-build"
+             , "--no-tests"
+             ]
+  config <- case getParseResult (execParserPure defaultPrefs synapseCCParserInfo args) of
+    Nothing -> fail "Failed to parse synapse-cc arguments"
+    Just c  -> return c
+
+  toolsResult <- discoverTools (cfgOptions config)
+  tools <- case toolsResult of
+    Left err -> fail $ T.unpack (formatError err)
+    Right t  -> return t
+  -- Run pipeline with cwd = tmpDir to simulate standalone use:
+  -- the user runs synapse-cc from the directory that becomes the package root.
+  result <- withCurrentDirectory tmpDir $ runPipeline config tools
+  case result of
+    Left err -> fail $ T.unpack (formatError err)
+    Right _  -> return TestEnv
+      { teOutputDir    = tmpDir
+      , tePipelineRan  = True
+      , tePipelineExit = ExitSuccess
+      , tePipelineError = ""
+      }
+
+-- | Set up the Tauri test environment: a user-owned project with a plexus/ output subdir
+setupTauriTestEnv :: IO TauriTestEnv
+setupTauriTestEnv = do
+  tmpBase <- getTemporaryDirectory
+  let tmpDir = tmpBase </> "synapse-cc-tauri-test"
+  tmpExists <- doesDirectoryExist tmpDir
+  when tmpExists $ removeDirectoryRecursive tmpDir
+  createDirectoryIfMissing True tmpDir
+
+  -- Write a user-owned package.json (no _generatedBy marker = integration mode)
+  writeFile (tmpDir </> "package.json") $
+    "{\n  \"name\": \"my-tauri-app\",\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"private\": true\n}\n"
+
+  -- Output goes into a plexus/ subdirectory (like a real Tauri integration)
+  let outputDir = tmpDir </> "plexus"
+
+  let args = [ "typescript", "substrate"
+             , "-P", "4444"
+             , "-o", outputDir
+             , "--transport", "browser"
+             , "--force"
+             , "--no-build"
+             , "--no-tests"
+             ]
+  config <- case getParseResult (execParserPure defaultPrefs synapseCCParserInfo args) of
+    Nothing -> fail "Failed to parse tauri synapse-cc arguments"
+    Just c  -> return c
+
+  toolsResult <- discoverTools (cfgOptions config)
+  tools <- case toolsResult of
+    Left err -> fail $ T.unpack (formatError err)
+    Right t  -> return t
+
+  result <- withCurrentDirectory tmpDir $ runPipeline config tools
+  case result of
+    Left err -> fail $ T.unpack (formatError err)
+    Right _  -> return TauriTestEnv
+      { taOutputDir = outputDir
+      , taProjDir   = tmpDir
+      }
+
+main :: IO ()
+main = do
+  setLocaleEncoding utf8
+  env <- setupTestEnv
+  tauriEnv <- setupTauriTestEnv
+  let dir = teOutputDir env
+  hspec $ do
+    -- ═══════════════════════════════════════════
+    -- Section 1: Pipeline
+    -- ═══════════════════════════════════════════
+    describe "Pipeline" $ do
+      it "exit code is 0" $ do
+        tePipelineExit env `shouldBe` ExitSuccess
+
+      it "output directory exists" $ do
+        exists <- doesDirectoryExist dir
+        exists `shouldBe` True
+
+    -- ═══════════════════════════════════════════
+    -- Section 2: File structure
+    -- ═══════════════════════════════════════════
+    describe "File structure" $ do
+      let coreFiles =
+            [ "types.ts", "rpc.ts", "transport.ts", "index.ts"
+            , "package.json", "tsconfig.json", "ir.json"
+            ]
+      forM_ coreFiles $ \f ->
+        it ("core file exists: " ++ f) $ do
+          exists <- doesFileExist (dir </> f)
+          exists `shouldBe` True
+
+      let knownNamespaces = ["echo", "health", "cone", "hyperforge"]
+      forM_ knownNamespaces $ \ns -> do
+        it (ns ++ "/types.ts exists") $ do
+          exists <- doesFileExist (dir </> ns </> "types.ts")
+          exists `shouldBe` True
+        it (ns ++ "/client.ts exists") $ do
+          exists <- doesFileExist (dir </> ns </> "client.ts")
+          exists `shouldBe` True
+        it (ns ++ "/index.ts exists") $ do
+          exists <- doesFileExist (dir </> ns </> "index.ts")
+          exists `shouldBe` True
+
+      it "nested namespace: solar/earth/luna/client.ts exists" $ do
+        exists <- doesFileExist (dir </> "solar" </> "earth" </> "luna" </> "client.ts")
+        exists `shouldBe` True
+
+      it "test/smoke.test.ts exists" $ do
+        exists <- doesFileExist (dir </> "test" </> "smoke.test.ts")
+        exists `shouldBe` True
+
+    -- ═══════════════════════════════════════════
+    -- Section 3: IR invariants
+    -- ═══════════════════════════════════════════
+    describe "IR invariants" $ do
+      it "ir.json parses as valid JSON" $ do
+        bs <- LBS.readFile (dir </> "ir.json")
+        let mVal = decode bs :: Maybe Value
+        mVal `shouldSatisfy` isJust
+
+      it "irVersion is \"2.0\"" $ do
+        bs <- LBS.readFile (dir </> "ir.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "irVersion" obj of
+              Just (String v) -> T.unpack v `shouldBe` "2.0"
+              other -> expectationFailure $ "irVersion not a string: " ++ show other
+          _ -> expectationFailure "ir.json is not an object"
+
+      it "irTypes is non-empty" $ do
+        bs <- LBS.readFile (dir </> "ir.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "irTypes" obj of
+              Just (Object types) -> KM.size types `shouldSatisfy` (> 0)
+              other -> expectationFailure $ "irTypes not an object: " ++ show other
+          _ -> expectationFailure "ir.json is not an object"
+
+      it "irMethods is non-empty" $ do
+        bs <- LBS.readFile (dir </> "ir.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "irMethods" obj of
+              Just (Object methods) -> KM.size methods `shouldSatisfy` (> 0)
+              other -> expectationFailure $ "irMethods not an object: " ++ show other
+          _ -> expectationFailure "ir.json is not an object"
+
+      it "irPlugins contains echo, health, cone, hyperforge" $ do
+        bs <- LBS.readFile (dir </> "ir.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "irPlugins" obj of
+              Just (Object plugins) -> do
+                let keys = map Key.toText $ KM.keys plugins
+                forM_ ["echo", "health", "cone", "hyperforge"] $ \ns ->
+                  keys `shouldSatisfy` (T.pack ns `elem`)
+              other -> expectationFailure $ "irPlugins not an object: " ++ show other
+          _ -> expectationFailure "ir.json is not an object"
+
+    -- ═══════════════════════════════════════════
+    -- Section 4: Import invariants
+    -- ═══════════════════════════════════════════
+    describe "Import invariants" $ do
+      it "no dots in import type identifiers" $ do
+        tsFiles <- findFilesWithExt dir ".ts"
+        forM_ tsFiles $ \f -> do
+          content <- readFile f
+          let importLines = filter ("import type" `isInfixOf`) (lines content)
+          forM_ importLines $ \line -> do
+            -- Check for patterns like {X.Y} which indicate unresolved qualified names
+            let braceContent = extractBraceContent line
+            forM_ braceContent $ \ident ->
+              ident `shouldSatisfy` (not . ('.' `elem`))
+
+      it "every namespace index.ts re-exports ./types and ./client" $ do
+        let namespaces = ["echo", "health", "cone", "hyperforge"]
+        forM_ namespaces $ \ns -> do
+          let indexPath = dir </> ns </> "index.ts"
+          hasTypes <- fileContains indexPath "./types"
+          hasClient <- fileContains indexPath "./client"
+          (hasTypes && hasClient) `shouldBe` True
+
+    -- ═══════════════════════════════════════════
+    -- Section 5: Code patterns
+    -- ═══════════════════════════════════════════
+    describe "Code patterns" $ do
+      let clientChecks =
+            [ ("echo",       "EchoClient",       "EchoClientImpl",       "createEchoClient")
+            , ("health",     "HealthClient",      "HealthClientImpl",     "createHealthClient")
+            , ("hyperforge", "HyperforgeClient",  "HyperforgeClientImpl", "createHyperforgeClient")
+            ]
+      forM_ clientChecks $ \(ns, iface, impl, factory) -> do
+        it (ns ++ "/client.ts has " ++ iface ++ " interface") $ do
+          has <- fileContains (dir </> ns </> "client.ts") ("interface " ++ iface)
+          has `shouldBe` True
+
+        it (ns ++ "/client.ts has " ++ impl ++ " class") $ do
+          has <- fileContains (dir </> ns </> "client.ts") ("class " ++ impl)
+          has `shouldBe` True
+
+        it (ns ++ "/client.ts has " ++ factory ++ " factory") $ do
+          has <- fileContains (dir </> ns </> "client.ts") ("function " ++ factory)
+          has `shouldBe` True
+
+      it "echo/types.ts has discriminated union with type: field" $ do
+        has <- fileContains (dir </> "echo" </> "types.ts") "type:"
+        has `shouldBe` True
+
+      it "cone/types.ts has discriminated union with type: field" $ do
+        has <- fileContains (dir </> "cone" </> "types.ts") "type:"
+        has `shouldBe` True
+
+      it "echo/client.ts uses collectOne (non-streaming)" $ do
+        has <- fileContains (dir </> "echo" </> "client.ts") "collectOne<"
+        has `shouldBe` True
+
+      it "cone/client.ts uses extractData (streaming)" $ do
+        has <- fileContains (dir </> "cone" </> "client.ts") "extractData<"
+        has `shouldBe` True
+
+      it "root index.ts export count matches namespace directory count" $ do
+        nsExports <- countNamespaceExports (dir </> "index.ts")
+        nsDirs <- countNamespaceDirs dir
+        -- The export count should be >= the namespace dir count
+        -- (could be more due to nested namespaces like solar/earth/luna)
+        nsExports `shouldSatisfy` (>= nsDirs)
+
+    -- ═══════════════════════════════════════════
+    -- Section 6: package.json
+    -- ═══════════════════════════════════════════
+    describe "package.json" $ do
+      it "parses as valid JSON" $ do
+        bs <- LBS.readFile (dir </> "package.json")
+        let mVal = decode bs :: Maybe Value
+        mVal `shouldSatisfy` isJust
+
+      it "name is @plexus/client" $ do
+        bs <- LBS.readFile (dir </> "package.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "name" obj of
+              Just (String name) -> T.unpack name `shouldBe` "@plexus/client"
+              other -> expectationFailure $ "name not a string: " ++ show other
+          _ -> expectationFailure "package.json is not an object"
+
+      it "type is module" $ do
+        bs <- LBS.readFile (dir </> "package.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "type" obj of
+              Just (String t) -> T.unpack t `shouldBe` "module"
+              other -> expectationFailure $ "type not a string: " ++ show other
+          _ -> expectationFailure "package.json is not an object"
+
+      it "has scripts.test" $ do
+        bs <- LBS.readFile (dir </> "package.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "scripts" obj of
+              Just (Object scripts) ->
+                KM.lookup "test" scripts `shouldSatisfy` isJust
+              other -> expectationFailure $ "scripts not an object: " ++ show other
+          _ -> expectationFailure "package.json is not an object"
+
+      it "has scripts.typecheck" $ do
+        bs <- LBS.readFile (dir </> "package.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "scripts" obj of
+              Just (Object scripts) ->
+                KM.lookup "typecheck" scripts `shouldSatisfy` isJust
+              other -> expectationFailure $ "scripts not an object: " ++ show other
+          _ -> expectationFailure "package.json is not an object"
+
+      it "has typescript devDependency" $ do
+        bs <- LBS.readFile (dir </> "package.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "devDependencies" obj of
+              Just (Object devDeps) ->
+                KM.lookup "typescript" devDeps `shouldSatisfy` isJust
+              other -> expectationFailure $ "devDependencies not an object: " ++ show other
+          _ -> expectationFailure "package.json is not an object"
+
+      it "has ws dependency" $ do
+        bs <- LBS.readFile (dir </> "package.json")
+        case decode bs of
+          Just (Object obj) -> do
+            case KM.lookup "dependencies" obj of
+              Just (Object deps) ->
+                KM.lookup "ws" deps `shouldSatisfy` isJust
+              other -> expectationFailure $ "dependencies not an object: " ++ show other
+          _ -> expectationFailure "package.json is not an object"
+
+    -- ═══════════════════════════════════════════
+    -- Section 7: TypeScript smoke tests
+    -- ═══════════════════════════════════════════
+    describe "TypeScript smoke tests" $ do
+      it "bun install exits 0" $ do
+        bunAvail <- findExecutable "bun"
+        case bunAvail of
+          Nothing -> pendingWith "bun not found on PATH"
+          Just bun -> do
+            let p = (proc bun ["install"]) { cwd = Just dir }
+            (exit, stdout, stderr) <- readCreateProcessWithExitCode p ""
+            case exit of
+              ExitSuccess -> return ()
+              ExitFailure 127 -> pendingWith $
+                "bun runtime unavailable: " ++ take 200 (stderr ++ stdout)
+              ExitFailure c -> expectationFailure $
+                "bun install failed (exit " ++ show c ++ "): "
+                ++ take 500 (stderr ++ stdout)
+
+      it "bun x tsc --noEmit exits 0" $ do
+        bunAvail <- findExecutable "bun"
+        case bunAvail of
+          Nothing -> pendingWith "bun not found on PATH"
+          Just bun -> do
+            nodeModules <- doesDirectoryExist (dir </> "node_modules")
+            unless nodeModules $ pendingWith "node_modules not found (bun install may have failed)"
+            let p = (proc bun ["x", "tsc", "--noEmit"]) { cwd = Just dir }
+            (exit, stdout, stderr) <- readCreateProcessWithExitCode p ""
+            case exit of
+              ExitSuccess -> return ()
+              ExitFailure 127 -> pendingWith $
+                "bun runtime unavailable: " ++ take 200 (stderr ++ stdout)
+              ExitFailure c -> expectationFailure $
+                "tsc --noEmit failed (exit " ++ show c ++ "): "
+                ++ take 500 (stderr ++ stdout)
+
+      it "bun test exits 0" $ do
+        bunAvail <- findExecutable "bun"
+        case bunAvail of
+          Nothing -> pendingWith "bun not found on PATH"
+          Just bun -> do
+            nodeModules <- doesDirectoryExist (dir </> "node_modules")
+            unless nodeModules $ pendingWith "node_modules not found (bun install may have failed)"
+            -- Discover substrate's actual URL through the registry and patch
+            -- the smoke test if it points at the wrong port (e.g. registry port)
+            let smokeTest = dir </> "test" </> "smoke.test.ts"
+            smokeExists <- doesFileExist smokeTest
+            when smokeExists $ do
+              mUrl <- discoverSubstrateUrl "4444"
+              case mUrl of
+                Just url -> patchSmokeTestUrl smokeTest url
+                Nothing -> putStrLn "  Could not discover substrate URL, running smoke test as-is"
+            let p = (proc bun ["test"]) { cwd = Just dir }
+            (exit, stdout, stderr) <- readCreateProcessWithExitCode p ""
+            case exit of
+              ExitSuccess -> return ()
+              ExitFailure 127 -> pendingWith $
+                "bun runtime unavailable: " ++ take 200 (stderr ++ stdout)
+              ExitFailure c -> expectationFailure $
+                "bun test failed (exit " ++ show c ++ "):\n"
+                ++ take 500 stdout ++ "\n" ++ take 500 stderr
+
+    -- ═══════════════════════════════════════════
+    -- Section 8: Tauri (browser transport)
+    -- ═══════════════════════════════════════════
+    describe "Tauri (browser transport)" $ do
+      let tdir = taOutputDir tauriEnv
+
+      describe "Integration mode file structure" $ do
+        it "output directory exists" $ do
+          exists <- doesDirectoryExist tdir
+          exists `shouldBe` True
+
+        it "no tsconfig.json in output (integration mode)" $ do
+          exists <- doesFileExist (tdir </> "tsconfig.json")
+          exists `shouldBe` False
+
+        it "no test/ directory in output (integration mode)" $ do
+          exists <- doesDirectoryExist (tdir </> "test")
+          exists `shouldBe` False
+
+        it "no package.json in output (host project owns it)" $ do
+          exists <- doesFileExist (tdir </> "package.json")
+          exists `shouldBe` False
+
+        let coreFiles = ["types.ts", "rpc.ts", "transport.ts", "index.ts", "ir.json"]
+        forM_ coreFiles $ \f ->
+          it ("core file exists: " ++ f) $ do
+            exists <- doesFileExist (tdir </> f)
+            exists `shouldBe` True
+
+      describe "Browser transport constraints" $ do
+        it "transport.ts has no 'import WebSocket from ws'" $ do
+          content <- readFile (tdir </> "transport.ts")
+          (("import WebSocket from 'ws'" `isInfixOf` content) ||
+           ("import WebSocket from \"ws\"" `isInfixOf` content))
+            `shouldBe` False
+
+        it "no 'export namespace' in any generated .ts file" $ do
+          tsFiles <- findFilesWithExt tdir ".ts"
+          forM_ tsFiles $ \f -> do
+            content <- readFile f
+            content `shouldSatisfy` (not . ("export namespace" `isInfixOf`))
+
+        it "no parameter properties (private readonly rpc) in any generated .ts file" $ do
+          tsFiles <- findFilesWithExt tdir ".ts"
+          forM_ tsFiles $ \f -> do
+            content <- readFile f
+            content `shouldSatisfy` (not . ("private readonly rpc" `isInfixOf`))
+
+      describe "TypeScript compilation (erasableSyntaxOnly)" $ do
+        it "tsc --noEmit with erasableSyntaxOnly: true exits 0" $ do
+          -- Write a strict tsconfig at project root targeting just the generated output
+          let tsconfigContent = unlines
+                [ "{"
+                , "  \"compilerOptions\": {"
+                , "    \"target\": \"ES2022\","
+                , "    \"module\": \"ESNext\","
+                , "    \"moduleResolution\": \"bundler\","
+                , "    \"strict\": true,"
+                , "    \"erasableSyntaxOnly\": true,"
+                , "    \"noUnusedLocals\": true,"
+                , "    \"lib\": [\"ES2022\", \"DOM\"]"
+                , "  },"
+                , "  \"include\": [\"plexus/**/*.ts\"]"
+                , "}"
+                ]
+          writeFile (taProjDir tauriEnv </> "tsconfig.tauri-check.json") tsconfigContent
+          let cp = (proc "bun" ["x", "tsc", "--noEmit", "-p", "tsconfig.tauri-check.json"])
+                     { cwd = Just (taProjDir tauriEnv) }
+          (exit, stdout, stderr) <- readCreateProcessWithExitCode cp ""
+          when (exit /= ExitSuccess) $
+            expectationFailure $ "tsc with erasableSyntaxOnly failed:\n" ++ stdout ++ stderr
+          exit `shouldBe` ExitSuccess
+
+    -- ═══════════════════════════════════════════
+    -- Section 9: Merge and cache invariants
+    -- ═══════════════════════════════════════════
+    -- These tests run a second pipeline against the already-generated dir
+    -- to verify the two most important correctness guarantees:
+    --   (a) user edits survive a normal rerun (three-way merge skips modified files)
+    --   (b) --force overwrites user edits (explicit opt-in to overwrite)
+    describe "Merge and cache invariants" $ do
+
+      -- Helper: run the pipeline with arbitrary extra args into `dir` as both
+      -- cwd and output, using the already-discovered tools.
+      let runAgain extraArgs = do
+            let args = [ "typescript", "substrate"
+                       , "-P", "4444"
+                       , "-o", dir
+                       , "--no-build", "--no-tests"
+                       ] ++ extraArgs
+            cfg <- case getParseResult (execParserPure defaultPrefs synapseCCParserInfo args) of
+                     Nothing -> fail "Failed to parse args for second run"
+                     Just c  -> return c
+            t <- discoverTools (cfgOptions cfg) >>= either (fail . T.unpack . formatError) return
+            withCurrentDirectory dir $ runPipeline cfg t
+
+      it "user edit in a generated file survives rerun without --force" $ do
+        -- This is the core safety guarantee of the whole system.
+        -- If it breaks, users lose work every time they regenerate.
+        let target = dir </> "rpc.ts"
+        original <- TIO.readFile target
+        let userEdit = original <> "\n// user addition — must survive rerun"
+        TIO.writeFile target userEdit
+
+        _ <- runAgain []   -- no --force
+
+        after <- TIO.readFile target
+        after `shouldBe` userEdit
+
+      it "user edit is overwritten with --force" $ do
+        -- --force must actually force; silent failure would make the flag useless.
+        let target = dir </> "rpc.ts"
+        original <- TIO.readFile target
+        let userEdit = original <> "\n// this should be gone after --force"
+        TIO.writeFile target userEdit
+
+        _ <- runAgain ["--force"]
+
+        after <- TIO.readFile target
+        after `shouldNotBe` userEdit
+
+    -- ═══════════════════════════════════════════
+    -- Section 10: synapse.config.json
+    -- ═══════════════════════════════════════════
+    describe "synapse.config.json" $ do
+
+      describe "initSynapseConfig" $ do
+        it "creates a valid JSON file" $ do
+          tmpBase <- getTemporaryDirectory
+          let dir' = tmpBase </> "synapse-cc-init-test"
+          tmpExists <- doesDirectoryExist dir'
+          when tmpExists $ removeDirectoryRecursive dir'
+          createDirectoryIfMissing True dir'
+          -- Pass [] — no detectors, so defaults apply regardless of test environment
+          result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []
+          result `shouldSatisfy` isRight
+          exists <- doesFileExist (dir' </> synapseConfigPath)
+          exists `shouldBe` True
+
+        it "returns Left if file already exists" $ do
+          tmpBase <- getTemporaryDirectory
+          let dir' = tmpBase </> "synapse-cc-init-exists-test"
+          createDirectoryIfMissing True dir'
+          writeFile (dir' </> synapseConfigPath) "{}"
+          result <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []
+          result `shouldSatisfy` isLeft
+
+        it "created file round-trips through loadSynapseConfig" $ do
+          tmpBase <- getTemporaryDirectory
+          let dir' = tmpBase </> "synapse-cc-init-roundtrip-test"
+          tmpExists <- doesDirectoryExist dir'
+          when tmpExists $ removeDirectoryRecursive dir'
+          createDirectoryIfMissing True dir'
+          _ <- withCurrentDirectory dir' $ initSynapseConfig synapseConfigPath []
+          result <- withCurrentDirectory dir' loadSynapseConfig
+          result `shouldSatisfy` isRight
+
+      describe "loadSynapseConfig" $ do
+        it "returns Left when file is missing" $ do
+          tmpBase <- getTemporaryDirectory
+          let dir' = tmpBase </> "synapse-cc-no-config-test"
+          tmpExists <- doesDirectoryExist dir'
+          when tmpExists $ removeDirectoryRecursive dir'
+          createDirectoryIfMissing True dir'
+          result <- withCurrentDirectory dir' loadSynapseConfig
+          result `shouldSatisfy` isLeft
+
+        it "parses the standalone fixture" $ do
+          let fixturePath = "test/fixtures/standalone.config.json"
+          exists <- doesFileExist fixturePath
+          if not exists
+            then pendingWith "test/fixtures/standalone.config.json not found"
+            else do
+              bs <- BS.readFile fixturePath
+              case Aeson.decodeStrict bs of
+                Nothing  -> expectationFailure "Fixture failed to parse as JSON"
+                Just cfg -> scBackend (cfg :: SynapseConfig) `shouldBe` Just "substrate"
+
+        it "fixture has two targets" $ do
+          let fixturePath = "test/fixtures/standalone.config.json"
+          exists <- doesFileExist fixturePath
+          if not exists
+            then pendingWith "test/fixtures/standalone.config.json not found"
+            else do
+              bs <- BS.readFile fixturePath
+              case Aeson.decodeStrict bs of
+                Nothing  -> expectationFailure "Fixture failed to parse"
+                Just cfg -> Map.size (scTargets (cfg :: SynapseConfig)) `shouldBe` 2
+
+      describe "validateSynapseConfig" $ do
+        it "accepts defaultSynapseConfig" $
+          validateSynapseConfig defaultSynapseConfig `shouldBe` Right ()
+
+        it "rejects empty backend" $
+          validateSynapseConfig (defaultSynapseConfig { scBackend = Just "" })
+            `shouldSatisfy` isLeft
+
+        it "rejects whitespace-only backend" $
+          validateSynapseConfig (defaultSynapseConfig { scBackend = Just "   " })
+            `shouldSatisfy` isLeft
+
+        it "rejects empty targets map" $
+          validateSynapseConfig (defaultSynapseConfig { scTargets = Map.empty })
+            `shouldSatisfy` isLeft
+
+        it "rejects target with empty generate list" $ do
+          let badTarget = TargetConfig
+                { tcGenerate  = []
+                , tcTransport = WsTransport
+                , tcOutputDir = "out"
+                , tcSmokePath = Nothing
+                }
+          let cfg = defaultSynapseConfig { scTargets = Map.singleton "t" badTarget }
+          validateSynapseConfig cfg `shouldSatisfy` isLeft
+
+        it "rejects target with empty outputDir" $ do
+          let badTarget = TargetConfig
+                { tcGenerate  = ["plugins"]
+                , tcTransport = WsTransport
+                , tcOutputDir = ""
+                , tcSmokePath = Nothing
+                }
+          let cfg = defaultSynapseConfig { scTargets = Map.singleton "t" badTarget }
+          validateSynapseConfig cfg `shouldSatisfy` isLeft
+
+      describe "JSON round-trip" $ do
+        it "encode/decode SynapseConfig preserves data" $ do
+          let cfg = defaultSynapseConfig
+          case Aeson.decode (Aeson.encode cfg) of
+            Nothing      -> expectationFailure "Failed to decode encoded SynapseConfig"
+            Just decoded -> scBackend (decoded :: SynapseConfig) `shouldBe` scBackend cfg
+
+        it "WatchConfig defaults parse correctly" $ do
+          let json = "{\"pollInterval\":500,\"hotReload\":true}"
+          case Aeson.decodeStrict (BSC8.pack json) of
+            Nothing -> expectationFailure "Failed to parse WatchConfig"
+            Just wc -> do
+              wcPollInterval (wc :: WatchConfig) `shouldBe` 500
+              wcHotReload wc `shouldBe` True
+
+    -- ═══════════════════════════════════════════
+    -- Section 11: CLI subcommand parsing
+    -- ═══════════════════════════════════════════
+    describe "CLI subcommand parsing" $ do
+      let parse args = getParseResult (execParserPure defaultPrefs synapseCCCommandParserInfo args)
+
+      describe "build subcommand" $ do
+        it "parses 'build typescript substrate'" $
+          parse ["build", "typescript", "substrate"] `shouldSatisfy` isJust
+
+        it "produces CmdBuild" $ do
+          case parse ["build", "typescript", "substrate"] of
+            Just (CmdBuild _) -> pure ()
+            other -> expectationFailure $ "Expected CmdBuild, got: " ++ show other
+
+        it "--transport browser gives BrowserTransport" $ do
+          case parse ["build", "typescript", "substrate", "--transport", "browser"] of
+            Just (CmdBuild cfg) -> optTransport (cfgOptions cfg) `shouldBe` BrowserTransport
+            _ -> expectationFailure "Parse failed or wrong command"
+
+        it "--no-install disables dep install" $ do
+          case parse ["build", "typescript", "substrate", "--no-install"] of
+            Just (CmdBuild cfg) -> optInstallDeps (cfgOptions cfg) `shouldBe` False
+            _ -> expectationFailure "Parse failed"
+
+        it "--no-build disables build step" $ do
+          case parse ["build", "typescript", "substrate", "--no-build"] of
+            Just (CmdBuild cfg) -> optBuild (cfgOptions cfg) `shouldBe` False
+            _ -> expectationFailure "Parse failed"
+
+        it "--force enables force flag" $ do
+          case parse ["build", "typescript", "substrate", "--force"] of
+            Just (CmdBuild cfg) -> optForce (cfgOptions cfg) `shouldBe` True
+            _ -> expectationFailure "Parse failed"
+
+        it "unknown target fails to parse" $
+          parse ["build", "cobol", "substrate"] `shouldSatisfy` isNothing
+
+        it "'build' with no args produces CmdBuildFromConfig" $
+          case parse ["build"] of
+            Just (CmdBuildFromConfig _) -> pure ()
+            other -> expectationFailure $ "Expected CmdBuildFromConfig, got: " ++ show other
+
+        it "'build --no-install' with no positional args passes flag through" $
+          case parse ["build", "--no-install"] of
+            Just (CmdBuildFromConfig opts) -> optInstallDeps opts `shouldBe` False
+            other -> expectationFailure $ "Expected CmdBuildFromConfig, got: " ++ show other
+
+        it "'build --force' with no positional args passes flag through" $
+          case parse ["build", "--force"] of
+            Just (CmdBuildFromConfig opts) -> optForce opts `shouldBe` True
+            other -> expectationFailure $ "Expected CmdBuildFromConfig, got: " ++ show other
+
+      describe "watch subcommand" $ do
+        it "parses 'watch substrate'" $
+          parse ["watch", "substrate"] `shouldSatisfy` isJust
+
+        it "produces CmdWatch" $ do
+          case parse ["watch", "substrate"] of
+            Just (CmdWatch _) -> pure ()
+            other -> expectationFailure $ "Expected CmdWatch, got: " ++ show other
+
+        it "backend is set correctly" $ do
+          case parse ["watch", "substrate"] of
+            Just (CmdWatch wa) -> waBackend wa `shouldBe` "substrate"
+            _ -> expectationFailure "Parse failed"
+
+        it "no plugin args -> empty list" $ do
+          case parse ["watch", "substrate"] of
+            Just (CmdWatch wa) -> waPlugins wa `shouldBe` []
+            _ -> expectationFailure "Parse failed"
+
+        it "plugin args become prefix filters" $ do
+          case parse ["watch", "substrate", "echo", "health"] of
+            Just (CmdWatch wa) -> waPlugins wa `shouldBe` ["echo", "health"]
+            other -> expectationFailure $ "Expected CmdWatch with plugins, got: " ++ show other
+
+        it "--target pins to a named target" $ do
+          case parse ["watch", "substrate", "echo", "--target", "client"] of
+            Just (CmdWatch wa) -> waTarget wa `shouldBe` Just "client"
+            other -> expectationFailure $ show other
+
+        it "no --target -> Nothing" $ do
+          case parse ["watch", "substrate"] of
+            Just (CmdWatch wa) -> waTarget wa `shouldBe` Nothing
+            _ -> expectationFailure "Parse failed"
+
+      describe "init subcommand" $ do
+        it "parses 'init'" $
+          parse ["init"] `shouldSatisfy` isJust
+
+        it "produces CmdInit" $ do
+          case parse ["init"] of
+            Just CmdInit -> pure ()
+            other -> expectationFailure $ "Expected CmdInit, got: " ++ show other
+
+      describe "legacy build parser compatibility" $ do
+        it "synapseCCParserInfo still parses old-style args" $
+          getParseResult (execParserPure defaultPrefs synapseCCParserInfo
+            ["typescript", "substrate", "--no-build", "--no-tests"])
+            `shouldSatisfy` isJust
+
+    -- ═══════════════════════════════════════════
+    -- Section 12: Watch utilities (pure)
+    -- ═══════════════════════════════════════════
+    describe "Watch utilities" $ do
+
+      describe "matchesAnyPrefix" $ do
+        it "exact match" $
+          matchesAnyPrefix ["echo"] "echo" `shouldBe` True
+        it "prefix.child match" $
+          matchesAnyPrefix ["echo"] "echo.workspace" `shouldBe` True
+        it "deep prefix match" $
+          matchesAnyPrefix ["echo"] "echo.workspace.repos" `shouldBe` True
+        it "does NOT match substring without dot" $
+          matchesAnyPrefix ["echo"] "echoes" `shouldBe` False
+        it "does NOT match unrelated namespace" $
+          matchesAnyPrefix ["echo"] "health" `shouldBe` False
+        it "empty filter list matches nothing" $
+          matchesAnyPrefix [] "echo" `shouldBe` False
+        it "multiple prefixes, first matches" $
+          matchesAnyPrefix ["echo", "health"] "echo.run" `shouldBe` True
+        it "multiple prefixes, second matches" $
+          matchesAnyPrefix ["echo", "health"] "health.status" `shouldBe` True
+        it "multiple prefixes, none match" $
+          matchesAnyPrefix ["echo", "health"] "cone.render" `shouldBe` False
+
+      describe "parseUrl" $ do
+        it "ws://localhost:4444" $
+          parseUrl "ws://localhost:4444" `shouldBe` ("localhost", "4444")
+        it "ws://127.0.0.1:4444" $
+          parseUrl "ws://127.0.0.1:4444" `shouldBe` ("127.0.0.1", "4444")
+        it "wss://host:8443" $
+          parseUrl "wss://host:8443" `shouldBe` ("host", "8443")
+        it "path after port is ignored" $
+          snd (parseUrl "ws://localhost:4444/backend") `shouldBe` "4444"
+        it "defaults port to 4444 when missing" $
+          snd (parseUrl "ws://localhost") `shouldBe` "4444"
+        it "custom port 9999" $
+          snd (parseUrl "ws://myhost:9999") `shouldBe` "9999"
+
+      describe "extractPluginHashesFromBytes" $ do
+        it "returns empty map for invalid JSON" $
+          extractPluginHashesFromBytes "not json" `shouldBe` Map.empty
+
+        it "returns empty map for JSON without irPluginHashes" $
+          extractPluginHashesFromBytes "{\"irVersion\":\"2.0\"}" `shouldBe` Map.empty
+
+        it "extracts hash from irPluginHashes" $ do
+          let ir = BSC8.pack "{\"irPluginHashes\":{\"echo\":{\"hash\":\"abc123\",\"selfHash\":\"x\",\"childrenHash\":\"y\"}}}"
+          Map.lookup "echo" (extractPluginHashesFromBytes ir) `shouldBe` Just "abc123"
+
+        it "extracts multiple plugin hashes" $ do
+          let ir = BSC8.pack "{\"irPluginHashes\":{\"echo\":{\"hash\":\"aaa\"},\"health\":{\"hash\":\"bbb\"}}}"
+          let hashes = extractPluginHashesFromBytes ir
+          Map.size hashes `shouldBe` 2
+          Map.lookup "echo"   hashes `shouldBe` Just "aaa"
+          Map.lookup "health" hashes `shouldBe` Just "bbb"
+
+        it "returns empty string for hash field missing" $ do
+          let ir = BSC8.pack "{\"irPluginHashes\":{\"echo\":{\"selfHash\":\"x\"}}}"
+          Map.lookup "echo" (extractPluginHashesFromBytes ir) `shouldBe` Just ""
+
+      describe "filterTargets" $ do
+        let mkTarget gen = TargetConfig gen WsTransport "out" Nothing
+        let targets = Map.fromList
+              [ ("client", mkTarget ["transport", "rpc", "plugins"])
+              , ("smoke",  mkTarget ["smoke"])
+              , ("all",    mkTarget ["all"])
+              , ("empty",  mkTarget ["transport"])
+              ]
+        let noPin = WatchArgs "substrate" [] Nothing Nothing defaultOptions
+
+        it "includes targets with 'plugins'" $
+          Map.member "client" (filterTargets noPin targets) `shouldBe` True
+        it "excludes targets without 'plugins' or 'all'" $
+          Map.member "smoke" (filterTargets noPin targets) `shouldBe` False
+        it "includes targets with 'all'" $
+          Map.member "all" (filterTargets noPin targets) `shouldBe` True
+        it "excludes transport-only targets" $
+          Map.member "empty" (filterTargets noPin targets) `shouldBe` False
+
+        it "--target pins to exactly one target" $ do
+          let pinned = WatchArgs "substrate" [] (Just "smoke") Nothing defaultOptions
+          Map.keys (filterTargets pinned targets) `shouldBe` ["smoke"]
+
+        it "--target for missing name returns empty" $ do
+          let pinned = WatchArgs "substrate" [] (Just "nonexistent") Nothing defaultOptions
+          filterTargets pinned targets `shouldBe` Map.empty
+
+      describe "buildConfigFromTarget" $ do
+        it "sets outputDir from target" $ do
+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions
+          let sc  = defaultSynapseConfig { scUrl = Just "ws://localhost:4444" }
+          let tc  = TargetConfig ["plugins"] BrowserTransport "my/output/dir" Nothing
+          let cfg = buildConfigFromTarget wa sc tc
+          optOutput (cfgOptions cfg) `shouldBe` "my/output/dir"
+
+        it "sets transport from target" $ do
+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions
+          let sc  = defaultSynapseConfig
+          let tc  = TargetConfig ["plugins"] BrowserTransport "out" Nothing
+          let cfg = buildConfigFromTarget wa sc tc
+          optTransport (cfgOptions cfg) `shouldBe` BrowserTransport
+
+        it "parses host from URL" $ do
+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions
+          let sc  = defaultSynapseConfig { scUrl = Just "ws://myhost:9000" }
+          let tc  = TargetConfig ["plugins"] WsTransport "out" Nothing
+          let cfg = buildConfigFromTarget wa sc tc
+          cfgHost cfg `shouldBe` "myhost"
+
+        it "parses port from URL" $ do
+          let wa  = WatchArgs "substrate" [] Nothing Nothing defaultOptions
+          let sc  = defaultSynapseConfig { scUrl = Just "ws://localhost:9000" }
+          let tc  = TargetConfig ["plugins"] WsTransport "out" Nothing
+          let cfg = buildConfigFromTarget wa sc tc
+          cfgPort cfg `shouldBe` "9000"
+
+    -- ═══════════════════════════════════════════
+    -- Section 13: Project detection (Detect.hs)
+    -- ═══════════════════════════════════════════
+    describe "SynapseCC.Detect" $ do
+
+      -- Helper: create a temp project dir, run action, clean up.
+      -- The action receives the dir path; withCurrentDirectory is the caller's responsibility.
+      let withTempProject :: (FilePath -> IO a) -> IO a
+          withTempProject action = do
+            tmpBase <- getTemporaryDirectory
+            let dir = tmpBase </> "synapse-detect-test"
+            exists <- doesDirectoryExist dir
+            when exists $ removeDirectoryRecursive dir
+            createDirectoryIfMissing True dir
+            action dir
+
+      describe "detectTauri" $ do
+        it "returns BrowserTransport when src-tauri/ exists" $ do
+          withTempProject $ \dir -> do
+            createDirectoryIfMissing True (dir </> "src-tauri")
+            result <- withCurrentDirectory dir (runDetector detectTauri)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns BrowserTransport when tauri.conf.json exists" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "tauri.conf.json") "{}"
+            result <- withCurrentDirectory dir (runDetector detectTauri)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns BrowserTransport when src-tauri/tauri.conf.json exists" $ do
+          withTempProject $ \dir -> do
+            createDirectoryIfMissing True (dir </> "src-tauri")
+            writeFile (dir </> "src-tauri" </> "tauri.conf.json") "{}"
+            result <- withCurrentDirectory dir (runDetector detectTauri)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns emptyHint when no Tauri markers present" $ do
+          withTempProject $ \dir -> do
+            result <- withCurrentDirectory dir (runDetector detectTauri)
+            phTransport result `shouldBe` Nothing
+
+      describe "detectVite" $ do
+        it "returns BrowserTransport for vite.config.ts" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "vite.config.ts") "export default {}"
+            result <- withCurrentDirectory dir (runDetector detectVite)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns BrowserTransport for vite.config.js" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "vite.config.js") "module.exports = {}"
+            result <- withCurrentDirectory dir (runDetector detectVite)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns BrowserTransport for vite.config.mts" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "vite.config.mts") "export default {}"
+            result <- withCurrentDirectory dir (runDetector detectVite)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns emptyHint when no Vite markers present" $ do
+          withTempProject $ \dir -> do
+            result <- withCurrentDirectory dir (runDetector detectVite)
+            phTransport result `shouldBe` Nothing
+
+      describe "detectNextJS" $ do
+        it "returns BrowserTransport for next.config.js" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "next.config.js") "module.exports = {}"
+            result <- withCurrentDirectory dir (runDetector detectNextJS)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns BrowserTransport for next.config.ts" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "next.config.ts") "export default {}"
+            result <- withCurrentDirectory dir (runDetector detectNextJS)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "returns emptyHint when no Next.js markers present" $ do
+          withTempProject $ \dir -> do
+            result <- withCurrentDirectory dir (runDetector detectNextJS)
+            phTransport result `shouldBe` Nothing
+
+      describe "detectNodeProject" $ do
+        it "returns WsTransport when package.json exists" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "package.json") "{\"name\":\"test\"}"
+            result <- withCurrentDirectory dir (runDetector detectNodeProject)
+            phTransport result `shouldBe` Just WsTransport
+
+        it "returns emptyHint when package.json is absent" $ do
+          withTempProject $ \dir -> do
+            result <- withCurrentDirectory dir (runDetector detectNodeProject)
+            phTransport result `shouldBe` Nothing
+
+      describe "runDetectors priority" $ do
+        it "Tauri beats Node (browser wins when both src-tauri/ and package.json present)" $ do
+          withTempProject $ \dir -> do
+            createDirectoryIfMissing True (dir </> "src-tauri")
+            writeFile (dir </> "package.json") "{}"
+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "Vite beats Node (browser wins when both vite.config.ts and package.json present)" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "vite.config.ts") "export default {}"
+            writeFile (dir </> "package.json") "{}"
+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "NextJS beats Node (browser wins when both next.config.js and package.json present)" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "next.config.js") "module.exports = {}"
+            writeFile (dir </> "package.json") "{}"
+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)
+            phTransport result `shouldBe` Just BrowserTransport
+
+        it "bare Node project → WsTransport (fallback)" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "package.json") "{}"
+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)
+            phTransport result `shouldBe` Just WsTransport
+
+        it "empty project → no transport opinion" $ do
+          withTempProject $ \dir -> do
+            result <- withCurrentDirectory dir (runDetectors defaultDetectors)
+            phTransport result `shouldBe` Nothing
+
+        it "custom detector prepended overrides defaultDetectors" $ do
+          withTempProject $ \dir -> do
+            -- Custom detector that always says WsTransport, even in a Tauri project
+            let myDetector = Detector "custom" (pure emptyHint { phTransport = Just WsTransport })
+            createDirectoryIfMissing True (dir </> "src-tauri")
+            result <- withCurrentDirectory dir (runDetectors (myDetector : defaultDetectors))
+            phTransport result `shouldBe` Just WsTransport
+
+      describe "initSynapseConfig with detectors" $ do
+        it "Tauri project → generated config has browser transport" $ do
+          withTempProject $ \dir -> do
+            createDirectoryIfMissing True (dir </> "src-tauri")
+            hint <- withCurrentDirectory dir $
+              initSynapseConfig synapseConfigPath defaultDetectors
+            hint `shouldSatisfy` isRight
+            cfgResult <- withCurrentDirectory dir loadSynapseConfig
+            case cfgResult of
+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err
+              Right sc -> do
+                let transports = map tcTransport (Map.elems (scTargets sc))
+                transports `shouldSatisfy` all (== BrowserTransport)
+
+        it "Vite project → generated config has browser transport" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "vite.config.ts") "export default {}"
+            hint <- withCurrentDirectory dir $
+              initSynapseConfig synapseConfigPath defaultDetectors
+            hint `shouldSatisfy` isRight
+            cfgResult <- withCurrentDirectory dir loadSynapseConfig
+            case cfgResult of
+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err
+              Right sc -> do
+                let transports = map tcTransport (Map.elems (scTargets sc))
+                transports `shouldSatisfy` all (== BrowserTransport)
+
+        it "Node project → generated config has ws transport" $ do
+          withTempProject $ \dir -> do
+            writeFile (dir </> "package.json") "{}"
+            hint <- withCurrentDirectory dir $
+              initSynapseConfig synapseConfigPath defaultDetectors
+            hint `shouldSatisfy` isRight
+            cfgResult <- withCurrentDirectory dir loadSynapseConfig
+            case cfgResult of
+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err
+              Right sc -> do
+                let transports = map tcTransport (Map.elems (scTargets sc))
+                transports `shouldSatisfy` all (== WsTransport)
+
+        it "empty project → generated config has ws transport (default)" $ do
+          withTempProject $ \dir -> do
+            hint <- withCurrentDirectory dir $
+              initSynapseConfig synapseConfigPath defaultDetectors
+            hint `shouldSatisfy` isRight
+            cfgResult <- withCurrentDirectory dir loadSynapseConfig
+            case cfgResult of
+              Left err -> expectationFailure $ "loadSynapseConfig failed: " <> T.unpack err
+              Right sc -> do
+                -- No detector fired → no hint → default transport (WsTransport from defaultSynapseConfig)
+                let transports = map tcTransport (Map.elems (scTargets sc))
+                transports `shouldSatisfy` (not . null)
+
+        it "hint reason is included in successful result" $ do
+          withTempProject $ \dir -> do
+            createDirectoryIfMissing True (dir </> "src-tauri")
+            result <- withCurrentDirectory dir $
+              initSynapseConfig synapseConfigPath defaultDetectors
+            case result of
+              Left err -> expectationFailure $ "Expected Right but got Left: " <> T.unpack err
+              Right hint -> phReason hint `shouldSatisfy` isJust
+
+    -- ═══════════════════════════════════════════
+    -- Section 14: Standalone config-driven build
+    -- ═══════════════════════════════════════════
+    describe "Standalone config-driven build" $ do
+
+      it "init + load + pipeline produces output in configured target dir" $ do
+        -- Write the standalone fixture config, run the pipeline with
+        -- settings derived from the config, verify output lands in tcOutputDir.
+        let fixturePath = "test/fixtures/standalone.config.json"
+        fixtureExists <- doesFileExist fixturePath
+        when (not fixtureExists) $ pendingWith "test/fixtures/standalone.config.json not found"
+
+        synapseAvail <- findExecutable "synapse"
+        when (not (isJust synapseAvail)) $ pendingWith "synapse binary not found"
+
+        tmpBase <- getTemporaryDirectory
+        let projDir = tmpBase </> "synapse-cc-standalone-project"
+        tmpExists <- doesDirectoryExist projDir
+        when tmpExists $ removeDirectoryRecursive projDir
+        createDirectoryIfMissing True projDir
+
+        -- Copy fixture config into project
+        fixtureBytes <- readFile fixturePath
+        writeFile (projDir </> synapseConfigPath) fixtureBytes
+
+        -- Load config and build using the "client" target
+        cfgResult <- withCurrentDirectory projDir loadSynapseConfig
+        case cfgResult of
+          Left err -> expectationFailure $ "Config load failed: " ++ T.unpack err
+          Right synapseConfig -> do
+            case Map.lookup "client" (scTargets synapseConfig) of
+              Nothing -> expectationFailure "Expected 'client' target in fixture"
+              Just targetCfg -> do
+                let wa      = WatchArgs (fromMaybe "" (scBackend synapseConfig)) [] Nothing Nothing defaultOptions
+                    config  = buildConfigFromTarget wa synapseConfig targetCfg
+                    config' = config { cfgOptions = (cfgOptions config)
+                                { optInstallDeps = False
+                                , optBuild       = False
+                                , optRunTests    = False
+                                , optForce       = True
+                                , optOutput      = projDir </> tcOutputDir targetCfg
+                                } }
+                toolsResult <- discoverTools (cfgOptions config')
+                case toolsResult of
+                  Left err -> expectationFailure $ T.unpack (formatError err)
+                  Right tools -> do
+                    result <- withCurrentDirectory projDir $ runPipeline config' tools
+                    case result of
+                      Left err -> expectationFailure $ T.unpack (formatError err)
+                      Right _  -> do
+                        let outDir = projDir </> tcOutputDir targetCfg
+                        exists <- doesDirectoryExist outDir
+                        exists `shouldBe` True
+                        typesExists <- doesFileExist (outDir </> "types.ts")
+                        typesExists `shouldBe` True
+
+      it "both targets in fixture produce separate output directories" $ do
+        let fixturePath = "test/fixtures/standalone.config.json"
+        fixtureExists <- doesFileExist fixturePath
+        when (not fixtureExists) $ pendingWith "test/fixtures/standalone.config.json not found"
+        synapseAvail <- findExecutable "synapse"
+        when (not (isJust synapseAvail)) $ pendingWith "synapse binary not found"
+
+        tmpBase <- getTemporaryDirectory
+        let projDir = tmpBase </> "synapse-cc-two-targets"
+        tmpExists <- doesDirectoryExist projDir
+        when tmpExists $ removeDirectoryRecursive projDir
+        createDirectoryIfMissing True projDir
+
+        fixtureBytes <- readFile fixturePath
+        writeFile (projDir </> synapseConfigPath) fixtureBytes
+
+        cfgResult <- withCurrentDirectory projDir loadSynapseConfig
+        case cfgResult of
+          Left err -> expectationFailure $ "Config load failed: " ++ T.unpack err
+          Right synapseConfig -> do
+            toolsResult <- discoverTools defaultOptions
+            case toolsResult of
+              Left err -> expectationFailure $ T.unpack (formatError err)
+              Right tools ->
+                forM_ (Map.toList (scTargets synapseConfig)) $ \(targetName, targetCfg) -> do
+                  let wa     = WatchArgs (fromMaybe "" (scBackend synapseConfig)) [] Nothing Nothing defaultOptions
+                      config = buildConfigFromTarget wa synapseConfig targetCfg
+                      config' = config { cfgOptions = (cfgOptions config)
+                                  { optInstallDeps = False
+                                  , optBuild       = False
+                                  , optRunTests    = False
+                                  , optForce       = True
+                                  , optOutput      = projDir </> tcOutputDir targetCfg
+                                  } }
+                  result <- withCurrentDirectory projDir $ runPipeline config' tools
+                  case result of
+                    Left err -> expectationFailure $
+                      "Target " ++ T.unpack targetName ++ " failed: " ++ T.unpack (formatError err)
+                    Right _ -> do
+                      exists <- doesDirectoryExist (projDir </> tcOutputDir targetCfg)
+                      exists `shouldBe` True
+
+    -- Section 14: Watch integration (requires fidget-spinner binary)
+    -- ═══════════════════════════════════════════════════════════════
+    describe "Watch integration (fidget-spinner)" $ do
+
+      let testPort    = 5557 :: Int
+          testPortStr = show testPort
+
+      -- Find fidget-spinner binary (installed or build output)
+      mbFidgetBin <- runIO $ do
+        mb <- findExecutable "fidget-spinner"
+        case mb of
+          Just p  -> pure (Just p)
+          Nothing -> do
+            let buildPath = "/workspace/hypermemetic/fidget-spinner/target/debug/fidget-spinner"
+            exists <- doesFileExist buildPath
+            pure $ if exists then Just buildPath else Nothing
+
+      -- Start fidget-spinner and wait for readiness
+      -- Returns Nothing if binary not found or failed to start
+      mbHandle <- runIO $ case mbFidgetBin of
+        Nothing -> pure Nothing
+        Just bin -> do
+          (_, _, _, ph) <- createProcess (proc bin ["--port", testPortStr, "--no-register"])
+            { std_out = NoStream, std_err = NoStream }
+          -- Poll until ready (up to 5 seconds)
+          ready <- waitForFidgetReady testPort 5000
+          if ready
+            then pure (Just ph)
+            else do
+              terminateProcess ph
+              _ <- waitForProcess ph
+              pure Nothing
+
+      -- Cleanup after all tests in this section
+      afterAll_ (case mbHandle of
+        Nothing -> pure ()
+        Just ph -> terminateProcess ph >> void (waitForProcess ph)) $ do
+
+        it "fidget-spinner starts and backend hash is non-empty" $ do
+          case mbHandle of
+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"
+            Just _ -> do
+              let cfg = makeFidgetConfig testPort
+              result <- fetchBackendHash cfg "fidget-spinner"
+              result `shouldSatisfy` isRight
+              case result of
+                Right h -> T.length h `shouldSatisfy` (> 0)
+                Left _  -> pure ()
+
+        it "initial build generates fidget/client.ts with register/unregister/list" $ do
+          case mbHandle of
+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"
+            Just _ -> do
+              tmpBase <- getTemporaryDirectory
+              let projDir = tmpBase </> "synapse-cc-fidget-initial"
+              createDirectoryIfMissing True projDir
+              let outDir = projDir </> "client"
+
+              let fixturePath = "test/fixtures/fidget.config.json"
+              fixtureBytes <- readFile fixturePath
+              writeFile (projDir </> "synapse.config.json") fixtureBytes
+
+              let config = makeFidgetPipelineConfig testPort outDir
+              toolsResult <- discoverTools (cfgOptions config)
+              case toolsResult of
+                Left err -> expectationFailure $ T.unpack (formatError err)
+                Right tools -> do
+                  result <- withCurrentDirectory projDir $ runPipeline config tools
+                  case result of
+                    Left err -> expectationFailure $ T.unpack (formatError err)
+                    Right _ -> do
+                      let clientFile = outDir </> "fidget" </> "client.ts"
+                      exists <- doesFileExist clientFile
+                      exists `shouldBe` True
+                      content <- readFile clientFile
+                      content `shouldSatisfy` ("register" `isInfixOf`)
+                      content `shouldSatisfy` ("unregister" `isInfixOf`)
+                      content `shouldSatisfy` ("list" `isInfixOf`)
+                      content `shouldSatisfy` (not . ("spin" `isInfixOf`))
+
+        it "register changes the backend hash" $ do
+          case mbHandle of
+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"
+            Just _ -> do
+              let cfg = makeFidgetConfig testPort
+              Right hash1 <- fetchBackendHash cfg "fidget-spinner"
+
+              -- Register a new method
+              callFidgetRegister testPortStr "hashtest" "A method to test hash changes"
+
+              Right hash2 <- fetchBackendHash cfg "fidget-spinner"
+              hash1 `shouldNotBe` hash2
+
+              -- Cleanup: unregister it
+              callFidgetUnregister testPortStr "hashtest"
+
+        it "register + rebuild adds the method to generated TypeScript" $ do
+          case mbHandle of
+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"
+            Just _ -> do
+              tmpBase <- getTemporaryDirectory
+              let projDir = tmpBase </> "synapse-cc-fidget-rebuild"
+              createDirectoryIfMissing True projDir
+              let outDir = projDir </> "client"
+
+              -- Initial build
+              let config = makeFidgetPipelineConfig testPort outDir
+              toolsResult <- discoverTools (cfgOptions config)
+              case toolsResult of
+                Left err -> expectationFailure $ T.unpack (formatError err)
+                Right tools -> do
+                  _ <- withCurrentDirectory projDir $ runPipeline config tools
+
+                  -- Register "spin"
+                  callFidgetRegister testPortStr "spin" "Make it spin"
+
+                  -- Rebuild with --force
+                  let forceConfig = config { cfgOptions = (cfgOptions config) { optForce = True } }
+                  result <- withCurrentDirectory projDir $ runPipeline forceConfig tools
+                  case result of
+                    Left err -> expectationFailure $ T.unpack (formatError err)
+                    Right _ -> do
+                      let clientFile = outDir </> "fidget" </> "client.ts"
+                      content <- readFile clientFile
+                      content `shouldSatisfy` ("spin" `isInfixOf`)
+
+                  -- Cleanup
+                  callFidgetUnregister testPortStr "spin"
+
+        it "unregister + rebuild removes the method from generated TypeScript" $ do
+          case mbHandle of
+            Nothing -> pendingWith "fidget-spinner binary not found or failed to start"
+            Just _ -> do
+              tmpBase <- getTemporaryDirectory
+              let projDir = tmpBase </> "synapse-cc-fidget-unregister"
+              createDirectoryIfMissing True projDir
+              let outDir = projDir </> "client"
+
+              -- Register "wobble" first
+              callFidgetRegister testPortStr "wobble" "A wobble method"
+
+              -- Build with wobble present
+              let config = makeFidgetPipelineConfig testPort outDir
+              toolsResult <- discoverTools (cfgOptions config)
+              case toolsResult of
+                Left err -> expectationFailure $ T.unpack (formatError err)
+                Right tools -> do
+                  _ <- withCurrentDirectory projDir $ runPipeline config tools
+
+                  -- Unregister "wobble"
+                  callFidgetUnregister testPortStr "wobble"
+
+                  -- Rebuild with --force
+                  let forceConfig = config { cfgOptions = (cfgOptions config) { optForce = True } }
+                  result <- withCurrentDirectory projDir $ runPipeline forceConfig tools
+                  case result of
+                    Left err -> expectationFailure $ T.unpack (formatError err)
+                    Right _ -> do
+                      let clientFile = outDir </> "fidget" </> "client.ts"
+                      content <- readFile clientFile
+                      content `shouldSatisfy` (not . ("wobble" `isInfixOf`))
+
+-- | Extract identifiers from import type { ... } braces
+extractBraceContent :: String -> [String]
+extractBraceContent line =
+  case break (== '{') line of
+    (_, []) -> []
+    (_, _:rest) ->
+      case break (== '}') rest of
+        (inside, _) ->
+          map (filter (\c -> isAlpha c || c == '.' || c == '_'))
+            $ filter (not . null)
+            $ map strip
+            $ splitOn ',' inside
+
+-- | Split a string on a delimiter
+splitOn :: Char -> String -> [String]
+splitOn _ [] = []
+splitOn c s =
+  let (chunk, rest) = break (== c) s
+  in chunk : case rest of
+    [] -> []
+    (_:xs) -> splitOn c xs
+
+-- | Strip leading/trailing whitespace
+strip :: String -> String
+strip = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ')
+
+-- ── fidget-spinner test helpers ──────────────────────────────────────────────
+
+-- | Poll until fidget-spinner is ready to serve requests.
+-- Returns True if ready within timeoutMs, False if timed out.
+waitForFidgetReady :: Int -> Int -> IO Bool
+waitForFidgetReady port timeoutMs = go (timeoutMs `div` 200)
+  where
+    go 0 = pure False
+    go n = do
+      let cfg = makeFidgetConfig port
+      result <- (try (Transport.rpcCallWith cfg "fidget-spinner.hash" Aeson.Null) :: IO (Either SomeException (Either TransportError [PlexusStreamItem])))
+      case result of
+        Right (Right (_ : _)) -> pure True
+        _ -> do
+          threadDelay 200000  -- 200ms
+          go (n - 1)
+
+-- | Build a SubstrateConfig for a local fidget-spinner on the given port.
+makeFidgetConfig :: Int -> SubstrateConfig
+makeFidgetConfig port = (defaultConfig "fidget-spinner")
+  { substrateHost = "127.0.0.1"
+  , substratePort = port
+  }
+
+-- | Build a Config for running the pipeline against fidget-spinner.
+makeFidgetPipelineConfig :: Int -> FilePath -> Config
+makeFidgetPipelineConfig port outDir =
+  Config
+    { cfgTarget  = TypeScript
+    , cfgBackend = Backend { backendName = "fidget-spinner" }
+    , cfgHost    = "127.0.0.1"
+    , cfgPort    = T.pack (show port)
+    , cfgOptions = defaultOptions
+        { optOutput      = outDir
+        , optInstallDeps = False
+        , optBuild       = False
+        , optRunTests    = False
+        , optForce       = True
+        , optTransport   = WsTransport
+        }
+    }
+
+-- | Call fidget.register via direct RPC.
+-- | Call fidget.register via the fidget-spinner.call RPC routing.
+callFidgetRegister :: String -> Text -> Text -> IO ()
+callFidgetRegister portStr name desc = do
+  let port = read portStr :: Int
+      cfg  = makeFidgetConfig port
+      innerParams = Aeson.object ["name" .= name, "description" .= desc]
+      callParams  = Aeson.object ["method" .= ("fidget.register" :: Text), "params" .= innerParams]
+  _ <- Transport.rpcCallWith cfg "fidget-spinner.call" callParams
+  pure ()
+
+-- | Call fidget.unregister via the fidget-spinner.call RPC routing.
+callFidgetUnregister :: String -> Text -> IO ()
+callFidgetUnregister portStr name = do
+  let port = read portStr :: Int
+      cfg  = makeFidgetConfig port
+      innerParams = Aeson.object ["name" .= name]
+      callParams  = Aeson.object ["method" .= ("fidget.unregister" :: Text), "params" .= innerParams]
+  _ <- Transport.rpcCallWith cfg "fidget-spinner.call" callParams
+  pure ()
