AI skills in use in my daily
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

244 lines
10 KiB

<#
.SYNOPSIS
Deterministic classifier for the Scratchpad folder. Read-only.
.DESCRIPTION
Emits JSON describing what cleanup *would* touch. Writes nothing, moves
nothing, deletes nothing. apply.ps1 consumes this; the scratchpad-cleanup
skill reads it to make the judgment calls the script deliberately avoids.
Every mechanical decision (age, size, vendored-tree detection, supersession
grouping, trash expiry) lives here so it is reproducible. Anything requiring
taste - "is this findings doc worth promoting?" - is only ever *flagged*.
.EXAMPLE
.\scan.ps1 | ConvertFrom-Json
.\scan.ps1 -Root D:\Repos\Scratchpad -QuarantineAfterDays 45
#>
[CmdletBinding()]
param(
[string] $Root = 'D:\Repos\Scratchpad',
# Files older than this (and otherwise eligible) become quarantine candidates.
# Default 45: Daniel's folder has a dense cluster at ~33 days, so 30 would
# sweep 147 files in one run while 45 takes 14. Re-check with -ReportOnlyAges
# before lowering this.
[int] $QuarantineAfterDays = 45,
# How long a _trash\<date> folder survives before apply.ps1 will purge it.
[int] $TrashGraceDays = 30,
# Files at or above this size are always reported, never auto-actioned.
[int] $LargeFileMB = 5,
# Markdown at or above this size, older than PromoteMinAgeDays, is treated as
# a possible durable finding and held back from quarantine.
[int] $PromoteMinKB = 2,
[int] $PromoteMinAgeDays = 14,
# Print an age-threshold histogram instead of the full scan. Use this before
# changing QuarantineAfterDays.
[switch] $ReportOnlyAges
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $Root)) { throw "Scratchpad root not found: $Root" }
$Root = (Resolve-Path -LiteralPath $Root).Path
$now = Get-Date
$trashRoot = Join-Path $Root '_trash'
# --- name patterns that mean "this file exists to protect something" ----------
# bellwether-prenuke-*.db is literally a pre-destructive-operation snapshot.
# Never let an age rule reach these.
$protectedNamePattern = 'backup|prenuke|restore|snapshot|\.bak$'
# A top-level folder is a vendored dependency tree or a checked-out git repo
# (not scratch output) if it carries any of these markers. Protect it wholesale -
# partial deletion of a module tree or a repo is worse than leaving it.
$vendorMarkers = @('.git', 'node_modules', 'package.json', '*.dll', '*.psd1', '*.csproj', 'requirements.txt')
$purgeExtensions = @('.log', '.err')
function Get-Rel([string] $FullName) { $FullName.Substring($Root.Length).TrimStart('\') }
function Get-Age($file) { [int]((New-TimeSpan -Start $file.LastWriteTime -End $now).TotalDays) }
$allFiles = @(Get-ChildItem -LiteralPath $Root -Recurse -File -Force -ErrorAction SilentlyContinue)
$allDirs = @(Get-ChildItem -LiteralPath $Root -Recurse -Directory -Force -ErrorAction SilentlyContinue)
if ($ReportOnlyAges) {
foreach ($t in 15, 30, 45, 60, 90, 120) {
$hit = @($allFiles | Where-Object { (Get-Age $_) -gt $t })
'{0,4}d -> {1,4} files {2,8} MB' -f $t, $hit.Count, [math]::Round((($hit | Measure-Object Length -Sum).Sum) / 1MB, 2)
}
return
}
# --- identify vendored top-level dirs ----------------------------------------
$vendorDirs = [System.Collections.Generic.HashSet[string]]::new()
foreach ($d in (Get-ChildItem -LiteralPath $Root -Directory -Force -ErrorAction SilentlyContinue)) {
if ($d.Name -eq '_trash') { continue }
foreach ($m in $vendorMarkers) {
if (Get-ChildItem -LiteralPath $d.FullName -Recurse -Force -Filter $m -ErrorAction SilentlyContinue | Select-Object -First 1) {
[void]$vendorDirs.Add($d.Name)
break
}
}
}
function Test-Protected($file) {
$rel = Get-Rel $file.FullName
$segments = $rel -split '\\'
$top = $segments[0]
if ($top -eq '_trash') { return 'trash' }
# Never touch anything inside a git repo's metadata, at any depth.
if ($segments -contains '.git') { return 'git-internals' }
if ($vendorDirs.Contains($top)) { return 'vendored' }
if ($file.Name -match $protectedNamePattern) { return 'protective-name' }
return $null
}
$buckets = @{
protected = [System.Collections.ArrayList]::new()
autoPurgeFiles = [System.Collections.ArrayList]::new()
emptyDirs = [System.Collections.ArrayList]::new()
quarantine = [System.Collections.ArrayList]::new()
promoteCandidates = [System.Collections.ArrayList]::new()
largeFiles = [System.Collections.ArrayList]::new()
supersession = [System.Collections.ArrayList]::new()
trashExpired = [System.Collections.ArrayList]::new()
keep = [System.Collections.ArrayList]::new()
}
function New-Entry($file, $reason) {
[PSCustomObject]@{
rel = Get-Rel $file.FullName
name = $file.Name
ageDays = Get-Age $file
kb = [math]::Round($file.Length / 1KB, 1)
reason = $reason
}
}
foreach ($f in $allFiles) {
$rel = Get-Rel $f.FullName
$top = ($rel -split '\\')[0]
# _trash is handled separately by folder, not per-file.
if ($top -eq '_trash') { continue }
$age = Get-Age $f
if ($f.Length -ge ($LargeFileMB * 1MB)) {
[void]$buckets.largeFiles.Add((New-Entry $f ">= $LargeFileMB MB - report only, never auto-actioned"))
}
$prot = Test-Protected $f
if ($prot) { [void]$buckets.protected.Add((New-Entry $f $prot)); continue }
if ($purgeExtensions -contains $f.Extension.ToLowerInvariant()) {
[void]$buckets.autoPurgeFiles.Add((New-Entry $f "disposable byproduct ($($f.Extension))"))
continue
}
# Durable-looking markdown is held back from quarantine so findings are never
# silently binned by an age rule. The skill decides promote vs drop.
if ($f.Extension -ieq '.md' -and $f.Length -ge ($PromoteMinKB * 1KB) -and $age -gt $PromoteMinAgeDays) {
[void]$buckets.promoteCandidates.Add((New-Entry $f "markdown >= $PromoteMinKB KB, ${age}d old - judgment required"))
continue
}
if ($age -gt $QuarantineAfterDays) {
[void]$buckets.quarantine.Add((New-Entry $f "stale: ${age}d > ${QuarantineAfterDays}d"))
continue
}
[void]$buckets.keep.Add((New-Entry $f 'active'))
}
# --- empty directories --------------------------------------------------------
foreach ($d in $allDirs) {
$rel = Get-Rel $d.FullName
$segments = $rel -split '\\'
if ($segments[0] -eq '_trash') { continue }
if ($segments -contains '.git') { continue }
if ($vendorDirs.Contains($segments[0])) { continue }
if (-not (Get-ChildItem -LiteralPath $d.FullName -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) {
[void]$buckets.emptyDirs.Add([PSCustomObject]@{ rel = $rel; reason = 'empty directory' })
}
}
# --- supersession groups ------------------------------------------------------
# Group numbered iterations with their unnumbered base: parse.js / parse2.js /
# parse3.js, render_v2.py / render_v4.py, smap-check.ps1 / smap-check2.ps1.
# Reported only - "newest wins" is usually right but not always, so a human calls it.
$groups = @{}
foreach ($f in $allFiles) {
$rel = Get-Rel $f.FullName
$top = ($rel -split '\\')[0]
if ($top -eq '_trash' -or $vendorDirs.Contains($top)) { continue }
$stem = $f.BaseName -replace '[ _-]?[vV]?\d+$', ''
if ([string]::IsNullOrWhiteSpace($stem)) { continue }
$key = '{0}|{1}|{2}' -f (Split-Path -Parent $rel), $stem, $f.Extension.ToLowerInvariant()
if (-not $groups.ContainsKey($key)) { $groups[$key] = [System.Collections.ArrayList]::new() }
[void]$groups[$key].Add($f)
}
foreach ($k in $groups.Keys) {
$members = @($groups[$k])
if ($members.Count -lt 2) { continue }
$sorted = @($members | Sort-Object LastWriteTime -Descending)
[void]$buckets.supersession.Add([PSCustomObject]@{
stem = ($k -split '\|')[1]
ext = ($k -split '\|')[2]
newest = (Get-Rel $sorted[0].FullName)
members = @($sorted | ForEach-Object {
[PSCustomObject]@{
rel = Get-Rel $_.FullName
ageDays = Get-Age $_
kb = [math]::Round($_.Length / 1KB, 1)
isNewest = ($_.FullName -eq $sorted[0].FullName)
}
})
})
}
# --- expired trash ------------------------------------------------------------
if (Test-Path -LiteralPath $trashRoot) {
foreach ($d in (Get-ChildItem -LiteralPath $trashRoot -Directory -ErrorAction SilentlyContinue)) {
$stamp = [datetime]::MinValue
if ([datetime]::TryParseExact($d.Name, 'yyyy-MM-dd', $null, [Globalization.DateTimeStyles]::None, [ref]$stamp)) {
$ageDays = [int]((New-TimeSpan -Start $stamp -End $now).TotalDays)
if ($ageDays -gt $TrashGraceDays) {
$n = @(Get-ChildItem -LiteralPath $d.FullName -Recurse -File -ErrorAction SilentlyContinue).Count
[void]$buckets.trashExpired.Add([PSCustomObject]@{ rel = "_trash\$($d.Name)"; ageDays = $ageDays; files = $n })
}
}
}
}
[PSCustomObject]@{
root = $Root
scannedAt = $now.ToString('o')
config = [PSCustomObject]@{
quarantineAfterDays = $QuarantineAfterDays
trashGraceDays = $TrashGraceDays
largeFileMB = $LargeFileMB
vendoredDirs = @($vendorDirs)
}
totals = [PSCustomObject]@{
files = $allFiles.Count
mb = [math]::Round((($allFiles | Measure-Object Length -Sum).Sum) / 1MB, 2)
}
counts = [PSCustomObject]@{
protected = $buckets.protected.Count
autoPurgeFiles = $buckets.autoPurgeFiles.Count
emptyDirs = $buckets.emptyDirs.Count
quarantine = $buckets.quarantine.Count
promoteCandidates = $buckets.promoteCandidates.Count
largeFiles = $buckets.largeFiles.Count
supersession = $buckets.supersession.Count
trashExpired = $buckets.trashExpired.Count
keep = $buckets.keep.Count
}
buckets = [PSCustomObject]$buckets
} | ConvertTo-Json -Depth 8