scripts/internal/bootstrap/ProjectMetadataAccessor.ps1
|
<#
.SYNOPSIS Resolve a feature ref against project-local metadata and git merged-status. .DESCRIPTION Resource accessor (IDesign): reads the feature's project-local presence (specs/<ref>/) and corroborates "not resumable" with git merged-status (data-storage d3). The feature ref is always re-resolved against the current project root - the committed absolute path is never trusted (FR-015). Git calls fail safe (a failure means "not provably merged", never a false clear). Feature 174 (FR-014, FR-015). Active-features registry + closeout-artifact signals are layered in later; presence + merged-status is the iteration-001 floor. #> function Test-SpecrewFeatureLocal { # specs/<ref>/ exists under the current project root. [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)][string] $SpecsRoot, [Parameter(Mandatory)][string] $FeatureRef) return (Test-Path -LiteralPath (Join-Path $SpecsRoot $FeatureRef) -PathType Container) } function Test-SpecrewBranchMergedToBase { # True when $Branch is fully merged into $BaseBranch (its tip is an ancestor of base). # Fails safe: any git error (missing branch/repo) returns $false, never a false "merged". [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][string] $RepoRoot, [Parameter(Mandatory)][string] $Branch, [Parameter(Mandatory)][string] $BaseBranch ) try { & git -C $RepoRoot merge-base --is-ancestor $Branch $BaseBranch 2>$null return ($LASTEXITCODE -eq 0) } catch { return $false } } function Get-SpecrewFeatureResumable { # Compose project-local presence + git merged-status into a resumability verdict. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $FeatureRef, [Parameter()][string] $BaseBranch = 'main' ) $present = Test-SpecrewFeatureLocal -SpecsRoot (Join-Path $ProjectRoot 'specs') -FeatureRef $FeatureRef $merged = $false if ($present) { $merged = Test-SpecrewBranchMergedToBase -RepoRoot $ProjectRoot -Branch $FeatureRef -BaseBranch $BaseBranch } [pscustomobject]@{ feature_ref = $FeatureRef present = $present merged = $merged resumable = ($present -and -not $merged) } } function Resolve-SpecrewBranchFeatureRef { # Resolve the active feature from the CURRENT git branch when no persisted anchor names one yet. # The pre-specify WORKSHOP window is the gap (F-174 T050): specs/<feature>/ already exists (Spec Kit's # create-new-feature scaffolds it before the workshop runs), but no boundary has crossed, so # start-context.json session_state.feature_ref is still blank. Without a feature, the Stop floor-writer # stamps an empty active_feature -> Test-SpecrewHandoverValidity returns 'no-feature' -> the handover is # NEVER surfaced on resume (the agent re-derives the whole situation from scratch - minutes). In Spec Kit # the branch name IS the feature slug (the specs dir shares its name), so the branch is the authoritative, # MULTI-FEATURE-SAFE key (a disk scan of specs/ would be ambiguous; the branch is not). # Returns the branch as the feature ref ONLY when it matches the Spec Kit feature-branch contract # (^\d{3}[-_]) AND specs/<branch>/ exists locally; else $null. So on main / a non-feature branch / a # closed feature whose dir was deleted -> $null (no bogus stamp; identical to today's behavior). The # read-side Test-SpecrewHandoverValidity still re-checks present + not-merged + the 24h freshness bound, # so a feature merged AFTER the stamp is caught on resume. Git failure -> $null (fail-safe). F-174 (T050). [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string] $ProjectRoot) $branch = $null try { $out = @(& git -C $ProjectRoot branch --show-current 2>$null) if ($LASTEXITCODE -eq 0 -and $out.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$out[0])) { $branch = ([string]$out[0]).Trim() } } catch { return $null } if ([string]::IsNullOrWhiteSpace($branch)) { return $null } if ($branch -notmatch '^\d{3}[-_]') { return $null } if (-not (Test-SpecrewFeatureLocal -SpecsRoot (Join-Path $ProjectRoot 'specs') -FeatureRef $branch)) { return $null } return $branch } function Get-SpecrewLensDecisionSummary { # F-174 iter-11 (T008, DF-1): a ONE-LINE decision recap for a design-workshop lens record, so a resume can # surface WHAT WAS DECIDED (the decision topics), not just the lens NAME (the iteration-010 multi-host # dogfood: pointer-mode hosts echoed lens names while the real decisions sat unread in the records). # Extracts the '## Decision N - <title>' headings (the design-workshop record convention) and joins their # titles, bounded. Fail-open: any read error / a record with no decision headings -> $null (the caller # falls back to the bare lens name). [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string] $RecordPath, [int] $MaxDecisions = 4, [int] $MaxLength = 220) try { if (-not (Test-Path -LiteralPath $RecordPath -PathType Leaf)) { return $null } $titles = New-Object System.Collections.Generic.List[string] foreach ($line in (Get-Content -LiteralPath $RecordPath -ErrorAction Stop)) { # '## Decision <N|Nb> [-|–|—|:] <title>' - anchor on the decision-id token (\S+) then the FIRST # separator, so an internal hyphen in the title (e.g. 'Atomic write-replace') is NOT mistaken for the # separator (review-signoff P5-1) and the em-dash (U+2014) is supported alongside hyphen/en-dash/colon. $m = [regex]::Match([string]$line, '^##\s+Decision\s+\S+\s*[-–—:]\s*(.+?)\s*$') if ($m.Success) { $t = ($m.Groups[1].Value -replace '`', '').Trim() if (-not [string]::IsNullOrWhiteSpace($t)) { $titles.Add($t) | Out-Null } } } if ($titles.Count -eq 0) { return $null } $shown = @($titles | Select-Object -First $MaxDecisions) $summary = ($shown -join '; ') if ($titles.Count -gt $MaxDecisions) { $summary += (' (+{0} more)' -f ($titles.Count - $MaxDecisions)) } if ($summary.Length -gt $MaxLength) { $summary = $summary.Substring(0, $MaxLength - 1).TrimEnd() + [char]0x2026 } return $summary } catch { return $null } } function Get-SpecrewWorkshopProgress { # Deterministic DISK-TRUTH scan of a feature's in-flight intent + status, for the bootstrap directive # (F-174 T050 round-2 finding): on resume, the intent (spec.md) and status (workshop records + # lens-applicability.json moved_on flags) ARE on disk, but nothing SURFACED them - so copilot asked # "what do you want to build" with the answer sitting in spec.md, and codex reported the hollow handover # then stopped ("re-derive from the artifacts" as an abstract pointer gets skimmed; surfaced CONTENT gets # followed - the iter-7 inline-the-contract lesson again). This accessor reads, the directive renders. # Fail open: any read error -> the empty shape (never blocks the bootstrap). [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter(Mandatory)][string] $FeatureRef ) $featureDir = Join-Path (Join-Path $ProjectRoot 'specs') $FeatureRef $specPath = Join-Path $featureDir 'spec.md' $specExists = Test-Path -LiteralPath $specPath -PathType Leaf # Lens records persisted under the workshop folder (the per-lens durable checkpoints). $lensRecords = @() try { $wdir = Join-Path $featureDir 'workshop' if (Test-Path -LiteralPath $wdir -PathType Container) { $lensRecords = @(Get-ChildItem -LiteralPath $wdir -Filter '*.md' -File | ForEach-Object { [System.IO.Path]::GetFileNameWithoutExtension($_.Name) } | Where-Object { $_ -ne 'lens-applicability' } | Sort-Object) } } catch { $lensRecords = @() } # lens-applicability.json: selected lenses + per-lens moved_on (done) flags. Hosts have written it both # feature-level (the skill's contract) and under workshop/ - accept either. $selected = @(); $done = @(); $applicabilityFound = $false foreach ($cand in @((Join-Path $featureDir 'lens-applicability.json'), (Join-Path (Join-Path $featureDir 'workshop') 'lens-applicability.json'))) { if (-not (Test-Path -LiteralPath $cand -PathType Leaf)) { continue } try { $la = Get-Content -LiteralPath $cand -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop $applicabilityFound = $true # StrictMode-safe local reads (this accessor stays self-contained; no SessionStateAccessor dep). $selProp = $la.PSObject.Properties['selected'] if ($selProp -and $null -ne $selProp.Value) { $selected = @($selProp.Value | ForEach-Object { [string]$_ }) } $wProp = $la.PSObject.Properties['workshop'] if ($wProp -and $null -ne $wProp.Value) { foreach ($p in $wProp.Value.PSObject.Properties) { $mo = $p.Value.PSObject.Properties['moved_on'] if ($mo -and [bool]$mo.Value) { $done += [string]$p.Name } } } break } catch { continue } } # done = the union of moved_on records and persisted lens files (a host that writes the file but not the # json - codex - still counts as progressed); remaining = selected minus done, in selected order. $doneAll = @(@($done) + @($lensRecords) | Select-Object -Unique) $remaining = @($selected | Where-Object { $doneAll -notcontains $_ }) # F-174 iter-11 (T008, DF-1): a one-line decision recap per DONE lens that has a workshop record, so the # resume directive can surface WHAT WAS DECIDED, not just the lens name. Ordered by $doneAll; only lenses # with an extractable decision summary appear (a record-less moved_on lens stays in `done` as a name only). $wdir = Join-Path $featureDir 'workshop' $doneDecisions = New-Object System.Collections.Generic.List[object] foreach ($lens in $doneAll) { $summary = Get-SpecrewLensDecisionSummary -RecordPath (Join-Path $wdir ($lens + '.md')) if (-not [string]::IsNullOrWhiteSpace($summary)) { $doneDecisions.Add([pscustomobject]@{ lens = [string]$lens; summary = $summary }) | Out-Null } } [pscustomobject]@{ feature_ref = $FeatureRef spec_exists = $specExists spec_path = if ($specExists) { "specs/$FeatureRef/spec.md" } else { $null } selected = $selected done = $doneAll # .ToArray() not @($doneDecisions): the array-subexpression operator on this List[object] of # pscustomobjects throws "Argument types do not match" as a hashtable value (a PowerShell quirk); the # explicit List.ToArray() is the reliable conversion. done_decisions = $doneDecisions.ToArray() remaining = $remaining has_applicability = $applicabilityFound in_flight = ($specExists -or ($doneAll.Count -gt 0)) } } function Test-SpecrewIsGitRepoRoot { # F-174 iter-10 (Prop-145 round-6, HIGH): is $ProjectRoot the TOP-LEVEL of its own git repo (or a worktree # root)? `git rev-parse --show-prefix` answers in O(repo-depth), NOT O(tree): empty output + exit 0 == # $ProjectRoot IS the top-level (a linked worktree root also returns empty -> passes); a NON-empty prefix == # $ProjectRoot sits BELOW some repo's root (nested); a non-zero exit == not a git repo at all. Why this and # not `--show-toplevel` + a path-compare: the toplevel path comes back in git's casing/slash form and a temp # root under an 8.3-short HOME ($env:TEMP = C:\Users\ALON~1.HOM) never string-equals git's C:\Users\alon.HOME # -> a false "nested" on the developer's own machine. --show-prefix sidesteps all path normalization. The # gate exists so Get-SpecrewSessionDelta never runs `git status` against a PARENT repo it merely lives inside # (e.g. a non-repo project root under a HOME that is itself a worktree) - that scan walks the WHOLE parent # tree and can hang the hook. Fail-safe: any error -> $false (treat as "not a clean repo root", skip the scan). [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)][string] $ProjectRoot) try { $prefix = (& git -C $ProjectRoot rev-parse --show-prefix 2>$null) return (($LASTEXITCODE -eq 0) -and [string]::IsNullOrEmpty(([string]$prefix).Trim())) } catch { return $false } } function Get-SpecrewEmptySessionDelta { # The empty/zero delta shape Get-SpecrewSessionDelta returns when there is no git scan to run (not a repo # root). Single source of truth so the gate and the happy path can never drift in shape. [OutputType([pscustomobject])] param() [pscustomobject]@{ branch = '' head_short = '' head_subject = '' uncommitted_count = 0 uncommitted_files = @() uncommitted_truncated = $false has_uncommitted = $false user_file_count = 0 user_files = @() managed_file_count = 0 new_commits = @() new_commit_count = 0 } } function Get-SpecrewSessionDelta { # F-174 iter-9: the git/filesystem delta the Stop hook captures as the rolling handover's MECHANICAL # body - host-universal, needs NO transcript and NO agent cooperation (the iter-8 dogfood proved the # agent-/gate-dependent author is hollow in practice; the git delta is always available). Fail-safe: any # git error yields the empty/zero shape so the handover degrades, never throws. $SinceCommit (the prior # handover's from_commit) bounds "new commits this session"; $null/unresolvable -> no new-commit list. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter()][AllowNull()][string] $SinceCommit, [Parameter()][int] $MaxFiles = 12, [Parameter()][int] $MaxCommits = 8 ) # Prop-145 round-6 (HIGH): gate the scan on "$ProjectRoot is its own repo root". When it is NOT (a non-repo # project root that merely sits under a parent git repo / worktree), `git status --untracked-files=all` # would scan the entire PARENT tree - unbounded, hangs the hook, and reports the parent's files as this # project's delta. Returning the empty shape here is the same fail-safe degrade as any git error. if (-not (Test-SpecrewIsGitRepoRoot -ProjectRoot $ProjectRoot)) { return Get-SpecrewEmptySessionDelta } $branch = ''; $headShort = ''; $headSubject = '' try { $branch = ([string](& git -C $ProjectRoot rev-parse --abbrev-ref HEAD 2>$null)).Trim() } catch { $null = $_ } try { $headShort = ([string](& git -C $ProjectRoot rev-parse --short HEAD 2>$null)).Trim() } catch { $null = $_ } try { $headSubject = ([string](& git -C $ProjectRoot log -1 --format=%s 2>$null)).Trim() } catch { $null = $_ } $uncommittedFiles = @() try { # --untracked-files=all expands untracked DIRECTORIES (e.g. specs/) into their individual files so the # user's real work (specs/<feature>/spec.md, workshop/<lens>.md) surfaces instead of a bare "specs/". $porcelain = @(& git -C $ProjectRoot status --porcelain --untracked-files=all 2>$null) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $uncommittedFiles = @($porcelain | ForEach-Object { ($_ -replace '^..\s+', '').Trim() } | Where-Object { $_ }) } catch { $uncommittedFiles = @() } # F-174 dogfood fix: the Specrew/Squad/Spec-Kit managed scaffolding (.agents/.claude/.copilot/.cursor/ # .github/.specify/.squad/.specrew + the init config files) sorts first in `git status` and was filling # the MaxFiles cap, capping OUT the user's REAL work (specs/, src/) - so the rolling handover surfaced # the same ~53 scaffolding paths every refresh and never the actual workshop/spec files. Partition # managed vs user and surface USER files FIRST so the real work is never drowned or capped. $managedRegex = '^\.(agents|claude|copilot|cursor|github|specify|squad|specrew)[/\\]|^\.(gitattributes|gitignore|markdownlint\.json)$' $userFiles = @($uncommittedFiles | Where-Object { ($_ -replace '\\', '/') -notmatch $managedRegex }) $managedFiles = @($uncommittedFiles | Where-Object { ($_ -replace '\\', '/') -match $managedRegex }) $prioritized = @(@($userFiles) + @($managedFiles)) $newCommits = @() if (-not [string]::IsNullOrWhiteSpace($SinceCommit)) { try { $log = @(& git -C $ProjectRoot log --oneline ("{0}..HEAD" -f $SinceCommit) 2>$null) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } if ($LASTEXITCODE -eq 0) { $newCommits = @($log) } } catch { $newCommits = @() } } [pscustomobject]@{ branch = $branch head_short = $headShort head_subject = $headSubject uncommitted_count = $uncommittedFiles.Count uncommitted_files = @($prioritized | Select-Object -First $MaxFiles) uncommitted_truncated = ($uncommittedFiles.Count -gt $MaxFiles) has_uncommitted = ($uncommittedFiles.Count -gt 0) user_file_count = $userFiles.Count user_files = @($userFiles | Select-Object -First $MaxFiles) managed_file_count = $managedFiles.Count new_commits = @($newCommits | Select-Object -First $MaxCommits) new_commit_count = $newCommits.Count } } function Get-SpecrewResumeReconciliation { # F-174 iter-10 (T001): the SHARED, CHEAP resume reconciliation. On RESUME, re-compute the CURRENT git # delta (one `git status` via Get-SpecrewSessionDelta) bounded by the handover's from_commit, so the # resuming agent is handed the ACTUAL tree state - NOT a stale last-stop snapshot - and is DIRECTED to # read what changed since and continue from the real state (the snapshot may predate the latest work; a # hard kill / no-PostToolUse host / antigravity all leave the handover behind the disk). Called by BOTH # the SessionStart hook (Invoke-SpecrewSessionBootstrap) AND `specrew start` (T008) so recovery is # host-universal. Lean by contract: ONE delta computation; the agent does the reading (the resume budget # is already spent on the launch contract). Fail-safe: any error yields $null and the resume degrades to # the snapshot, never throws. [OutputType([pscustomobject])] param( [Parameter(Mandatory)][string] $ProjectRoot, [Parameter()][AllowNull()][pscustomobject] $Handover ) $sinceCommit = if ($null -ne $Handover) { [string]$Handover.from_commit } else { $null } $delta = $null try { $delta = Get-SpecrewSessionDelta -ProjectRoot $ProjectRoot -SinceCommit $sinceCommit } catch { $delta = $null } if ($null -eq $delta) { return $null } $lastStop = if ($null -ne $Handover) { [string]$Handover.recorded_at } else { '' } $lastBoundary = if ($null -ne $Handover) { [string]$Handover.active_boundary } else { '' } $changedNow = @($delta.user_files) $newCommits = @($delta.new_commits) $lines = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace($lastStop)) { $bn = if (-not [string]::IsNullOrWhiteSpace($lastBoundary)) { " (boundary $lastBoundary)" } else { '' } $lines.Add(("Last captured stop: {0}{1}." -f $lastStop, $bn)) | Out-Null } if ($changedNow.Count -gt 0) { $more = if (([int]$delta.user_file_count) -gt $changedNow.Count) { ', +more' } else { '' } $lines.Add(("Files changed since (re-computed NOW - may post-date the last stop): {0}{1}." -f ($changedNow -join ', '), $more)) | Out-Null $lines.Add('READ those files to recover the true current state (the handover snapshot may predate your latest work), THEN continue.') | Out-Null } elseif (([int]$delta.managed_file_count) -gt 0) { $lines.Add(("No user files changed since the last commit ({0} Specrew-managed scaffolding uncommitted); continue the next lifecycle step." -f $delta.managed_file_count)) | Out-Null } else { $lines.Add('Working tree is clean since the last commit; continue the next lifecycle step.') | Out-Null } if ($newCommits.Count -gt 0) { $lines.Add(("New commits since the handover: {0}." -f ($newCommits -join ' | '))) | Out-Null } [pscustomobject]@{ last_stop_recorded_at = $lastStop last_boundary = $lastBoundary branch = [string]$delta.branch head_short = [string]$delta.head_short changed_user_files = $changedNow user_file_count = [int]$delta.user_file_count managed_file_count = [int]$delta.managed_file_count new_commits = $newCommits directive_text = ($lines -join ' ') } } |