extensions/specrew-speckit/scripts/specrew-bootstrap-provider.ps1

# Specrew B2 bootstrap provider (Feature 174, FR-001/FR-002/FR-020).
# Registered as a provider row in refocus-scopes.json. The SpecrewHookDispatcher
# invokes this on SessionStart with `--event-json <json>` and injects its stdout.
# It fires on B2 ONLY (launch: source startup|resume|clear) and stays SILENT on B1
# (source compact) so F-171 B1 post-compaction behaviour is unchanged (FR-011).
# Fail-open doctrine (P1): any error -> no output, exit 0; never block a session.
#
# --event-json <json> the host SessionStart event (dispatcher contract)
# --project-root <path> optional override (testability); else resolve up to .specrew

$ErrorActionPreference = 'Stop'

# SPECREW-UTF8-OUTPUT (F-174 iter-10, Prop-145 P3): declare UTF-8 stdout/stderr so non-ASCII provider output -
# notably the handover content this provider INLINES into the SessionStart directive (Hebrew/emoji/unicode
# dialogue captured into 'Recent conversation') - is not mangled to '?' by the child pwsh's default OEM console
# codepage when the dispatcher captures it. The dispatcher reads UTF-8 (ProcessStartInfo.StandardOutputEncoding);
# this is the child half of that contract. Fail-open.
try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) } catch { $null = $_ }  # best-effort: a host that rejects UTF-8 console encoding must still run (fail-open)

function Get-BootstrapProjectRoot {
    $c = (Get-Location).Path
    while (-not [string]::IsNullOrWhiteSpace($c)) {
        if (Test-Path -LiteralPath (Join-Path $c '.specrew') -PathType Container) { return $c }
        $p = Split-Path -Parent $c
        if ($p -eq $c) { break }
        $c = $p
    }
    return (Get-Location).Path
}

function Get-SpecrewContractDeliveryMode {
    # T007/M1 (F-174 iter-10): the ONE seam deciding how the SessionStart launch contract reaches the agent -
    # 'inline' (the full body in the directive) or 'pointer' (the agent reads .specrew/last-start-prompt.md).
    # Default is BEHAVIOR-PRESERVING; flipping a host is a one-line change HERE.
    # claude -> pointer. DISPROVEN 2026-06-14 (iter-11 real-host, Claude Code v2.1.177): SessionStart IS plain
    # STDOUT, but the host caps hook STDOUT at 10,000 chars too - an oversized payload is saved to a
    # file and the model receives only a ~2KB preview + a file pointer. CONFIRMED live: the ~58KB
    # inline-contract directive was dropped, the orientation banner never rendered. The earlier
    # "stdout has no additionalContext cap" premise was empirically WRONG. So claude joins the pointer
    # arm: the directive stays lean (the banner + BOUNDED resume context inline; the full ~45KB
    # contract pointed-at on disk). RESIDUAL (maintainer's call): claude skims past file pointers (the
    # iter-6 disproof), so in pointer mode the contract's user-profile/expertise adaptation (banner
    # item 3) is not read - it degrades to the /specrew-user-profile fallback. That is a graceful,
    # integrity-SAFE degrade (governance is SCRIPT-enforced, not directive-borne); the follow-up
    # option is to extract+inline JUST the small user-profile block to restore item 3.
    # codex -> pointer. ROLLOUT-PROVEN 2026-06-10 to silently DROP the oversized (~50KB) SessionStart
    # additionalContext; codex reads files, so the lean pointer lands.
    # copilot -> inline. UNVERIFIED drop. copilot/cursor deliver SessionStart via additionalContext /
    # cursor -> inline. additional_context (the SAME mechanism codex drops). The host research matrix
    # (specs/171-specrew-refocus/research-matrix.md) records a 10k cap only for CLAUDE's
    # additionalContext - NONE is documented for copilot/cursor, and copilot rendered in-band in BOTH
    # the iter-8 and iter-11 dogfoods. An oversized drop is SUSPECTED (same mechanism) but UNPROVEN;
    # flipping on suspicion would regress a host that works, so they stay inline. TO FLIP once
    # confirmed on-host (both are in the dogfood loop): move the host into the pointer arm below.
    # Residual tracked in the T009 continuity docs. (NOTE: even inline hosts get the BOUNDED handover/
    # reconciliation below - that cap is mode-independent; only the 45KB contract is mode-gated.)
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string] $HostKind)
    switch ($HostKind) {
        'codex'  { return 'pointer' }
        'claude' { return 'pointer' }
        default  { return 'inline' }   # copilot / cursor / antigravity
    }
}

function Limit-SpecrewInlineBlock {
    # F-174 iter-11 (P2): bound a variable-length block inlined into the SessionStart directive so the assembled
    # hook payload stays under the host's output cap. Claude Code v2.1.177 silently drops hook output over 10,000
    # chars to a file + a ~2KB preview, so the directive never reaches the model; codex/copilot/cursor caps are
    # the same-or-unknown. The mechanical handover sections (esp. "What I just did") are an unbounded git-delta log
    # that repeats the full uncommitted-file list per entry (~6KB in the iter-11 worst case) - inlining them
    # verbatim alone blew the cap. Truncate at a line boundary when possible and append an elision pointer to the
    # full on-disk source (the agent reads it for depth). Behavior on under-budget input: returns $Text unchanged.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][string]$Text, [int]$MaxChars = 480, [string]$Pointer = '')
    if ([string]::IsNullOrEmpty($Text) -or $Text.Length -le $MaxChars) { return $Text }
    $cut = $Text.Substring(0, $MaxChars)
    $nl = $cut.LastIndexOf("`n")
    if ($nl -gt [int]($MaxChars / 2)) { $cut = $cut.Substring(0, $nl) }
    $note = if ([string]::IsNullOrWhiteSpace($Pointer)) { '... (truncated to fit the session-start delivery cap)' }
            else { ('... (truncated to fit the session-start delivery cap; full content at {0})' -f $Pointer) }
    return ($cut.TrimEnd() + "`n" + $note)
}

function Format-BootstrapDirective {
    param($Result, [AllowNull()][string]$ContractBody = $null, [AllowNull()]$InFlight = $null, [AllowNull()]$PendingVerdict = $null, [AllowNull()][string]$SpecrewVersion = $null, [AllowNull()][string]$Branch = $null)
    $d = $Result.directive
    $reads = @($d.required_reads)
    $contractRead = if ($reads.Count -ge 1 -and -not [string]::IsNullOrWhiteSpace([string]$reads[0])) { [string]$reads[0] } else { '.specrew/last-start-prompt.md' }
    $stateRead = if ($reads.Count -ge 2 -and -not [string]::IsNullOrWhiteSpace([string]$reads[1])) { [string]$reads[1] } else { '.specrew/start-context.json' }
    $lines = New-Object System.Collections.Generic.List[string]
    $lines.Add('[specrew-bootstrap] SessionStart B2 - render this as VISIBLE PROSE before any structured picker (render-first; FR-004/FR-020).')
    $lines.Add(("Bootstrap mode: {0}." -f $d.mode))
    # FR-001 (banner fix, 2026-06-10): the orientation BANNER is mandatory on EVERY host and must render
    # FIRST. It was skipped on claude (the render instruction sat AFTER the ~45KB inline contract, so claude
    # skimmed past it to the task; copilot rendered it). Hoist the full, EXPANDED banner mandate to the TOP -
    # before the contract - and name the "how we work" + user-profile/expertise content the human expects.
    $lines.Add('=== MANDATORY FIRST ACTION - render before anything else, on EVERY host ===')
    $lines.Add('On your VERY FIRST response - BEFORE anything else and REGARDLESS of the user''s first message (even a task like "create a feature ...") - render the Specrew ORIENTATION BANNER as visible prose, THEN act on the request. The banner is mandatory on every host; never skip it. Render, in order:')
    $lines.Add(' (1) Specrew is governing this session, and HOW we work: a spec-driven lifecycle with human-authorized boundaries - you DRIVE the gates and do NOT free-run the SDLC.')
    $lines.Add(' (2) Specrew version, the host you are, the project + branch, and the current lifecycle position.')
    $lines.Add(' (3) How you will adapt to the HUMAN - the user-profile / expertise dials from the contract (e.g. "I''ll treat you as an expert on Software Architecture ...") - so they see what you know about them. If the contract carries NO user-profile/expertise adaptation (none is set), instead tell them they can set how you adapt by running /specrew-user-profile - the hook cannot ask, but they can (FR-025).')
    $lines.Add(' (4) Any validated handover summary; (5) a one-line state reason when non-default; (6) a brief recommended next step for THIS state; (7) the Resume / New / Pick-feature menu as TEXT (offer Resume only when a valid active session exists).')
    # F-174 iter-11 (T009, DF-2): EMBED the resolved version + branch in the directive TEXT so a pointer-mode
    # host (codex - it does NOT inline the contract, so the version/branch never reached its banner; the
    # iteration-010 codex pointer-banner showed "not resolved") renders a COMPLETE banner item 2 from literal
    # values. claude/copilot/cursor get these from the inlined contract; this makes the directive self-sufficient.
    # Fail-soft: a value that could not be resolved is omitted (the agent falls back to what it can see).
    $resolved = New-Object System.Collections.Generic.List[string]
    if (-not [string]::IsNullOrWhiteSpace($SpecrewVersion)) { $resolved.Add("Specrew version $SpecrewVersion") | Out-Null }
    if (-not [string]::IsNullOrWhiteSpace($Branch)) { $resolved.Add("branch $Branch") | Out-Null }
    if ($resolved.Count -gt 0) {
        $lines.Add((" Resolved for THIS session (use these LITERAL values in banner item 2 - do NOT render 'unknown'/'not resolved'): {0}." -f ($resolved.ToArray() -join '; ')))
    }
    # FR-002/FR-023 (iter-7 T044, Ruling b): DRIVE by INLINING the contract, not pointing at a file. The
    # iter-6 directive told the agent to READ last-start-prompt.md BEFORE acting; the side-by-side disproof
    # showed the agent never read it (a file is a skip the agent self-orients past). So when the contract
    # body is available, inline it HERE - the agent acts on the in-context contract, with no file to skip;
    # the file stays the durable reference re-consulted at later boundaries. Fallback to the read-the-file
    # directive only when the body could not be captured (deployed resolution failure).
    if (-not [string]::IsNullOrWhiteSpace($ContractBody)) {
        $lines.Add(("Your governed launch contract for THIS session is BELOW - the SAME contract specrew start hands the agent (FR-023): the full lifecycle rules, governance, boundary authorization, the user-profile/expertise adaptation, and the coordinator framing. Follow it EXACTLY; do NOT bypass clarify or governance gates, and do NOT drive from raw Spec Kit scripts. The same contract is saved at {0} (and the current lifecycle state at {1}) for reference as you work each boundary." -f $contractRead, $stateRead))
        $lines.Add('')
        $lines.Add('===== BEGIN SPECREW LAUNCH CONTRACT (follow this) =====')
        $lines.Add($ContractBody.TrimEnd())
        $lines.Add('===== END SPECREW LAUNCH CONTRACT =====')
        $lines.Add('')
    }
    else {
        # POINTER mode (claude + codex - hosts whose hook-output cap drops the ~45KB inline contract; F-174 iter-11
        # P2). The contract is NOT inlined - it is on disk - so name everything that lives ONLY in it (incl. the
        # user-profile/expertise adaptation that feeds banner item 3 + the coordinator framing) and the explicit
        # item-3 fallback, since a host that skims the pointer (claude, iter-6 disproof) would otherwise render an
        # empty item 3. Governance itself is script-enforced, so skimming the contract does NOT bypass any gate.
        $lines.Add(("DRIVE this session from the governed contract (FR-023): READ {0} (the authoritative Specrew launch contract - the full lifecycle rules, governance scripts, boundary authorization, policy classes, the user-profile/expertise adaptation that feeds banner item 3, and the coordinator framing) and {1} (the current lifecycle state) from the project root BEFORE acting. Follow the governed lifecycle EXACTLY as that contract directs; do NOT bypass clarify or governance gates, and do NOT drive the work from raw Spec Kit scripts. (Banner item 3 lives ONLY in that contract here - read it; if it carries no adaptation or you cannot read it, use the /specrew-user-profile fallback named at the top - do NOT invent one.)" -f $contractRead, $stateRead))
    }
    if ($d.PSObject.Properties['handover'] -and $null -ne $d.handover -and $d.handover.present) {
        if ($d.handover.placeholder) {
            $lines.Add('[!] HOLLOW HANDOVER (rare) - the previous session''s Stop hook captured NO session delta (git unavailable?), so resume context is reduced to the lifecycle artifacts + git state. Re-derive the situation from the artifacts and surface this gap to the human - you are the backstop.')
        }
        else {
            $lines.Add(("Validated handover captured by the previous session (as of {0}; boundary: {1}). This is your resume context - surface it (render item 2), do not merely cite that it exists. The mechanical sections are hook-captured git/session state; any interpretive sections are agent-authored:" -f $d.handover.recorded_at, $d.handover.active_boundary))
            # F-174 iter-11 (P2): the inlined handover is the dominant SessionStart-payload bloat - the "What I
            # just did" mechanical log repeats the full uncommitted-file list per entry (~6KB in the iter-11
            # worst case), and inlining every section verbatim alone exceeded the host's 10K hook-output cap (the
            # whole directive was then dropped to a file on claude). Bound it two ways: a per-section char cap AND
            # a running TOTAL budget across sections - so one fat mechanical section cannot starve the rest, and
            # the agent reads the full on-disk handover for depth. Interpretive sections are normally short; in
            # practice the cap only bites the mechanical git-delta log.
            $hoPointer = 'file:///.specrew/handover/session-handover.md'
            $hoBudget = 380
            # F-174 iter-11 (P2 + Prop-145 RES-1): the tight handover budget MUST be spent on the AGENT-AUTHORED
            # interpretive sections FIRST - those (open questions, working hypothesis) are the only resume context
            # NO other block carries, and the FR-022 footer promises "what you hand off == what the next session
            # inherits". Iterating in raw section order spent the budget on the mechanical "What I just did"
            # git-delta log first (which the RECONCILIATION + IN-FLIGHT scans below already re-derive) and starved
            # the interpretive tail - the exact opposite of the intent. So: agent-owned sections first, then the
            # rest; cap "What I just did" hardest; and charge ONLY the content length against the budget, never the
            # elision-note boilerplate (else one truncated section's note alone ate ~1/3 of the budget).
            $agentOwned = @(Get-SpecrewHandoverAgentOwnedSections)
            $allKeys = @($d.handover.sections.Keys)
            $orderedKeys = @(@($allKeys | Where-Object { $agentOwned -contains $_ }) + @($allKeys | Where-Object { $agentOwned -notcontains $_ }))
            foreach ($k in $orderedKeys) {
                $c = [string]$d.handover.sections[$k]
                if (-not (Test-SpecrewHandoverSectionAuthored -Content $c)) { continue }
                if ($hoBudget -le 0) {
                    $lines.Add((" - (further handover sections omitted to fit the delivery cap; read {0})" -f $hoPointer))
                    break
                }
                $secCap = [Math]::Min(($(if ($k -match 'just did') { 140 } else { 220 })), $hoBudget)
                $rendered = Limit-SpecrewInlineBlock -Text $c -MaxChars $secCap -Pointer $hoPointer
                $hoBudget -= [Math]::Min($c.Length, $secCap)   # charge CONTENT only, not the elision-note boilerplate
                $clines = @($rendered -split "`r?`n")
                if ($clines.Count -le 1) {
                    $lines.Add((" - {0}: {1}" -f $k, $rendered))
                }
                else {
                    $lines.Add((" - {0}:" -f $k))
                    foreach ($cl in $clines) { $lines.Add((" {0}" -f $cl)) }
                }
            }
        }
    }
    # F-174 iter-10 (T001): the resume RECONCILIATION - the CURRENT delta re-computed NOW vs the snapshot
    # above, so the agent reads what changed SINCE the last stop and continues from the REAL state (the
    # snapshot may predate the latest work: antigravity, no-PostToolUse hosts, and hard-kills all lag the
    # disk). Lean: a pointer to the changed files; the agent does the reading.
    if ($d.PSObject.Properties['reconciliation'] -and $null -ne $d.reconciliation -and -not [string]::IsNullOrWhiteSpace([string]$d.reconciliation.directive_text)) {
        $lines.Add('')
        $lines.Add('=== RESUME RECONCILIATION (current tree, re-computed now) ===')
        # F-174 iter-11 (P2): the reconciliation re-lists the changed files (overlaps the handover delta) - bound
        # it too so the assembled payload stays under the host hook-output cap; the agent reads the tree itself.
        # NOTE (2026-06-15): this 300 excerpt is a DOGFOODED resume floor - do NOT cut it to buy cap headroom
        # (DirectiveDeliveryCap guards it >= 300). Recover headroom from the co-resident refocus B2 tail instead;
        # the durable reduction is Proposal 191 (pre-compute the in-flight digest to a file + pointer).
        $lines.Add((Limit-SpecrewInlineBlock -Text ([string]$d.reconciliation.directive_text) -MaxChars 300 -Pointer 'file:///.specrew/handover/session-handover.md'))
    }
    # F-174 iteration 011 (T006 part 2, FR-027 / decision f174-i011-verdict-authority-stop-hook): committed !=
    # authorized. When a boundary was mechanically crossed (sync) but NOT human-authorized (no captured verdict),
    # the resume MUST surface "awaiting your verdict" and the agent MUST NOT treat the committed boundary as
    # approved, MUST NOT advance on it, and MUST NOT record an authorization itself. This is the SECOND-CHANCE
    # re-confirm surface: on a hook host the human's re-confirmation is captured by the next hook fire; on a
    # hookless host (antigravity) the agent relays it. Surfaced HIGH (right after the resume context) because it
    # is integrity-critical - a committed boundary read as approved is exactly the DF-4/DF-5 failure.
    if ($null -ne $PendingVerdict -and [bool]$PendingVerdict.HasPendingVerdict) {
        $lines.Add('')
        $lines.Add('=== AWAITING YOUR VERDICT (committed != authorized - FR-027) ===')
        $lines.Add([string]$PendingVerdict.Message)
        $lines.Add('Treat that boundary as NOT YET approved: do NOT advance the lifecycle on it and do NOT record an authorization yourself. SURFACE this in your orientation banner and ASK the human to confirm; their actual response is the verdict (captured by the next hook fire, or their explicit confirmation), else stay at the prior authorized boundary.')
    }
    # F-174 T050 round-2 (the last-mile resume gap): the intent + status ARE on disk, but neither a hollow
    # handover ("re-derive from the artifacts" - skimmed as an abstract pointer) nor full mode (the contract's
    # project-state stub is empty; the hook makes no scan) ever SURFACED them - so copilot asked "what do you
    # want to build" with the answer in spec.md, and codex reported the hollow handover then stopped. Surface
    # the deterministic disk scan HERE, with the concrete next action named (content gets followed; pointers
    # get skimmed - the iter-7 inline-the-contract lesson).
    if ($null -ne $InFlight -and [bool]$InFlight.in_flight) {
        $lines.Add('')
        $lines.Add(('=== IN-FLIGHT WORK ON DISK (deterministic scan - this project is NOT new) ===') )
        $lines.Add(("Feature {0} is in flight on this branch. The intent and status live in FILES - read them FIRST, before asking the human anything:" -f $InFlight.feature_ref))
        if ($InFlight.spec_exists) { $lines.Add((" - the intent (what we are building): {0}" -f $InFlight.spec_path)) }
        if (@($InFlight.done).Count -gt 0) {
            # F-174 iter-11 (T008, DF-1): surface each done lens's DECISION (one line from its record), not just
            # the lens NAME, so a pointer/terse host (codex) can SYNTHESIZE "what we decided so far" instead of
            # echoing lens names. Fall back to the bare names when no decision record could be parsed.
            $ddProp = $InFlight.PSObject.Properties['done_decisions']
            # F-174 iter-11 (real-host fix 2026-06-14): an EMPTY @() returned from an if-EXPRESSION branch collapses
            # to $null under StrictMode-Latest, so `$x = if(..){@(..)}else{@()}` makes $x null when the value is
            # empty -> `$x.Count` then THROWS ("property 'Count' cannot be found") -> the whole directive fails ->
            # the bootstrap banner never surfaces. This bit on a real workshop with done lenses but NO parseable
            # decision summaries (done_decisions = empty array). Use DIRECT assignment (no if-expression), which
            # preserves an empty array (Count 0). $ddProp.Value truthiness is $false for $null AND for an empty
            # array, so the guard also avoids the @($null)->1-element-of-null trap.
            $decisions = @()
            if ($ddProp -and $ddProp.Value) { $decisions = @($ddProp.Value) }
            if ($decisions.Count -gt 0) {
                $lines.Add((" - design-workshop DECISIONS recorded so far (records under specs/{0}/workshop/) - SYNTHESIZE these into a 'what we decided so far' recap for the human; do NOT just echo lens names:" -f $InFlight.feature_ref))
                # F-174 iter-11 (P2): cap BOTH the per-summary length AND the number of decisions inlined so the
                # whole recap cannot blow the host hook-output cap (a full 9-lens workshop dumps ~1KB here). The
                # lens name + decision-point heads are enough to synthesize; the full records are on disk.
                # F-174 iter-11 (P2 + Prop-145 RES-3): inline the per-lens SUMMARY for the first few, but ALWAYS
                # name EVERY decided lens (the overflow lenses get their bare names on one line, not just a count) -
                # a pointer/skimming host that will not open the on-disk records still needs the full agenda to
                # synthesize the recap. Lens names are short + catalog-bounded, so this stays budget-neutral.
                $decMax = 3
                $decShown = @($decisions | Select-Object -First $decMax)
                foreach ($dec in $decShown) { $lines.Add((" * {0}: {1}" -f $dec.lens, (Limit-SpecrewInlineBlock -Text ([string]$dec.summary) -MaxChars 95))) }
                $decRest = @(@($decisions | Select-Object -Skip $decMax) | ForEach-Object { [string]$_.lens })
                if ($decRest.Count -gt 0) { $lines.Add((" * (also decided - synthesize from the records on disk under specs/{0}/workshop/): {1}" -f $InFlight.feature_ref, ($decRest -join ', '))) }
                $named = @($decisions | ForEach-Object { [string]$_.lens })
                $bare = @($InFlight.done | Where-Object { $named -notcontains $_ })
                if ($bare.Count -gt 0) { $lines.Add((" (also recorded done, no decision record: {0})" -f ($bare -join ', '))) }
            }
            else {
                $lines.Add((" - design-workshop lenses already DONE (records under specs/{0}/workshop/): {1}" -f $InFlight.feature_ref, (@($InFlight.done) -join ', ')))
            }
        }
        if (@($InFlight.remaining).Count -gt 0) { $lines.Add((" - workshop lenses REMAINING (from lens-applicability.json): {0}" -f (@($InFlight.remaining) -join ', '))) }
        # Codex round-3 lesson: with lens records but NO persisted agenda, "resume at the recorded position"
        # was too open - the host re-ran specify (rewrote spec.md) instead of continuing the workshop. When
        # records exist, name the only safe move explicitly - and distinguish the three shapes:
        # remaining > 0 -> resume at that exact lens
        # agenda persisted + all selected done -> workshop COMPLETE; resume at the boundary, don't redo it
        # records but NO agenda (codex shape) -> re-propose the remaining agenda, continue the workshop
        $next = if (@($InFlight.remaining).Count -gt 0) { ("resume the design workshop at the next remaining lens: {0}" -f @($InFlight.remaining)[0]) }
        elseif ([bool]$InFlight.has_applicability -and @($InFlight.done).Count -gt 0) { 'the design workshop is COMPLETE (every selected lens is recorded done) - do NOT redo or re-propose it; resume at the lifecycle position AFTER the workshop (typically presenting the specify boundary packet / awaiting the human verdict, or the recorded boundary)' }
        elseif (@($InFlight.done).Count -gt 0) { 'CONTINUE the design workshop: the agenda was not persisted, so RE-PROPOSE the remaining lens agenda to the human (skipping the DONE lenses above) and proceed lens-by-lens. Do NOT re-run specify and do NOT rewrite spec.md - the spec already exists' }
        else { 'resume at the recorded lifecycle position (read the spec + workshop records to locate it)' }
        $lines.Add(("When the human says 'continue' (or similar), {0}. Do NOT restart discovery, do NOT re-ask completed lenses, and do NOT ask 'what do you want to build' - spec.md answers that. Open your welcome-back with a 1-2 sentence SYNTHESIS of what we have decided so far (from the decisions/records above - synthesize the substance, do NOT just list lens names) and your resume point, then proceed." -f $next))
    }
    $lines.Add('Reminder (do not skip): your FIRST response MUST open with the MANDATORY orientation banner described at the top, and only THEN address the user''s request.')
    if (@($d.validation_findings).Count -gt 0) {
        $lines.Add(("State notes: {0}." -f ((@($d.validation_findings)) -join '; ')))
    }
    $lines.Add('Handover protocol (FR-022): to carry your INTERPRETIVE context across a session switch - your open questions + working hypothesis, which NO hook can author - persist it via `specrew handover author --from <file>` (a markdown body with `## ` section headers; run `specrew handover --help` for the section names), so what you hand off == what the next session inherits. Refresh before you expect to stop. On Claude the Stop hook ALSO captures your rendered boundary packet verbatim, but it is transcript-blind for the interpretive sections - only you can author those. NEVER delete + recreate .specrew/handover/session-handover.md with generic file tools - a crash between the delete and the create loses the handover; the writer (and `specrew handover author`) replaces the file ATOMICALLY and keeps session-handover.md.old as the crash backup (the bootstrap reader falls back to it automatically).')
    $lines.Add('This directive is advisory and non-authorizing: it never advances a lifecycle boundary on its own.')
    return ($lines -join "`n")
}

try {
    $eventJson = ''
    $rootOverride = $null
    $hostKind = 'claude'
    for ($i = 0; $i -lt $args.Count; $i++) {
        if ($args[$i] -eq '--event-json' -and ($i + 1) -lt $args.Count) { $eventJson = [string]$args[$i + 1] }
        elseif ($args[$i] -eq '--project-root' -and ($i + 1) -lt $args.Count) { $rootOverride = [string]$args[$i + 1] }
        elseif ($args[$i] -eq '--host-kind' -and ($i + 1) -lt $args.Count) { $hostKind = [string]$args[$i + 1] }
    }
    # Hooks only deploy for these kinds; an unknown value fails safe to the claude default.
    if ($hostKind -notin @('claude', 'codex', 'copilot', 'cursor')) { $hostKind = 'claude' }

    # B1 (compact) is unchanged - the bootstrap is B2 only (FR-011).
    $source = $null
    if (-not [string]::IsNullOrWhiteSpace($eventJson)) {
        try { $source = ($eventJson | ConvertFrom-Json).source } catch { $source = $null }
    }
    if ($source -eq 'compact') { exit 0 }

    $root = if ($rootOverride) { $rootOverride } else { Get-BootstrapProjectRoot }

    # Component resolution (D-001 downstream deploy): components sit beside the provider in the
    # self-host tree (scripts/internal/bootstrap); in a downstream project the provider deploys to
    # the extension tree while the components ship in the installed Specrew module (FileList), so
    # fall back to the module's scripts/internal/bootstrap. SPECREW_MODULE_PATH (the documented
    # dev-tree override, honored by specrew.ps1) wins first so a dev/unpublished module is testable.
    $bdir = Join-Path $PSScriptRoot 'bootstrap'
    if (-not (Test-Path -LiteralPath $bdir)) {
        $devBdir = if ($env:SPECREW_MODULE_PATH) { Join-Path $env:SPECREW_MODULE_PATH 'scripts/internal/bootstrap' } else { $null }
        if ($devBdir -and (Test-Path -LiteralPath $devBdir)) { $bdir = $devBdir }
        else {
            # F-174 iter-11 (P1): pick the newest installed module that ACTUALLY CONTAINS scripts/internal/bootstrap,
            # not blindly the newest. Not every Specrew version ships the bootstrap components in its FileList (0.34.0
            # did; 0.35.0/0.36.0 did not), so "newest module" can resolve to a bootstrap-LESS path -> the dot-source
            # below throws -> the top-level try swallows it -> exit 0 -> the hook silently writes NOTHING. Filtering
            # for the bootstrap dir makes the fallback land on a version that can actually serve it. Fail-open is
            # unchanged: if NO installed module carries bootstrap, $bdir stays the (absent) co-located path and the
            # dot-source fails into the same silent no-op - but a bootstrap-bearing older module is no longer skipped.
            $mod = Get-Module -ListAvailable Specrew | Sort-Object Version -Descending |
                Where-Object { Test-Path -LiteralPath (Join-Path $_.ModuleBase 'scripts/internal/bootstrap') } |
                Select-Object -First 1
            if ($mod) { $bdir = Join-Path $mod.ModuleBase 'scripts/internal/bootstrap' }
        }
    }
    foreach ($f in 'HostEventAdapter', 'SessionStateAccessor', 'ProjectMetadataAccessor', 'HandoverStore', 'ClassificationEngine', 'ValidationEngine', 'DirectiveEngine', 'SessionBootstrapManager', 'LauncherIntegration') {
        . (Join-Path $bdir "$f.ps1")
    }

    # FR-023: the hook hands the agent the SAME launch contract `specrew start` does, via the SAME
    # generator (Get-StartPrompt) - no second hand-rolled directive (no drift). Resolve the generator +
    # its transitive deps through the SAME 3-tier chain that found $bdir (co-located | SPECREW_MODULE_PATH |
    # installed module): launch-contract.ps1 + coordinator-resume.ps1 live in scripts/internal;
    # shared-governance.ps1 (boundary policy-class map + boundary_enforcement state) lives in the extension
    # scripts tree. Deriving from $bdir inherits the bootstrap components' proven deployed resolution.
    $internalDir = Split-Path $bdir -Parent
    $moduleRoot = Split-Path (Split-Path $internalDir)
    . (Join-Path $internalDir 'launch-contract.ps1')
    . (Join-Path $internalDir 'coordinator-resume.ps1')
    # iter-7 T043: the coordinator-surgery step + the user-profile reader carry the user-profile/expertise
    # adaptation + per-host coordinator framing into the contract (the content iter-6 omitted). Same 3-tier
    # resolution (both live in scripts/internal beside launch-contract.ps1).
    . (Join-Path $internalDir 'coordinator-prompt-surgery.ps1')
    . (Join-Path $internalDir 'user-profile.ps1')
    . (Join-Path (Join-Path $moduleRoot 'extensions/specrew-speckit/scripts') 'shared-governance.ps1')

    # Launcher<->hook dedupe (FR-007, SC-002): if `specrew start` just bootstrapped this session,
    # stay silent so the startup yields exactly one bootstrap surface.
    $nowUtc = (Get-Date).ToUniversalTime().ToString('o')
    if (Test-SpecrewLauncherBootstrapRecent -ProjectRoot $root -NowUtc $nowUtc) { exit 0 }

    $journalPath = Join-Path $root '.specrew/runtime/bootstrap-journal.jsonl'
    $result = Invoke-SpecrewSessionBootstrap -RawEvent $eventJson -HostName $hostKind -ProjectRoot $root -BaseBranch 'main' -JournalPath $journalPath

    # F-174 iter-10 double-render dedupe: capture the manager's canonical key (== safe_session_id, the SAME id
    # the journal records) NOW. The ATOMIC render CLAIM is taken LATER - right before Write-Output (see there) -
    # so every fallible step (the contract write + the in-flight scan below) runs BEFORE the claim and the only
    # thing between winning the claim and emitting is pure string building, which cannot suppress a sibling fire.
    $renderDedupeKey = [string]$result.record.dedupe_key

    # FR-023: the hook DRIVES - write the SAME launch contract + ensure boundary_enforcement on disk. The
    # manager component owns this logic (Write-SpecrewLaunchContractArtifact); the adapter invokes it here
    # so the pure classification path (Invoke-SpecrewSessionBootstrap) stays test-isolated from the
    # generator's StrictMode-Latest dependency tree. Inside the fail-open try: a broken deployed resolution
    # surfaces as no-write + exit 0 (caught by the T038 deployed floor), never a blocked session.
    # iter-7 T044: capture the contract path, read its body, and INLINE it into the directive (Ruling b) -
    # the agent acts on the in-context contract instead of being told to read a file it skips. The contract
    # file is ALWAYS written here (the codex pointer path below depends on it existing on disk).
    # Resolve the Specrew version from the module manifest ($moduleRoot came from the same 3-tier chain) so the
    # mandatory orientation banner renders the REAL version, not "Specrew: unknown" (the surgery defaults to
    # "unknown" with no version). Fail-soft: an unreadable manifest leaves it null (banner falls back to unknown).
    $specrewVersion = $null
    try { $specrewVersion = [string]((Import-PowerShellDataFile -Path (Join-Path $moduleRoot 'Specrew.psd1')).ModuleVersion) } catch { $specrewVersion = $null }
    # F-174 iter-11 (T009, DF-2): resolve the branch HERE (in the fallible-work region, BEFORE the atomic render
    # claim below) so the directive can embed the literal version + branch for pointer-mode hosts. Must NOT run
    # after the claim (the claim->emit window must stay pure string building - a git call could fail/hang).
    $branch = $null
    try { $branch = ([string](& git -C $root rev-parse --abbrev-ref HEAD 2>$null)).Trim(); if ([string]::IsNullOrWhiteSpace($branch)) { $branch = $null } } catch { $branch = $null }
    $contractPath = Write-SpecrewLaunchContractArtifact -ProjectRoot $root -Mode $result.mode -SessionState $result.validity.anchor -SpecrewVersion $specrewVersion
    $contractBody = if ($contractPath -and (Test-Path -LiteralPath $contractPath)) { Get-Content -LiteralPath $contractPath -Raw } else { '' }

    # Host delivery policy (DELIVERY only; contract FRAMING unchanged). The per-host inline-vs-pointer rule +
    # its rationale + the copilot/cursor UNVERIFIED-drop residual live in the ONE testable seam below (T007/M1).
    $inlineContract = ((Get-SpecrewContractDeliveryMode -HostKind $hostKind) -eq 'inline')
    $directiveBody = if ($inlineContract) { $contractBody } else { '' }

    # F-174 T050 round-2: deterministic in-flight disk scan for the directive (the last-mile resume gap).
    # Feature source: the validated anchor first, else the branch (the pre-boundary workshop window - same
    # resolver the Stop floor-writer uses). Fail open: any error -> no block (never blocks the bootstrap).
    $inFlight = $null
    try {
        $ifFeature = $null
        if ($null -ne $result.validity.anchor -and -not [string]::IsNullOrWhiteSpace([string]$result.validity.anchor.feature_ref)) {
            $ifFeature = [string]$result.validity.anchor.feature_ref
        }
        if ([string]::IsNullOrWhiteSpace($ifFeature)) { $ifFeature = Resolve-SpecrewBranchFeatureRef -ProjectRoot $root }
        if (-not [string]::IsNullOrWhiteSpace($ifFeature)) { $inFlight = Get-SpecrewWorkshopProgress -ProjectRoot $root -FeatureRef $ifFeature }
    }
    catch { $inFlight = $null }

    # F-174 iteration 011 (T006 part 2, FR-027): the honest "committed != authorized" gate state, read from the
    # SAME boundary_enforcement the sync writes. When the working boundary is ahead of the last HUMAN-authorized
    # one, the directive surfaces "awaiting your verdict" (below). Fail-open (the helper never throws + never
    # fabricates a pending state; the guard is belt-and-suspenders for a missing-helper deploy edge).
    $pendingVerdict = $null
    try { $pendingVerdict = Get-SpecrewPendingVerdictState -ProjectRoot $root } catch { $pendingVerdict = $null }

    # F-174 iter-10 ATOMIC double-render dedupe (the CLAIM). codex fires SessionStart twice per launch
    # near-SIMULTANEOUSLY (worktree dogfood 2026-06-13: two fires ~microseconds apart, same session id +
    # source), so a recency/record-after-render scheme cannot dedupe them - both check before either records
    # and BOTH render (the dogfood saw exactly that: two render markers ~10us apart). Elect exactly ONE
    # renderer with an ATOMIC create-if-absent claim per (session, source): the winner renders, every
    # concurrent sibling finds the claim present and exits silent. 'no-session' (no stable id - the self-host
    # repo where codex sends none, or any Stop event) is NEVER claimed -> always renders; /clear (different
    # source) wins its OWN claim -> re-renders. Fail-open (the claim returns $true on any non-"already-exists"
    # error). The claim sits HERE - the last step before emit, AFTER all fallible work (Invoke, contract write,
    # in-flight scan) - so the winner->emit window holds only pure string building; a transient failure in one
    # fire cannot suppress the other. Invoke already ran, so the journal records BOTH fires (forensic count
    # intact); only one RENDERS. Scope: the bootstrap directive only - the refocus banner (provider order 10) +
    # handover (order 30) still re-run on the duplicate dispatcher fire (the refocus-banner doubling is the
    # known benign residual; a dispatcher-level dedupe was rejected for blast radius - highest in the chain).
    if ($renderDedupeKey -and $renderDedupeKey -ne 'no-session') {
        if (-not (Request-SpecrewHookRenderClaim -ProjectRoot $root -DedupeKey $renderDedupeKey -Source ([string]$source) -RecordedAt $nowUtc)) { exit 0 }
    }
    Write-Output (Format-BootstrapDirective -Result $result -ContractBody $directiveBody -InFlight $inFlight -PendingVerdict $pendingVerdict -SpecrewVersion $specrewVersion -Branch $branch)
    exit 0
}
catch {
    [Console]::Error.WriteLine("[specrew-bootstrap] WARN PROVIDER_FAILED $($_.Exception.Message)")
    exit 0
}