Browse Source

chore: sync Scout skills 2026-09-14

main
Daniel Kucinski 4 weeks ago
parent
commit
887196d8e4
  1. 1
      README.md
  2. 9
      brainstorming/SKILL.md
  3. 140
      scratchpad-cleanup/SKILL.md
  4. 154
      scratchpad-cleanup/apply.ps1
  5. 244
      scratchpad-cleanup/scan.ps1
  6. 36
      teams-export/SKILL.md

1
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. | | [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. | | [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\". | | [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. | | [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. | | [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'. | | [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'. |

9
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. 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 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. 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-<topic>-design.md` and commit if in a git repo (skip if user prefers otherwise or no repo) 5. **Write design doc** — save to `docs/specs/YYYY-MM-DD-<topic>-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. **Spec self-review** — check for placeholders (TBD/TODO), internal contradictions, scope creep, ambiguity. Fix inline. 6. **Commit the design doc** if in a git repo (skip if the user prefers otherwise or there's no repo).
7. **User reviews written spec** — ask user to review the spec file before proceeding 7. **Spec self-review** — check for placeholders (TBD/TODO), internal contradictions, scope creep, ambiguity. Fix inline.
8. **Transition to implementation** — invoke the writing-plans skill to create an implementation plan. Do NOT invoke any other implementation skill directly. 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 ## Key Principles
- One question at a time — don't overwhelm - One question at a time — don't overwhelm

140
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\<date>\` 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 "<skillDir>\scan.ps1" | ConvertFrom-Json
# preview actions, then commit them
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" -Execute
# undo a batch
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\apply.ps1" -Mode Restore -RestoreDate 2026-09-14 -Execute
# before ever changing the age threshold, look at the cliff
powershell -NoProfile -ExecutionPolicy Bypass -File "<skillDir>\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\<date>\` |
| `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.

154
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\<date>\ (never delete)
PurgeTrash delete _trash\<date>\ 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 }

244
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\<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

36
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 1. Build the set of known identifiers from `sources.yaml`: every `chat_id` and
`channel_id` from all three sections — **including entries with `channel_id` from all three sections — **including entries with
`enabled: false`**. A deliberately-retired source must never come back as a `enabled: false`**. A deliberately-retired source must never come back as a
nomination. nomination. Also collect `email` values from `oneOnOne` entries that have no
2. **Resolve `oneOnOne` entries that have no cached `chat_id`.** Several entries cached `chat_id`, and treat a candidate as known if it resolves to one of
are identified only by `email`, so they have no ID to match against and will those people.
be falsely nominated as "new" unless handled. For every 1:1 candidate, get 2. Read the optional top-level `ignore:` list (entries with `chat_id` or
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
`channel_id`). Treat everything in it as known. A missing or empty `ignore:` `channel_id`). Treat everything in it as known. A missing or empty `ignore:`
is normal — don't error. 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 `skipToken` until you reach chats whose last activity predates the run
window. Don't page further back than the window. Filter on window. Don't page further back than the window.
`lastUpdatedDateTime` first — this is what removes the long tail of dormant 4. For each chat not in the known set, count messages inside the run window.
chats before you spend any calls counting messages.
5. 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 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 single-reply meeting chats out. Skip any chat whose in-window messages are
all system/bot events (joins, leaves, call records, recording notices) with all system/bot events (joins, leaves, call records, recording notices) with
no human authorship. no human authorship.
6. Load the state file (below) and apply the repeat rules. 5. Load the state file (below) and apply the repeat rules.
7. Report survivors in the run summary. 6. Report survivors in the run summary.
**State file:** `D:\Repos\Obsidian\00 - Chats\.nominations.json` **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 check it doesn't collide with an existing alias — aliases are filenames and must
stay unique. 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 ### Run summary
After every run, print a short summary to the user: After every run, print a short summary to the user:

Loading…
Cancel
Save