private/Invoke-WtwT3Project.ps1

# T3 Code integration.
#
# T3 Code 0.0.33 (alpha) ships no CLI and registers no `open-file`/`open-url`
# handler — its `second-instance` hook only reveals the existing window, and the
# `t3code://` scheme is the internal Electron renderer origin, not a routable
# deep link. So there is no supported way to tell a running T3 Code "open this
# folder".
#
# What it does have is an event-sourced store at ~/.t3/userdata/state.sqlite.
# Projects are `project.created` / `project.meta-updated` events on a `project`
# stream; `projection_projects` is a derived read model that the server replays
# forward from the checkpoint in `projection_state`. Appending one well-formed
# event is therefore enough — T3 materializes the project itself on next start,
# and wtw never has to touch a projection table.
#
# Project schema has `title` (the sidebar name) and `faviconPath` (an image), but
# no color field of any kind, so `wtw color` has nothing to drive here.

function Get-WtwT3BaseDir {
    <#
    .SYNOPSIS
        T3 Code's per-user base directory (`~/.t3` on every platform).
    #>

    [CmdletBinding()]
    param()

    return [System.IO.Path]::GetFullPath((Join-Path $HOME '.t3'))
}

function Get-WtwT3StatePath {
    <#
    .SYNOPSIS
        Path to T3 Code's event-store database. May not exist yet.
    #>

    [CmdletBinding()]
    param()

    return Join-Path (Join-Path (Get-WtwT3BaseDir) 'userdata') 'state.sqlite'
}

function Get-WtwT3SettingsPath {
    <#
    .SYNOPSIS
        Path to T3 Code's settings file. May not exist yet.
    #>

    [CmdletBinding()]
    param()

    return Join-Path (Join-Path (Get-WtwT3BaseDir) 'userdata') 'settings.json'
}

function Get-WtwT3ClientSettingsPath {
    <#
    .SYNOPSIS
        Path to T3 Code's renderer settings file. May not exist yet.
    #>

    [CmdletBinding()]
    param()

    return Join-Path (Join-Path (Get-WtwT3BaseDir) 'userdata') 'client-settings.json'
}

function Get-WtwT3EnvironmentId {
    <#
    .SYNOPSIS
        This machine's T3 environment id, or $null.
    .DESCRIPTION
        T3 addresses projects as `<environmentId>:<workspaceRoot>`, where the
        environment is the local install (remote environments are reachable only
        through the authenticated relay). wtw always works against this file, so
        it can never target another machine's projects by accident.
    #>

    [CmdletBinding()]
    param()

    $path = Join-Path (Join-Path (Get-WtwT3BaseDir) 'userdata') 'environment-id'
    if (-not (Test-Path $path)) { return $null }

    $id = (Get-Content -Path $path -Raw -ErrorAction SilentlyContinue)
    if (-not $id) { return $null }

    return $id.Trim()
}

function ConvertTo-WtwT3NormalizedPath {
    <#
    .SYNOPSIS
        Normalize a workspace root the way T3's renderer does.
    .DESCRIPTION
        Mirrors T3's `Dx()`: trim, strip trailing separators, and — for Windows
        drive-letter or UNC paths only — switch to backslashes and lowercase.
        POSIX paths are left case-sensitive. The key has to match byte for byte
        or the per-project override silently applies to nothing.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    $trimmed = $Path.Trim()
    if (-not $trimmed) { return $trimmed }

    # A bare root ('/', '\', 'C:\') is already normal — trimming it would empty it.
    if ($trimmed -in '/', '\' -or $trimmed -match '^[a-zA-Z]:[/\\]?$') { return $trimmed }

    $stripped = if ($trimmed.StartsWith('/')) {
        $trimmed -replace '/+$', ''
    } else {
        $trimmed -replace '[\\/]+$', ''
    }
    if (-not $stripped) { return $trimmed }

    $isWindowsPath = $stripped -match '^[a-zA-Z]:([/\\]|$)' -or $stripped.StartsWith('\\')
    if ($isWindowsPath) {
        return $stripped.Replace('/', '\').ToLowerInvariant()
    }

    return $stripped
}

function Get-WtwT3ProjectKey {
    <#
    .SYNOPSIS
        T3's `<environmentId>:<workspaceRoot>` key for a directory, or $null.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $WorkspaceRoot
    )

    $environmentId = Get-WtwT3EnvironmentId
    if (-not $environmentId) { return $null }

    $full = [System.IO.Path]::GetFullPath($WorkspaceRoot)
    return "${environmentId}:$(ConvertTo-WtwT3NormalizedPath -Path $full)"
}

function Test-WtwT3GroupingHidesTitles {
    <#
    .SYNOPSIS
        True when T3's sidebar will hide per-worktree project titles.
    .DESCRIPTION
        T3 groups sidebar projects by `sidebarProjectGroupingMode`, which defaults
        to "repository". That key is `repositoryIdentity.canonicalKey`, derived at
        runtime from the git remote — so every worktree of one repo lands in a
        single group, and the group renders its own label (the repository name)
        instead of the member projects' titles. A group only shows a project
        `title` when it has exactly one member.

        Under "separate" the key is the workspace root, so each worktree is its
        own single-member group and its wtw pretty name is what shows.

        wtw deliberately does not change this. Grouping sibling worktrees under
        one repository is T3's model — a worktree is the same project on another
        branch, and T3 already labels each thread with its branch. wtw only reads
        the effective mode (a per-project override beats the global setting) so it
        can word its own output honestly.
    #>

    [CmdletBinding()]
    param(
        [string] $WorkspaceRoot
    )

    $path = Get-WtwT3ClientSettingsPath
    if (-not (Test-Path $path)) { return $false }

    try {
        $settings = Get-Content -Path $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    } catch {
        return $false
    }

    if ($WorkspaceRoot) {
        $key = Get-WtwT3ProjectKey -WorkspaceRoot $WorkspaceRoot
        $overrides = Get-WtwPropertyValue -Object $settings -Name 'sidebarProjectGroupingOverrides'
        if ($key -and $overrides -and (Get-WtwPropertyNames -Object $overrides) -contains $key) {
            return (Get-WtwPropertyValue -Object $overrides -Name $key) -ne 'separate'
        }
    }

    $mode = Get-WtwPropertyValue -Object $settings -Name 'sidebarProjectGroupingMode' -DefaultValue 'repository'
    return $mode -ne 'separate'
}

function Get-WtwT3MacAppName {
    <#
    .SYNOPSIS
        First installed T3 Code app bundle name, or $null.
    .DESCRIPTION
        The alpha ships as "T3 Code (Alpha).app"; the candidate list lets the
        stable and beta bundle names work without a wtw release.
    #>

    [CmdletBinding()]
    param(
        [string[]] $Candidates = @('T3 Code', 'T3 Code (Alpha)', 'T3 Code (Beta)')
    )

    if (-not $IsMacOS) { return $null }

    foreach ($candidate in $Candidates) {
        if ($candidate -and (Test-Path "/Applications/$candidate.app")) { return $candidate }
    }

    return $null
}

function Test-WtwT3AppRunning {
    <#
    .SYNOPSIS
        True when T3 Code already has a process running.
    .DESCRIPTION
        Matched the same way as the Claude desktop app: pgrep misses the main
        Electron process on macOS, so compare the exact inner binary path from
        `ps` — helpers live under Contents/Frameworks/ and are excluded by
        anchoring the whole path.
    #>

    [CmdletBinding()]
    param(
        [string[]] $Candidates = @('T3 Code', 'T3 Code (Alpha)', 'T3 Code (Beta)')
    )

    if ($IsMacOS) {
        $processes = @(& ps -axo comm= 2>$null)
        foreach ($candidate in $Candidates) {
            if ($processes | Where-Object { $_ -eq "/Applications/$candidate.app/Contents/MacOS/$candidate" }) {
                return $true
            }
        }
        return $false
    }

    foreach ($candidate in $Candidates) {
        if (Get-Process -Name $candidate -ErrorAction SilentlyContinue) { return $true }
    }

    return $false
}

function Find-WtwT3WindowsExecutable {
    <#
    .SYNOPSIS
        Path to the winget-installed T3 Code executable, or $null.
    .DESCRIPTION
        `winget install T3Tools.T3Code` runs an electron-builder NSIS installer,
        which lands per-user under %LOCALAPPDATA%\Programs and adds no PATH entry
        — hence the explicit probe. The name candidates cover the alpha suffix the
        same way the macOS bundle lookup does.
    #>

    [CmdletBinding()]
    param()

    $roots = @()
    foreach ($base in @($env:LOCALAPPDATA, $env:ProgramFiles, ${env:ProgramFiles(x86)})) {
        if (-not $base) { continue }
        $roots += (Join-Path $base 'Programs')
        $roots += $base
    }

    foreach ($root in $roots) {
        foreach ($name in @('T3 Code (Alpha)', 'T3 Code (Beta)', 'T3 Code', 't3code')) {
            $candidate = Join-Path (Join-Path $root $name) "$name.exe"
            if (Test-Path $candidate) { return $candidate }
        }
    }

    return $null
}

function Stop-WtwT3Process {
    <#
    .SYNOPSIS
        Ask T3 Code to quit, escalating to a kill. $true once it is gone.
    .DESCRIPTION
        Tries a graceful window close first so the server checkpoints its WAL and
        shuts down cleanly, and only force-kills if that does not take.
    #>

    [CmdletBinding()]
    param(
        [string[]] $Candidates = @('T3 Code', 'T3 Code (Alpha)', 'T3 Code (Beta)'),

        [int] $TimeoutSeconds = 15
    )

    $procs = @(Get-Process -Name $Candidates -ErrorAction SilentlyContinue)
    if ($procs.Count -eq 0 -and -not (Test-WtwT3AppRunning -Candidates $Candidates)) { return $true }

    foreach ($process in $procs) {
        try { $process.CloseMainWindow() | Out-Null } catch { }
    }

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        if (-not (Test-WtwT3AppRunning -Candidates $Candidates)) { return $true }
        Start-Sleep -Milliseconds 250
    }

    foreach ($process in @(Get-Process -Name $Candidates -ErrorAction SilentlyContinue)) {
        try { $process.Kill($true) } catch { try { $process.Kill() } catch { } }
    }

    $deadline = (Get-Date).AddSeconds(5)
    while ((Get-Date) -lt $deadline) {
        if (-not (Test-WtwT3AppRunning -Candidates $Candidates)) { return $true }
        Start-Sleep -Milliseconds 250
    }

    return -not (Test-WtwT3AppRunning -Candidates $Candidates)
}

function Resolve-WtwT3StateConflict {
    <#
    .SYNOPSIS
        Decide what to do when T3 Code is running and holding its event store.
    .DESCRIPTION
        T3's server owns state.sqlite while it runs and tracks stream versions in
        memory, so an outside append can be lost or collide. Unlike the Codex
        equivalent there is deliberately no "write anyway" option — the failure
        mode is a corrupted stream rather than an overwritten label.

        Non-interactive sessions skip rather than block on a prompt.
    .OUTPUTS
        Hashtable with `proceed` and `relaunch`.
    #>

    [CmdletBinding()]
    param(
        [string[]] $Candidates = @('T3 Code', 'T3 Code (Alpha)', 'T3 Code (Beta)')
    )

    if (-not (Test-WtwT3AppRunning -Candidates $Candidates)) {
        return @{ proceed = $true; relaunch = $false }
    }

    if (-not [Environment]::UserInteractive) {
        return @{ proceed = $false; relaunch = $false }
    }

    Write-Host ''
    Write-Host ' T3 Code is running — it owns its project store while open.' -ForegroundColor Yellow
    Write-Host ' How should I register this worktree as a project?' -ForegroundColor Yellow
    Write-Host ' [c] Close T3 Code yourself, then register (I will wait, then relaunch)'
    Write-Host ' [k] Quit T3 Code for me, register, relaunch'
    Write-Host ' [s] Skip — just bring T3 Code forward'

    $answer = (Read-Host ' Choice [c/k/s]').Trim().ToLowerInvariant()
    if (-not $answer) { $answer = 'c' }

    switch ($answer) {
        'c' {
            Write-Host ' Waiting for T3 Code to close (Ctrl+C to abort)...' -ForegroundColor Cyan
            while (Test-WtwT3AppRunning -Candidates $Candidates) { Start-Sleep -Milliseconds 500 }
            Write-Host ' T3 Code closed.' -ForegroundColor Green
            return @{ proceed = $true; relaunch = $true }
        }
        'k' {
            Write-Host ' Closing T3 Code...' -ForegroundColor Cyan
            if (-not (Stop-WtwT3Process -Candidates $Candidates)) {
                Write-Host ' Could not stop T3 Code — skipping project registration.' -ForegroundColor Red
                return @{ proceed = $false; relaunch = $false }
            }
            Write-Host ' T3 Code stopped.' -ForegroundColor Green
            return @{ proceed = $true; relaunch = $true }
        }
        default {
            return @{ proceed = $false; relaunch = $false }
        }
    }
}

function Get-WtwSqliteCommand {
    <#
    .SYNOPSIS
        Path to a real sqlite3 executable, or $null.
    .DESCRIPTION
        -CommandType Application so a shell function or alias named sqlite3 is
        not mistaken for the binary. Ships with macOS; on Windows and most Linux
        distros it is a separate install, and every caller degrades to a no-op
        when it is missing.
    #>

    [CmdletBinding()]
    param()

    return (Get-Command sqlite3 -CommandType Application -ErrorAction SilentlyContinue |
        Select-Object -First 1)?.Source
}

function ConvertTo-WtwSqlLiteral {
    <#
    .SYNOPSIS
        Quote a string as a SQL literal, escaping embedded single quotes.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [AllowNull()]
        [string] $Value
    )

    if ($null -eq $Value) { return 'NULL' }
    return "'" + $Value.Replace("'", "''") + "'"
}

function Invoke-WtwT3Query {
    <#
    .SYNOPSIS
        Query T3 Code's store. Returns @{ Ok; Rows; Error }.
    .DESCRIPTION
        Uses sqlite3's -json output so values survive the paths and emoji titles
        that would break the default pipe-delimited format.

        Deliberately NOT `-readonly`: T3's store is in WAL mode, and a read-only
        SQLite connection has to be able to create the `-shm` shared-memory file
        to read a WAL database. It cannot, so `-readonly` fails with "unable to
        open database file (14)" — intermittently while T3 is running, and again
        once it has quit leaving a hot WAL behind. A normal connection reads fine;
        the statements here are all SELECTs, so nothing is modified either way.

        Failure is reported rather than flattened into "no rows": callers must be
        able to tell "cannot read this store" from "this store has no such
        project", or they report the wrong reason to the user.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Sqlite,

        [Parameter(Mandatory)]
        [string] $DatabasePath,

        [Parameter(Mandatory)]
        [string] $Query
    )

    $failed = { param($message) [PSCustomObject]@{ Ok = $false; Rows = @(); Error = $message } }

    try {
        $raw = & $Sqlite -json $DatabasePath $Query 2>&1
    } catch {
        return & $failed $_.Exception.Message
    }

    if ($LASTEXITCODE -ne 0) {
        return & $failed (($raw -join ' ').Trim())
    }

    $text = ($raw -join "`n").Trim()
    if (-not $text) {
        return [PSCustomObject]@{ Ok = $true; Rows = @(); Error = $null }
    }

    try {
        return [PSCustomObject]@{ Ok = $true; Rows = @($text | ConvertFrom-Json -ErrorAction Stop); Error = $null }
    } catch {
        return & $failed "could not parse sqlite3 output: $($_.Exception.Message)"
    }
}

function Invoke-WtwT3Write {
    <#
    .SYNOPSIS
        Execute a SQL script against T3 Code's store. $true when it succeeded.
    .DESCRIPTION
        Piped in on stdin rather than passed as an argument so multi-statement
        scripts and embedded quoting survive the PowerShell → native boundary
        unchanged.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Sqlite,

        [Parameter(Mandatory)]
        [string] $DatabasePath,

        [Parameter(Mandatory)]
        [string] $Script
    )

    try {
        $Script | & $Sqlite $DatabasePath 2>&1 | Out-Null
    } catch {
        Write-Warning "T3 Code: could not write $DatabasePath — project not registered. ($($_.Exception.Message))"
        return $false
    }

    if ($LASTEXITCODE -ne 0) {
        Write-Warning "T3 Code: sqlite3 rejected the project event (exit $LASTEXITCODE) — project not registered."
        return $false
    }

    return $true
}

function Test-WtwT3StateSchema {
    <#
    .SYNOPSIS
        Check the store still matches the schema wtw appends to. Returns @{ Ok; Error }.
    .DESCRIPTION
        T3 Code is alpha, so its event schema can change under us. Verify the
        exact columns this integration writes before touching anything, and bail
        out rather than inserting a row a newer T3 cannot decode.

        An unreadable store is reported as its own failure, never as schema drift.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Sqlite,

        [Parameter(Mandatory)]
        [string] $DatabasePath
    )

    $required = @(
        'event_id', 'aggregate_kind', 'stream_id', 'stream_version', 'event_type',
        'occurred_at', 'command_id', 'causation_event_id', 'correlation_id',
        'actor_kind', 'payload_json', 'metadata_json'
    )

    $result = Invoke-WtwT3Query -Sqlite $Sqlite -DatabasePath $DatabasePath `
        -Query "SELECT name FROM pragma_table_info('orchestration_events')"
    if (-not $result.Ok) {
        return [PSCustomObject]@{ Ok = $false; Error = "could not read T3 Code's store — $($result.Error)" }
    }

    $columns = @($result.Rows | ForEach-Object { $_.name })
    if ($columns.Count -eq 0) {
        return [PSCustomObject]@{ Ok = $false; Error = "no orchestration_events table in T3 Code's store" }
    }

    $missing = @($required | Where-Object { $columns -notcontains $_ })
    if ($missing.Count -gt 0) {
        return [PSCustomObject]@{ Ok = $false; Error = "T3 Code's event schema changed (missing: $($missing -join ', ')) — skipping" }
    }

    # The read model wtw is relying on T3 to rebuild from the appended event.
    $projection = Invoke-WtwT3Query -Sqlite $Sqlite -DatabasePath $DatabasePath `
        -Query "SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = 'projection_projects'"
    if (-not $projection.Ok) {
        return [PSCustomObject]@{ Ok = $false; Error = "could not read T3 Code's store — $($projection.Error)" }
    }
    if ($projection.Rows.Count -eq 0) {
        return [PSCustomObject]@{ Ok = $false; Error = "no projection_projects table in T3 Code's store — skipping" }
    }

    # The pending-event lookup reads payloads with json_extract (SQLite JSON1).
    # Without it wtw cannot tell an already-appended project from a new one and
    # would create duplicates, so treat a missing JSON1 as a hard stop.
    $json = Invoke-WtwT3Query -Sqlite $Sqlite -DatabasePath $DatabasePath `
        -Query "SELECT json_extract('{""a"":1}', '`$.a') AS a"
    if (-not $json.Ok) {
        return [PSCustomObject]@{ Ok = $false; Error = 'this sqlite3 has no JSON1 support — cannot register T3 projects safely' }
    }

    return [PSCustomObject]@{ Ok = $true; Error = $null }
}

function Get-WtwT3Project {
    <#
    .SYNOPSIS
        The live T3 Code project for a workspace root, or $null.
    .OUTPUTS
        PSCustomObject with ProjectId and Title.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Sqlite,

        [Parameter(Mandatory)]
        [string] $DatabasePath,

        [Parameter(Mandatory)]
        [string] $WorkspaceRoot
    )

    # Look in the event stream as well as the read model, not just the read model.
    # wtw appends events and lets T3 project them on next start, so between the
    # append and that restart `projection_projects` still has no row — and a
    # projection-only lookup would happily create the same project a second time.
    #
    # Title comes from the newest title-bearing event on the stream so a pending
    # rename is not re-issued either, falling back to the projected title when a
    # store has been compacted and the creating event is gone.
    $literal = ConvertTo-WtwSqlLiteral $WorkspaceRoot
    $result = Invoke-WtwT3Query -Sqlite $Sqlite -DatabasePath $DatabasePath -Query @"
WITH candidate AS (
  SELECT project_id, title, workspace_root
  FROM projection_projects
  WHERE deleted_at IS NULL
  UNION ALL
  SELECT e.stream_id,
         json_extract(e.payload_json, '`$.title'),
         json_extract(e.payload_json, '`$.workspaceRoot')
  FROM orchestration_events e
  WHERE e.aggregate_kind = 'project' AND e.event_type = 'project.created'
)
SELECT c.project_id AS projectId,
       COALESCE((
         SELECT json_extract(x.payload_json, '`$.title')
         FROM orchestration_events x
         WHERE x.aggregate_kind = 'project' AND x.stream_id = c.project_id
           AND json_extract(x.payload_json, '`$.title') IS NOT NULL
         ORDER BY x.stream_version DESC LIMIT 1
       ), c.title) AS title
FROM candidate c
WHERE c.workspace_root = $literal
  AND NOT EXISTS (
    SELECT 1 FROM orchestration_events d
    WHERE d.aggregate_kind = 'project' AND d.stream_id = c.project_id
      AND d.event_type = 'project.deleted'
  )
  AND NOT EXISTS (
    SELECT 1 FROM projection_projects p
    WHERE p.project_id = c.project_id AND p.deleted_at IS NOT NULL
  )
LIMIT 1
"@


    if (-not $result.Ok -or $result.Rows.Count -eq 0) { return $null }

    return [PSCustomObject]@{
        ProjectId = $result.Rows[0].projectId
        Title     = $result.Rows[0].title
    }
}

function New-WtwT3EventStatements {
    <#
    .SYNOPSIS
        SQL appending one project event plus its command receipt (no transaction).
    .DESCRIPTION
        stream_version is derived in SQL exactly the way T3's own event store
        derives it (last version on the stream + 1, or 0 for a new stream), so a
        concurrently-written event cannot leave a gap. The receipt row mirrors
        what T3 writes for its own commands; the projection is built from the
        event, so the receipt is bookkeeping only.

        The receipt's result_sequence looks the event up by event_id rather than
        using last_insert_rowid(): when several of these are chained, the previous
        statement is a receipt insert, so last_insert_rowid() would point at that
        receipt instead of the event.
    .PARAMETER EventType
        'project.created' or 'project.meta-updated'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('project.created', 'project.meta-updated')]
        [string] $EventType,

        [Parameter(Mandatory)]
        [string] $ProjectId,

        [Parameter(Mandatory)]
        [string] $Payload,

        [Parameter(Mandatory)]
        [string] $OccurredAt
    )

    $eventId   = [guid]::NewGuid().ToString()
    $commandId = [guid]::NewGuid().ToString()

    $eventLit   = ConvertTo-WtwSqlLiteral $eventId
    $commandLit = ConvertTo-WtwSqlLiteral $commandId
    $streamLit  = ConvertTo-WtwSqlLiteral $ProjectId
    $typeLit    = ConvertTo-WtwSqlLiteral $EventType
    $whenLit    = ConvertTo-WtwSqlLiteral $OccurredAt
    $payloadLit = ConvertTo-WtwSqlLiteral $Payload

    return @"
INSERT INTO orchestration_events (
  event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at,
  command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json
) VALUES (
  $eventLit, 'project', $streamLit,
  COALESCE((
    SELECT stream_version + 1 FROM orchestration_events
    WHERE aggregate_kind = 'project' AND stream_id = $streamLit
    ORDER BY stream_version DESC LIMIT 1
  ), 0),
  $typeLit, $whenLit, $commandLit, NULL, $commandLit, 'client', $payloadLit, '{}'
);
INSERT INTO orchestration_command_receipts (
  command_id, aggregate_kind, aggregate_id, accepted_at, result_sequence, status, error
) VALUES (
  $commandLit, 'project', $streamLit, $whenLit,
  (SELECT sequence FROM orchestration_events WHERE event_id = $eventLit),
  'accepted', NULL
);
"@

}

function New-WtwT3EventScript {
    <#
    .SYNOPSIS
        Wrap one or more event statement blocks in a single transaction.
    .DESCRIPTION
        Creating a project takes two events — `project.created`, then a
        `project.meta-updated` carrying the fields the created payload has no
        room for. One transaction keeps a half-registered project impossible.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]] $Statements
    )

    return "BEGIN IMMEDIATE;`n" + ($Statements -join "`n") + "`nCOMMIT;"
}

function Get-WtwT3RegistrationPlan {
    <#
    .SYNOPSIS
        Work out what registering a worktree would do, without writing anything.
    .DESCRIPTION
        Every check here is a read, so it is safe (and accurate) while T3 Code is
        running. Splitting it out is what lets `wtw t3` stay quiet on an
        already-registered worktree instead of asking to quit T3 on every run.
    .OUTPUTS
        PSCustomObject with Action ('create' | 'rename' | 'none' | 'blocked'),
        Reason, Sqlite, DatabasePath, ProjectId and FullPath.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ProjectPath,

        [Parameter(Mandatory)]
        [string] $PrettyName
    )

    $blocked = { param($reason) [PSCustomObject]@{ Action = 'blocked'; Reason = $reason } }

    $databasePath = Get-WtwT3StatePath
    if (-not (Test-Path $databasePath)) {
        return & $blocked 'T3 Code has no store yet — launch it once first.'
    }

    $sqlite = Get-WtwSqliteCommand
    if (-not $sqlite) {
        return & $blocked 'no sqlite3 on PATH — cannot register the project.'
    }

    $schema = Test-WtwT3StateSchema -Sqlite $sqlite -DatabasePath $databasePath
    if (-not $schema.Ok) {
        return & $blocked $schema.Error
    }

    $fullPath = [System.IO.Path]::GetFullPath($ProjectPath)
    $existing = Get-WtwT3Project -Sqlite $sqlite -DatabasePath $databasePath -WorkspaceRoot $fullPath

    $action = if (-not $existing) { 'create' } elseif ($existing.Title -ne $PrettyName) { 'rename' } else { 'none' }

    # Not `$existing?.ProjectId`: `?` is legal in a PowerShell variable name, so
    # that parses as the variable `$existing?` and silently yields $null.
    $projectId = if ($existing) { $existing.ProjectId } else { $null }

    return [PSCustomObject]@{
        Action       = $action
        Reason       = $null
        Sqlite       = $sqlite
        DatabasePath = $databasePath
        ProjectId    = $projectId
        FullPath     = $fullPath
    }
}

function Register-WtwT3Project {
    <#
    .SYNOPSIS
        Make a worktree show up in T3 Code's sidebar under its wtw pretty name.
    .DESCRIPTION
        Appends a `project.created` event when T3 has never seen the directory,
        or a `project.meta-updated` event when it has but under a different
        title. No-ops when the title already matches.

        Refuses to write while T3 Code is running: its server owns the database
        and tracks stream versions in memory, so an outside append during a live
        session can be lost or conflict. Nothing here is destructive — the worst
        case is an extra sidebar entry the user can delete in the UI.
    .PARAMETER ProjectPath
        Worktree directory to register as the project's workspace root.
    .PARAMETER PrettyName
        Sidebar title. T3 Code has no project color, so this is the whole of the
        per-worktree identity it can express.
    .OUTPUTS
        PSCustomObject with Status ('created' | 'renamed' | 'unchanged' | 'skipped')
        and Reason when skipped.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ProjectPath,

        [Parameter(Mandatory)]
        [string] $PrettyName
    )

    $skipped = { param($reason) [PSCustomObject]@{ Status = 'skipped'; Reason = $reason } }

    if (Test-WtwT3AppRunning) {
        return & $skipped 'T3 Code is running — quit it first to register this worktree.'
    }

    $plan = Get-WtwT3RegistrationPlan -ProjectPath $ProjectPath -PrettyName $PrettyName
    if ($plan.Action -eq 'blocked') { return & $skipped $plan.Reason }
    if ($plan.Action -eq 'none')    { return [PSCustomObject]@{ Status = 'unchanged'; Reason = $null } }

    $sqlite       = $plan.Sqlite
    $databasePath = $plan.DatabasePath
    $fullPath     = $plan.FullPath
    $now = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')

    if ($plan.Action -eq 'rename') {
        $payload = [ordered]@{
            projectId = $plan.ProjectId
            title     = $PrettyName
            updatedAt = $now
        } | ConvertTo-Json -Compress -Depth 5

        $script = New-WtwT3EventScript -Statements @(
            New-WtwT3EventStatements -EventType 'project.meta-updated' `
                -ProjectId $plan.ProjectId -Payload $payload -OccurredAt $now
        )

        if (-not (Invoke-WtwT3Write -Sqlite $sqlite -DatabasePath $databasePath -Script $script)) {
            return & $skipped 'the rename event could not be written.'
        }

        return [PSCustomObject]@{ Status = 'renamed'; Reason = $null }
    }

    $projectId = [guid]::NewGuid().ToString()
    # defaultModelSelection is nullable in T3's payload schema; leaving it null
    # lets the app apply the user's configured default instead of pinning a model.
    $payload = [ordered]@{
        projectId             = $projectId
        title                 = $PrettyName
        workspaceRoot         = $fullPath
        defaultModelSelection = $null
        scripts               = @()
        createdAt             = $now
        updatedAt             = $now
    } | ConvertTo-Json -Compress -Depth 5

    # `defaultThreadEnvMode` has no slot in ProjectCreatedPayload, and Effect
    # Schema drops unknown keys, so pinning it takes a follow-up meta-update.
    # It matters: the worktree already exists, so new threads must run in this
    # checkout ("local"). Left unset, a global default of "worktree" would have
    # T3 cut a fresh git worktree *from* a wtw worktree for every thread.
    $envPayload = [ordered]@{
        projectId            = $projectId
        defaultThreadEnvMode = 'local'
        updatedAt            = $now
    } | ConvertTo-Json -Compress -Depth 5

    $script = New-WtwT3EventScript -Statements @(
        (New-WtwT3EventStatements -EventType 'project.created' `
            -ProjectId $projectId -Payload $payload -OccurredAt $now),
        (New-WtwT3EventStatements -EventType 'project.meta-updated' `
            -ProjectId $projectId -Payload $envPayload -OccurredAt $now)
    )

    if (-not (Invoke-WtwT3Write -Sqlite $sqlite -DatabasePath $databasePath -Script $script)) {
        return & $skipped 'the project event could not be written.'
    }

    return [PSCustomObject]@{ Status = 'created'; Reason = $null }
}

function Set-WtwT3AddProjectBaseDirectory {
    <#
    .SYNOPSIS
        Point T3 Code's "Add project starts in" picker at a directory.
    .DESCRIPTION
        A fallback for the cases Register-WtwT3Project has to skip (T3 running,
        no sqlite3): the Add Project browser opens here instead of `~/`, so the
        worktree is one click away. No-ops when the settings file is absent —
        wtw does not bootstrap T3 Code's config.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    $settingsPath = Get-WtwT3SettingsPath
    if (-not (Test-Path $settingsPath)) { return $false }

    try {
        $settings = Get-Content -Path $settingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    } catch {
        Write-Warning "T3 Code: could not read $settingsPath — leaving settings alone. ($($_.Exception.Message))"
        return $false
    }

    if (-not $settings) { $settings = [PSCustomObject]@{} }

    $fullPath = [System.IO.Path]::GetFullPath($Path)
    if ((Get-WtwPropertyValue -Object $settings -Name 'addProjectBaseDirectory') -eq $fullPath) {
        return $true
    }

    $settings | Add-Member -NotePropertyName 'addProjectBaseDirectory' -NotePropertyValue $fullPath -Force

    # -Depth 100: the default of 2 would flatten nested provider settings into
    # type names and corrupt the file.
    $temp = "$settingsPath.wtw-$PID.tmp"
    try {
        Set-Content -Path $temp -Value ($settings | ConvertTo-Json -Depth 100) -NoNewline -ErrorAction Stop
        Move-Item -Path $temp -Destination $settingsPath -Force -ErrorAction Stop
    } catch {
        Remove-Item -Path $temp -Force -ErrorAction SilentlyContinue
        Write-Warning "T3 Code: could not write $settingsPath — settings unchanged. ($($_.Exception.Message))"
        return $false
    }

    return $true
}