scripts/internal/bootstrap/SessionBootstrapManager.ps1
|
<#
.SYNOPSIS Orchestrate the SessionStart B2 bootstrap: event -> validate -> classify -> directive -> journal. .DESCRIPTION Manager (IDesign): orchestrates one use case by calling the engines + accessors; it holds no business rules of its own and is NON-INTERACTIVE (FR-003) - it only produces the directive the agent renders, never asks questions or branches on a menu response. Reads the anchor via the ValidationEngine (which owns its accessor reads), decides the mode via the pure ClassificationEngine, and builds the directive via the pure DirectiveEngine. Writes a basic classification record when a journal path is supplied (the full F-171 journal envelope is iteration 003, T018). Feature 174 (FR-001, FR-002, FR-003, FR-016, FR-020). Depends on the other bootstrap component files (co-loaded by the module). .OUTPUTS [pscustomobject] { directive, mode, record, validity } #> function Invoke-SpecrewSessionBootstrap { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][AllowEmptyString()][string] $RawEvent, [Parameter(Mandatory)][ValidateSet('claude', 'codex', 'copilot', 'cursor')][string] $HostName, [Parameter(Mandatory)][string] $ProjectRoot, # Defaults to the project-local session-state file. [Parameter()][string] $StatePath, [Parameter()][string] $BaseBranch = 'main', # ISO-8601 'now' for handover-freshness (deterministic for tests; live default below). [Parameter()][string] $NowUtc = ((Get-Date).ToUniversalTime().ToString('o')), # When supplied, a one-line classification record is appended (advisory journal). [Parameter()][string] $JournalPath ) $normalizedEvent = ConvertFrom-SpecrewHostHookEvent -RawEvent $RawEvent -HostName $HostName -ProjectRoot $ProjectRoot $dedupeKey = if ($normalizedEvent.safe_session_id) { $normalizedEvent.safe_session_id } else { 'no-session' } $resolvedStatePath = if ($StatePath) { $StatePath } else { Join-Path $ProjectRoot '.specrew/start-context.json' } $validity = Test-SpecrewAnchorValidity -StatePath $resolvedStatePath -ProjectRoot $ProjectRoot -BaseBranch $BaseBranch # Handover-first (architecture-core d2): a validated handover from a prior SessionEnd is the # primary resume signal, read + validated before the anchor decides the mode. $handoverValid = $false $handover = $null $handoverInvalidFindings = @() try { $handover = Get-SpecrewRollingHandover -HandoverDir (Join-Path $ProjectRoot '.specrew/handover') -NowUtc $NowUtc if ($null -ne $handover) { $hv = Test-SpecrewHandoverValidity -Handover $handover -ProjectRoot $ProjectRoot -BaseBranch $BaseBranch $handoverValid = [bool]$hv.valid # Prop-145 round-6 (MEDIUM): a present-but-INVALID handover (stale / wrong-branch / malformed) is # NOT authoritative resume truth - capture WHY so the directive surfaces it (and so the stale # snapshot below is never passed to reconciliation). The findings name the reason (e.g. "handover # older than the freshness window: ..."). if (-not $handoverValid) { $handoverInvalidFindings = @(@($hv.findings) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($handoverInvalidFindings.Count -eq 0) { $r = if ($hv.reason) { [string]$hv.reason } else { 'invalid' } $handoverInvalidFindings = @("ignored a present-but-invalid handover ($r); resuming from anchor/current state, not the stale snapshot") } } } } catch { $handoverValid = $false } # F-174 iter-5: surface the agent-authored body on resume; flag a placeholder (hollow) body so the # bootstrap renders the prominent backstop warn (carry #3). Only a VALID handover is surfaced. $handoverDirective = $null if ($null -ne $handover -and $handoverValid) { $bp = Test-SpecrewHandoverBodyPlaceholder -Sections $handover.sections $handoverDirective = [pscustomobject]@{ present = $true placeholder = [bool]$bp.placeholder recorded_at = $handover.recorded_at active_boundary = $handover.active_boundary sections = $handover.sections } } # F-174 iter-10 (T001): re-compute the CURRENT delta on resume so the agent gets the ACTUAL tree (not the # stale snapshot) + a directive to read what changed since the last stop. SHARED with `specrew start` (T008). # Prop-145 round-6 (MEDIUM): pass the handover ONLY when it is VALID - an invalid (stale/wrong-branch) # handover must not seed "Last captured stop: <old timestamp>" into the resume directive (the current git # delta is still computed from $null, so the agent gets the REAL tree without a stale-snapshot anchor). $reconciliationHandover = if ($handoverValid) { $handover } else { $null } $reconciliation = $null try { $reconciliation = Get-SpecrewResumeReconciliation -ProjectRoot $ProjectRoot -Handover $reconciliationHandover } catch { $reconciliation = $null } $mode = Resolve-SpecrewBootstrapMode -AnchorValid $validity.valid -AnchorClearedReason $validity.cleared_reason -HandoverValid $handoverValid # Advisory SessionStart marker + same-worktree concurrency (US-4, FR-018/019). Never blocks; the # marker is local-only. We read the prior marker, classify concurrency, then stamp our own. $concurrent = $false $concurrencyReason = 'none' try { $markerPath = Join-Path $ProjectRoot '.specrew/runtime/session-marker.json' $cc = Test-SpecrewConcurrentSession -Marker (Get-SpecrewSessionMarker -MarkerPath $markerPath) -ProjectRoot $ProjectRoot -NowUtc $NowUtc $concurrent = [bool]$cc.concurrent $concurrencyReason = $cc.reason $branch = ''; $head = '' try { $branch = (& git -C $ProjectRoot rev-parse --abbrev-ref HEAD 2>$null) } catch { $null = $_ } try { $head = (& git -C $ProjectRoot rev-parse --short HEAD 2>$null) } catch { $null = $_ } Write-SpecrewSessionMarker -MarkerPath $markerPath -HostName $HostName -ProjectRoot $ProjectRoot -Branch $branch -HeadCommit $head -StartedAt $NowUtc | Out-Null } catch { $null = $_ } $allFindings = @($validity.findings) if ($handoverInvalidFindings.Count -gt 0) { $allFindings += $handoverInvalidFindings } if ($concurrent) { $allFindings += 'advisory: another session may be active in this worktree (marker within 1h)' } $directive = New-SpecrewBootstrapDirective ` -Mode $mode.mode ` -DedupeKey $dedupeKey ` -ValidationFindings $allFindings ` -RequiredReads @('.specrew/last-start-prompt.md', '.specrew/start-context.json') ` -Handover $handoverDirective ` -Reconciliation $reconciliation ` -Sources ([pscustomobject]@{ anchor_present = ($null -ne $validity.anchor); handover_valid = $handoverValid; concurrent_session = $concurrent }) $record = [pscustomobject]@{ host = $HostName mode = $mode.mode anchor_cleared = $validity.cleared_reason handover_valid = $handoverValid handover_placeholder = ($null -ne $handoverDirective -and $handoverDirective.placeholder) concurrent_session = $concurrent concurrency_reason = $concurrencyReason dedupe_key = $dedupeKey # F-174 iter-10: the launch source (startup|resume|clear) on this fire. Free observability for the # double-render dedupe: a host that re-fires SessionStart writes TWO journal rows; if both carry the # SAME source, the (dedupe_key, source)-keyed render dedupe is correct to suppress the second. NOT # consumed by the dedupe itself (the provider keys off its own parse) - this row is the diagnostic. source = $normalizedEvent.source findings = $allFindings } if ($JournalPath) { $dir = Split-Path -Parent $JournalPath if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } ($record | ConvertTo-Json -Compress) | Add-Content -LiteralPath $JournalPath -Encoding UTF8 } [pscustomobject]@{ directive = $directive mode = $mode.mode record = $record validity = $validity } } function Write-SpecrewLaunchContractArtifact { <# .SYNOPSIS FR-023: hand the agent the SAME launch contract `specrew start` does, by REUSING its generator. .DESCRIPTION The hook DRIVES (not merely orients): it writes the full launch contract (Get-StartPrompt) to `.specrew/last-start-prompt.md` and ensures `boundary_enforcement` in `.specrew/start-context.json`, so a host launched WITHOUT `specrew start` inherits the same governed contract + state. NON-LAUNCHER: it writes ONLY those two artifacts via narrow atomic writes - never the git baseline, session frontmatter, host selection, or approval/launch mode that `Save-StartArtifacts` (a launcher monolith) owns. Launcher-only context the hook has no scan for (roster/routing/project state) is passed as EMPTY-SHAPED stubs - not null - so the SHARED generator stays byte-identical (no drift) on its null-safe paths; the invariant ~48-rule contract is unaffected. Depends on Get-StartPrompt (launch-contract.ps1), Get-/Initialize-SpecrewBoundaryEnforcementState + Write-Utf8FileAtomic (shared-governance.ps1), and the coordinator-resume blocks - all dot-sourced into scope by the provider alongside the bootstrap components. The provider's fail-open try/catch is the backstop: a broken deployed resolution surfaces as no-write (caught by the T038 deployed floor), never a hang. .OUTPUTS [string] the last-start-prompt.md path written. #> param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $Mode, [AllowNull()][pscustomobject] $SessionState, [ValidateSet('copilot', 'claude', 'codex', 'antigravity', 'cursor')][string] $HostKind = 'claude', [AllowNull()][string] $SpecrewVersion = $null ) # The hook's anchor (Get-SpecrewSessionAnchor) and the generator's resume block use DIFFERENT field # names: the anchor carries `boundary`/`iteration` (and no `task_id`), while Get-StartPrompt's resume # block reads `boundary_type`/`iteration_number`/`task_id` (+ feature_ref/feature_path). Map the anchor # into the SHAPE the generator reads so it never throws under StrictMode-Latest on an absent property # (a raw anchor would throw on three fields -> provider fail-open -> silent no-contract = the D-009 # trap). Get-SpecrewProp returns $null for any absent field; the SHARED generator stays untouched. $generatorSessionState = $null if ($null -ne $SessionState) { $generatorSessionState = [pscustomobject]@{ feature_ref = Get-SpecrewProp $SessionState 'feature_ref' feature_path = Get-SpecrewProp $SessionState 'feature_path' boundary_type = Get-SpecrewProp $SessionState 'boundary' iteration_number = Get-SpecrewProp $SessionState 'iteration' task_id = Get-SpecrewProp $SessionState 'task_id' } } $featurePath = [string](Get-SpecrewProp $generatorSessionState 'feature_path') $currentBoundary = $null $boundaryValue = Get-SpecrewProp $generatorSessionState 'boundary_type' if (-not [string]::IsNullOrWhiteSpace([string]$boundaryValue)) { $currentBoundary = [string]$boundaryValue } # Empty-shaped launcher-only context (the hook makes no casting/routing/project-scan decisions). NOT # null: Get-RoutingPlanPromptBlock calls $RoutingPlan.roles.GetEnumerator() which throws on a null # `.roles`, so a shaped-empty object keeps the SHARED generator on its self-contained path untouched. $contract = Get-StartPrompt ` -ResolvedProjectPath $ProjectRoot ` -Mode $Mode ` -FeatureRequest '' ` -ResolvedFeaturePath $featurePath ` -TeamRoster ([pscustomobject]@{ mode = 'none' }) ` -RoutingPlan ([pscustomobject]@{ enabled_agents = @(); roles = @{}; fallback_events = @() }) ` -ProjectState ([pscustomobject]@{ state = 'active'; spec_directories = @(); detected_entries = @() }) ` -BrownfieldDiscovery $null ` -DeliveryGuidance $null ` -SessionState $generatorSessionState ` -RecoverySession $null # T043 (FR-023, iter-7 Ruling a): apply the SAME coordinator-prompt surgery `specrew start` does # (specrew-start.ps1 ~L3348) so the hook's contract reaches CONTENT PARITY. The user-profile/expertise # adaptation (the ExpertiseLine) + the per-host coordinator framing live in THIS step, NOT in # Get-StartPrompt - iter-6 skipped it, producing the thin contract the side-by-side disproved. # Get-SpecrewProfileOrientationLine reads the session-available user-profile (~/.specrew/user-profile.yml); # $null when none is set. CrewRuntimeStatus stays at its AllowNull default (the hook makes no crew-runtime # scan); SpecrewVersion is threaded from the provider (resolved from the module manifest) so the mandatory # orientation banner renders the REAL version instead of "Specrew: unknown" (the banner-fix follow-on). $expertiseLine = $null try { $expertiseLine = Get-SpecrewProfileOrientationLine -Profile (Get-UserProfile) } catch { $expertiseLine = $null } $featureRefValue = [string](Get-SpecrewProp $generatorSessionState 'feature_ref') if ([string]::IsNullOrWhiteSpace($featureRefValue) -and $featurePath) { $featureRefValue = Split-Path -Leaf $featurePath } $contract = Invoke-SpecrewCoordinatorPromptSurgery ` -Prompt $contract ` -HostKind $HostKind ` -SpecrewVersion $SpecrewVersion ` -LifecycleMode $Mode ` -FeatureRef $featureRefValue ` -BoundaryType $currentBoundary ` -ExpertiseLine $expertiseLine $promptPath = Join-Path $ProjectRoot '.specrew/last-start-prompt.md' $specrewDir = Split-Path -Parent $promptPath if ($specrewDir -and -not (Test-Path -LiteralPath $specrewDir)) { New-Item -ItemType Directory -Path $specrewDir -Force | Out-Null } Write-Utf8FileAtomic -Path $promptPath -Content ($contract + [Environment]::NewLine) # Preserve-merge: initialize boundary_enforcement ONLY when absent; never clobber an existing block (a # prior `specrew start` / session). Get-/Initialize- own start-context.json I/O + key preservation. $beState = Get-SpecrewBoundaryEnforcementState -ProjectRoot $ProjectRoot if ($null -eq $beState.State) { Initialize-SpecrewBoundaryEnforcementState -ProjectRoot $ProjectRoot -CurrentBoundary $currentBoundary | Out-Null } return $promptPath } |