diff --git a/README.md b/README.md index a08fac0..f7f2785 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Generated by `sync-skills.ps1`; do not edit manually. | [receiving-code-review](receiving-code-review/SKILL.md) | Handle incoming code review feedback with technical rigor instead of reflexive agreement - verify each claim, push back with evidence when a suggestion is wrong or does not apply, ask when feedback is unclear, and apply a YAGNI check before adding suggested professional features. Covers GitHub thread replies and implementation order. Triggers: here is the review feedback, address these comments, the reviewer said, respond to this PR comment, they want me to change. | | [requesting-code-review](requesting-code-review/SKILL.md) | Request an independent review of completed work before merging or declaring it done - builds the reviewer prompt, states what was implemented, the requirements, and the exact git range to review, and demands a read-only critical review with calibrated severity. Triggers: review my changes, can you check this over, is this ready to merge, get a second opinion, code review this, check my work before I ship it. | | [rise-15-5](rise-15-5/SKILL.md) | Co-author and post Daniel's weekly RISE 15-5 status update to the \"RISE Standups\" Loop doc. Reviews the past week's activity, walks through each required section (Accomplishments, Work in Progress, Blockers/Risks, Focus for Next Week, Help Needed) one at a time for feedback/co-authoring, then posts the finished entry to the shared Loop doc. Triggers: /rise-15-5, \"my 15-5\", \"weekly status\", \"post my 15-5\", \"RISE status update\". | +| [scratchpad-cleanup](scratchpad-cleanup/SKILL.md) | Use when tidying, auditing, or freeing space in the Scratchpad working folder at D:\\Repos\\Scratchpad - the weekly sweep or an ad-hoc pass. Also use when reviewing or restoring files quarantined under _trash, or deciding whether a scratch artifact should be promoted into the Obsidian vault. Triggers: /scratchpad-cleanup, clean up scratchpad, tidy scratchpad, scratchpad is a mess, what can I delete in scratchpad, restore from _trash. | | [subagent-driven-development](subagent-driven-development/SKILL.md) | Execute a multi-task implementation plan in the current session by dispatching each task to an implementer sub-agent and then a reviewer sub-agent, with pre-flight plan review, model selection, status handling, and durable progress tracking between tasks. Use when the plan has independent tasks and you want them built and reviewed without burning main-session context. Triggers: work through the plan with subagents, delegate these tasks, build this out task by task. | | [systematic-debugging](systematic-debugging/SKILL.md) | Find the actual root cause before proposing any fix - investigate, analyze patterns across similar cases, form and test a hypothesis, then implement. Use at the first sign of ANY bug, test failure, crash, error message, flaky test, or behavior that does not match expectations, and especially when tempted to try a quick patch. Triggers: this is broken, it is failing, why does this not work, weird error, test is red, it worked yesterday, intermittent failure. | | [teams-export](teams-export/SKILL.md) | Export Microsoft Teams messages (1:1 chats, group chats, and channel posts) into the Obsidian vault at D:\\Repos\\Obsidian\\00 - Chats\\ as markdown. Reads the user-maintained source list at D:\\Repos\\Obsidian\\00 - Chats\\sources.yaml to know what to copy. The invoking prompt or automation decides the time range (e.g. last N messages, today, since last export, explicit date range). Triggers: 'teams export', 'export chat', 'export channel', 'copy teams messages', '/teams-export'. | diff --git a/brainstorming/SKILL.md b/brainstorming/SKILL.md index f8c5c24..ee13560 100644 --- a/brainstorming/SKILL.md +++ b/brainstorming/SKILL.md @@ -14,10 +14,11 @@ HARD GATE: Do NOT write any code, scaffold any project, or take any implementati 2. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria. Prefer multiple choice when possible. 3. **Propose 2-3 approaches** — with trade-offs, lead with your recommendation and reasoning 4. **Present design** — in sections scaled to complexity (a few sentences if straightforward, up to 200-300 words if nuanced). Ask after each section whether it looks right. Cover: architecture, components, data flow, error handling, testing. -5. **Write design doc** — save to `docs/plans/YYYY-MM-DD--design.md` and commit if in a git repo (skip if user prefers otherwise or no repo) -6. **Spec self-review** — check for placeholders (TBD/TODO), internal contradictions, scope creep, ambiguity. Fix inline. -7. **User reviews written spec** — ask user to review the spec file before proceeding -8. **Transition to implementation** — invoke the writing-plans skill to create an implementation plan. Do NOT invoke any other implementation skill directly. +5. **Write design doc** — save to `docs/specs/YYYY-MM-DD--design.md`, creating the folder if it doesn't exist. Deviate only when the user states a preference, the repo documents a policy that conflicts, or the repo already has a convention *for where design documents live* — an observed pattern of existing design docs counts, not just a documented rule — in which case that convention wins. The absence of any of these is never a reason to deviate. +6. **Commit the design doc** if in a git repo (skip if the user prefers otherwise or there's no repo). +7. **Spec self-review** — check for placeholders (TBD/TODO), internal contradictions, scope creep, ambiguity. Fix inline. +8. **User reviews written spec** — ask user to review the spec file before proceeding +9. **Transition to implementation** — invoke the writing-plans skill to create an implementation plan. Do NOT invoke any other implementation skill directly. ## Key Principles - One question at a time — don't overwhelm diff --git a/scratchpad-cleanup/SKILL.md b/scratchpad-cleanup/SKILL.md new file mode 100644 index 0000000..c23b120 --- /dev/null +++ b/scratchpad-cleanup/SKILL.md @@ -0,0 +1,140 @@ +--- +name: "scratchpad-cleanup" +description: "Use when tidying, auditing, or freeing space in the Scratchpad working folder at D:\\Repos\\Scratchpad - the weekly sweep or an ad-hoc pass. Also use when reviewing or restoring files quarantined under _trash, or deciding whether a scratch artifact should be promoted into the Obsidian vault. Triggers: /scratchpad-cleanup, clean up scratchpad, tidy scratchpad, scratchpad is a mess, what can I delete in scratchpad, restore from _trash." +--- + +## Scratchpad Cleanup + +Keeps `D:\Repos\Scratchpad` from silting up, without ever destroying something +that mattered. + +**Core principle: Scratchpad is NOT a git repo.** There is no history and no +undo. Every irreversible action is therefore either provably safe or routed +through a `_trash\\` quarantine with a grace period. The quarantine *is* +the undo. + +### When to Use + +- The weekly automated sweep. +- Ad-hoc: "scratchpad is a mess", "what can I delete", "what's stale in there". +- Reviewing what got quarantined, or restoring something binned by mistake. +- Deciding whether a scratch artifact is actually a durable finding that belongs + in the vault or a repo. + +### Division of labour + +The mechanical decisions live in scripts so they are reproducible and identical +every run. The skill only makes the calls a script *shouldn't*. + +| Script | Does | Writes? | +|---|---|---| +| `scan.ps1` | Classifies every file into buckets, emits JSON | No - read-only | +| `apply.ps1` | Executes the safe actions | Only with `-Execute` | + +```pwsh +# classify (read-only); this is always step 1 +powershell -NoProfile -ExecutionPolicy Bypass -File "\scan.ps1" | ConvertFrom-Json + +# preview actions, then commit them +powershell -NoProfile -ExecutionPolicy Bypass -File "\apply.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "\apply.ps1" -Execute + +# undo a batch +powershell -NoProfile -ExecutionPolicy Bypass -File "\apply.ps1" -Mode Restore -RestoreDate 2026-09-14 -Execute + +# before ever changing the age threshold, look at the cliff +powershell -NoProfile -ExecutionPolicy Bypass -File "\scan.ps1" -ReportOnlyAges +``` + +**Always invoke the scripts with `powershell -File` and an absolute path.** They +are saved UTF-8 *with BOM* deliberately: Windows PowerShell 5.1 reads a BOM-less +file as Windows-1252, which corrupts any non-ASCII byte and silently changes +control flow. Keep both scripts ASCII-only; do not paste em dashes or smart +quotes into them. + +### Buckets, and who owns each + +| Bucket | Owner | Action | +|---|---|---| +| `autoPurgeFiles` | script | delete - `.log` / `.err` | +| `emptyDirs` | script | delete | +| `quarantine` | script | move to `_trash\\` | +| `trashExpired` | script | delete batches past grace | +| `protected` | nobody | never touched | +| `largeFiles` | **you** | report only | +| `supersession` | **you** | report only | +| `promoteCandidates` | **you** | report only | + +### The judgment calls + +**1. Promotion candidates.** Markdown >= 2 KB and older than 14 days is held +*out* of quarantine, because an age rule must never silently bin a findings +doc. For each, decide: does this belong in the Obsidian vault (a durable +finding, design note, or decision record), in a real repo (a script worth +keeping), or is it genuinely spent? Recommend a destination; never move it +yourself without the user agreeing. + +**2. Supersession groups.** Numbered siblings - `parse.js` / `parse2.js` / +`parse3.js`, `render_v2.py` / `render_v4.py`, `smap-check.ps1` / +`smap-check2.ps1`. Newest-wins is the usual answer but not always right; the +older one occasionally holds the approach that actually worked. Present the +group with dates and sizes and let the user choose. + +**3. Large files.** Anything >= 5 MB is reported and never auto-actioned. In +this folder the space story is almost entirely a handful of `.db` snapshots, so +"reclaim space" and "reduce clutter" are different jobs with different targets. +Note that a file whose name contains `backup`, `prenuke`, `restore`, `snapshot` +or `.bak` is protected outright - those exist precisely to save someone. + +### Protection rules (never overridden) + +- Any path containing a `.git` segment - repo internals. +- Any top-level folder carrying `.git`, `node_modules`, `package.json`, `*.dll`, + `*.psd1`, `*.csproj`, or `requirements.txt` - a vendored tree or checked-out + repo, protected wholesale. Partial deletion of a module tree is worse than + leaving it. +- Names matching `backup|prenuke|restore|snapshot|\.bak$`. +- Everything already under `_trash\`. + +### Run report + +Lead with what changed, then what needs a decision: + +``` +Scratchpad cleanup - 2026-09-14 + Reclaimed: 5 log files, 2 empty dirs + Quarantined: 14 files -> _trash\2026-09-14 (restorable until 2026-10-14) + +Needs your call: + Promote? 5 findings docs, 34d idle - e.g. "SMAP-Kusto-Findings.md" + Superseded? parse.js/parse2.js/parse3.js - keep parse3 only? + Large: bellwether-prenuke-*.db (20.8 MB) - protected, but is it still needed? +``` + +Silence is fine. If nothing was reclaimed and nothing needs a decision, say so +in one line rather than padding the report. + +### Guardrails + +- Never act outside `D:\Repos\Scratchpad`. +- Never delete anything that has not been through quarantine first, except + `.log` / `.err` files and empty directories. +- `apply.ps1` is dry-run by default. Do not pass `-Execute` to a mode you have + not previewed in the same run. +- `MaxQuarantinePerRun` (60) is a deliberate circuit breaker. If it trips, do + NOT raise it to get past it - it means the age threshold changed and is about + to sweep the folder. Check `scan.ps1 -ReportOnlyAges` and report to the user + instead. +- Never move a promotion candidate into the vault unprompted; the vault has its + own schema rules (see `D:\Repos\Obsidian\AGENTS.md`) and an unannounced write + there will fail lint. + +### Common Mistakes + +- Lowering the age threshold without checking `-ReportOnlyAges` first. The + distribution is lumpy: a single day's work can put 100+ files in one band, so + a small threshold change can be the difference between 14 files and 147. +- Treating `.db` / `.tm7` / `.excalidraw` as junk because they are large or + binary. They are usually the most expensive artifacts in the folder. +- Deleting an empty directory inside `.git` - it corrupts the repo. The scan + already excludes these; do not re-add them by hand. diff --git a/scratchpad-cleanup/apply.ps1 b/scratchpad-cleanup/apply.ps1 new file mode 100644 index 0000000..5744bb3 --- /dev/null +++ b/scratchpad-cleanup/apply.ps1 @@ -0,0 +1,154 @@ +<# +.SYNOPSIS + Executes the safe half of Scratchpad cleanup. Dry-run unless -Execute. + +.DESCRIPTION + Consumes scan.ps1's classification and performs only the reversible or + provably-safe actions: + + AutoPurge delete .log/.err files and empty directories + Quarantine MOVE stale files to _trash\\ (never delete) + PurgeTrash delete _trash\\ folders past the grace period + Restore move a quarantine batch back where it came from + + It deliberately does NOT act on the protected, promoteCandidates, + supersession, or largeFiles buckets. Those require judgment and belong to + the scratchpad-cleanup skill's report. + + Scratchpad is not a git repo, so quarantine is the only undo that exists. + Every batch writes a manifest.json recording original paths. + +.EXAMPLE + .\apply.ps1 # dry run, all safe modes + .\apply.ps1 -Execute + .\apply.ps1 -Mode Quarantine -Execute + .\apply.ps1 -Mode Restore -RestoreDate 2026-09-14 -Execute +#> +[CmdletBinding()] +param( + [string] $Root = 'D:\Repos\Scratchpad', + + [ValidateSet('All', 'AutoPurge', 'Quarantine', 'PurgeTrash', 'Restore')] + [string] $Mode = 'All', + + # Nothing is written unless this is present. Default is a dry run on purpose. + [switch] $Execute, + + [int] $QuarantineAfterDays = 45, + [int] $TrashGraceDays = 30, + + # Safety valve: refuse to quarantine more than this many files in one run. + # Guards against a threshold change silently sweeping the whole folder. + [int] $MaxQuarantinePerRun = 60, + + [string] $RestoreDate +) + +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path -LiteralPath $Root).Path +$trashRoot = Join-Path $Root '_trash' +$stamp = (Get-Date).ToString('yyyy-MM-dd') +$batchDir = Join-Path $trashRoot $stamp +$scanScript = Join-Path $PSScriptRoot 'scan.ps1' +if (-not (Test-Path -LiteralPath $scanScript)) { throw "scan.ps1 not found next to apply.ps1 ($scanScript)" } + +$actions = [System.Collections.ArrayList]::new() +function Add-Action($kind, $from, $to, $note) { + [void]$actions.Add([PSCustomObject]@{ kind = $kind; from = $from; to = $to; note = $note }) +} + +# --- Restore is standalone; it does not need a scan --------------------------- +if ($Mode -eq 'Restore') { + if (-not $RestoreDate) { throw 'Restore requires -RestoreDate yyyy-MM-dd.' } + $src = Join-Path $trashRoot $RestoreDate + if (-not (Test-Path -LiteralPath $src)) { throw "No quarantine batch at $src" } + $manifestPath = Join-Path $src 'manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath)) { throw "Batch has no manifest.json; refusing to guess original paths." } + + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + foreach ($item in $manifest.items) { + $from = Join-Path $src $item.rel + $to = Join-Path $Root $item.rel + if (-not (Test-Path -LiteralPath $from)) { Add-Action 'restore-skip' $from $to 'missing in batch'; continue } + if (Test-Path -LiteralPath $to) { Add-Action 'restore-skip' $from $to 'destination already exists'; continue } + Add-Action 'restore' $from $to '' + if ($Execute) { + $parent = Split-Path -Parent $to + if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } + Move-Item -LiteralPath $from -Destination $to + } + } + $actions | Format-Table -AutoSize + if (-not $Execute) { Write-Host 'DRY RUN - nothing moved. Re-run with -Execute.' -ForegroundColor Yellow } + return +} + +# --- everything else is scan-driven ------------------------------------------- +$scanRaw = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scanScript -Root $Root -QuarantineAfterDays $QuarantineAfterDays -TrashGraceDays $TrashGraceDays +$scan = ($scanRaw -join "`n") | ConvertFrom-Json + +if ($Mode -in 'All', 'AutoPurge') { + foreach ($e in @($scan.buckets.autoPurgeFiles)) { + $p = Join-Path $Root $e.rel + Add-Action 'delete-file' $p '' $e.reason + if ($Execute -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Force } + } + # Deepest-first so nested empties collapse correctly. + foreach ($e in (@($scan.buckets.emptyDirs) | Sort-Object { ($_.rel -split '\\').Count } -Descending)) { + $p = Join-Path $Root $e.rel + Add-Action 'delete-dir' $p '' $e.reason + if ($Execute -and (Test-Path -LiteralPath $p)) { + # Re-check emptiness at execution time; scan data may be seconds stale. + if (-not (Get-ChildItem -LiteralPath $p -Recurse -Force -ErrorAction SilentlyContinue | Select-Object -First 1)) { + Remove-Item -LiteralPath $p -Force + } + } + } +} + +if ($Mode -in 'All', 'Quarantine') { + $q = @($scan.buckets.quarantine) + if ($q.Count -gt $MaxQuarantinePerRun) { + throw ("Refusing to quarantine {0} files in one run (cap {1}). This usually means QuarantineAfterDays was lowered. Review with scan.ps1 -ReportOnlyAges, then raise -MaxQuarantinePerRun deliberately." -f $q.Count, $MaxQuarantinePerRun) + } + $manifestItems = [System.Collections.ArrayList]::new() + foreach ($e in $q) { + $from = Join-Path $Root $e.rel + $to = Join-Path $batchDir $e.rel + Add-Action 'quarantine' $from $to $e.reason + [void]$manifestItems.Add([PSCustomObject]@{ rel = $e.rel; ageDays = $e.ageDays; kb = $e.kb; reason = $e.reason }) + if ($Execute -and (Test-Path -LiteralPath $from)) { + $parent = Split-Path -Parent $to + if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } + Move-Item -LiteralPath $from -Destination $to -Force + } + } + if ($Execute -and $manifestItems.Count -gt 0) { + if (-not (Test-Path -LiteralPath $batchDir)) { New-Item -ItemType Directory -Path $batchDir -Force | Out-Null } + [PSCustomObject]@{ + batch = $stamp + createdAt = (Get-Date).ToString('o') + root = $Root + purgeEligibleOn = (Get-Date).AddDays($TrashGraceDays).ToString('yyyy-MM-dd') + items = @($manifestItems) + } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $batchDir 'manifest.json') -Encoding UTF8 + } +} + +if ($Mode -in 'All', 'PurgeTrash') { + foreach ($e in @($scan.buckets.trashExpired)) { + $p = Join-Path $Root $e.rel + Add-Action 'purge-trash' $p '' ("{0}d old, grace {1}d, {2} files" -f $e.ageDays, $TrashGraceDays, $e.files) + if ($Execute -and (Test-Path -LiteralPath $p)) { Remove-Item -LiteralPath $p -Recurse -Force } + } +} + +if ($actions.Count -eq 0) { + Write-Host 'Nothing to do.' -ForegroundColor Green +} +else { + $actions | Group-Object kind | Sort-Object Name | ForEach-Object { '{0,-14} {1}' -f $_.Name, $_.Count } + Write-Host '' + $actions | Format-Table -AutoSize kind, from, note +} +if (-not $Execute) { Write-Host 'DRY RUN - nothing changed. Re-run with -Execute.' -ForegroundColor Yellow } diff --git a/scratchpad-cleanup/scan.ps1 b/scratchpad-cleanup/scan.ps1 new file mode 100644 index 0000000..2e2ee60 --- /dev/null +++ b/scratchpad-cleanup/scan.ps1 @@ -0,0 +1,244 @@ +<# +.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\ 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 diff --git a/teams-export/SKILL.md b/teams-export/SKILL.md index 12d2af7..99596dc 100644 --- a/teams-export/SKILL.md +++ b/teams-export/SKILL.md @@ -225,31 +225,22 @@ nominations (and vice versa). 1. Build the set of known identifiers from `sources.yaml`: every `chat_id` and `channel_id` from all three sections — **including entries with `enabled: false`**. A deliberately-retired source must never come back as a - nomination. -2. **Resolve `oneOnOne` entries that have no cached `chat_id`.** Several entries - are identified only by `email`, so they have no ID to match against and will - be falsely nominated as "new" unless handled. For every 1:1 candidate, get - the other participant's email — call `workiq_list_chats` with - `expand: "members"`, or `workiq_get_chat` on the candidate — and treat the - chat as known if that email matches an `email` in `sources.yaml` - (case-insensitive). When you resolve one this way, report its `chat_id` in - the run summary under the existing "newly resolved 1:1 chat_id" item, so the - user can cache it and remove the ambiguity permanently. -3. Read the optional top-level `ignore:` list (entries with `chat_id` or + nomination. Also collect `email` values from `oneOnOne` entries that have no + cached `chat_id`, and treat a candidate as known if it resolves to one of + those people. +2. Read the optional top-level `ignore:` list (entries with `chat_id` or `channel_id`). Treat everything in it as known. A missing or empty `ignore:` is normal — don't error. -4. Call `workiq_list_chats` ordered by most recent activity, paging with +3. Call `workiq_list_chats` ordered by most recent activity, paging with `skipToken` until you reach chats whose last activity predates the run - window. Don't page further back than the window. Filter on - `lastUpdatedDateTime` first — this is what removes the long tail of dormant - chats before you spend any calls counting messages. -5. For each chat not in the known set, count messages inside the run window. + window. Don't page further back than the window. +4. For each chat not in the known set, count messages inside the run window. Nominate only if **≥5 messages** land in the window — this is what keeps the single-reply meeting chats out. Skip any chat whose in-window messages are all system/bot events (joins, leaves, call records, recording notices) with no human authorship. -6. Load the state file (below) and apply the repeat rules. -7. Report survivors in the run summary. +5. Load the state file (below) and apply the repeat rules. +6. Report survivors in the run summary. **State file:** `D:\Repos\Obsidian\00 - Chats\.nominations.json` @@ -301,15 +292,6 @@ Suggest a kebab-case `alias` derived from the topic or participant names, and check it doesn't collide with an existing alias — aliases are filenames and must stay unique. -**Topics are not unique.** Distinct chats routinely share a topic (Daniel's -tenant currently has two separate `Patch Plan V-Team Sync` meeting chats with -different `chat_id`s). Never assume topic identifies a chat. Always key state, -dedup, and known-set matching on `chat_id` / `channel_id`. When two candidates -in the same report share a topic, disambiguate the suggested aliases with a date -or sequence suffix (e.g. `patch-plan-vteam-2026-09`) and say plainly in the -report that these are two different chats, so the user doesn't assume it's a -duplicate and opt in only one. - ### Run summary After every run, print a short summary to the user: