Compare commits

..

1 Commits

Author SHA1 Message Date
Gordon Lam (SH)
a99a86b98b feat(runner): add shared task-runner library for parallel execution and retries 2026-03-02 19:00:16 +08:00
2713 changed files with 60784 additions and 478978 deletions

View File

@@ -1,5 +1,5 @@
# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys
properties:
resources:
- resource: Microsoft.Windows.Settings/WindowsSettings
@@ -13,11 +13,11 @@ properties:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: vsPackage
directives:
description: Install Visual Studio 2026 Enterprise (Any edition will work)
description: Install Visual Studio 2022 Enterprise (Any edition will work)
# Requires elevation for the set operation
securityContext: elevated
settings:
id: Microsoft.VisualStudio.Enterprise
id: Microsoft.VisualStudio.2022.Enterprise
source: winget
- resource: Microsoft.VisualStudio.DSC/VSComponents
dependsOn:
@@ -29,7 +29,7 @@ properties:
securityContext: elevated
settings:
productId: Microsoft.VisualStudio.Product.Enterprise
channelId: VisualStudio.18.Release
channelId: VisualStudio.17.Release
vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig'
configurationVersion: 0.2.0

View File

@@ -1,5 +1,5 @@
# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys
properties:
resources:
- resource: Microsoft.Windows.Settings/WindowsSettings
@@ -13,11 +13,11 @@ properties:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: vsPackage
directives:
description: Install Visual Studio 2026 Professional (Any edition will work)
description: Install Visual Studio 2022 Professional (Any edition will work)
# Requires elevation for the set operation
securityContext: elevated
settings:
id: Microsoft.VisualStudio.Professional
id: Microsoft.VisualStudio.2022.Professional
source: winget
- resource: Microsoft.VisualStudio.DSC/VSComponents
dependsOn:
@@ -29,7 +29,7 @@ properties:
securityContext: elevated
settings:
productId: Microsoft.VisualStudio.Product.Professional
channelId: VisualStudio.18.Release
channelId: VisualStudio.17.Release
vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig'
configurationVersion: 0.2.0

View File

@@ -1,5 +1,5 @@
# yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started
# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys
properties:
resources:
- resource: Microsoft.Windows.Settings/WindowsSettings
@@ -13,11 +13,11 @@ properties:
- resource: Microsoft.WinGet.DSC/WinGetPackage
id: vsPackage
directives:
description: Install Visual Studio 2026 Community (Any edition will work)
description: Install Visual Studio 2022 Community (Any edition will work)
# Requires elevation for the set operation
securityContext: elevated
settings:
id: Microsoft.VisualStudio.Community
id: Microsoft.VisualStudio.2022.Community
source: winget
- resource: Microsoft.VisualStudio.DSC/VSComponents
dependsOn:
@@ -29,7 +29,7 @@ properties:
securityContext: elevated
settings:
productId: Microsoft.VisualStudio.Product.Community
channelId: VisualStudio.18.Release
channelId: VisualStudio.17.Release
vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig'
configurationVersion: 0.2.0

View File

@@ -40,6 +40,7 @@ body:
- Other (please specify in "Steps to Reproduce")
validations:
required: true
- type: dropdown
attributes:
label: Area(s) with issue?
@@ -57,7 +58,6 @@ body:
- Environment Variables
- FancyZones
- FancyZones Editor
- Grab And Move
- File Locksmith
- "File Explorer: Preview Pane"
- "File Explorer: Thumbnail preview"
@@ -70,7 +70,6 @@ body:
- Mouse Without Borders
- New+
- Peek
- Power Display
- PowerRename
- PowerToys Run
- Quick Accent
@@ -107,13 +106,7 @@ body:
placeholder: What happened instead?
validations:
required: false
- type: upload
id: bugreportfile
attributes:
label: Upload Bug Report ZIP-file
description: Right-clicking the PowerToys tray icon in the taskbar and selecting “Report bug” generates a ZIP file containing diagnostic information about your setup and PowerToys logs, helping us better understand and troubleshoot the issue.
validations:
required: false
- id: additionalInfo
type: textarea
attributes:

View File

@@ -1,7 +1,6 @@
# COLORS
argb
Bgr
bgra
BLACKONWHITE
BLUEGRAY
@@ -17,9 +16,8 @@ LIGHTTURQUOISE
NCol
OLIVEGREEN
PALEBLUE
pargb
pbgra
SRGBTo
PArgb
Pbgra
WHITEONBLACK
@@ -30,7 +28,6 @@ RUS
AYUV
bak
HDP
Bcl
bgcode
Deflatealgorithm
@@ -41,7 +38,6 @@ Gbps
gcode
Heatshrink
Mbits
Kbits
MBs
mkv
msix
@@ -51,7 +47,6 @@ resw
resx
srt
Stereolithography
taskmgr
terabyte
UYVY
xbf
@@ -102,7 +97,6 @@ Yubico
Perplexity
Groq
svgl
devhome
# KEYS
@@ -128,7 +122,6 @@ HOLDSPACE
HOLDBACKSPACE
IDIGNORE
KBDLLHOOKSTRUCT
keydowns
keyevent
LAlt
LBUTTON
@@ -187,12 +180,6 @@ xmlutil
# Prefix
pcs
# EXPRTK / C++ MATH
ifunction
isinf
isnan
# User32.SYSTEM_METRICS_INDEX.cs
CLEANBOOT
@@ -308,8 +295,6 @@ pwa
AOT
Aot
ify
TFM
# YML
onefuzz
@@ -317,7 +302,6 @@ onefuzz
# NameInCode
leilzh
mengyuanchen
contoso
# DllName
testhost
@@ -329,7 +313,6 @@ xef
xes
PACKAGEVERSIONNUMBER
APPXMANIFESTVERSION
PROGMAN
# MRU lists
CACHEWRITE
@@ -337,61 +320,8 @@ MRUCMPPROC
MRUINFO
REGSTR
#Xaml
NVI
Storyboards
# Misc Win32 APIs and PInvokes
DEFAULTTONEAREST
INVOKEIDLIST
LCMAP
MEMORYSTATUSEX
ABE
Mdt
HTCAPTION
POSCHANGED
QPC
QUERYPOS
SETAUTOHIDEBAR
ULW
WINDOWPOS
WINEVENTPROC
WORKERW
FULLSCREENAPP
ACLO
CACLI
DOENVSUBST
FILESYSONLY
URLIS
WAITTIMEOUT
DEFAULTTONEAREST
DWRITE
LWIN
VCENTER
VREDRAW
# COM/WinRT interface prefixes and type fragments
BAlt
BShift
Cmanifest
Cmodule
Cuuid
Dng
IApplication
IDisposable
IEnum
IFolder
IInitialize
IMemory
IOle
ipreview
IProperty
IShell
ithumbnail
IVirtual
# Test frameworks
MSTEST
# PowerRename metadata pattern abbreviations (used in tests and regex patterns)
DDDD
@@ -403,12 +333,6 @@ YYY
# Unicode
precomposed
# names of characters
zwsp
# mermaid
autonumber
# GitHub issue/PR commands
azp
feedbackhub
@@ -418,20 +342,3 @@ reportbug
#ffmpeg
crf
nostdin
# Performance counter keys
engtype
Nonpaged
# XAML
Untargeted
# Program names
SEARCHHOST
SHELLEXPERIENCEHOST
SHELLHOST
STARTMENUEXPERIENCEHOST
WIDGETBOARD
# URIs
actioncenter

View File

@@ -178,9 +178,7 @@ Taras
TBM
Teutsch
tilovell
traies
Triet
udit
urnotdfs
vednig
waaverecords
@@ -194,7 +192,6 @@ ycv
yeelam
Yuniardi
yuyoyuppe
zadjii
Zeol
Zhao
Zhaopeng
@@ -209,8 +206,6 @@ Bilibili
BVID
capturevideosample
cmdow
contoso
Contoso
Controlz
cortana
devhints
@@ -226,7 +221,6 @@ Moq
mozilla
mspaint
Newtonsoft
NVIDIA
onenote
openai
Quickime
@@ -234,7 +228,6 @@ regedit
roslyn
Skia
Spotify
taskmgr
tldr
Vanara
wangyi
@@ -250,3 +243,4 @@ xamlstyler
Xavalon
Xbox
Youdao
zadjii

View File

@@ -1,54 +1,9 @@
accelscroll
acq
ADDTO
ADDTOOL
adr
Adr
ALWAYSTIP
APPLYTOSUBMENUS
ARCHMASK
archs
AUDCLNT
autocorr
avx
axisdefer
axisflip
axisstart
backlight
BEOS
bfi
BFIN
bfly
BGRX
bitmaps
bitrev
blits
Borgerding
Borland
breakc
BREAKSCR
BUFFERFLAGS
bugzilla
Cands
capturepath
cbs
centiseconds
cexp
cfx
cfy
cgem
cifx
cify
CLASSW
coeffs
colblocks
constantbuffer
coprime
cpuid
cpx
CREATEDIBSECTION
CREATESTRUCTW
crossfades
Ctl
CTLCOLOR
CTLCOLORBTN
@@ -56,367 +11,53 @@ CTLCOLORDLG
CTLCOLOREDIT
CTLCOLORLISTBOX
CTrim
CVTEPI
DBuffer
dcl
dct
ddx
ddy
Deinterleave
denoise
denoised
DEVSOURCE
DFCS
DIVSCALAR
DJGPP
dlg
dlu
dnn
DONTCARE
downsample
DRAWITEM
DRAWITEMSTRUCT
droppedband
Droppedband
DSPs
dsum
dupburst
dupsegments
DWLP
eband
ebx
ECX
EDITCONTROL
EDSP
emmintrin
EMX
ENABLEHOOK
endloop
ENDOFSTREAM
ener
enh
ettings
expectedlock
expf
fabs
fabsf
facbuf
fastscroll
FDE
ffast
FIXDIV
floorf
fmadd
fout
fstride
fxc
GETCHANNELRECT
GETCHECK
GETCOUNT
GETDISPINFO
GETSCREENSAVEACTIVE
GETSCREENSAVETIMEOUT
GETTHUMBRECT
GIFs
glu
groupshared
gru
hcfdark
hcfwhitespace
hlsl
Hsieh
hstride
HTBOTTOMRIGHT
HTHEME
htol
ICONINFORMATION
ICONWARNING
idct
IDIn
IDISHWND
ifft
igc
ilog
imad
imax
imin
immintrin
Inj
interp
inttypes
ishl
itof
jumprecover
kfft
kheight
kissfft
KSDATAFORMAT
ksize
ktime
lastg
latestcapture
ldx
LEFTNOWORDWRAP
legitjumps
lenmem
letterbox
lld
lldx
llu
llums
logfont
lookback
lpc
lpcnet
LPNMHDR
LPNMTTDISPINFO
lround
lte
luma
Luma
maj
manualdrop
maskcache
maxabs
maxcorr
MAXFACTORS
maxperiod
maxstep
memalign
memid
memneeded
MENUINFO
MFSTARTUP
mfxhw
mic
middledrop
minperiod
MIPSr
MJPEG
MMRESULT
momentumreversal
movc
mrate
mrt
MULBYSCALAR
MULC
MWERKS
mycfg
narrowstrip
nbak
nbytes
ncapture
nchw
ncm
nduplicates
nfft
NHWC
niterations
nmonitor
nnet
NONCLIENTMETRICS
NONOTIFY
nonvle
normf
nredraw
nstop
nsubpixel
ntorn
numthreads
nvw
Octasic
osc
OSCE
ovflw
OWNERDRAW
PBGRA
periodictrap
pfdc
pillarbox
playhead
pnmh
pointerreuse
PPW
prereq
PSHR
pstdint
PSWA
pwfx
QCONST
qpc
Qpc
quantums
qweight
RCSEGMODEL
RCZOOMITSCR
readback
READERF
realcapture
REFKNOWNFOLDERID
relu
reposted
RETURNCMD
rnn
rnnoise
rotateleft
rsqrt
rtcd
RTEXT
RTH
rtvs
SCALEIN
SCALEOUT
SCREENSAVE
SCRNSAVE
SCRNSAVECONFIGURE
scrnsavw
Scrnsavw
scrollramp
SCROLLSIZEGRIP
selfie
selftest
SETBARCOLOR
SETBKCOLOR
SETDEFID
SETRECT
SETSCREENSAVETIMEOUT
SETTIPSIDE
sgem
sgemv
sgv
SHAREMODE
SHAREVIOLATION
shortlist
simde
siv
slowthenfast
smallstart
SNIPOCR
softmax
sqrtf
SROUND
srvs
ssi
startuprecovery
stdint
stf
stopafter
STREAMFLAGS
SUBFROM
subias
submix
sxx
sxy
symbian
synthesising
syy
tallportal
TBTS
tci
tcsicmp
TEXTCALLBACK
TEXTMETRIC
tgsm
THIRDPARTY
tinystep
tme
toolbars
TOOLINFO
TRACKMOUSEEVENT
TRIANGLELIST
TTM
TTN
TWID
UADD
uav
uavs
uge
Unadvise
upscaled
upscales
USUB
utof
vad
vaddq
vaddvq
valgrind
Valin
vandq
vblank
vcgeq
vdup
vectorizer
VERTID
VIDCAP
vld
vle
Vle
VLE
vminq
vmlal
vmull
vqaddq
VSHR
vshrn
vsntprintf
vsnwprintf
vsync
WASAPI
WAVEFORMATEX
WAVEFORMATEXTENSIBLE
webcam
Webcam
webcams
Wextra
wfopen
WGC
wideportal
wil
WMU
wrapjump
wtol
WTSSESSION
WTSUn
wxyz
xchg
xcorr
XEnd
Xfl
Xiang
Xiph
xmmintrin
xptr
xshift
XStart
XStep
xxxy
xxyx
xxyz
xyw
xywx
xyxx
xyxz
xyzw
xyzx
xzwx
xzxx
Yfl
YInternal
yshift
YUV
yyyx
yyzw
yzw
yzwy
yzyy
Zhou
Zhu
ZMBS
zncc
Zncc
ZNCC
zrh
zwzz
zyzw
zzwz
zzzw

View File

@@ -1,3 +1,6 @@
# D2D
#D?2D
# Repeated letters
\b([a-z])\g{-1}{2,}\b
@@ -11,7 +14,7 @@
^.*\b[Cc][Ss][Pp][Ee][Ll]{2}:\s*[Dd][Ii][Ss][Aa][Bb][Ll][Ee]-[Ll][Ii][Nn][Ee]\b
# copyright
Copyright (?:\([Cc]\)|©|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+
Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+
# patch hunk comments
^@@ -\d+(?:,\d+|) \+\d+(?:,\d+|) @@ .*
@@ -19,10 +22,10 @@ Copyright (?:\([Cc]\)|©|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+
index (?:[0-9a-z]{7,40},|)[0-9a-z]{7,40}\.\.[0-9a-z]{7,40}
# file permissions
(?:^|['"`\s])(?!-+\s)[-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\+?['"`\s]
['"`\s][-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\+?['"`\s]
# css fonts
\bfont(?:-family(?:[-\w+]*)|):[^;}]+
\bfont(?:-family|):[^;}]+
# css url wrappings
\burl\([^)]+\)
@@ -87,9 +90,6 @@ arn:aws:[-/:\w]+
# AWS VPC
vpc-\w+
# Azure AD
\baad\.\w{48}\b
# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there
# YouTube url
\b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]*
@@ -171,7 +171,7 @@ themes\.googleusercontent\.com/static/fonts/[^/\s"]+/v\d+/[^.]+.
GHSA(?:-[0-9a-z]{4}){3}
# GitHub actions
\buses:\s+(['"]?)[-\w.]+/[-\w./]+@[-\w.]+\g{-1}
\buses:\s+[-\w.]+/[-\w./]+@[-\w.]+
# GitLab commit
\bgitlab\.[^/\s"]*/\S+/\S+/commit/[0-9a-f]{7,16}#[0-9a-f]{40}\b
@@ -240,7 +240,7 @@ accounts\.binance\.com/[a-z/]*oauth/authorize\?[-0-9a-zA-Z&%]*
\bmedium\.com/@?[^/\s"]+/[-\w]+
# microsoft
\b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%?#]*
\b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%]*
# powerbi
\bapp\.powerbi\.com/reportEmbed/[^"' ]*
# vs devops
@@ -414,7 +414,7 @@ ipfs://[0-9a-zA-Z]{3,}
\bgetopts\s+(?:"[^"]+"|'[^']+')
# ANSI color codes
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[(?:\d+(?:;\d+)*|)m
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m
# URL escaped characters
%[0-9A-F][A-F](?=[A-Za-z])
@@ -431,7 +431,7 @@ sha\d+:[0-9a-f]*?[a-f]{3,}[0-9a-f]*
# sha-... -- uses a fancy capture
(\\?['"]|")[0-9a-f]{40,}\g{-1}
# hex runs
\b(?=(?:[a-fA-F]{0,2}\d)*[a-fA-F]{3})[0-9a-fA-F]{16,}\b
\b[0-9a-fA-F]{16,}\b
# hex in url queries
=[0-9a-fA-F]*?(?:[A-F]{3,}|[a-f]{3,})[0-9a-fA-F]*?&
# ssh
@@ -455,11 +455,7 @@ LS0tLS1CRUdJT.*
# uuid:
\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b
# unicode escaped characters (4)
\\u[0-9a-fA-F]{4}
# hex digits including css/html color classes
# hex digits including css/html color classes:
(?:[\\0][xX]|\\u\{?|[uU]\+|#x?|%23|&H)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b
# integrity
@@ -482,7 +478,7 @@ Name\[[^\]]+\]=.*
(?:(?:\b|_|(?<=[a-z]))I|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b))
# python
#\b(?i)py(?!gment|gmy|lon|ramid|ro|th)(?=[a-z]{2,})
#\b(?i)py(?!gments|gmy|lon|ramid|ro|th)(?=[a-z]{2,})
# crypt
(['"])\$2[ayb]\$.{56}\g{-1}
@@ -502,21 +498,12 @@ Name\[[^\]]+\]=.*
# go.sum
\bh1:\S+
# golang print-f-style functions
#(?i)(?<=append|comma|debug|equal|err|error|exit|fatal|format|info|log|name|panic|print|skip|scan|string|trace|true|warn|warning|wrap|write)(?:f|ln)(?:[ (]|$)
# golang regular expression
(?<!")\br".+?"
# imports
^import\s+(?:(?:static|type)\s+|)(?:[\w.]|\{\s*\w*?(?:,\s*(?:\w*|\*))+\s*\})+(?:\s+from (['"]).*?\g{-1}|)
^import\s+(?:(?:static|type)\s+|)(?:[\w.]|\{\s*\w*?(?:,\s*(?:\w*|\*))+\s*\})+
# scala modules
#("[^"]+"\s*%%?\s*){2,3}"[^"]+"
# Dataframes / NumPy
#\b(?:df|np)\.\w{3,}
# container images
image: [-\w./:@]+
@@ -546,18 +533,12 @@ content: (['"])[-a-zA-Z=;:/0-9+]*=\g{-1}
# Note that there's a high false positive rate, remove the `?=` and search for the regex to see if the matches seem like reasonable strings
(?<!['"])\b(?:B|BR|Br|F|FR|Fr|R|RB|RF|Rb|Rf|U|UR|Ur|b|bR|br|f|fR|fr|r|rB|rF|rb|rf|u|uR|ur)['"](?=[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,})
# Regular expression for word breaks
#\\b(?=[a-z]{2})
# Regular expressions for (P|p)assword
\([A-Z]\|[a-z]\)[a-z]+
# Java regular expressions
Pattern\.(?:compile|matches)\(".*"
# JavaScript regular expressions
# javascript exec/test regex
/.{3,}?/[gim]*\.(?:exec|test)\(
# javascript test regex
/.{3,}/[gim]*\.test\(
# javascript match regex
\.match\(/[^/\s"]{3,}/[gim]*\s*
# javascript match regex
@@ -584,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+
regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\)
# regex choice
#\((?:\?:|)[^)|]+(?<! )\|(?!(?:jq|xargs)\b)[^)| ][^)]*\)
# \(\?:[^)]+\|[^)]+\)
# proto
^\s*(\w+)\s\g{-1} =
@@ -607,9 +588,6 @@ urn:shemas-jetbrains-com
# Debian changelog severity
[-\w]+ \(.*\) (?:\w+|baseline|unstable|experimental); urgency=(?:low|medium|high|emergency|critical)\b
# Red Hat Package management spec file dependencies
^(?:Build|)Requires: [-.\w]+
# kubernetes pod status lists
# https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase
\w+(?:-\w+)+\s+\d+/\d+\s+(?:Running|Pending|Succeeded|Failed|Unknown)\s+
@@ -664,8 +642,6 @@ PrependWithABINamepsace
>[-a-zA-Z=;:/0-9+]{3,}=</
# base64 encoded content, possibly wrapped in mime
#(?:^|[\s=;:?])[-a-zA-Z=;:/0-9+]{50,}(?:[\s=;:?]|$)
# jwt
(?:\be[wy][-a-zA-Z=;:/0-9+]+\.){2}[-_\w]+
# base64 encoded json
\beyJ[-a-zA-Z=;:/0-9+]+
# base64 encoded pkcs
@@ -703,9 +679,9 @@ systemd.*?running in system mode \([-+].*\)$
# Non-English
# Even repositories expecting pure English content can unintentionally have Non-English content... People will occasionally mistakenly enter [homoglyphs](https://en.wikipedia.org/wiki/Homoglyph) which are essentially typos, and using this pattern will mean check-spelling will not complain about them.
# .
#
# If the content to be checked should be written in English and the only Non-English items will be people's names, then you can consider adding this.
# .
#
# Alternatively, if you're using check-spelling v0.0.25+, and you would like to _check_ the Non-English content for spelling errors, you can. For information on how to do so, see:
# https://docs.check-spelling.dev/Feature:-Configurable-word-characters.html#unicode
[a-zA-Z]*[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]*|[a-zA-Z]{3,}[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]|[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3,}
@@ -717,7 +693,7 @@ systemd.*?running in system mode \([-+].*\)$
# This corpus only had capital letters, but you probably want lowercase ones as well.
\b[LN]'+[a-z]{2,}\b
# LaTeX
# latex (check-spelling >= 0.0.22)
\\\w{2,}\{
# American Mathematical Society (AMS) / Doxygen
@@ -744,6 +720,7 @@ nolint:\s*[\w,]+
# cygwin paths
/cygdrive/[a-zA-Z]/(?:Program Files(?: \(.*?\)| ?)(?:/[-+.~\\/()\w ]+)*|[-+.~\\/()\w])+
# in check-spelling@v0.0.22+, printf markers aren't automatically consumed
# printf markers
#(?<!\\)\\[nrt](?=[a-z]{2,})
# alternate printf markers if you run into latex and friends
@@ -772,12 +749,12 @@ W/"[^"]+"
# Compiler flags (Unix, Java/Scala)
# Use if you have things like `-Pdocker` and want to treat them as `docker`
#(?:^|[\t ,>"'`=\[(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})
#(?:^|[\t ,>"'`=(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})
# Compiler flags (Windows / PowerShell)
# This is a subset of the more general compiler flags pattern.
# It avoids matching `-Path` to prevent it from being treated as `ath`
#(?:^|[\t ,"'`=\[(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}))
#(?:^|[\t ,"'`=(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}))
# Compiler flags (linker)
,-B
@@ -785,7 +762,7 @@ W/"[^"]+"
# Library prefix
# e.g., `lib`+`archive`, `lib`+`raw`, `lib`+`unwind`
# (ignores some words that happen to start with `lib`)
(?:\b|_)[Ll]ib(?!era[lt])(?:re(?=office)|era|)(?!ero|erty|rar(?:i(?:an|es)|y))(?=[a-z])
(?:\b|_)[Ll]ib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z])
# iSCSI iqn (approximate regex)
\biqn\.[0-9]{4}-[0-9]{2}(?:[\.-][a-z][a-z0-9]*)*\b
@@ -796,9 +773,9 @@ W/"[^"]+"
# curl arguments
\b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)*
# set arguments
\b(?:bash|(?<!\.)sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)*
\b(?:bash|sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)*
# tar arguments
\b(?:\\n|)g?tar(?:\.exe|)(?:\s-C \S+|(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
\b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
# tput arguments -- https://man7.org/linux/man-pages/man5/terminfo.5.html -- technically they can be more than 5 chars long...
\btput\s+(?:(?:-[SV]|-T\s*\w+)\s+)*\w{3,5}\b
# macOS temp folders

View File

@@ -110,8 +110,7 @@
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Common\.UnitTests/Text/.*\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$
@@ -135,14 +134,11 @@
^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$
^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/modules/ZoomIt/ZoomIt/rnnoise/
^src/modules/ZoomIt/ZoomIt/selfie_segmentation\.onnx$
^src/modules/ZoomIt/ZoomIt/ZoomIt\.idc$
^src/Monaco/
^tools/project_template/ModuleTemplate/resource\.h$
^tools/Verification scripts/Check preview handler registration\.ps1$
ignore$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$
^src/common/CalculatorEngineCommon/exprtk\.hpp$
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
^src/modules/powerrename/unittests/testdata/avif_test\.avif$
^src/modules/powerrename/unittests/testdata/heif_test\.heic$
^deps/spdlog-msvc-fix/

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,10 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns
Inno Setup
FFmpeg
# https://github.com/MicrosoftEdge/edge-launcher
MIcrosoftEdgeLauncherCsharp
# x64
(?:(?<=[a-df-z])x|(?<=[A-Z]X))64
# reversed irreversible binomials
\b(?:mouse down and up|low and high)\b
# marker to ignore all code on line
^.*/\* #no-spell-check-line \*/.*$
# marker for ignoring a comment to the end of the line
// #no-spell-check.*$
# JavaScript regex literals that start with \b can be reported as "b..." words.
# Example: /\bclass\s+.../
^\s*/\\[b].{3,}?/[gim]*\s*(?:\)(?:;|$)|,$)
# GitHub API header token used in code (not natural language).
\bx-ratelimit-reset\b
# Gaelic
Gàidhlig
@@ -91,14 +71,11 @@ StringComparer.OrdinalIgnoreCase\) \{.*\}
# the last line of mimetype="application/x-microsoft.net.object.bytearray.base64" things in .resx files
^\s*[-a-zA-Z=;:/0-9+]*[-a-zA-Z;:/0-9+][-a-zA-Z=;:/0-9+]*=$
# DateTime Formats
Get-Date -Format \w+|DateTime\.Now(?::|\.ToString\(")\w+
# Automatically suggested patterns
# hit-count: 5402 file-count: 1339
# IServiceProvider / isAThing
(?:(?:\b|_|(?<=[a-z]))[A-Z]|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b))
(?:(?:\b|_|(?<=[a-z]))[IT]|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b))
# hit-count: 2073 file-count: 842
# #includes
@@ -182,10 +159,6 @@ aka\.ms/[a-zA-Z0-9]+
# kubernetes crd patterns
^\s*pattern: .*$
# hit-count: 7 file-count: 3
# unicode escaped characters (4)
\\u[0-9a-fA-F]{4}
# hit-count: 5 file-count: 3
# URL escaped characters
%[0-9A-F][A-F](?=[A-Za-z])
@@ -198,10 +171,6 @@ aka\.ms/[a-zA-Z0-9]+
# medium
\bmedium\.com/@?[^/\s"]+/[-\w:/*.]+
# hit-count: 2 file-count: 2
# tar arguments
\b(?:\\n|)g?tar(?:\.exe|)(?:\s-C \S+|(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
# hit-count: 2 file-count: 1
# While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there
# YouTube url
@@ -215,9 +184,22 @@ aka\.ms/[a-zA-Z0-9]+
# curl arguments
\b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)*
# hit-count: 1 file-count: 1
# tar arguments
\b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+
# #pragma lib
^\s*#pragma comment\(lib, ".*?"\)
# UnitTests
\[DataRow\(.*\)\]
# AdditionalDependencies
<AdditionalDependencies>.*<
# the last line of mimetype="application/x-microsoft.net.object.bytearray.base64" things in .resx files
^\s*[-a-zA-Z=;:/0-9+]*[-a-zA-Z;:/0-9+][-a-zA-Z=;:/0-9+]*=$
RegExp\(@?([`'"]).*?\g{-1}\)|(?:escapes|regEx):\s*(?:/.*/|([`'"]).*?\g{-1})|return/.*?/
# Questionably acceptable forms of `in to`
@@ -237,15 +219,13 @@ RegExp\(@?([`'"]).*?\g{-1}\)|(?:escapes|regEx):\s*(?:/.*/|([`'"]).*?\g{-1})|retu
# mount
\bmount\s+-t\s+(\w+)\s+\g{-1}\b
# C types and repeated CSS values
\s(auto|await|buffalo|center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?:\s\g{-1})+\s
\s(auto|buffalo|center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?:\s\g{-1})+\s
# C enum and struct
\b(?:enum|struct)\s+(\w+)\s+\g{-1}\b
# go templates
\s(\w+)\s+\g{-1}\s+\`(?:graphql|inject|json|yaml):
# doxygen / javadoc / .net
(?:[\\@](?:brief|defgroup|groupname|link|t?param|return|retval)|(?:public|private|\[Parameter(?:\(.+\)|)\])(?:\s+(?:static|override|readonly|required|virtual))*)(?:\s+\{\w+\}|)\s+(\w+)\s+\g{-1}\s
# C# getter/setter
\s(\w+)\s+\g{-1}\s*\{\s*[gs]et;
# macOS file path
(?:Contents\W+|(?!iOS)/)MacOS\b
@@ -309,12 +289,3 @@ St&yle
# Microsoft Store URLs and product IDs
ms-windows-store://\S+
# ANSI color codes
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m
# Special licenses text from RNNoise (BSD-style disclaimer: ``AS IS'')
``AS IS''
# Old school moniker for macOS from RNNoise
MacOS

View File

@@ -1,30 +1,23 @@
attache
aroynt.*
bellows?
^attache$
^bellows?$
benefitting
occurences?
.*dnt
dependan.*
developement
developp?e
Devers?
devex.*
devide
Devinn?[ae]
devisals?
devisors?
diables?
hasta?
hastat.*
immediatly
inisle
inital
linge
oer
^dependan.*
^develope$
^developement$
^developpe
^Devers?$
^devex
^devide
^Devinn?[ae]
^devisal
^devisor
^diables?$
^oer$
Sorce
[Ss]pae.*
Teh
untill
untilling
venders?
wether.*
^[Ss]pae.*
^Teh$
^untill$
^untilling$
^venders?$
^wether.*

View File

@@ -13,7 +13,7 @@ You are an **IMPLEMENTATION AGENT** specialized in executing implementation plan
## Identity & Expertise
- Expert at translating plans into working code
- Deep knowledge of PowerToys codebase patterns and conventions
- Deep knowledge of the repository's codebase patterns and conventions
- Skilled at writing tests, handling edge cases, and validating builds
- You follow plans precisely while handling ambiguity gracefully
@@ -39,11 +39,13 @@ If the plan doesn't exist, invoke PlanIssue agent first via `runSubagent`.
## Strategy
> **Skills & prompts root**: Look for prompts and skills in `.github/` (GitHub Copilot) or `.claude/` (Claude). Check which exists in the current repo and use that path throughout.
**Core Loop** — For every unit of work:
1. **Edit**: Make focused changes to implement one logical piece
2. **Build**: Run `tools\build\build.cmd` and check for exit code 0
3. **Verify**: Use `problems` tool for lint/compile errors; run relevant tests
4. **Commit**: Only after build passes — use `.github/prompts/create-commit-title.prompt.md`
4. **Commit**: Only after build passes — use `{prompts_root}/create-commit-title.prompt.md`
Never skip steps. Never commit broken code. Never proceed if build fails.
@@ -67,7 +69,7 @@ Never skip steps. Never commit broken code. Never proceed if build fails.
**DO**:
- Follow the plan exactly
- Validate build before every commit — **NEVER commit broken code**
- Use `.github/prompts/create-commit-title.prompt.md` for commit messages
- Use `{prompts_root}/create-commit-title.prompt.md` for commit messages
- Add comprehensive tests for changed behavior
- Use worktrees for large changes (3+ files or cross-module)
- Document deviations from plan

95
.github/agents/FixPR.agent.md vendored Normal file
View File

@@ -0,0 +1,95 @@
---
description: 'Fix active PR review comments and resolve GitHub review threads'
name: 'FixPR'
tools: ['execute', 'read', 'edit', 'search', 'github/*', 'github.vscode-pull-request-github/*', 'todo']
argument-hint: 'PR number(s) to fix (e.g., 45286 or 45286,45287)'
handoffs:
- label: Re-review After Fixes
agent: ReviewPR
prompt: 'Re-review PR #{{pr_number}} after fixes were applied'
infer: true
---
# FixPR Agent
You are a **PR FIX AGENT** that reads review threads on a pull request, applies the requested changes, and resolves the threads.
## Identity & Expertise
- Expert at interpreting review feedback and implementing targeted fixes
- Skilled at resolving GitHub review threads via GraphQL API
- Understands the two-tool-chain architecture: CLI scripts for code fixes + VS Code MCP for thread resolution
- You fix review comments precisely without scope creep
## Goal
Given a **pr_number**, bring all actionable review threads to resolution:
1. Every actionable review comment has its requested change implemented
2. Every resolved comment thread is marked resolved via GitHub's GraphQL API
3. The PR is ready for re-review
## Capabilities
> **Skills root**: Skills live at `.github/skills/` (GitHub Copilot) or `.claude/skills/` (Claude). Check which exists in the current repo and use that path throughout.
### Issue Review Context
When a PR is linked to an issue, check for prior analysis before applying fixes:
- `Generated Files/issueReview/<issue_number>/overview.md` — feasibility scores, risk assessment
- `Generated Files/issueReview/<issue_number>/implementation-plan.md` — planned approach
Use the PR description or `github/*` to find the linked issue number. If issue review outputs exist, use the implementation plan to understand the intended design — this helps you apply fixes that stay aligned with the original plan rather than diverging.
### MCP & Tools
- **GitHub MCP** (`github/*`) — fetch PR data, review threads, file contents, post comments
- **VS Code PR Extension** (`github.vscode-pull-request-github/*`) — **resolve review threads** via GraphQL. This is the only way to mark threads resolved.
- **Edit** — apply code changes to source files
- **Search** — find context, patterns, and related code in the codebase
- **Execute** — run fix scripts, poll progress
### Thread Resolution Architecture
There are **two separate tool chains** for PR operations:
| Tool Chain | What It Does | MCP Prefix |
|-----------|-------------|------------|
| GitHub CLI | Fetch PR data, diffs, comments, apply fixes | `github/*` |
| VS Code PR Extension | Resolve threads, request reviewers | `github.vscode-pull-request-github/*` |
Thread resolution **only** works through the VS Code PR Extension (`resolveReviewThread`) or directly via `gh api graphql` with the `resolveReviewThread` mutation.
### Skill Reference
Read `{skills_root}/pr-fix/SKILL.md` for full documentation. The fix prompt template is at `{skills_root}/pr-fix/references/fix-pr-comments.prompt.md`.
## Self-Review
After applying fixes:
1. **Verify each change** — re-read modified files to confirm the fix matches the review request
2. **Check for collateral damage** — did fixing one comment break adjacent logic?
3. **Count resolved vs total** — are there threads you skipped? If so, document why.
4. **Build validation** — if feasible, run a build to catch compile errors from your changes
## Continuous Improvement
When fixes are incomplete or incorrect:
- **Update the fix prompt** in `{skills_root}/pr-fix/references/` if the LLM consistently misinterprets a pattern
- **Record common misunderstandings** — if review comments use ambiguous phrasing that leads to wrong fixes, note patterns in the skill docs
- **Update SKILL.md** if script behavior or parameters changed
## Boundaries
- Never mark a thread resolved without implementing the requested change
- Never create new review comments — you fix, you don't review
- No drive-by refactors outside review scope
- If a review comment is ambiguous or requests an architectural change you're unsure about, **leave it unresolved** and report it
- Hand off to `ReviewPR` for re-review after fixes are complete
## Parameter
- **pr_number**: Extract from `#123`, `PR 123`, or plain number. If missing, **ASK** the user.

99
.github/agents/IssueToPR.agent.md vendored Normal file
View File

@@ -0,0 +1,99 @@
---
description: 'End-to-end orchestrator: issue analysis → fix → PR creation → review → fix loop. Coordinates ReviewIssue, ReviewTheReview, FixIssue, ReviewPR, FixPR, and TriagePR agents.'
name: 'IssueToPR'
tools: ['execute', 'read', 'edit', 'search', 'web', 'agent', 'github/*', 'github.vscode-pull-request-github/*', 'todo']
argument-hint: 'Issue or PR numbers (e.g., issues 44044,32950 or PRs 45365,45366)'
infer: true
---
# IssueToPR Orchestrator Agent
You are the **ORCHESTRATION BRAIN** that coordinates the full issue-to-PR lifecycle by invoking specialized agents for each phase.
## Identity & Expertise
- Master orchestrator for the AI contributor pipeline
- Coordinates ReviewIssue → ReviewTheReview → FixIssue → ReviewPR → FixPR cycle
- Monitors signal files and manages quality gates between phases
- Performs VS Code MCP operations directly (resolve threads, request reviewers)
## Goal
Given **issue_numbers** or **pr_numbers**, drive the full lifecycle to completion:
- Issues → analyzed, quality-gated, fixed, PR created, reviewed, review comments addressed
- PRs → reviewed, review comments fixed, threads resolved
Every phase produces signal files. Track them to know when to proceed.
## Capabilities
> **Skills root**: Skills live at `.github/skills/` (GitHub Copilot) or `.claude/skills/` (Claude). Check which exists in the current repo and use that path throughout.
### Agents
| Agent | Purpose | Signal Location |
|-------|---------|----------------|
| `ReviewIssue` | Analyze issue, produce overview + implementation plan | `Generated Files/issueReview/<N>/.signal` |
| `ReviewTheReview` | Validate review quality (score ≥ 90 gate) | `Generated Files/issueReviewReview/<N>/.signal` |
| `FixIssue` | Create worktree, apply fix, build, create PR | `Generated Files/issueFix/<N>/.signal` |
| `ReviewPR` | 13-step comprehensive PR review | `Generated Files/prReview/<N>/.signal` |
| `FixPR` | Fix review comments, resolve threads | `Generated Files/prFix/<N>/.signal` |
| `TriagePR` | Categorize and prioritize PRs | On demand |
Invoke agents via `runSubagent` with a clear task description. Each agent is self-contained.
### MCP & Tools
- **Agent** (`agent`) — invoke sub-agents via `runSubagent`
- **GitHub MCP** (`github/*`) — fetch issue/PR data, create PRs, post comments
- **VS Code PR Extension** (`github.vscode-pull-request-github/*`) — resolve review threads, request reviewers (GraphQL)
- **Execute** — run scripts directly for batch operations
- **Search / Web** — research context as needed
- **Edit** — direct file modifications when needed
- **Todo** — track multi-phase progress
### Quality Gates
| Gate | Criteria | Action on Failure |
|------|----------|-------------------|
| Review quality | `qualityScore ≥ 90` in ReviewTheReview signal | Re-run ReviewIssue with feedback (max 3 iterations) |
| Real implementation | No placeholder/stub code | Reject and re-fix |
| Build passes | `tools/build/build.cmd` exit code 0 | Fix build errors before PR |
| PR description | Based on actual diff, Conventional Commits title | Regenerate |
### Skill Reference
Read `{skills_root}/issue-to-pr-cycle/SKILL.md` for full orchestration documentation. Also see `{skills_root}/parallel-job-orchestrator/SKILL.md` for the execution engine.
## Self-Review
After each phase completes:
1. **Check signal files** — verify status is `success`, investigate `failure` signals
2. **Validate quality gates** — especially the review-review score before proceeding to fix
3. **Track agent performance** — which agents produced good output vs needed retries?
4. **End-to-end check** — after the full cycle, verify the PR is actually reviewable (has description, builds, no stubs)
## Continuous Improvement
When the pipeline produces poor results:
- **Identify the weakest agent** — which phase consistently fails or needs retries?
- **Update that agent's skill** — refine prompts, add examples, adjust parameters
- **Tune quality thresholds** — if `qualityScore ≥ 90` is too strict/lenient, adjust
- **Record failure patterns** — if specific issue shapes (multi-file, cross-module) cause problems, document them in the relevant skill's SKILL.md
- **Update this orchestrator** if workflow dependencies change
## Boundaries
- Don't skip quality gates — they exist for a reason
- Don't report completion before all phases finish
- Don't spawn separate terminals — use parallel scripts
- For VS Code MCP operations (resolve threads, request reviewers), do them directly — these can't be delegated to CLI sub-agents
- If an issue is ambiguous after ReviewIssue + ReviewTheReview, **stop and ask** rather than producing a bad fix
## Parameter
- **issue_numbers** or **pr_numbers**: Extract from user message. If missing, **ASK** the user which issues or PRs to process.

View File

@@ -22,7 +22,7 @@ You are a **PLANNING AGENT** specialized in analyzing GitHub issues and producin
## Identity & Expertise
- Expert at issue triage, priority scoring, and technical analysis
- Deep knowledge of PowerToys architecture and codebase patterns
- Deep knowledge of the repository's architecture and codebase patterns
- Skilled at breaking down problems into actionable implementation steps
- You research thoroughly before planning, gathering 80% confidence before drafting
@@ -36,7 +36,9 @@ Above is the core interaction with the end user. If you cannot produce the files
## Core Directive
**Follow the template in `.github/prompts/review-issue.prompt.md` exactly.** Read it first, then apply every section as specified.
> **Skills & prompts root**: Look for prompts and skills in `.github/` (GitHub Copilot) or `.claude/` (Claude). Check which exists in the current repo and use that path throughout.
**Follow the template in `{prompts_root}/review-issue.prompt.md` exactly.** (Where `{prompts_root}` is `.github/prompts/` or `.claude/prompts/` — whichever exists.) Read it first, then apply every section as specified.
- Fetch issue details: reactions, comments, linked PRs, images, logs
- Search related code and similar past fixes
@@ -56,7 +58,7 @@ Plans describe what the USER or FixIssue agent will execute later.
## References
- [Review Issue Prompt](../.github/prompts/review-issue.prompt.md) — Template for plan structure
- `{prompts_root}/review-issue.prompt.md` — Template for plan structure
- [Architecture Overview](../../doc/devdocs/core/architecture.md) — System design context
- [AGENTS.md](../../AGENTS.md) — Full contributor guide

79
.github/agents/ReviewIssue.agent.md vendored Normal file
View File

@@ -0,0 +1,79 @@
---
description: 'Analyzes GitHub issues for feasibility, scoring, and implementation planning'
name: 'ReviewIssue'
tools: ['execute', 'read', 'edit', 'search', 'web', 'github/*', 'agent', 'github-artifacts/*', 'todo']
argument-hint: 'GitHub issue number (e.g., #12345)'
handoffs:
- label: Validate Review Quality
agent: ReviewTheReview
prompt: 'Validate the review quality for issue #{{issue_number}}'
- label: Start Implementation
agent: FixIssue
prompt: 'Fix issue #{{issue_number}} using the implementation plan'
infer: true
---
# ReviewIssue Agent
You are a **PLANNING AGENT** that analyzes GitHub issues and produces feasibility assessments and implementation plans for the current repository.
## Identity & Expertise
- Expert at issue triage, priority scoring, and technical analysis
- Deep knowledge of the repository's architecture and codebase patterns
- Skilled at breaking down problems into actionable implementation steps
- Researches thoroughly before planning, gathering 80% confidence before drafting
## Goal
For the given **issue_number**, produce:
- `Generated Files/issueReview/{{issue_number}}/overview.md` — Feasibility/clarity scores and risk assessment
- `Generated Files/issueReview/{{issue_number}}/implementation-plan.md` — Actionable implementation plan
You are a PLANNING agent. You never write implementation code or edit source files.
## Capabilities
> **Skills root**: Skills live at `.github/skills/` (GitHub Copilot) or `.claude/skills/` (Claude). Check which exists in the current repo and use that path throughout.
### MCP & Tools
- **GitHub MCP** (`github/*`) — fetch issue details, reactions, comments, linked PRs, images, logs
- **GitHub Artifacts** (`github-artifacts/*`) — download attached diagnostic ZIPs and logs
- **Web** — research external references, related bugs, API docs
- **Search** — find related code, similar past fixes, subject matter experts via git history
- **Agent** — hand off to `ReviewTheReview` (quality gate) or `FixIssue` (implementation)
### Skill Reference
Read `{skills_root}/issue-review/SKILL.md` for full parameters, output format, and signal file schema. The AI prompt template is at `{skills_root}/issue-review/references/review-issue.prompt.md`.
## Self-Review
After producing outputs, validate your own work:
1. **Read back** `overview.md` and `implementation-plan.md` — do scores have evidence? Are file paths real?
2. **Spot-check** that referenced files exist in the codebase (`search` tool)
3. **Compare** your plan against similar past fixes to catch missed patterns
4. **If gaps found**, re-run the skill with corrections or update the prompt template in `references/` so future runs are better
If the `ReviewTheReview` agent later finds quality < 90, accept its feedback file and re-run with `-FeedbackFile` and `-Force`.
## Continuous Improvement
When you notice recurring problems in review quality:
- Update `{skills_root}/issue-review/references/review-issue.prompt.md` to address the gap
- Update `{skills_root}/issue-review/SKILL.md` if parameters or behavior changed
- Record concrete failure examples so the same mistake isn't repeated
## Boundaries
- Never write implementation code — plans describe what `FixIssue` will execute later
- Never edit source files outside `Generated Files/issueReview/`
- Ask for clarification when the issue is ambiguous after research
## Parameter
- **issue_number**: Extract from `#123`, `issue 123`, or plain number. If missing, **ASK** the user.

105
.github/agents/ReviewPR.agent.md vendored Normal file
View File

@@ -0,0 +1,105 @@
---
description: 'Comprehensive pull request review with 13-step analysis covering functionality, security, performance, accessibility, and more'
name: 'ReviewPR'
tools: ['execute', 'read', 'edit', 'search', 'web', 'github/*', 'todo']
argument-hint: 'PR number(s) to review (e.g., 45234 or 45234,45235)'
handoffs:
- label: Fix Review Comments
agent: FixPR
prompt: 'Fix review comments on PR #{{pr_number}}'
infer: true
---
# ReviewPR Agent
You are a **PR REVIEW AGENT** that performs comprehensive, multi-dimensional code review for the current repository.
## Identity & Expertise
- Expert at multi-dimensional code review (functionality, security, performance, accessibility, i18n, SOLID, and more)
- Deep knowledge of the repository's coding conventions and architecture
- Produces structured, actionable findings across 13 analysis dimensions
- You review only — you never modify source code
## Goal
For each given **pr_number**, produce a complete review:
- `Generated Files/prReview/{{pr_number}}/00-OVERVIEW.md` — Summary of all findings
- `Generated Files/prReview/{{pr_number}}/01-functionality.md` through `13-copilot-guidance.md` — Per-dimension analysis
- `Generated Files/prReview/{{pr_number}}/.signal` — Completion signal
You are a REVIEW agent. You never edit source code in the repository.
## Capabilities
> **Skills root**: Skills live at `.github/skills/` (GitHub Copilot) or `.claude/skills/` (Claude). Check which exists in the current repo and use that path throughout.
### Issue Review Context
When a PR is linked to an issue, check for prior analysis before reviewing:
- `Generated Files/issueReview/<issue_number>/overview.md` — feasibility scores, risk assessment
- `Generated Files/issueReview/<issue_number>/implementation-plan.md` — planned approach
- `Generated Files/issueReviewReview/<issue_number>/reviewTheReview.md` — quality gate feedback
Use the PR description or `github/*` to find the linked issue number. If issue review outputs exist, use them as baseline context — verify the PR actually implements what was planned, and flag deviations.
### MCP & Tools
- **GitHub MCP** (`github/*`) — fetch PR data, diffs, file contents, review threads
- **Web** — research external references (WCAG criteria, OWASP rules, CWE IDs)
- **Search** — find related patterns, conventions, and prior art in the codebase
- **Execute** — run review scripts, poll orchestrator logs
### 13 Review Dimensions
The review prompt files at `{skills_root}/pr-review/references/` define each dimension. The script loads them on-demand:
| # | Dimension | Focus |
|---|-----------|-------|
| 01 | Functionality | Correctness, edge cases |
| 02 | Compatibility | Breaking changes, versioning |
| 03 | Performance | Perf implications, async |
| 04 | Accessibility | WCAG 2.1 |
| 05 | Security | OWASP, CWE, SDL |
| 06 | Localization | L10n readiness |
| 07 | Globalization | BiDi, ICU, date/time |
| 08 | Extensibility | Plugin API, SemVer |
| 09 | SOLID Design | Design principles |
| 10 | Repo Patterns | Repository conventions |
| 11 | Docs & Automation | Documentation |
| 12 | Code Comments | Comment quality |
| 13 | Copilot Guidance | Agent/prompt files |
### Skill Reference
Read `{skills_root}/pr-review/SKILL.md` for full documentation. The main workflow prompt is at `{skills_root}/pr-review/references/review-pr.prompt.md`.
## Self-Review
After a review run completes:
1. **Verify outputs exist** — check that `00-OVERVIEW.md` and the expected step files were produced for each PR
2. **Spot-check 2-3 step files** — are findings specific with file/line references, or vague and generic?
3. **Check signal files** — look for `failure` status and investigate root causes (CLI crash, timeout, model refusal)
4. **Validate severity calibration** — are high-severity findings truly high-impact, or noise?
## Continuous Improvement
When review quality is inconsistent:
- **Refine the step prompt** in `{skills_root}/pr-review/references/NN-*.prompt.md` that produced weak output
- **Update SKILL.md** if script parameters or behavior changed
- **Record failure patterns** — if a specific dimension consistently produces vague findings, add concrete examples to its prompt
- **Tune MinSeverity** — if too many low-value comments are posted, raise the threshold
## Boundaries
- Never edit source code — hand off to `FixPR` for that
- Never approve or merge PRs without human confirmation
- Never spawn separate terminals — use the parallel orchestrator
## Parameter
- **pr_number**: Extract from `#123`, `PR 123`, or plain number. If missing, **ASK** the user.

84
.github/agents/ReviewTheReview.agent.md vendored Normal file
View File

@@ -0,0 +1,84 @@
---
description: 'Meta-review of issue-review outputs to validate scoring accuracy and implementation plan quality'
name: 'ReviewTheReview'
tools: ['execute', 'read', 'edit', 'search', 'github/*', 'todo']
argument-hint: 'GitHub issue number whose review to validate (e.g., #12345)'
handoffs:
- label: Re-run Issue Review with Feedback
agent: ReviewIssue
prompt: 'Re-review issue #{{issue_number}} using feedback from Generated Files/issueReviewReview/{{issue_number}}/reviewTheReview.md'
- label: Proceed to Fix
agent: FixIssue
prompt: 'Fix issue #{{issue_number}} — review passed quality gate'
infer: true
---
# ReviewTheReview Agent
You are a **QUALITY GATE AGENT** that validates the accuracy and completeness of issue reviews produced by the `ReviewIssue` agent.
## Identity & Expertise
- Expert at cross-checking analysis quality against evidence
- Identifies gaps in implementation plans, wrong file paths, unsupported scores
- Produces actionable corrective feedback that feeds back into `ReviewIssue`
- You are the gate between planning and implementation — nothing proceeds without your approval
## Goal
For the given **issue_number**, validate the existing review and produce:
- `Generated Files/issueReviewReview/{{issue_number}}/reviewTheReview.md` — Quality score (0-100) and corrective feedback
- `Generated Files/issueReviewReview/{{issue_number}}/.signal` — Signal with `qualityScore` and `needsReReview`
Quality ≥ 90 → proceed to `FixIssue`. Quality < 90 → hand back to `ReviewIssue` with feedback.
## Capabilities
> **Skills root**: Skills live at `.github/skills/` (GitHub Copilot) or `.claude/skills/` (Claude). Check which exists in the current repo and use that path throughout.
### MCP & Tools
- **GitHub MCP** (`github/*`) — fetch original issue data to cross-check review claims
- **Search** — verify file paths and code patterns referenced in the implementation plan
- **Execute** — run the meta-review scripts
### Skill Reference
Read `{skills_root}/issue-review-review/SKILL.md` for parameters and signal schema. The AI prompt is at `{skills_root}/issue-review-review/references/review-the-review.prompt.md`.
## Quality Dimensions
| Dimension | What It Checks | Weight |
|-----------|---------------|--------|
| Score Accuracy | Do scores match the evidence cited? | 30% |
| Implementation Correctness | Are the right files/patterns identified? | 25% |
| Risk Assessment | Are risks properly identified and mitigated? | 15% |
| Completeness | All aspects covered (perf, security, a11y, i18n)? | 15% |
| Actionability | Can an AI agent execute the plan as written? | 15% |
## Self-Review
After producing the meta-review:
1. **Verify your own feedback is specific** — vague feedback like "needs improvement" is useless; cite exact lines and missing evidence
2. **Check that file paths you reference actually exist** — don't flag a "wrong path" unless you searched the codebase
3. **Confirm the quality score is consistent** with the dimension breakdown
## Continuous Improvement
When you notice patterns in review failures:
- Update `{skills_root}/issue-review-review/references/review-the-review.prompt.md` to catch the pattern earlier
- Update the `ReviewIssue` prompt template if the root cause is upstream
- Log recurring issues so the feedback loop converges faster
## Boundaries
- Never modify the original review files — produce feedback only
- Never write implementation code
- Maximum 3 feedback iterations per issue before escalating to human review
## Parameter
- **issue_number**: Extract from `#123`, `issue 123`, or plain number. If missing, **ASK** the user.

100
.github/agents/TriagePR.agent.md vendored Normal file
View File

@@ -0,0 +1,100 @@
---
description: 'Triage, categorize, and prioritize open pull requests with AI-powered analysis and reporting'
name: 'TriagePR'
tools: ['execute', 'read', 'edit', 'search', 'web', 'github/*', 'todo']
argument-hint: 'PR numbers to triage (e.g., 45234,45235,45236)'
handoffs:
- label: Review Specific PR
agent: ReviewPR
prompt: 'Review PR #{{pr_number}} in detail'
- label: Fix PR Comments
agent: FixPR
prompt: 'Fix review comments on PR #{{pr_number}}'
infer: true
---
# TriagePR Agent
You are a **PR TRIAGE AGENT** that categorizes, prioritizes, and produces actionable reports for open pull requests in the current repository.
## Identity & Expertise
- Expert at PR lifecycle management and backlog analysis
- Skilled at identifying stale, abandoned, blocked, and ready-to-merge PRs
- Uses AI enrichment for multi-dimensional PR scoring
- Produces structured triage reports with recommended actions per category
## Goal
For the given **pr_numbers**, run the triage pipeline and produce a final triage report (`summary.md`) with:
- Category breakdown (ready-to-merge, needs-work, stale, abandoned, blocked)
- Per-PR action recommendations
- Quick-wins table for low-effort merges
Intermediate artifacts: `all-prs.json`, per-PR review outputs, `ai-enrichment.json`, `categorized-prs.json`.
## Capabilities
> **Skills root**: Skills live at `.github/skills/` (GitHub Copilot) or `.claude/skills/` (Claude). Check which exists in the current repo and use that path throughout.
### Issue Review Context
When triaging PRs linked to issues, check for prior analysis:
- `Generated Files/issueReview/<issue_number>/overview.md` — feasibility scores, risk assessment
- `Generated Files/issueReview/<issue_number>/implementation-plan.md` — planned approach
Use the PR description or `github/*` to find linked issue numbers. If issue review outputs exist, factor them into triage scoring — a PR with a high-quality implementation plan backing it is more likely ready-to-merge.
### MCP & Tools
- **GitHub MCP** (`github/*`) — fetch PR metadata, labels, review state, check runs
- **Web** — research external context for stale PRs or dependency questions
- **Search** — find related PRs, issues, and codebase patterns
- **Execute** — run triage scripts, poll orchestrator logs
### 5-Step Pipeline
| Step | Output File | Can Skip? |
|------|-------------|-----------|
| 1. Collect | `all-prs.json` | No |
| 2. Review | `prReview/<N>/` | Yes (`-SkipReview`) |
| 3. AI Enrich | `ai-enrichment.json` | Yes (`-SkipAiEnrichment`) |
| 4. Categorize | `categorized-prs.json` | No |
| 5. Report | `summary.md` | No |
Each step checks for existing output and skips if present. Use `-Force` to redo.
### Skill Reference
Read `{skills_root}/pr-triage/SKILL.md` for full documentation. Step-specific references are at `{skills_root}/pr-triage/references/`.
## Self-Review
After triage completes:
1. **Verify all 5 steps finished** — don't report success if only steps 1-2 completed (the pipeline has 5 steps)
2. **Spot-check AI enrichment** — open `ai-enrichment.json`, verify scores are calibrated (not all max or all zero)
3. **Validate categorization** — do the category assignments make sense for known PRs?
4. **Read `summary.md`** — is the report actionable with clear next-steps per PR?
## Continuous Improvement
When triage quality is inconsistent:
- **Tune enrichment prompts** in `{skills_root}/pr-triage/references/` if scoring dimensions produce noisy results
- **Update categorization rules** in `Invoke-PrCategorization.ps1` if PRs are misclassified
- **Update SKILL.md** if script parameters, steps, or outputs changed
- **Record failure patterns** — if AI enrichment fails for specific PR shapes (huge diffs, draft PRs), add guards
## Boundaries
- Never modify source code in PRs — hand off to `ReviewPR` or `FixPR`
- Never close or merge PRs without human confirmation
- For large batches (20+ PRs), launch as a detached process to avoid terminal idle kill
- Don't report completion after Step 2 — wait for all 5 steps
## Parameter
- **pr_numbers**: Extract from PR numbers in user message. If missing, **ASK** the user.

View File

@@ -33,4 +33,4 @@ These are auto-applied based on file location:
## Detailed Documentation
- [Architecture](../doc/devdocs/core/architecture.md)
- [Coding Style](../doc/devdocs/development/style.md)
- [Coding Style](../doc/devdocs/development/style.md)

View File

@@ -163,7 +163,7 @@ configuration:
association: Collaborator
then:
- addReply:
reply: We've identified this issue as a duplicate of an existing one and are closing this thread so discussion stays in one place.<br/><br/>Please see the comment above for the link to the original tracking issue, and feel free to subscribe there for updates.
reply: Hi! We've identified this issue as a duplicate of another one that already exists on this Issue Tracker. This specific instance is being closed in favor of tracking the concern over on the referenced thread. Thanks for your report!
- closeIssue
- removeLabel:
label: Needs-Triage
@@ -233,30 +233,6 @@ configuration:
- addReply:
reply: Hi! Thanks for making us aware of the problem. We raised the issue with our internal localization team. This issue should be fixed hopefully in the next version of PowerToys.
description:
- if:
- payloadType: Issue_Comment
- commentContains:
pattern: '\/need-monitor-info'
isRegex: True
- hasLabel:
label: Product-Cursor Wrap
- or:
- activitySenderHasAssociation:
association: Owner
- activitySenderHasAssociation:
association: Member
- activitySenderHasAssociation:
association: Collaborator
then:
- removeLabel:
label: Needs-Triage
- removeLabel:
label: Needs-Team-Response
- addLabel:
label: Needs-Author-Feedback
- addReply:
reply: "To help debug your layout, please run [this script](https://github.com/microsoft/PowerToys/blob/main/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1) and attach the generated JSON output to this thread.\n\nThis allows us to better understand the issue and investigate potential fixes."
description:
- if:
- payloadType: Issue_Comment
- commentContains:
@@ -266,5 +242,16 @@ configuration:
- addReply:
reply: Hi! Your last comment indicates to our system, that you might want to contribute to this feature/fix this bug. Thank you! Please make us aware on our ["Would you like to contribute to PowerToys?" thread](https://github.com/microsoft/PowerToys/issues/28769), as we don't see all the comments. <br /><br />_I'm a bot (beep!) so please excuse any mistakes I may make_
description:
- if:
- payloadType: Issues
- isAction:
action: Opened
- bodyContains:
pattern: 'Area\(s\) with issue\?\s*\nWorkspaces'
isRegex: True
then:
- addLabel:
label: Product-Workspaces
description:
onFailure:
onSuccess:

View File

@@ -0,0 +1,156 @@
param(
[Parameter(Mandatory = $true)]
[string] $CategorizedPrsPath,
[Parameter(Mandatory = $true)]
[string] $ReviewRoot,
[int] $MaxConcurrent = 6,
[int] $IdleMinutes = 5,
[int] $MaxRetries = 2,
[int] $PollSeconds = 20
)
$ErrorActionPreference = "Stop"
function Get-ReviewedPrNumbers {
param([string] $Root)
@(Get-ChildItem $Root -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName "00-OVERVIEW.md") } |
ForEach-Object { [int]$_.Name })
}
function Get-LatestWriteTime {
param([string] $Folder)
if (-not (Test-Path $Folder)) {
return $null
}
$files = Get-ChildItem $Folder -File -ErrorAction SilentlyContinue
if (-not $files) {
return $null
}
($files | Sort-Object LastWriteTime -Descending | Select-Object -First 1).LastWriteTime
}
function Start-PrReviewJob {
param(
[int] $PrNumber,
[string] $WorkingDir
)
Start-Job -ScriptBlock {
param($wd, $n)
Set-Location $wd
& copilot -p "Review PR #$n using the review-pr.prompt.md workflow. Write all output files to 'Generated Files/prReview/$n/'" --yolo -s 2>&1
} -ArgumentList $WorkingDir, $PrNumber
}
if (-not (Test-Path $CategorizedPrsPath)) {
throw "Categorized PRs file not found: $CategorizedPrsPath"
}
if (-not (Test-Path $ReviewRoot)) {
New-Item -Path $ReviewRoot -ItemType Directory -Force | Out-Null
}
$data = Get-Content $CategorizedPrsPath -Raw | ConvertFrom-Json
$allPrs = @($data.Prs | ForEach-Object { [int]$_.Number })
$workingDir = (Get-Location).Path
$running = @{}
$retries = @{}
$failed = New-Object System.Collections.Generic.HashSet[int]
Write-Host "Starting review batch: $($allPrs.Count) PRs" -ForegroundColor Cyan
while ($true) {
$reviewed = Get-ReviewedPrNumbers -Root $ReviewRoot
$remaining = @($allPrs | Where-Object { $_ -notin $reviewed -and -not $failed.Contains($_) })
if ($remaining.Count -eq 0 -and $running.Count -eq 0) {
Write-Host "ALL DONE!" -ForegroundColor Green
break
}
foreach ($entry in @($running.GetEnumerator())) {
$pr = $entry.Key
$job = $entry.Value
$folder = Join-Path $ReviewRoot $pr
$latestWrite = Get-LatestWriteTime -Folder $folder
$idleFor = if ($latestWrite) { (New-TimeSpan -Start $latestWrite -End (Get-Date)).TotalMinutes } else { $null }
$isDone = $job.State -in @("Completed", "Failed", "Stopped")
$hasOverview = Test-Path (Join-Path $folder "00-OVERVIEW.md")
$isIdleTooLong = $idleFor -ne $null -and $idleFor -ge $IdleMinutes
if ($isDone -and -not $hasOverview) {
$retries[$pr] = ($retries[$pr] + 1)
if ($retries[$pr] -le $MaxRetries) {
Write-Host "PR #$pr finished without overview. Retrying ($($retries[$pr])/$MaxRetries)..." -ForegroundColor Yellow
Remove-Job $job -Force -ErrorAction SilentlyContinue
$running.Remove($pr)
} else {
Write-Host "PR #$pr failed after $MaxRetries retries." -ForegroundColor Red
$null = $failed.Add($pr)
New-Item -Path (Join-Path $folder "__error.flag") -ItemType File -Force | Out-Null
Remove-Job $job -Force -ErrorAction SilentlyContinue
$running.Remove($pr)
}
} elseif (-not $hasOverview -and $isIdleTooLong) {
$retries[$pr] = ($retries[$pr] + 1)
if ($retries[$pr] -le $MaxRetries) {
Write-Host "PR #$pr idle for $([int]$idleFor)m. Restarting ($($retries[$pr])/$MaxRetries)..." -ForegroundColor Yellow
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
$running.Remove($pr)
} else {
Write-Host "PR #$pr idle repeatedly; giving up after $MaxRetries retries." -ForegroundColor Red
$null = $failed.Add($pr)
New-Item -Path (Join-Path $folder "__error.flag") -ItemType File -Force | Out-Null
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
$running.Remove($pr)
}
} elseif ($isDone -and $hasOverview) {
Remove-Job $job -Force -ErrorAction SilentlyContinue
$running.Remove($pr)
}
}
$reviewed = Get-ReviewedPrNumbers -Root $ReviewRoot
$remaining = @($allPrs | Where-Object { $_ -notin $reviewed -and -not $failed.Contains($_) })
while ($running.Count -lt $MaxConcurrent -and $remaining.Count -gt 0) {
$next = $remaining | Select-Object -First 1
$remaining = $remaining | Select-Object -Skip 1
if (-not $retries.ContainsKey($next)) {
$retries[$next] = 0
}
if ($retries[$next] -gt $MaxRetries) {
continue
}
$job = Start-PrReviewJob -PrNumber $next -WorkingDir $workingDir
$running[$next] = $job
Write-Host "Started PR #$next (running: $($running.Count))" -ForegroundColor Cyan
}
$reviewedCount = $reviewed.Count
$pendingCount = $remaining.Count
Write-Host "Progress: $reviewedCount/$($allPrs.Count) complete | Running: $($running.Count) | Pending: $pendingCount | Failed: $($failed.Count)" -ForegroundColor Gray
if ($remaining.Count -eq 0 -and $running.Count -eq 0) {
if ($failed.Count -gt 0) {
Write-Host "Completed with failures: $($failed.Count)." -ForegroundColor Yellow
}
break
}
Start-Sleep -Seconds $PollSeconds
}

View File

@@ -1,377 +0,0 @@
#!/usr/bin/env node
/**
* Detects telemetry-event additions/modifications in a pull request and
* posts (or updates) a PR comment when telemetry-related changes are found.
*
* This script is executed by .github/workflows/telemetry-pr-check.yml.
* Keep both files aligned when changing trigger behavior, env usage, or messaging.
*/
const fs = require('node:fs');
const REVIEWER_LOGIN = 'chatasweetie';
const REVIEWER_MENTION = `@${REVIEWER_LOGIN}`;
const COMMENT_MARKER = '<!-- telemetry-event-check -->';
const COMMENT_BODY_WITH_PRIVACY_UPDATE = `${COMMENT_MARKER}
Thank you for contributing to PowerToys. We've detected that this PR might include a new or modified telemetry event. After this PR is merged, please follow these next steps:
- [ ] Reach out to Jessica (${REVIEWER_MENTION}) to follow up on the next steps: https://aka.ms/pt-telemetry-process
`;
const COMMENT_BODY_WITHOUT_PRIVACY_UPDATE = `${COMMENT_MARKER}
Thank you for contributing to PowerToys. We've detected that this PR might include a new or modified telemetry event. Please ensure the following before merging:
- [ ] Add your telemetry events to [DATA_AND_PRIVACY](https://github.com/microsoft/PowerToys/blob/main/DATA_AND_PRIVACY.md).md within this PR.
- [ ] Reach out to Jessica (${REVIEWER_MENTION}) to follow up on the next steps: https://aka.ms/pt-telemetry-process`;
const TELEMETRY_PATH_PATTERNS = [
/(^|\/)trace\.(h|hpp|cpp|cs)$/i,
/(^|\/)telemetry\//i,
/(^|\/)events\/.+event\.cs$/i,
/^src\/common\/Telemetry\//i,
/^src\/common\/ManagedTelemetry\//i,
/^src\/runner\/trace\.(h|cpp)$/i,
/^src\/settings-ui\/.+\/Telemetry\//i,
];
const TELEMETRY_LINE_PATTERNS = [
/TraceLoggingWriteWrapper\s*\(/,
/\bTraceLoggingWrite\s*\(/,
/\bTRACELOGGING_DEFINE_PROVIDER\b/,
/\bTraceLoggingOptionProjectTelemetry\b/,
/\bProjectTelemetryPrivacyDataTag\b/,
/\bPROJECT_KEYWORD_MEASURE\b/,
/\bRegisterProvider\s*\(/,
/\bUnregisterProvider\s*\(/,
/\bPowerToysTelemetry\.Log\.WriteEvent\s*\(/,
/\bclass\s+\w+\s*:\s*EventBase\s*,\s*IEvent\b/,
/\bclass\s+\w+\s*:\s*TelemetryBase\b/,
/\bPartA_PrivTags\b/,
/\[EventData\]/,
/\bEventName\b/,
];
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function validateRepository(repository) {
if (!/^[^/]+\/[^/]+$/.test(repository)) {
throw new Error(
`GITHUB_REPOSITORY must be in owner/repo format, received: ${JSON.stringify(repository)}`
);
}
}
function readEventPayload(eventPath) {
let raw;
try {
raw = fs.readFileSync(eventPath, 'utf8');
} catch (error) {
throw new Error(`Failed to read event payload at ${eventPath}: ${error.message}`);
}
try {
return JSON.parse(raw);
} catch (error) {
throw new Error(`Failed to parse JSON from ${eventPath}: ${error.message}`);
}
}
function resolvePullNumber(event) {
const fromPullRequest = event?.pull_request?.number;
const fromWorkflowDispatch = event?.inputs?.pr_number;
const rawPullNumber = fromPullRequest ?? fromWorkflowDispatch;
if (rawPullNumber === undefined || rawPullNumber === null || rawPullNumber === '') {
throw new Error(
'Unable to determine pull request number from event payload. Expected pull_request.number or inputs.pr_number.'
);
}
const pullNumber = Number.parseInt(String(rawPullNumber), 10);
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
throw new Error(`Invalid pull request number: ${JSON.stringify(rawPullNumber)}`);
}
return pullNumber;
}
function isTelemetryPath(filePath) {
return TELEMETRY_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
}
function changedLinesFromPatch(patch) {
if (!patch) {
return [];
}
return patch
.split('\n')
.filter((line) => {
if (line.startsWith('+++') || line.startsWith('---')) {
return false;
}
return line.startsWith('+') || line.startsWith('-');
})
.map((line) => line.slice(1));
}
function hasTelemetryLineSignal(lines) {
return lines.some((line) => TELEMETRY_LINE_PATTERNS.some((pattern) => pattern.test(line)));
}
async function apiRequest(url, method = 'GET', body) {
const token = requireEnv('GITHUB_TOKEN');
let response;
try {
response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (error) {
throw new Error(`Network error during ${method} ${url}: ${error.message}`);
}
if (!response.ok) {
const text = await response.text();
const rateLimitReset = response.headers.get('x-ratelimit-reset');
const rateLimitHint =
response.status === 403 && rateLimitReset
? ` (rate limit reset at epoch ${rateLimitReset})`
: '';
throw new Error(`${method} ${url} failed (${response.status})${rateLimitHint}: ${text}`);
}
if (response.status === 204) {
return null;
}
try {
return await response.json();
} catch (error) {
throw new Error(`Failed to parse JSON response for ${method} ${url}: ${error.message}`);
}
}
async function getAllPullFiles(apiBaseUrl, repository, pullNumber) {
const files = [];
let page = 1;
while (true) {
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}/files?per_page=100&page=${page}`;
const batch = await apiRequest(url);
if (!Array.isArray(batch)) {
throw new Error(`Unexpected response while listing PR files on page ${page}.`);
}
if (batch.length === 0) {
break;
}
files.push(...batch);
if (batch.length < 100) {
break;
}
page += 1;
}
return files;
}
async function getPullRequest(apiBaseUrl, repository, pullNumber) {
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}`;
const pullRequest = await apiRequest(url);
if (!pullRequest || typeof pullRequest !== 'object') {
throw new Error('Unexpected response while fetching pull request details.');
}
return pullRequest;
}
async function ensureReviewerRequested(apiBaseUrl, repository, pullNumber, pullRequest) {
const authorLogin = String(pullRequest?.user?.login || '').toLowerCase();
const targetReviewer = REVIEWER_LOGIN.toLowerCase();
if (authorLogin === targetReviewer) {
console.log(`Skipping reviewer request: ${REVIEWER_LOGIN} is the PR author.`);
return;
}
const requestedReviewers = Array.isArray(pullRequest?.requested_reviewers)
? pullRequest.requested_reviewers
: [];
const alreadyRequested = requestedReviewers.some(
(reviewer) => String(reviewer?.login || '').toLowerCase() === targetReviewer
);
if (alreadyRequested) {
console.log(`Reviewer ${REVIEWER_LOGIN} is already requested.`);
return;
}
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}/requested_reviewers`;
try {
await apiRequest(url, 'POST', { reviewers: [REVIEWER_LOGIN] });
console.log(`Requested reviewer ${REVIEWER_LOGIN}.`);
} catch (error) {
// Reviewer request should not fail the telemetry guidance workflow.
console.warn(
`Unable to request reviewer ${REVIEWER_LOGIN}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
async function findExistingTelemetryComment(apiBaseUrl, repository, pullNumber) {
let page = 1;
while (true) {
const commentsUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments?per_page=100&page=${page}`;
const comments = await apiRequest(commentsUrl);
if (!Array.isArray(comments)) {
throw new Error(`Unexpected response while listing issue comments on page ${page}.`);
}
const existing = comments.find(
(comment) => typeof comment.body === 'string' && comment.body.includes(COMMENT_MARKER)
);
if (existing) {
return existing;
}
if (comments.length < 100) {
return null;
}
page += 1;
}
}
function detectTelemetryChanges(files) {
const matches = [];
for (const file of files) {
const filename = file.filename || '';
const telemetryPath = isTelemetryPath(filename);
const changedLines = changedLinesFromPatch(file.patch);
const telemetryLineSignal = hasTelemetryLineSignal(changedLines);
// Some large diffs omit patch content. If the file path is telemetry-centric,
// treat it as a telemetry modification to avoid false negatives.
const patchUnavailable = !file.patch && telemetryPath;
if (telemetryPath || telemetryLineSignal || patchUnavailable) {
matches.push({
filename,
telemetryPath,
telemetryLineSignal,
patchUnavailable,
});
}
}
return matches;
}
function hasDataAndPrivacyChange(files) {
return files.some((file) => {
const filename = (file.filename || '').toLowerCase();
return filename === 'data_and_privacy.md';
});
}
async function upsertPrComment(apiBaseUrl, repository, pullNumber, body) {
const existing = await findExistingTelemetryComment(apiBaseUrl, repository, pullNumber);
if (existing) {
const updateUrl = `${apiBaseUrl}/repos/${repository}/issues/comments/${existing.id}`;
await apiRequest(updateUrl, 'PATCH', { body });
console.log(`Updated existing telemetry comment (id: ${existing.id}).`);
return;
}
const createUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments`;
await apiRequest(createUrl, 'POST', { body });
console.log('Created telemetry comment on PR.');
}
async function main() {
const eventPath = requireEnv('GITHUB_EVENT_PATH');
const repository = requireEnv('GITHUB_REPOSITORY');
const apiBaseUrl = process.env.GITHUB_API_URL || 'https://api.github.com';
validateRepository(repository);
let parsedApiBaseUrl;
try {
parsedApiBaseUrl = new URL(apiBaseUrl);
} catch {
throw new Error(`Invalid GITHUB_API_URL: ${JSON.stringify(apiBaseUrl)}`);
}
const event = readEventPayload(eventPath);
const pullNumber = resolvePullNumber(event);
console.log(`Event name: ${process.env.GITHUB_EVENT_NAME || 'unknown'}`);
console.log(`Repository: ${repository}`);
console.log(`PR number: ${pullNumber}`);
const files = await getAllPullFiles(parsedApiBaseUrl.origin, repository, pullNumber);
if (files.length === 0) {
console.log('No changed files found for PR; skipping telemetry comment update.');
return;
}
const matches = detectTelemetryChanges(files);
const dataAndPrivacyChanged = hasDataAndPrivacyChange(files);
console.log(`Scanned ${files.length} changed files.`);
console.log(`Telemetry matches found: ${matches.length}.`);
console.log(`DATA_AND_PRIVACY.md changed: ${dataAndPrivacyChanged}.`);
if (matches.length === 0) {
console.log('No telemetry-related additions/modifications detected.');
return;
}
for (const match of matches) {
console.log(
`- ${match.filename} (telemetryPath=${match.telemetryPath}, telemetryLineSignal=${match.telemetryLineSignal}, patchUnavailable=${match.patchUnavailable})`
);
}
try {
const pullRequest = await getPullRequest(parsedApiBaseUrl.origin, repository, pullNumber);
await ensureReviewerRequested(parsedApiBaseUrl.origin, repository, pullNumber, pullRequest);
} catch (error) {
console.warn(
'Failed to fetch PR details or request reviewer; continuing to post telemetry guidance comment.'
);
console.warn(error instanceof Error ? error.stack || error.message : error);
}
const commentBody = dataAndPrivacyChanged
? COMMENT_BODY_WITH_PRIVACY_UPDATE
: COMMENT_BODY_WITHOUT_PRIVACY_UPDATE;
await upsertPrComment(apiBaseUrl, repository, pullNumber, commentBody);
}
main().catch((error) => {
console.error('Telemetry PR check failed.');
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});

View File

@@ -0,0 +1,346 @@
---
name: continuous-issue-triage
description: Automated issue triage assistant for periodic (daily/weekly) issue queue management. Use when asked to triage issues, review issue backlog, find trending issues, identify stale issues needing response, categorize unlabeled issues, find issues ready for fix, draft reply messages, check for issues needing clarification, find closeable issues after PR merge, or run periodic issue health checks. Supports both open and closed issues with activity tracking between runs.
license: Complete terms in LICENSE.txt
---
# Continuous Issue Triage Skill
Automated periodic triage of GitHub issues to keep the issue queue healthy. Designed to run daily, twice-weekly, or weekly, tracking activity between runs and categorizing issues by actionable priority.
## Output Directory
All artifacts are placed under `Generated Files/triage-issues/` at the repository root (gitignored).
```
Generated Files/triage-issues/
├── triage-state.json # Persistent state between runs
├── current-run/
│ ├── summary.md # Executive summary for this run
│ ├── trending.md # Trending issues report
│ ├── needs-label.md # Issues missing area labels
│ ├── ready-for-fix.md # Issues confident for fix
│ ├── needs-info.md # Issues needing author feedback
│ ├── needs-clarification.md # Clarification requests (not bugs)
│ ├── closeable.md # Issues ready to close
│ └── draft-replies/ # Pre-drafted reply messages
│ └── issue-XXXXX.md
├── history/
│ └── YYYY-MM-DD/ # Historical run archives
└── issue-cache/ # Cached issue reviews (reuse review-issue)
└── XXXXX/
├── overview.md
└── implementation-plan.md
```
## When to Use This Skill
- Run periodic triage (daily, twice-weekly, weekly)
- Find trending issues with high activity
- Identify unlabeled issues needing categorization
- Find issues ready for implementation
- Draft replies for issues needing clarification
- Identify closeable issues after PR merge/release
- Track follow-up actions between triage sessions
- Review closed issues with new comments
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- MCP Server: github-mcp-server (optional, for images/attachments)
- Access to `.github/prompts/review-issue.prompt.md` for deep analysis
## Workflow Overview
```
┌─────────────────────────────────┐
│ 1. Load Previous State │
│ (triage-state.json) │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 2. Collect Active Issues │
│ - Recently updated open │
│ - Closed with new comments │
│ - Previously flagged │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 3. Categorize Issues │
│ (Apply category rules) │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 4. Deep Analysis (selective) │
│ (Use review-issue prompt) │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 5. Generate Reports & Drafts │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 6. Save State for Next Run │
└─────────────────────────────────┘
```
## Issue Categories
Issues are categorized into actionable buckets with prioritization scores:
| Category | Emoji | Criteria | Human Action |
|----------|-------|----------|--------------|
| **Trending** | 🔥 | 5+ new comments since last run | Review conversation, respond |
| **Needs-Label** | 🏷️ | Missing `Product-*` or `Area-*` label | Apply suggested label |
| **Ready-for-Fix** | ✅ | High clarity, feasible, validated | Assign or implement |
| **Needs-Info** | ❓ | Missing repro, impact, or expected result | Post drafted questions |
| **Needs-Clarification** | 💬 | Question/discussion, not a bug | Post explanation reply |
| **Closeable** | ✔️ | Fixed by PR, released, or resolved | Close with message |
| **Stale-Waiting** | ⏳ | Waiting on author >14 days | Ping or close |
| **Duplicate-Candidate** | 🔁 | Similar to existing issue | Link and close |
## Detailed Workflow Docs
Read steps progressively—only load what you need:
- [Step 1: State Management](./references/step1-state-management.md)
- [Step 2: Issue Collection](./references/step2-collection.md)
- [Step 3: Categorization Rules](./references/step3-categorization.md)
- [Step 4: Deep Analysis](./references/step4-deep-analysis.md)
- [Step 5: Report Generation](./references/step5-reports.md)
- [Step 6: Reply Templates](./references/step6-reply-templates.md)
## Available Scripts
| Script | Purpose |
|--------|---------|
| [run-triage.ps1](./scripts/run-triage.ps1) | **Main orchestrator** - runs full triage with parallel Copilot CLI |
| [collect-active-issues.ps1](./scripts/collect-active-issues.ps1) | Fetch issues updated since last run (standalone) |
| [categorize-issues.ps1](./scripts/categorize-issues.ps1) | Apply categorization rules (standalone) |
| [generate-summary.ps1](./scripts/generate-summary.ps1) | Create executive summary (standalone) |
## Quick Start
1. **First Run**: Creates initial state, analyzes recent activity
2. **Subsequent Runs**: Compares against previous state, highlights changes (delta)
### Running the Triage
**PowerShell 7 Required** - Uses parallel processing for efficiency.
```powershell
# Basic run (weekly, 5 parallel, 5min timeout, 3 retries)
.\.github\skills\continuous-issue-triage\scripts\run-triage.ps1
# Daily run with more parallelism
.\.github\skills\continuous-issue-triage\scripts\run-triage.ps1 -RunType daily -MaxParallel 10
# With specific model
.\.github\skills\continuous-issue-triage\scripts\run-triage.ps1 -Model "claude-sonnet-4"
# Force re-analyze all (ignore cache)
.\.github\skills\continuous-issue-triage\scripts\run-triage.ps1 -Force
# With MCP config
.\.github\skills\continuous-issue-triage\scripts\run-triage.ps1 -McpConfig ".\.github\mcp.json"
```
### Parameters
| Parameter | Default | Description |
|-----------|---------|-------------|
| `-RunType` | weekly | daily, twice-weekly, weekly |
| `-MaxParallel` | 5 | Concurrent Copilot CLI invocations |
| `-TimeoutMinutes` | 5 | Timeout per issue analysis |
| `-MaxRetries` | 3 | Retries on timeout/failure |
| `-Model` | (default) | Copilot model to use |
| `-McpConfig` | (none) | Path to MCP config file |
| `-LookbackDays` | 7 | Days to look back on first run |
| `-Force` | false | Re-analyze all, ignore cache |
### Example Invocation (via Copilot Chat)
```
"Run issue triage" or "Triage issues for this week"
```
The skill will:
1. Check for existing `triage-state.json`
2. Collect issues updated since last run (or last 7 days for first run)
3. **Run parallel Copilot CLI analysis** with timeout/retry handling
4. Categorize and prioritize (using cached results where valid)
5. Generate actionable reports with draft replies
6. Save state for next run (delta tracking)
## Parallel Execution Model
The skill uses PowerShell 7's `ForEach-Object -Parallel` to analyze issues concurrently:
```
┌─────────────────────────────────────────────────────────────┐
│ run-triage.ps1 │
├─────────────────────────────────────────────────────────────┤
│ Issue #123 ──┐ │
│ Issue #124 ──┼── ForEach-Object -Parallel ─┬── Result #123 │
│ Issue #125 ──┤ (ThrottleLimit: 5) ├── Result #124 │
│ Issue #126 ──┤ ├── Result #125 │
│ Issue #127 ──┘ └── Result #126 │
│ ... │
│ Each issue: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ copilot -p "Analyze #N..." --yolo │ │
│ │ ├── Timeout: 5 minutes │ │
│ │ ├── Retry: up to 3 times │ │
│ │ └── Output: JSON analysis result │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Timeout & Retry Handling
- Each Copilot CLI invocation has a **5 minute timeout** (configurable)
- On timeout: job is killed, waits 10 seconds, retries
- **3 retries maximum** before marking as failed
- Failed analyses are logged and reported separately
## Delta Tracking
The skill tracks state between runs to report **what changed**:
```json
{
"lastRun": "2026-02-05T10:30:00Z",
"issueSnapshots": {
"12345": {
"lastSeenAt": "2026-02-05T...",
"category": "trending",
"priorityScore": 82
}
},
"analysisResults": {
"12345": {
"success": true,
"analyzedAt": "2026-02-05T...",
"data": { ... }
}
}
}
```
**Delta Report Shows**:
- Issues with **new activity** since last run
- **Newly analyzed** vs **cached** results
- Category **changes** (e.g., was needs-info, now ready-for-fix)
- **Analysis failures** that need retry
## Output Format
### Executive Summary (`summary.md`)
```markdown
# Issue Triage Summary - 2026-02-05
**Run Type**: Weekly | **Issues Analyzed**: 47 | **Since**: 2026-01-29
## Action Required by Category
| Category | Count | Top Priority |
|----------|-------|--------------|
| 🔥 Trending | 3 | #12345 (12 new comments) |
| 🏷️ Needs-Label | 5 | #12346 (suggest: FancyZones) |
| ✅ Ready-for-Fix | 2 | #12347 (score: 85/100) |
| ❓ Needs-Info | 8 | #12348 (missing repro) |
| 💬 Needs-Clarification | 4 | #12349 (question about feature) |
| ✔️ Closeable | 6 | #12350 (fixed in v0.99) |
## Quick Actions
- [ ] Review #12345 - trending with negative sentiment
- [ ] Label #12346 as Product-FancyZones
- [ ] Assign #12347 to @contributor
- [ ] Post clarification on #12348 (draft ready)
- [ ] Close #12350 with release note link
```
## State Schema
See [State Management](./references/step1-state-management.md) for full schema.
```json
{
"version": "1.0",
"lastRun": "2026-02-05T10:30:00Z",
"lastRunType": "weekly",
"issueSnapshots": {
"12345": {
"number": 12345,
"title": "FancyZones: Window snapping issue",
"state": "open",
"lastSeenAt": "2026-02-05T...",
"category": "trending",
"priorityScore": 82
}
},
"analysisResults": {
"12345": {
"success": true,
"analyzedAt": "2026-02-05T10:30:00Z",
"data": {
"issueNumber": 12345,
"category": "trending",
"categoryReason": "8 new comments, heated discussion",
"priorityScore": 82,
"suggestedAction": "Review conversation urgently",
"draftReply": "...",
"clarityScore": 75,
"feasibilityScore": 80
}
}
},
"statistics": {
"totalRunCount": 12,
"issuesAnalyzed": 234
}
}
```
## Cache Invalidation Rules
Analysis results are **cached** and reused when:
- Issue has **no new activity** since last analysis
- Analysis is **less than 7 days old**
- `-Force` flag is **not** specified
Re-analysis triggers:
- New comments on the issue
- Issue state changed
- Cache older than 7 days
- Explicit `-Force` flag
## Integration with review-issue Prompt
For issues in **Ready-for-Fix** or complex **Needs-Info** categories, this skill automatically invokes the [review-issue prompt](../../prompts/review-issue.prompt.md) to generate:
- Detailed `overview.md` with scoring
- `implementation-plan.md` for ready issues
Results are cached in `issue-cache/XXXXX/` and reused across runs.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| No `triage-state.json` | First run—will create initial state |
| PowerShell version error | Requires PowerShell 7+ for `-Parallel` |
| Copilot CLI not found | Install: `gh extension install github/gh-copilot` |
| Too many timeouts | Increase `-TimeoutMinutes` or reduce `-MaxParallel` |
| High failure rate | Check `issue-cache/*/error.log` for details |
| Stale cache | Use `-Force` to re-analyze all issues |
| gh rate limit | Wait or reduce `-MaxParallel` |
| Empty analysis results | Check Copilot CLI auth: `gh auth status` |
## Conventions
- **Preserve history**: Archive each run to `history/YYYY-MM-DD/`
- **Draft replies**: Always human-review before posting
- **Label suggestions**: Confidence threshold 70% for auto-suggest
- **Closed issues**: Track for 30 days after close for late comments

View File

@@ -0,0 +1,223 @@
# Step 1: State Management
The triage skill maintains persistent state between runs to track issue activity and pending actions.
## State File Location
```
Generated Files/triage-issues/triage-state.json
```
## Initial State Creation
On first run (no existing state file), create initial state:
```powershell
# Check if state exists
$statePath = "Generated Files/triage-issues/triage-state.json"
if (-not (Test-Path $statePath)) {
# First run - create initial state
$initialState = @{
version = "1.0"
lastRun = $null
lastRunType = $null
issueSnapshots = @{}
pendingFollowUps = @()
closedWithActivity = @()
configuration = @{
trendingThreshold = 5
staleWaitingDays = 14
closedTrackingDays = 30
labelConfidenceThreshold = 70
}
}
New-Item -ItemType Directory -Force -Path (Split-Path $statePath)
$initialState | ConvertTo-Json -Depth 10 | Set-Content $statePath
}
```
## Full State Schema
```json
{
"version": "1.0",
"lastRun": "2026-02-05T10:30:00Z",
"lastRunType": "weekly",
"issueSnapshots": {
"12345": {
"number": 12345,
"title": "FancyZones: Window snapping not working",
"state": "open",
"labels": ["Product-FancyZones", "Issue-Bug"],
"commentCount": 15,
"lastCommentAt": "2026-02-04T15:30:00Z",
"lastCommentAuthor": "user123",
"reactions": {
"thumbsUp": 10,
"thumbsDown": 0,
"heart": 2
},
"category": "trending",
"categoryReason": "12 new comments since last run",
"priorityScore": 75,
"pendingAction": "review",
"actionTaken": false,
"actionTakenAt": null,
"draftReplyPath": null,
"linkedPRs": [],
"firstSeenAt": "2026-01-15T...",
"lastAnalyzedAt": "2026-02-01T..."
}
},
"pendingFollowUps": [
{
"issueNumber": 12346,
"action": "post-clarification",
"scheduledFor": "2026-02-07T...",
"draftPath": "draft-replies/issue-12346.md",
"status": "pending"
}
],
"closedWithActivity": [
{
"issueNumber": 12350,
"closedAt": "2026-01-20T...",
"lastCheckedAt": "2026-02-05T...",
"newCommentsSinceClosed": 2,
"needsReview": true
}
],
"configuration": {
"trendingThreshold": 5,
"staleWaitingDays": 14,
"closedTrackingDays": 30,
"labelConfidenceThreshold": 70
},
"statistics": {
"totalRunCount": 12,
"issuesTriaged": 234,
"repliesPosted": 45,
"issuesClosed": 89
}
}
```
## Loading State
```powershell
function Load-TriageState {
param([string]$StatePath = "Generated Files/triage-issues/triage-state.json")
if (Test-Path $StatePath) {
$state = Get-Content $StatePath | ConvertFrom-Json -AsHashtable
Write-Host "Loaded state from $($state.lastRun)"
return $state
}
Write-Host "No previous state found - initializing fresh run"
return $null
}
```
## Saving State
After each run, update and save the state:
```powershell
function Save-TriageState {
param(
[hashtable]$State,
[string]$StatePath = "Generated Files/triage-issues/triage-state.json",
[switch]$Archive
)
$State.lastRun = (Get-Date).ToUniversalTime().ToString("o")
# Archive previous run if requested
if ($Archive -and (Test-Path $StatePath)) {
$archiveDate = (Get-Date).ToString("yyyy-MM-dd")
$archivePath = "Generated Files/triage-issues/history/$archiveDate"
New-Item -ItemType Directory -Force -Path $archivePath
Copy-Item $StatePath "$archivePath/triage-state.json"
# Also archive current-run folder
if (Test-Path "Generated Files/triage-issues/current-run") {
Copy-Item -Recurse "Generated Files/triage-issues/current-run" $archivePath
}
}
$State | ConvertTo-Json -Depth 10 | Set-Content $StatePath
Write-Host "State saved at $($State.lastRun)"
}
```
## State Transitions
### Issue Snapshot Lifecycle
```
NEW ISSUE DETECTED
┌──────────────────┐
│ issueSnapshots │ ← Add with initial data
│ category: null │
└──────────────────┘
CATEGORIZATION PASS
┌──────────────────┐
│ category: set │ ← trending/needs-label/etc.
│ priorityScore │
│ pendingAction │
└──────────────────┘
HUMAN TAKES ACTION (external)
┌──────────────────┐
│ actionTaken: true│ ← Mark as handled
│ actionTakenAt │
└──────────────────┘
NEXT RUN: RE-EVALUATE
┌──────────────────┐
│ category: update │ ← May change category
│ reset action? │ if new activity
└──────────────────┘
```
### Detecting Changes Between Runs
```powershell
function Get-IssueChanges {
param(
[hashtable]$PreviousSnapshot,
[hashtable]$CurrentData
)
$changes = @{
newComments = $CurrentData.commentCount - $PreviousSnapshot.commentCount
stateChanged = $CurrentData.state -ne $PreviousSnapshot.state
labelsChanged = (Compare-Object $PreviousSnapshot.labels $CurrentData.labels).Count -gt 0
reactionsChanged = $CurrentData.reactions.thumbsUp -ne $PreviousSnapshot.reactions.thumbsUp
}
return $changes
}
```
## Configuration Options
| Setting | Default | Description |
|---------|---------|-------------|
| `trendingThreshold` | 5 | Minimum new comments to flag as trending |
| `staleWaitingDays` | 14 | Days waiting on author before stale |
| `closedTrackingDays` | 30 | Days to monitor closed issues for new comments |
| `labelConfidenceThreshold` | 70 | Minimum confidence % for label suggestions |
## Best Practices
1. **Always archive before overwriting**: Preserve history for audit trail
2. **Atomic updates**: Update state only after successful run completion
3. **Graceful degradation**: If state is corrupted, allow fresh start
4. **Version field**: Enables future schema migrations

View File

@@ -0,0 +1,225 @@
# Step 2: Issue Collection
Collect issues that need triage attention based on activity since last run.
## Collection Strategy
### Issue Sources
1. **Recently Updated Open Issues**: Any open issue with activity since last run
2. **Closed Issues with New Comments**: People may ask questions on closed issues
3. **Previously Flagged Issues**: Issues with pending actions from last run
4. **New Issues**: Issues created since last run
## GitHub CLI Commands
### Collect Recently Updated Open Issues
```powershell
# Get open issues updated since last run
$since = "2026-01-29T00:00:00Z" # From triage-state.json.lastRun
gh issue list `
--state open `
--json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments `
--limit 500 `
| ConvertFrom-Json `
| Where-Object { [datetime]$_.updatedAt -gt [datetime]$since }
```
### Collect Closed Issues with Recent Activity
```powershell
# Closed issues that might have new comments
$trackingDays = 30
gh issue list `
--state closed `
--json number,title,updatedAt,closedAt,comments `
--limit 200 `
| ConvertFrom-Json `
| Where-Object {
$closedDate = [datetime]$_.closedAt
$updatedDate = [datetime]$_.updatedAt
$cutoff = (Get-Date).AddDays(-$trackingDays)
# Closed within tracking window AND updated after closed
($closedDate -gt $cutoff) -and ($updatedDate -gt $closedDate)
}
```
### Full Issue Details
For each issue needing analysis, fetch complete data:
```powershell
function Get-IssueDetails {
param([int]$IssueNumber)
$issue = gh issue view $IssueNumber `
--json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments,linkedPullRequests `
| ConvertFrom-Json
return @{
number = $issue.number
title = $issue.title
body = $issue.body
author = $issue.author.login
state = $issue.state
createdAt = $issue.createdAt
updatedAt = $issue.updatedAt
labels = $issue.labels | ForEach-Object { $_.name }
milestone = $issue.milestone.title
reactions = @{
thumbsUp = ($issue.reactions | Where-Object { $_.content -eq "THUMBS_UP" }).Count
thumbsDown = ($issue.reactions | Where-Object { $_.content -eq "THUMBS_DOWN" }).Count
heart = ($issue.reactions | Where-Object { $_.content -eq "HEART" }).Count
}
commentCount = $issue.comments.Count
comments = $issue.comments | ForEach-Object {
@{
author = $_.author.login
createdAt = $_.createdAt
body = $_.body
}
}
linkedPRs = $issue.linkedPullRequests | ForEach-Object {
@{
number = $_.number
title = $_.title
state = $_.state
mergedAt = $_.mergedAt
}
}
}
}
```
## Filtering Logic
### First Run (No Previous State)
```powershell
# Collect issues from last 7 days
$lookbackDays = 7
$since = (Get-Date).AddDays(-$lookbackDays).ToUniversalTime().ToString("o")
$openIssues = gh issue list --state open --json number,updatedAt --limit 500 `
| ConvertFrom-Json `
| Where-Object { [datetime]$_.updatedAt -gt [datetime]$since }
Write-Host "First run: Found $($openIssues.Count) issues from last $lookbackDays days"
```
### Subsequent Runs
```powershell
function Get-IssuesToTriage {
param(
[hashtable]$State,
[string]$RunType = "weekly" # daily, twice-weekly, weekly
)
$since = [datetime]$State.lastRun
$issues = @()
# 1. Open issues updated since last run
$openUpdated = gh issue list --state open --json number,updatedAt --limit 500 `
| ConvertFrom-Json `
| Where-Object { [datetime]$_.updatedAt -gt $since }
$issues += $openUpdated
# 2. Closed issues we're tracking
foreach ($tracked in $State.closedWithActivity) {
$issueData = gh issue view $tracked.issueNumber --json updatedAt,comments | ConvertFrom-Json
if ([datetime]$issueData.updatedAt -gt [datetime]$tracked.lastCheckedAt) {
$issues += @{ number = $tracked.issueNumber; source = "closed-tracking" }
}
}
# 3. Issues with pending actions (re-check status)
foreach ($pending in $State.pendingFollowUps) {
if ($pending.status -eq "pending") {
$issues += @{ number = $pending.issueNumber; source = "pending-action" }
}
}
# 4. Issues previously categorized but action not taken
foreach ($snapshot in $State.issueSnapshots.Values) {
if ($snapshot.pendingAction -and -not $snapshot.actionTaken) {
if ($issues.number -notcontains $snapshot.number) {
$issues += @{ number = $snapshot.number; source = "unhandled" }
}
}
}
return $issues | Sort-Object -Property number -Unique
}
```
## Comment Analysis
For trending detection, analyze comment activity:
```powershell
function Get-CommentDelta {
param(
[int]$IssueNumber,
[hashtable]$PreviousSnapshot
)
$current = gh issue view $IssueNumber --json comments | ConvertFrom-Json
$previousCount = if ($PreviousSnapshot) { $PreviousSnapshot.commentCount } else { 0 }
$previousLastComment = if ($PreviousSnapshot) { $PreviousSnapshot.lastCommentAt } else { $null }
$newComments = $current.comments | Where-Object {
-not $previousLastComment -or [datetime]$_.createdAt -gt [datetime]$previousLastComment
}
return @{
totalComments = $current.comments.Count
newCommentCount = $newComments.Count
newComments = $newComments | ForEach-Object {
@{
author = $_.author.login
createdAt = $_.createdAt
bodyPreview = $_.body.Substring(0, [Math]::Min(200, $_.body.Length))
}
}
lastCommentAt = ($current.comments | Sort-Object createdAt -Descending | Select-Object -First 1).createdAt
lastCommentAuthor = ($current.comments | Sort-Object createdAt -Descending | Select-Object -First 1).author.login
}
}
```
## Output Format
Save collected issues to working file:
```powershell
$collectedIssues | ConvertTo-Json -Depth 10 | Set-Content "Generated Files/triage-issues/current-run/collected-issues.json"
```
## Rate Limiting
GitHub API has rate limits. For large backlogs:
```powershell
# Check rate limit
gh api rate_limit --jq '.resources.core'
# Batch requests with delay if needed
$batchSize = 50
$delaySeconds = 2
for ($i = 0; $i -lt $issues.Count; $i += $batchSize) {
$batch = $issues[$i..([Math]::Min($i + $batchSize - 1, $issues.Count - 1))]
# Process batch...
Start-Sleep -Seconds $delaySeconds
}
```
## Next Step
After collection, proceed to [Step 3: Categorization](./step3-categorization.md).

View File

@@ -0,0 +1,432 @@
# Step 3: Categorization Rules
Apply categorization rules to assign each issue to an actionable bucket.
## Category Definitions
| Category | ID | Priority | Criteria |
|----------|-----|----------|----------|
| 🔥 **Trending** | `trending` | 1 | 5+ new comments since last run |
| 🏷️ **Needs-Label** | `needs-label` | 2 | Missing `Product-*` or `Area-*` label |
| ✅ **Ready-for-Fix** | `ready-for-fix` | 3 | High clarity (≥70), feasible (≥60), validated |
| ❓ **Needs-Info** | `needs-info` | 4 | Missing repro, impact, or expected result |
| 💬 **Needs-Clarification** | `needs-clarification` | 5 | Question/discussion, not actionable bug |
| ✔️ **Closeable** | `closeable` | 6 | Fixed by merged PR, or released, or resolved |
| ⏳ **Stale-Waiting** | `stale-waiting` | 7 | Waiting on author >14 days after ask |
| 🔁 **Duplicate-Candidate** | `duplicate-candidate` | 8 | Likely duplicate of existing issue |
## Categorization Algorithm
```
FOR EACH issue in collected_issues:
# Priority order - first match wins
1. CHECK TRENDING
IF new_comments >= 5:
category = "trending"
CONTINUE
2. CHECK CLOSEABLE
IF has_merged_PR AND PR_in_released_version:
category = "closeable"
reason = "Fixed in PR #X, released in vY.Z"
CONTINUE
IF state == "open" AND all_linked_PRs_merged:
category = "closeable"
reason = "All linked PRs merged"
CONTINUE
3. CHECK NEEDS-LABEL
IF missing_product_or_area_label:
category = "needs-label"
suggested_label = analyze_content()
CONTINUE
4. CHECK STALE-WAITING
IF has_label("Needs-Author-Feedback"):
IF days_since_last_author_response > 14:
category = "stale-waiting"
CONTINUE
5. CHECK NEEDS-CLARIFICATION (question, not bug)
IF is_question_not_bug():
category = "needs-clarification"
draft_reply = generate_explanation()
CONTINUE
6. CHECK NEEDS-INFO
IF missing_repro_steps OR missing_expected_result OR missing_version:
category = "needs-info"
missing_items = identify_gaps()
draft_questions = generate_questions()
CONTINUE
7. CHECK READY-FOR-FIX
IF clarity_score >= 70 AND feasibility_score >= 60:
category = "ready-for-fix"
CONTINUE
8. CHECK DUPLICATE
IF similar_issues_found AND confidence > 80:
category = "duplicate-candidate"
duplicate_of = [similar_issue_numbers]
CONTINUE
9. DEFAULT
category = "review-needed"
# Needs human judgment
```
## Category Rule Details
### 🔥 Trending Detection
```powershell
function Test-Trending {
param(
[hashtable]$Issue,
[hashtable]$PreviousSnapshot,
[int]$Threshold = 5
)
$previousCount = if ($PreviousSnapshot) { $PreviousSnapshot.commentCount } else { 0 }
$newComments = $Issue.commentCount - $previousCount
if ($newComments -ge $Threshold) {
return @{
isTrending = $true
newCommentCount = $newComments
reason = "$newComments new comments since last triage"
sentiment = Get-CommentSentiment $Issue.comments # Optional
}
}
return @{ isTrending = $false }
}
```
### 🏷️ Label Analysis
```powershell
function Test-NeedsLabel {
param([hashtable]$Issue)
$productLabels = $Issue.labels | Where-Object { $_ -like "Product-*" }
$areaLabels = $Issue.labels | Where-Object { $_ -like "Area-*" }
if ($productLabels.Count -eq 0 -and $areaLabels.Count -eq 0) {
# Analyze content to suggest label
$suggestion = Get-LabelSuggestion $Issue
return @{
needsLabel = $true
missingType = "product-or-area"
suggestedLabels = $suggestion.labels
confidence = $suggestion.confidence
reason = $suggestion.reason
}
}
return @{ needsLabel = $false }
}
function Get-LabelSuggestion {
param([hashtable]$Issue)
# Keyword mapping to products
$productKeywords = @{
"Product-FancyZones" = @("fancy zones", "fancyzones", "zone", "snap", "layout", "window arrangement")
"Product-PowerToys Run" = @("run", "launcher", "alt+space", "search", "plugin")
"Product-Color Picker" = @("color picker", "colorpicker", "eyedropper", "hex", "rgb")
"Product-Keyboard Manager" = @("keyboard", "remap", "shortcut", "key")
"Product-Mouse Utils" = @("mouse", "crosshairs", "find my mouse", "highlighter", "pointer")
"Product-File Explorer" = @("file explorer", "preview", "thumbnail", "markdown preview", "svg")
"Product-Image Resizer" = @("image resizer", "resize", "bulk resize")
"Product-PowerRename" = @("rename", "power rename", "bulk rename", "regex rename")
"Product-Awake" = @("awake", "keep awake", "prevent sleep", "caffeinate")
"Product-Shortcut Guide" = @("shortcut guide", "win key", "keyboard shortcuts")
"Product-Text Extractor" = @("text extractor", "ocr", "screen text", "copy text from screen")
"Product-Hosts File Editor" = @("hosts", "hosts file", "dns")
"Product-Peek" = @("peek", "quick preview", "spacebar preview")
"Product-Crop And Lock" = @("crop", "crop and lock", "window crop")
"Product-Paste As Plain Text" = @("paste", "plain text", "paste as")
"Product-Registry Preview" = @("registry", "reg file", "registry preview")
"Product-Environment Variables" = @("environment", "env", "variables", "path")
"Product-Command Not Found" = @("command not found", "winget suggest")
"Product-New+" = @("new+", "new plus", "file template")
"Product-Advanced Paste" = @("advanced paste", "ai paste", "clipboard")
"Product-Workspaces" = @("workspaces", "workspace", "project launcher")
"Product-Cmd Palette" = @("command palette", "cmd palette", "palette")
"Product-ZoomIt" = @("zoomit", "zoom it", "screen zoom", "magnifier")
}
$titleLower = $Issue.title.ToLower()
$bodyLower = if ($Issue.body) { $Issue.body.ToLower() } else { "" }
$combined = "$titleLower $bodyLower"
$matches = @()
foreach ($product in $productKeywords.Keys) {
$keywords = $productKeywords[$product]
$matchCount = ($keywords | Where-Object { $combined -match $_ }).Count
if ($matchCount -gt 0) {
$matches += @{
label = $product
matchCount = $matchCount
confidence = [Math]::Min(100, $matchCount * 25 + 25)
}
}
}
$best = $matches | Sort-Object confidence -Descending | Select-Object -First 1
if ($best -and $best.confidence -ge 50) {
return @{
labels = @($best.label)
confidence = $best.confidence
reason = "Matched $($best.matchCount) keywords for $($best.label)"
}
}
return @{
labels = @()
confidence = 0
reason = "No confident label match - needs human review"
}
}
```
### ✅ Ready-for-Fix Detection
Leverage the `review-issue` prompt scores:
```powershell
function Test-ReadyForFix {
param(
[hashtable]$Issue,
[string]$CachePath = "Generated Files/triage-issues/issue-cache"
)
$overviewPath = "$CachePath/$($Issue.number)/overview.md"
if (-not (Test-Path $overviewPath)) {
# Need to run deep analysis first
return @{ needsAnalysis = $true }
}
# Parse scores from cached overview
$overview = Get-Content $overviewPath -Raw
$clarityScore = [regex]::Match($overview, 'Requirement Clarity.*?(\d+)/100').Groups[1].Value
$feasibilityScore = [regex]::Match($overview, 'Technical Feasibility.*?(\d+)/100').Groups[1].Value
if ([int]$clarityScore -ge 70 -and [int]$feasibilityScore -ge 60) {
return @{
readyForFix = $true
clarityScore = [int]$clarityScore
feasibilityScore = [int]$feasibilityScore
reason = "High clarity ($clarityScore) and feasible ($feasibilityScore)"
}
}
return @{ readyForFix = $false }
}
```
### ❓ Needs-Info Detection
```powershell
function Test-NeedsInfo {
param([hashtable]$Issue)
$missingItems = @()
$body = $Issue.body
# Check for repro steps
if ($body -notmatch '(?i)(steps to reproduce|repro|how to reproduce|reproduction)') {
$missingItems += "reproduction steps"
}
# Check for expected result
if ($body -notmatch '(?i)(expected|should|supposed to)') {
$missingItems += "expected behavior"
}
# Check for version
if ($body -notmatch '(?i)(version|v\d+\.\d+|\d+\.\d+\.\d+)') {
$missingItems += "PowerToys version"
}
# Check for OS version
if ($body -notmatch '(?i)(windows 1[01]|win1[01]|22h2|23h2|24h2|build \d+)') {
$missingItems += "Windows version"
}
# Check for actual result (for bugs)
if ($Issue.labels -contains "Issue-Bug") {
if ($body -notmatch '(?i)(actual|instead|but|however|currently)') {
$missingItems += "actual behavior/result"
}
}
if ($missingItems.Count -gt 0) {
return @{
needsInfo = $true
missingItems = $missingItems
reason = "Missing: " + ($missingItems -join ", ")
}
}
return @{ needsInfo = $false }
}
```
### 💬 Needs-Clarification (Not a Bug)
```powershell
function Test-NeedsClarification {
param([hashtable]$Issue)
$questionPatterns = @(
'(?i)^(how (do|can|to)|why (does|is|doesn''t)|is (it|there|this) (possible|a way))',
'(?i)\?$', # Ends with question mark
'(?i)(wondering|curious|question|asking)',
'(?i)(is this (intended|by design|expected))',
'(?i)(can (someone|you) (explain|help))'
)
$titleAndBody = $Issue.title + " " + $Issue.body
$isQuestion = $false
foreach ($pattern in $questionPatterns) {
if ($titleAndBody -match $pattern) {
$isQuestion = $true
break
}
}
# Also check if explicitly marked as question
if ($Issue.labels -contains "Issue-Question" -or $Issue.labels -contains "Type-Question") {
$isQuestion = $true
}
if ($isQuestion -and ($Issue.labels -notcontains "Issue-Bug")) {
return @{
needsClarification = $true
type = "question"
reason = "Appears to be a question/inquiry rather than bug report"
}
}
return @{ needsClarification = $false }
}
```
### ✔️ Closeable Detection
```powershell
function Test-Closeable {
param([hashtable]$Issue)
$closeReasons = @()
# Check for merged linked PRs
$mergedPRs = $Issue.linkedPRs | Where-Object { $_.state -eq "MERGED" }
if ($mergedPRs.Count -gt 0) {
$closeReasons += @{
type = "fixed-by-pr"
prNumbers = $mergedPRs.number
reason = "Fixed by PR(s): #" + ($mergedPRs.number -join ", #")
}
}
# Check comments for "fixed in" or "released in"
$recentComments = $Issue.comments | Sort-Object createdAt -Descending | Select-Object -First 5
foreach ($comment in $recentComments) {
if ($comment.body -match '(?i)(fixed in|released in|available in|shipped in) v?(\d+\.\d+)') {
$version = $Matches[2]
$closeReasons += @{
type = "released"
version = $version
reason = "Released in v$version"
}
break
}
}
# Check if marked as duplicate
if ($Issue.labels -contains "Resolution-Duplicate") {
$closeReasons += @{
type = "duplicate"
reason = "Marked as duplicate"
}
}
# Check if marked as won't fix
if ($Issue.labels -contains "Resolution-Won't Fix" -or $Issue.labels -contains "Resolution-By-Design") {
$closeReasons += @{
type = "wont-fix"
reason = "Marked as won't fix / by design"
}
}
if ($closeReasons.Count -gt 0) {
return @{
closeable = $true
reasons = $closeReasons
}
}
return @{ closeable = $false }
}
```
## Priority Scoring
Combine signals for overall priority within category:
```powershell
function Get-PriorityScore {
param([hashtable]$Issue)
$score = 50 # Base score
# Reaction boost
$thumbsUp = $Issue.reactions.thumbsUp
$score += [Math]::Min(20, $thumbsUp * 2)
# Comment engagement
$score += [Math]::Min(15, $Issue.commentCount)
# Recency boost (updated recently)
$daysSinceUpdate = ((Get-Date) - [datetime]$Issue.updatedAt).Days
if ($daysSinceUpdate -le 7) { $score += 10 }
elseif ($daysSinceUpdate -le 30) { $score += 5 }
# Label boosts
if ($Issue.labels -contains "Priority-High") { $score += 15 }
if ($Issue.labels -match "Regression") { $score += 20 }
if ($Issue.labels -match "Security") { $score += 25 }
return [Math]::Min(100, $score)
}
```
## Output
Save categorization results:
```json
{
"12345": {
"category": "trending",
"categoryReason": "8 new comments since last run",
"priorityScore": 82,
"additionalFlags": ["negative-sentiment"],
"suggestedAction": "Review urgent - heated discussion"
}
}
```
## Next Step
Proceed to [Step 4: Deep Analysis](./step4-deep-analysis.md) for complex issues.

View File

@@ -0,0 +1,274 @@
# Step 4: Deep Analysis
For issues requiring detailed analysis, leverage the `review-issue` prompt to generate comprehensive reviews.
## When to Run Deep Analysis
| Category | Deep Analysis? | Reason |
|----------|---------------|--------|
| Trending | Optional | If conversation is contentious |
| Needs-Label | No | Label detection is keyword-based |
| Ready-for-Fix | Yes (cached) | Need scores for validation |
| Needs-Info | Optional | To identify specific gaps |
| Needs-Clarification | No | Simple question detection |
| Closeable | No | Mechanical check |
| Stale-Waiting | No | Time-based |
| Duplicate-Candidate | Optional | Similar issue search |
## Integration with review-issue Prompt
The `review-issue` prompt generates two artifacts:
- `overview.md` - Scoring, signals, suggested actions
- `implementation-plan.md` - Technical breakdown
### Invoking the Prompt
```markdown
# Within the agent's execution, reference the prompt:
For issue #{{issue_number}}, I need detailed analysis.
Use the review-issue prompt at `.github/prompts/review-issue.prompt.md` to generate:
1. `Generated Files/triage-issues/issue-cache/{{issue_number}}/overview.md`
2. `Generated Files/triage-issues/issue-cache/{{issue_number}}/implementation-plan.md`
```
### Caching Strategy
```
Generated Files/triage-issues/issue-cache/
├── 12345/
│ ├── overview.md
│ ├── implementation-plan.md
│ └── metadata.json
└── 12346/
└── ...
```
**metadata.json**:
```json
{
"issueNumber": 12345,
"analyzedAt": "2026-02-05T10:30:00Z",
"issueUpdatedAt": "2026-02-04T15:30:00Z",
"commentCountAtAnalysis": 15,
"isStale": false
}
```
### Cache Invalidation
Re-run analysis if:
1. Issue has new comments since last analysis
2. Issue state changed (open ↔ closed)
3. Labels changed significantly
4. More than 7 days since last analysis
5. User explicitly requests refresh
```powershell
function Test-CacheValid {
param(
[int]$IssueNumber,
[hashtable]$CurrentIssueData
)
$cachePath = "Generated Files/triage-issues/issue-cache/$IssueNumber"
$metadataPath = "$cachePath/metadata.json"
if (-not (Test-Path $metadataPath)) {
return @{ valid = $false; reason = "No cached analysis" }
}
$metadata = Get-Content $metadataPath | ConvertFrom-Json
# Check freshness
$daysSinceAnalysis = ((Get-Date) - [datetime]$metadata.analyzedAt).Days
if ($daysSinceAnalysis -gt 7) {
return @{ valid = $false; reason = "Cache older than 7 days" }
}
# Check for new comments
if ($CurrentIssueData.commentCount -gt $metadata.commentCountAtAnalysis) {
return @{ valid = $false; reason = "New comments added" }
}
# Check for state change
if ($CurrentIssueData.updatedAt -gt $metadata.issueUpdatedAt) {
return @{ valid = $false; reason = "Issue updated since analysis" }
}
return @{ valid = $true }
}
```
## Selective Analysis
Don't analyze every issue - be selective:
### Batch 1: High-Priority Analysis
Analyze first:
- Trending issues with negative sentiment
- Potential ready-for-fix candidates (unclear if ready)
- Issues with high reaction counts (>10 👍)
### Batch 2: Moderate Priority
Analyze if time permits:
- Needs-Info issues (to draft better questions)
- Complex duplicate candidates
### Batch 3: Skip Analysis
Don't analyze:
- Clear closeable issues
- Stale-waiting issues
- Already-analyzed recent issues
## Extracting Scores from Analysis
After running `review-issue`, parse the `overview.md`:
```powershell
function Get-AnalysisScores {
param([string]$OverviewPath)
$content = Get-Content $OverviewPath -Raw
# Extract from the At-a-Glance Score Table
$scores = @{}
# Business Importance
if ($content -match '\*\*A\) Business Importance\*\*.*?(\d+)/100') {
$scores.businessImportance = [int]$Matches[1]
}
# Community Excitement
if ($content -match '\*\*B\) Community Excitement\*\*.*?(\d+)/100') {
$scores.communityExcitement = [int]$Matches[1]
}
# Technical Feasibility
if ($content -match '\*\*C\) Technical Feasibility\*\*.*?(\d+)/100') {
$scores.technicalFeasibility = [int]$Matches[1]
}
# Requirement Clarity
if ($content -match '\*\*D\) Requirement Clarity\*\*.*?(\d+)/100') {
$scores.requirementClarity = [int]$Matches[1]
}
# Overall Priority
if ($content -match '\*\*Overall Priority\*\*.*?(\d+)/100') {
$scores.overallPriority = [int]$Matches[1]
}
# Effort Estimate
if ($content -match '\*\*Effort Estimate\*\*.*?(\d+) days.*?(XS|S|M|L|XL|XXL|Epic)') {
$scores.effortDays = [int]$Matches[1]
$scores.effortTShirt = $Matches[2]
}
return $scores
}
```
## Similar Issue Search
For duplicate detection, search existing issues:
```powershell
function Find-SimilarIssues {
param([hashtable]$Issue)
# Extract key terms from title
$searchTerms = $Issue.title -split '\s+' | Where-Object { $_.Length -gt 3 }
$searchQuery = ($searchTerms | Select-Object -First 5) -join ' '
# Search both open and closed
$similar = gh issue list `
--search "$searchQuery" `
--state all `
--json number,title,state,closedAt,labels `
--limit 10 `
| ConvertFrom-Json `
| Where-Object { $_.number -ne $Issue.number }
# Score similarity
$results = $similar | ForEach-Object {
$similarity = Get-TitleSimilarity $Issue.title $_.title
@{
number = $_.number
title = $_.title
state = $_.state
closedAt = $_.closedAt
similarityScore = $similarity
}
} | Where-Object { $_.similarityScore -gt 50 } | Sort-Object similarityScore -Descending
return $results
}
function Get-TitleSimilarity {
param(
[string]$Title1,
[string]$Title2
)
$words1 = $Title1.ToLower() -split '\W+' | Where-Object { $_.Length -gt 2 }
$words2 = $Title2.ToLower() -split '\W+' | Where-Object { $_.Length -gt 2 }
$common = ($words1 | Where-Object { $words2 -contains $_ }).Count
$total = [Math]::Max($words1.Count, $words2.Count)
if ($total -eq 0) { return 0 }
return [int](($common / $total) * 100)
}
```
## MCP Tools for Rich Context
When available, use MCP tools for additional context:
### Images (UI issues)
```markdown
If the issue mentions screenshots or UI problems, use MCP:
github_issue_images(owner: "microsoft", repo: "PowerToys", issueNumber: 12345)
```
### Attachments (Logs)
```markdown
If the issue mentions logs or diagnostic reports:
github_issue_attachments(
owner: "microsoft",
repo: "PowerToys",
issueNumber: 12345,
extractFolder: "Generated Files/triage-issues/issue-cache/12345/logs"
)
```
## Analysis Output
Save analysis metadata for state tracking:
```powershell
$metadata = @{
issueNumber = $Issue.number
analyzedAt = (Get-Date).ToUniversalTime().ToString("o")
issueUpdatedAt = $Issue.updatedAt
commentCountAtAnalysis = $Issue.commentCount
scores = $extractedScores
suggestedCategory = $determinedCategory
}
$metadata | ConvertTo-Json | Set-Content "$cachePath/metadata.json"
```
## Next Step
Proceed to [Step 5: Report Generation](./step5-reports.md).

View File

@@ -0,0 +1,316 @@
# Step 5: Report Generation
Generate actionable reports for each category and an executive summary.
## Report Structure
```
Generated Files/triage-issues/current-run/
├── summary.md # Executive summary (start here)
├── trending.md # 🔥 Trending issues
├── needs-label.md # 🏷️ Issues missing labels
├── ready-for-fix.md # ✅ Ready for implementation
├── needs-info.md # ❓ Needs author feedback
├── needs-clarification.md # 💬 Questions/discussions
├── closeable.md # ✔️ Can be closed
├── stale-waiting.md # ⏳ Waiting on author
├── duplicate-candidate.md # 🔁 Potential duplicates
└── draft-replies/ # Pre-drafted messages
├── issue-12345.md
└── issue-12346.md
```
## Executive Summary Template
**File**: `summary.md`
```markdown
# Issue Triage Summary - {{DATE}}
**Run Type**: {{RUN_TYPE}} | **Issues Analyzed**: {{TOTAL_COUNT}} | **Since**: {{LAST_RUN_DATE}}
## 📊 Quick Stats
| Metric | Value | Change |
|--------|-------|--------|
| Total issues scanned | {{TOTAL}} | {{DELTA}} |
| New issues since last run | {{NEW_COUNT}} | — |
| Issues with new activity | {{ACTIVE_COUNT}} | — |
| Closed issues with comments | {{CLOSED_ACTIVE}} | — |
## ⚡ Action Required by Category
| Category | Count | Top Priority | Draft Ready? |
|----------|-------|--------------|--------------|
| 🔥 Trending | {{COUNT}} | [#{{NUM}}]({{LINK}}) ({{COMMENTS}} new comments) | — |
| 🏷️ Needs-Label | {{COUNT}} | [#{{NUM}}]({{LINK}}) (suggest: {{LABEL}}) | — |
| ✅ Ready-for-Fix | {{COUNT}} | [#{{NUM}}]({{LINK}}) (score: {{SCORE}}/100) | — |
| ❓ Needs-Info | {{COUNT}} | [#{{NUM}}]({{LINK}}) (missing: {{ITEMS}}) | ✅ |
| 💬 Needs-Clarification | {{COUNT}} | [#{{NUM}}]({{LINK}}) | ✅ |
| ✔️ Closeable | {{COUNT}} | [#{{NUM}}]({{LINK}}) ({{REASON}}) | ✅ |
| ⏳ Stale-Waiting | {{COUNT}} | [#{{NUM}}]({{LINK}}) ({{DAYS}} days) | ✅ |
| 🔁 Duplicate-Candidate | {{COUNT}} | [#{{NUM}}]({{LINK}}) → #{{DUP_OF}} | — |
## 🎯 Top 5 Priority Actions
1. **[Urgent]** Review [#{{NUM}}]({{LINK}}) - {{REASON}}
2. **[High]** Post clarification on [#{{NUM}}]({{LINK}}) - draft ready
3. **[High]** Assign [#{{NUM}}]({{LINK}}) - ready for implementation
4. **[Medium]** Label [#{{NUM}}]({{LINK}}) as {{LABEL}}
5. **[Low]** Close [#{{NUM}}]({{LINK}}) - fixed in v{{VERSION}}
## 📁 Detailed Reports
- [Trending Issues](./trending.md)
- [Needs Label](./needs-label.md)
- [Ready for Fix](./ready-for-fix.md)
- [Needs Information](./needs-info.md)
- [Needs Clarification](./needs-clarification.md)
- [Closeable](./closeable.md)
- [Stale Waiting](./stale-waiting.md)
- [Duplicate Candidates](./duplicate-candidate.md)
## 📝 Draft Replies Ready
{{COUNT}} draft replies prepared in `draft-replies/`:
{{DRAFT_LIST}}
## ⏭️ Follow-ups from Last Run
| Issue | Previous Action | Status |
|-------|-----------------|--------|
| [#{{NUM}}]({{LINK}}) | Posted clarification | ✅ Resolved |
| [#{{NUM}}]({{LINK}}) | Requested info | ⏳ No response |
| [#{{NUM}}]({{LINK}}) | Assigned to @{{USER}} | 🔄 In progress |
---
*Generated by continuous-issue-triage skill | Next suggested run: {{NEXT_RUN}}*
```
## Category Report Templates
### Trending Report (`trending.md`)
```markdown
# 🔥 Trending Issues
Issues with significant activity since last triage ({{THRESHOLD}}+ new comments).
| # | Issue | New Comments | Total | Sentiment | Last Activity |
|---|-------|--------------|-------|-----------|---------------|
| 1 | [#{{NUM}}]({{LINK}}) {{TITLE}} | +{{NEW}} | {{TOTAL}} | {{SENTIMENT}} | {{TIME_AGO}} |
---
## #{{ISSUE_NUM}}: {{TITLE}}
**Activity**: +{{NEW}} comments ({{TOTAL}} total) | **Sentiment**: {{SENTIMENT}}
### Recent Discussion Summary
{{SUMMARY_OF_RECENT_COMMENTS}}
### Key Participants
- @{{USER1}} ({{COMMENT_COUNT}} comments) - {{STANCE}}
- @{{USER2}} ({{COMMENT_COUNT}} comments) - {{STANCE}}
### Recommended Action
{{RECOMMENDATION}}
---
```
### Needs-Label Report (`needs-label.md`)
```markdown
# 🏷️ Issues Missing Area/Product Labels
These issues need categorization for proper routing.
| # | Issue | Suggested Label | Confidence | Reason |
|---|-------|-----------------|------------|--------|
| 1 | [#{{NUM}}]({{LINK}}) {{TITLE}} | `{{LABEL}}` | {{CONF}}% | {{REASON}} |
---
## Quick Apply Commands
```bash
# Apply suggested labels (review first!)
gh issue edit {{NUM}} --add-label "{{LABEL}}"
gh issue edit {{NUM}} --add-label "{{LABEL}}"
```
---
## Detailed Analysis
### #{{ISSUE_NUM}}: {{TITLE}}
**Suggested**: `{{LABEL}}` ({{CONFIDENCE}}% confidence)
**Why**: {{DETAILED_REASON}}
**Alternative labels to consider**: {{ALTERNATIVES}}
---
```
### Ready-for-Fix Report (`ready-for-fix.md`)
```markdown
# ✅ Issues Ready for Implementation
High-clarity issues that are technically feasible.
| # | Issue | Clarity | Feasibility | Effort | Potential Assignee |
|---|-------|---------|-------------|--------|-------------------|
| 1 | [#{{NUM}}]({{LINK}}) {{TITLE}} | {{CLARITY}}/100 | {{FEASIBILITY}}/100 | {{EFFORT}} | @{{USER}} |
---
## #{{ISSUE_NUM}}: {{TITLE}}
**Scores**: Clarity {{CLARITY}}/100 | Feasibility {{FEASIBILITY}}/100 | Priority {{PRIORITY}}/100
**Effort**: {{DAYS}} days ({{TSHIRT}})
**Product Area**: {{LABELS}}
### Problem Summary
{{BRIEF_PROBLEM}}
### Implementation Hints
{{FROM_IMPLEMENTATION_PLAN}}
### Suggested Assignees
- @{{USER1}} - {{REASON}}
- @{{USER2}} - {{REASON}}
**Full Analysis**: [issue-cache/{{NUM}}/overview.md](../issue-cache/{{NUM}}/overview.md)
---
```
### Needs-Info Report (`needs-info.md`)
```markdown
# ❓ Issues Needing More Information
These issues lack details needed for investigation or planning.
| # | Issue | Missing | Days Open | Draft Ready |
|---|-------|---------|-----------|-------------|
| 1 | [#{{NUM}}]({{LINK}}) {{TITLE}} | {{MISSING}} | {{DAYS}} | [View](./draft-replies/issue-{{NUM}}.md) |
---
## #{{ISSUE_NUM}}: {{TITLE}}
**Missing Information**:
- [ ] {{ITEM_1}}
- [ ] {{ITEM_2}}
- [ ] {{ITEM_3}}
**Draft Reply**: [draft-replies/issue-{{NUM}}.md](./draft-replies/issue-{{NUM}}.md)
### Quick Post
```bash
gh issue comment {{NUM}} --body-file "Generated Files/triage-issues/current-run/draft-replies/issue-{{NUM}}.md"
gh issue edit {{NUM}} --add-label "Needs-Author-Feedback"
```
---
```
### Closeable Report (`closeable.md`)
```markdown
# ✔️ Issues Ready to Close
These issues can be closed with appropriate messaging.
| # | Issue | Close Reason | PR/Version | Draft Ready |
|---|-------|--------------|------------|-------------|
| 1 | [#{{NUM}}]({{LINK}}) {{TITLE}} | {{REASON}} | {{REFERENCE}} | [View](./draft-replies/issue-{{NUM}}.md) |
---
## Batch Close Commands
```bash
# Review drafts first, then close with message
# Fixed by PR
gh issue close {{NUM}} --comment "Fixed in #{{PR_NUM}}. This fix is available in v{{VERSION}}. Thank you for reporting!"
# Duplicate
gh issue close {{NUM}} --comment "Closing as duplicate of #{{DUP_NUM}}. Please follow that issue for updates."
# By design / Won't fix
gh issue close {{NUM}} --comment "After review, this is working as designed. {{EXPLANATION}}"
```
---
## #{{ISSUE_NUM}}: {{TITLE}}
**Reason**: {{DETAILED_REASON}}
**Draft Close Message**: [draft-replies/issue-{{NUM}}.md](./draft-replies/issue-{{NUM}}.md)
---
```
## Generation Script
```powershell
function New-TriageReports {
param(
[hashtable]$CategorizedIssues,
[hashtable]$State,
[string]$OutputPath = "Generated Files/triage-issues/current-run"
)
# Ensure directory exists
New-Item -ItemType Directory -Force -Path $OutputPath
New-Item -ItemType Directory -Force -Path "$OutputPath/draft-replies"
# Group by category
$byCategory = $CategorizedIssues.Values | Group-Object -Property category
# Generate category reports
foreach ($group in $byCategory) {
$categoryName = $group.Name
$issues = $group.Group | Sort-Object priorityScore -Descending
$reportPath = "$OutputPath/$categoryName.md"
$reportContent = New-CategoryReport -Category $categoryName -Issues $issues
$reportContent | Set-Content $reportPath
}
# Generate summary
$summaryContent = New-ExecutiveSummary -Categories $byCategory -State $State
$summaryContent | Set-Content "$OutputPath/summary.md"
Write-Host "Reports generated at $OutputPath"
}
```
## Report Conventions
1. **Always link to GitHub**: Use `[#NUM](https://github.com/microsoft/PowerToys/issues/NUM)`
2. **Include quick commands**: Provide `gh` CLI commands for easy action
3. **Sort by priority**: Highest priority issues first within each category
4. **Cross-reference drafts**: Link to draft replies when available
5. **Show deltas**: Compare to previous run where applicable
## Next Step
Proceed to [Step 6: Reply Templates](./step6-reply-templates.md) for draft message generation.

View File

@@ -0,0 +1,340 @@
# Step 6: Reply Templates
Generate draft replies for issues requiring human response.
## Draft Reply Location
```
Generated Files/triage-issues/current-run/draft-replies/
├── issue-12345.md # Needs-info draft
├── issue-12346.md # Clarification draft
├── issue-12347.md # Close message draft
└── ...
```
## Reply Categories
| Category | Reply Type | Tone | Key Elements |
|----------|------------|------|--------------|
| Needs-Info | Question list | Friendly, helpful | Specific questions, context why needed |
| Needs-Clarification | Explanation | Educational, patient | Answer the question, link to docs |
| Closeable (fixed) | Thank you + reference | Grateful | PR link, version, appreciation |
| Closeable (duplicate) | Redirect | Brief, helpful | Link to original, explain |
| Closeable (by-design) | Explanation | Respectful | Rationale, alternatives |
| Stale-Waiting | Gentle ping | Patient | Reminder, offer to close |
## Template: Needs-Info Reply
**File**: `issue-XXXXX.md`
```markdown
Hi @{{AUTHOR}},
Thank you for reporting this issue! To help us investigate further, could you please provide the following information?
{{#IF_MISSING_REPRO}}
**Reproduction Steps**
- What exact steps lead to this issue?
- Can you provide a minimal, consistent way to reproduce it?
{{/IF_MISSING_REPRO}}
{{#IF_MISSING_VERSION}}
**Environment Details**
- PowerToys version (Settings > General > Version):
- Windows version (winver):
- Did this work in a previous version? If so, which one?
{{/IF_MISSING_VERSION}}
{{#IF_MISSING_EXPECTED}}
**Expected vs Actual Behavior**
- What did you expect to happen?
- What actually happened instead?
{{/IF_MISSING_EXPECTED}}
{{#IF_MISSING_SCREENSHOTS}}
**Visual Evidence** (if applicable)
- Could you attach a screenshot or screen recording showing the issue?
{{/IF_MISSING_SCREENSHOTS}}
{{#IF_MISSING_LOGS}}
**Diagnostic Logs**
- Please run PowerToys and reproduce the issue
- Generate a bug report: Settings > General > "Generate Bug Report"
- Attach the resulting ZIP file
{{/IF_MISSING_LOGS}}
This information will help us reproduce and fix the issue faster. Thanks!
```
## Template: Needs-Clarification Reply
**File**: `issue-XXXXX.md`
```markdown
Hi @{{AUTHOR}},
Thanks for reaching out! Let me help clarify this:
{{EXPLANATION}}
{{#IF_BY_DESIGN}}
This behavior is actually by design. Here's the reasoning:
- {{REASON_1}}
- {{REASON_2}}
{{/IF_BY_DESIGN}}
{{#IF_HOW_TO}}
Here's how you can achieve what you're looking for:
1. {{STEP_1}}
2. {{STEP_2}}
3. {{STEP_3}}
{{/IF_HOW_TO}}
{{#IF_DOCS_LINK}}
You can find more information in our documentation:
- [{{DOC_TITLE}}]({{DOC_LINK}})
{{/IF_DOCS_LINK}}
{{#IF_RELATED_ISSUE}}
There's also an existing discussion about this in #{{RELATED_NUM}} that might be helpful.
{{/IF_RELATED_ISSUE}}
{{#IF_FEATURE_REQUEST}}
If you'd like to request this as a new feature, I'd suggest:
1. Search existing issues to see if it's already requested
2. If not, open a new feature request issue with your use case
We track feature popularity through 👍 reactions, so feel free to upvote any existing requests that match your needs!
{{/IF_FEATURE_REQUEST}}
Let me know if you have any other questions!
```
## Template: Close (Fixed by PR)
**File**: `issue-XXXXX.md`
```markdown
Hi @{{AUTHOR}},
Great news! This issue has been addressed in PR #{{PR_NUM}}.
{{#IF_RELEASED}}
**The fix is now available in PowerToys v{{VERSION}}**
You can update to the latest version through:
- Microsoft Store (automatic updates)
- GitHub Releases: https://github.com/microsoft/PowerToys/releases/tag/v{{VERSION}}
- WinGet: `winget upgrade Microsoft.PowerToys`
{{/IF_RELEASED}}
{{#IF_NOT_RELEASED}}
The fix has been merged and will be included in the next release (v{{NEXT_VERSION}}).
You can track the release progress in our [milestones](https://github.com/microsoft/PowerToys/milestones).
{{/IF_NOT_RELEASED}}
Thank you for reporting this issue and helping improve PowerToys! 🙏
Closing this issue as resolved. If you encounter any further problems, please don't hesitate to open a new issue.
```
## Template: Close (Duplicate)
**File**: `issue-XXXXX.md`
```markdown
Hi @{{AUTHOR}},
Thanks for reporting this! It looks like this issue is a duplicate of #{{ORIGINAL_NUM}}.
To avoid splitting the discussion, I'm closing this in favor of the original issue. Please:
- 👍 React to #{{ORIGINAL_NUM}} to show your interest
- Add any additional context or reproduction details as a comment there
- Subscribe to #{{ORIGINAL_NUM}} for updates
{{#IF_DIFFERENT_CONTEXT}}
I noticed your report includes some additional context that might be helpful. I'll add a comment to #{{ORIGINAL_NUM}} referencing this issue.
{{/IF_DIFFERENT_CONTEXT}}
Thank you for understanding!
```
## Template: Close (By Design / Won't Fix)
**File**: `issue-XXXXX.md`
```markdown
Hi @{{AUTHOR}},
Thank you for taking the time to report this and share your feedback.
After reviewing this issue, we've determined that this behavior is **{{RESOLUTION_TYPE}}**.
{{#IF_BY_DESIGN}}
### Why This Is By Design
{{RATIONALE}}
This design choice was made because:
- {{REASON_1}}
- {{REASON_2}}
{{/IF_BY_DESIGN}}
{{#IF_WONT_FIX}}
### Why We're Not Addressing This
{{RATIONALE}}
We've decided not to implement this change because:
- {{REASON_1}}
- {{REASON_2}}
{{/IF_WONT_FIX}}
{{#IF_WORKAROUND}}
### Workaround
In the meantime, you might try:
{{WORKAROUND}}
{{/IF_WORKAROUND}}
{{#IF_ALTERNATIVE}}
### Alternative Approaches
You might consider:
- {{ALTERNATIVE_1}}
- {{ALTERNATIVE_2}}
{{/IF_ALTERNATIVE}}
We appreciate your understanding. If you have additional context that might change our assessment, please let us know!
```
## Template: Stale-Waiting Ping
**File**: `issue-XXXXX.md`
```markdown
Hi @{{AUTHOR}},
We haven't heard back from you in a while. Are you still experiencing this issue?
{{#IF_WAITING_FOR_INFO}}
We're still waiting for the additional information requested above to help investigate this issue.
{{/IF_WAITING_FOR_INFO}}
{{#IF_WAITING_FOR_CONFIRMATION}}
Could you confirm if the suggested solution worked for you?
{{/IF_WAITING_FOR_CONFIRMATION}}
If we don't hear back within the next {{DAYS}} days, we'll close this issue to keep our backlog manageable. You're always welcome to reopen it or create a new issue if the problem persists.
Thanks for your understanding! 🙏
```
## Template: Closed Issue with New Comment
**File**: `issue-XXXXX.md`
```markdown
Hi @{{COMMENTER}},
Thanks for your comment! This issue was closed {{TIME_AGO}} because {{CLOSE_REASON}}.
{{#IF_SAME_ISSUE}}
If you're experiencing the same issue and it's not resolved, please open a new issue with:
- Your PowerToys version
- Steps to reproduce
- Any error messages or screenshots
This helps us track and prioritize effectively.
{{/IF_SAME_ISSUE}}
{{#IF_QUESTION}}
Regarding your question:
{{ANSWER}}
{{/IF_QUESTION}}
{{#IF_DIFFERENT_ISSUE}}
It sounds like you might be experiencing a different issue. Please open a new issue with details about your specific problem so we can help you better.
{{/IF_DIFFERENT_ISSUE}}
```
## Draft Generation Logic
```powershell
function New-DraftReply {
param(
[hashtable]$Issue,
[string]$Category,
[hashtable]$AnalysisData
)
$draftPath = "Generated Files/triage-issues/current-run/draft-replies/issue-$($Issue.number).md"
switch ($Category) {
"needs-info" {
$content = New-NeedsInfoDraft -Issue $Issue -Missing $AnalysisData.missingItems
}
"needs-clarification" {
$content = New-ClarificationDraft -Issue $Issue -QuestionType $AnalysisData.questionType
}
"closeable" {
$content = New-CloseDraft -Issue $Issue -CloseReason $AnalysisData.closeReason
}
"stale-waiting" {
$content = New-StalePingDraft -Issue $Issue -DaysWaiting $AnalysisData.daysWaiting
}
default {
return $null # No draft needed
}
}
# Add metadata header
$header = @"
---
issue: $($Issue.number)
title: $($Issue.title)
category: $Category
generated: $(Get-Date -Format "o")
status: draft
---
"@
($header + $content) | Set-Content $draftPath
return $draftPath
}
```
## Draft Review Checklist
Before posting any draft:
- [ ] Read the full issue context
- [ ] Check for recent comments not in analysis
- [ ] Personalize if needed (remove boilerplate feel)
- [ ] Verify links work
- [ ] Ensure tone is appropriate
- [ ] Remove any placeholder text (`{{...}}`)
## Posting Drafts
```bash
# Post a single draft
gh issue comment 12345 --body-file "Generated Files/triage-issues/current-run/draft-replies/issue-12345.md"
# Add label if needed
gh issue edit 12345 --add-label "Needs-Author-Feedback"
# Close with message
gh issue close 12345 --comment "$(cat draft-replies/issue-12345.md)"
```
## Best Practices
1. **Never auto-post**: Always human review before posting
2. **Be empathetic**: Remember there's a person on the other side
3. **Be specific**: Generic responses feel dismissive
4. **Provide value**: Every reply should move the issue forward
5. **Link resources**: Documentation, related issues, PRs
6. **Thank contributors**: Acknowledge their time and effort

View File

@@ -0,0 +1,217 @@
<#
.SYNOPSIS
Runs Copilot CLI analysis on issues using review-issue prompt.
.DESCRIPTION
Kicks off GitHub Copilot CLI to analyze each issue using
the review-issue.prompt.md file. Processes sequentially with timeout handling.
.PARAMETER IssueNumbers
Array of issue numbers to analyze. If not provided, collects from recent activity.
.PARAMETER TimeoutMinutes
Timeout for each Copilot analysis. Default: 8
.PARAMETER MaxRetryCount
Maximum retries on timeout/failure. Default: 3
.PARAMETER Model
Copilot model to use (optional).
.EXAMPLE
.\analyze-issues-parallel.ps1 -IssueNumbers @(45201, 45107, 45321)
.EXAMPLE
.\analyze-issues-parallel.ps1 -TimeoutMinutes 10 -MaxRetries 2
#>
[CmdletBinding()]
param(
[Parameter()]
[int[]]$IssueNumbers,
[Parameter()]
[int]$TimeoutMinutes = 8,
[Parameter()]
[int]$MaxRetryCount = 3,
[Parameter()]
[string]$Model,
[Parameter()]
[int]$LookbackDays = 14,
[Parameter()]
[int]$MaxIssues = 15
)
$ErrorActionPreference = "Stop"
$repoRoot = (git rev-parse --show-toplevel 2>$null); if (-not $repoRoot) { $repoRoot = (Get-Location).Path }; $repoRoot = (Resolve-Path $repoRoot).Path
# Resolve config directory name (.github or .claude) from script location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
$triageRoot = Join-Path $repoRoot "Generated Files\triage-issues"
$issueCachePath = Join-Path $triageRoot "issue-cache"
$promptPath = Join-Path $repoRoot "$_cfgDir\prompts\review-issue.prompt.md"
# Ensure directories exist
if (-not (Test-Path $issueCachePath)) {
New-Item -ItemType Directory -Path $issueCachePath -Force | Out-Null
}
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Issue Analysis with Copilot CLI" -ForegroundColor Cyan
Write-Host " Using: review-issue.prompt.md" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
# If no issues provided, collect from recent activity
if (-not $IssueNumbers -or $IssueNumbers.Count -eq 0) {
Write-Host "Collecting issues from last $LookbackDays days..." -ForegroundColor Yellow
$issues = gh issue list --state open --json number,title,comments,updatedAt --limit 200 | ConvertFrom-Json
$recent = $issues | Where-Object { [datetime]$_.updatedAt -gt (Get-Date).AddDays(-$LookbackDays) }
# Prioritize: trending first, then by recency
$prioritized = $recent | Sort-Object { -$_.comments.Count }, { [datetime]$_.updatedAt } -Descending
$IssueNumbers = ($prioritized | Select-Object -First $MaxIssues).number
Write-Host " Found $($recent.Count) recent issues, selected top $($IssueNumbers.Count) for analysis" -ForegroundColor Green
}
Write-Host ""
Write-Host "Issues to analyze: $($IssueNumbers -join ', ')" -ForegroundColor Cyan
Write-Host "Timeout: ${TimeoutMinutes}m | Retries: $MaxRetryCount" -ForegroundColor Gray
Write-Host ""
# Results tracking
$results = @{}
$startTime = Get-Date
$totalIssues = $IssueNumbers.Count
$current = 0
foreach ($issueNum in $IssueNumbers) {
$current++
$issueDir = Join-Path $issueCachePath $issueNum
if (-not (Test-Path $issueDir)) {
New-Item -ItemType Directory -Path $issueDir -Force | Out-Null
}
$logFile = Join-Path $issueDir "analysis.log"
$errorFile = Join-Path $issueDir "error.log"
$statusFile = Join-Path $issueDir "status.json"
Write-Host ""
Write-Host "[$current/$totalIssues] #$issueNum - Beginning analysis..." -ForegroundColor Yellow
$success = $false
$lastError = $null
$retryCount = 0
for ($retry = 0; $retry -lt $MaxRetryCount -and -not $success; $retry++) {
$retryCount = $retry + 1
if ($retry -gt 0) {
Write-Host " [RETRY] Attempt $retryCount/$MaxRetryCount (waiting 10s)..." -ForegroundColor Yellow
Start-Sleep -Seconds 10
}
try {
# Build the prompt - use the review-issue prompt directly
$prompt = @"
Analyze GitHub issue #$issueNum using the methodology from $_cfgDir/prompts/review-issue.prompt.md
First, fetch the issue data:
gh issue view $issueNum --json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments,linkedPullRequests
Then produce a concise JSON summary with this structure (output ONLY the JSON):
{
"issueNumber": $issueNum,
"title": "issue title",
"category": "trending|needs-label|ready-for-fix|needs-info|needs-clarification|closeable|stale-waiting|duplicate-candidate|review-needed",
"categoryReason": "brief explanation",
"priorityScore": 0-100,
"clarityScore": 0-100,
"feasibilityScore": 0-100,
"suggestedAction": "what human should do",
"suggestedLabels": ["label1", "label2"],
"missingInfo": ["item1", "item2"],
"draftReply": "if needs-info or needs-clarification, draft the reply"
}
"@
# Build Copilot CLI arguments
$copilotArgs = @('-p', $prompt, '--yolo', '--agent', 'ReviewIssue')
if ($Model) {
$copilotArgs += @('--model', $Model)
}
Write-Host " Running copilot CLI..." -ForegroundColor Gray
# Run copilot directly (not in job)
$output = & copilot @copilotArgs 2>&1
$outputStr = $output | Out-String
# Save the output
$outputStr | Out-File -FilePath $logFile -Force
# Check for valid output
if ($outputStr.Length -gt 200) {
$success = $true
Write-Host " [SUCCESS] Analysis complete ($($outputStr.Length) chars)" -ForegroundColor Green
}
else {
$lastError = "Output too short ($($outputStr.Length) chars)"
Write-Host " [WARN] $lastError" -ForegroundColor Yellow
}
}
catch {
$lastError = $_.Exception.Message
Write-Host " [ERROR] $lastError" -ForegroundColor Red
}
}
# Save status
$status = @{
issueNumber = $issueNum
success = $success
attempts = $retryCount
lastError = $lastError
analyzedAt = (Get-Date).ToUniversalTime().ToString("o")
}
$status | ConvertTo-Json | Out-File -FilePath $statusFile -Force
$results[$issueNum] = $status
if (-not $success) {
$lastError | Out-File -FilePath $errorFile -Force
Write-Host " [FAILED] All $MaxRetryCount attempts failed: $lastError" -ForegroundColor Red
}
}
$elapsed = (Get-Date) - $startTime
Write-Host ""
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Analysis Complete" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
Write-Host "Duration: $([math]::Round($elapsed.TotalMinutes, 1)) minutes" -ForegroundColor Gray
Write-Host "Total issues: $($IssueNumbers.Count)" -ForegroundColor Gray
$successCount = ($results.Values | Where-Object { $_.success }).Count
$failCount = ($results.Values | Where-Object { -not $_.success }).Count
Write-Host "Successful: $successCount" -ForegroundColor Green
Write-Host "Failed: $failCount" -ForegroundColor $(if ($failCount -gt 0) { 'Red' } else { 'Gray' })
if ($failCount -gt 0) {
Write-Host ""
Write-Host "Failed issues:" -ForegroundColor Red
$results.Values | Where-Object { -not $_.success } | ForEach-Object {
Write-Host " #$($_.issueNumber): $($_.lastError)" -ForegroundColor Red
}
}
Write-Host ""
Write-Host "Results saved to: $issueCachePath" -ForegroundColor Cyan

View File

@@ -0,0 +1,376 @@
<#
.SYNOPSIS
Categorizes collected issues into actionable buckets.
.DESCRIPTION
Applies categorization rules to issues collected by collect-active-issues.ps1.
Outputs categorized results with priority scores and suggested actions.
.PARAMETER InputPath
Path to collected issues JSON. Default: Generated Files/triage-issues/current-run/collected-issues.json
.PARAMETER StatePath
Path to triage state JSON. Default: Generated Files/triage-issues/triage-state.json
.PARAMETER OutputPath
Path to save categorized results. Default: Generated Files/triage-issues/current-run/categorized-issues.json
.PARAMETER TrendingThreshold
Minimum new comments to flag as trending. Default: 5
.EXAMPLE
.\categorize-issues.ps1
.EXAMPLE
.\categorize-issues.ps1 -TrendingThreshold 10
#>
param(
[Parameter()]
[string]$InputPath = "Generated Files/triage-issues/current-run/collected-issues.json",
[Parameter()]
[string]$StatePath = "Generated Files/triage-issues/triage-state.json",
[Parameter()]
[string]$OutputPath = "Generated Files/triage-issues/current-run/categorized-issues.json",
[Parameter()]
[int]$TrendingThreshold = 5
)
$ErrorActionPreference = "Stop"
# Product keyword mapping
$ProductKeywords = @{
"Product-FancyZones" = @("fancy zones", "fancyzones", "zone", "snap", "layout", "window arrangement", "virtual desktop")
"Product-PowerToys Run" = @("run", "launcher", "alt+space", "alt space", "search", "plugin", "powertoys run")
"Product-Color Picker" = @("color picker", "colorpicker", "eyedropper", "hex", "rgb", "color code")
"Product-Keyboard Manager" = @("keyboard", "remap", "shortcut", "key mapping", "keyboard manager")
"Product-Mouse Utils" = @("mouse", "crosshairs", "find my mouse", "highlighter", "pointer", "mouse without borders")
"Product-File Explorer" = @("file explorer", "preview", "thumbnail", "markdown preview", "svg preview", "preview pane")
"Product-Image Resizer" = @("image resizer", "resize image", "bulk resize", "resize pictures")
"Product-PowerRename" = @("rename", "power rename", "powerrename", "bulk rename", "regex rename")
"Product-Awake" = @("awake", "keep awake", "prevent sleep", "caffeinate", "stay awake")
"Product-Shortcut Guide" = @("shortcut guide", "win key", "windows key guide")
"Product-Text Extractor" = @("text extractor", "ocr", "screen text", "copy text from screen")
"Product-Hosts File Editor" = @("hosts", "hosts file", "dns mapping")
"Product-Peek" = @("peek", "quick preview", "spacebar preview", "file peek")
"Product-Crop And Lock" = @("crop", "crop and lock", "window crop", "cropped window")
"Product-Paste As Plain Text" = @("paste", "plain text", "paste as plain")
"Product-Registry Preview" = @("registry", "reg file", "registry preview")
"Product-Environment Variables" = @("environment", "env variable", "path variable", "system variable")
"Product-Command Not Found" = @("command not found", "winget suggest", "command suggestion")
"Product-New+" = @("new\+", "newplus", "file template", "new file")
"Product-Advanced Paste" = @("advanced paste", "ai paste", "clipboard ai", "smart paste")
"Product-Workspaces" = @("workspaces", "workspace launcher", "project layout")
"Product-Cmd Palette" = @("command palette", "cmd palette", "quick command")
"Product-ZoomIt" = @("zoomit", "zoom it", "screen zoom", "presentation zoom")
}
# Load collected issues
if (-not (Test-Path $InputPath)) {
Write-Error "Input file not found: $InputPath. Run collect-active-issues.ps1 first."
exit 1
}
$collected = Get-Content $InputPath | ConvertFrom-Json
# Load previous state
$previousState = $null
if (Test-Path $StatePath) {
$previousState = Get-Content $StatePath | ConvertFrom-Json
}
function Get-IssueDetails {
param([int]$IssueNumber)
$json = gh issue view $IssueNumber `
--json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments,linkedPullRequests 2>$null
if (-not $json) { return $null }
$issue = $json | ConvertFrom-Json
return @{
number = $issue.number
title = $issue.title
body = $issue.body
author = $issue.author.login
state = $issue.state
createdAt = $issue.createdAt
updatedAt = $issue.updatedAt
labels = @($issue.labels | ForEach-Object { $_.name })
milestone = $issue.milestone.title
reactions = @{
thumbsUp = ($issue.reactions | Where-Object { $_.content -eq "THUMBS_UP" }).Count
thumbsDown = ($issue.reactions | Where-Object { $_.content -eq "THUMBS_DOWN" }).Count
heart = ($issue.reactions | Where-Object { $_.content -eq "HEART" }).Count
}
commentCount = $issue.comments.Count
comments = @($issue.comments | ForEach-Object {
@{
author = $_.author.login
createdAt = $_.createdAt
body = $_.body
}
})
linkedPRs = @($issue.linkedPullRequests | ForEach-Object {
@{
number = $_.number
state = $_.state
mergedAt = $_.mergedAt
}
})
}
}
function Get-LabelSuggestion {
param([hashtable]$Issue)
$titleLower = $Issue.title.ToLower()
$bodyLower = if ($Issue.body) { $Issue.body.ToLower() } else { "" }
$combined = "$titleLower $bodyLower"
$matches = @()
foreach ($product in $ProductKeywords.Keys) {
$keywords = $ProductKeywords[$product]
$matchCount = ($keywords | Where-Object { $combined -match $_ }).Count
if ($matchCount -gt 0) {
$matches += @{
label = $product
matchCount = $matchCount
confidence = [Math]::Min(100, $matchCount * 25 + 25)
}
}
}
$best = $matches | Sort-Object confidence -Descending | Select-Object -First 1
if ($best -and $best.confidence -ge 50) {
return @{
labels = @($best.label)
confidence = $best.confidence
reason = "Matched $($best.matchCount) keywords"
}
}
return @{ labels = @(); confidence = 0; reason = "No confident match" }
}
function Get-PriorityScore {
param([hashtable]$Issue)
$score = 50
# Reactions
$score += [Math]::Min(20, $Issue.reactions.thumbsUp * 2)
# Comments
$score += [Math]::Min(15, $Issue.commentCount)
# Recency
$daysSinceUpdate = ((Get-Date) - [datetime]$Issue.updatedAt).Days
if ($daysSinceUpdate -le 7) { $score += 10 }
elseif ($daysSinceUpdate -le 30) { $score += 5 }
# Labels
if ($Issue.labels -contains "Priority-High") { $score += 15 }
if ($Issue.labels -match "Regression") { $score += 20 }
if ($Issue.labels -match "Security") { $score += 25 }
return [Math]::Min(100, $score)
}
# Process each issue
$categorized = @{}
$issueCount = $collected.issues.Count
$current = 0
Write-Host "Categorizing $issueCount issues..."
Write-Host ""
foreach ($collectedIssue in $collected.issues) {
$current++
$issueNum = $collectedIssue.number
Write-Host "[$current/$issueCount] Processing #$issueNum..."
# Get full issue details
$issue = Get-IssueDetails -IssueNumber $issueNum
if (-not $issue) {
Write-Host " Warning: Could not fetch issue #$issueNum"
continue
}
# Get previous snapshot
$previousSnapshot = $null
if ($previousState -and $previousState.issueSnapshots.$issueNum) {
$previousSnapshot = $previousState.issueSnapshots.$issueNum
}
# Calculate new comments
$previousCommentCount = if ($previousSnapshot) { $previousSnapshot.commentCount } else { 0 }
$newComments = $issue.commentCount - $previousCommentCount
# Categorize (priority order - first match wins)
$category = $null
$categoryReason = $null
$suggestedAction = $null
$additionalData = @{}
# 1. Trending
if ($newComments -ge $TrendingThreshold) {
$category = "trending"
$categoryReason = "$newComments new comments since last run"
$suggestedAction = "Review conversation urgently"
}
# 2. Closeable (check for merged PRs)
if (-not $category) {
$mergedPRs = $issue.linkedPRs | Where-Object { $_.state -eq "MERGED" }
if ($mergedPRs.Count -gt 0 -and $issue.state -eq "OPEN") {
$category = "closeable"
$categoryReason = "Has merged PR(s): #" + ($mergedPRs.number -join ", #")
$suggestedAction = "Close with thank you message"
$additionalData.mergedPRs = $mergedPRs.number
}
}
# 3. Needs-Label
if (-not $category) {
$productLabels = $issue.labels | Where-Object { $_ -like "Product-*" }
$areaLabels = $issue.labels | Where-Object { $_ -like "Area-*" }
if ($productLabels.Count -eq 0 -and $areaLabels.Count -eq 0) {
$suggestion = Get-LabelSuggestion -Issue $issue
$category = "needs-label"
$categoryReason = "Missing Product/Area label"
$suggestedAction = "Apply label: $($suggestion.labels -join ', ')"
$additionalData.suggestedLabels = $suggestion.labels
$additionalData.labelConfidence = $suggestion.confidence
}
}
# 4. Stale-Waiting
if (-not $category) {
if ($issue.labels -contains "Needs-Author-Feedback") {
$lastAuthorComment = $issue.comments |
Where-Object { $_.author -eq $issue.author } |
Sort-Object createdAt -Descending |
Select-Object -First 1
if ($lastAuthorComment) {
$daysSince = ((Get-Date) - [datetime]$lastAuthorComment.createdAt).Days
if ($daysSince -gt 14) {
$category = "stale-waiting"
$categoryReason = "Waiting on author for $daysSince days"
$suggestedAction = "Ping or close"
$additionalData.daysWaiting = $daysSince
}
}
}
}
# 5. Needs-Clarification (question, not bug)
if (-not $category) {
$isQuestion = $false
$titleAndBody = "$($issue.title) $($issue.body)"
if ($titleAndBody -match '\?$' -or
$titleAndBody -match '(?i)(how (do|can|to)|why (does|is)|is (it|there) possible)' -or
$issue.labels -contains "Issue-Question") {
$isQuestion = $true
}
if ($isQuestion -and ($issue.labels -notcontains "Issue-Bug")) {
$category = "needs-clarification"
$categoryReason = "Appears to be a question/inquiry"
$suggestedAction = "Draft explanation reply"
}
}
# 6. Needs-Info
if (-not $category) {
$missingItems = @()
$body = $issue.body
if ($body -and $body.Length -gt 0) {
if ($body -notmatch '(?i)(steps to reproduce|repro|how to reproduce)') {
$missingItems += "repro steps"
}
if ($body -notmatch '(?i)(expected|should|supposed to)') {
$missingItems += "expected behavior"
}
if ($body -notmatch '(?i)(version|v\d+\.\d+)') {
$missingItems += "PowerToys version"
}
} else {
$missingItems += "description"
}
if ($missingItems.Count -gt 0) {
$category = "needs-info"
$categoryReason = "Missing: " + ($missingItems -join ", ")
$suggestedAction = "Post clarifying questions"
$additionalData.missingItems = $missingItems
}
}
# 7. Default: review-needed
if (-not $category) {
$category = "review-needed"
$categoryReason = "Needs human review for categorization"
$suggestedAction = "Manual triage"
}
# Calculate priority score
$priorityScore = Get-PriorityScore -Issue $issue
# Store result
$categorized[$issueNum] = @{
number = $issue.number
title = $issue.title
state = $issue.state
labels = $issue.labels
category = $category
categoryReason = $categoryReason
priorityScore = $priorityScore
suggestedAction = $suggestedAction
newComments = $newComments
totalComments = $issue.commentCount
reactions = $issue.reactions
updatedAt = $issue.updatedAt
additionalData = $additionalData
}
Write-Host " -> $category (priority: $priorityScore)"
}
# Group by category for summary
$byCategory = $categorized.Values | Group-Object category
Write-Host ""
Write-Host "=== Categorization Summary ==="
foreach ($group in $byCategory | Sort-Object Count -Descending) {
Write-Host " $($group.Name): $($group.Count) issues"
}
# Save results
$output = @{
categorizedAt = (Get-Date).ToUniversalTime().ToString("o")
totalCategorized = $categorized.Count
byCategory = @{}
issues = $categorized
}
foreach ($group in $byCategory) {
$output.byCategory[$group.Name] = @{
count = $group.Count
topIssues = @($group.Group | Sort-Object priorityScore -Descending | Select-Object -First 3 | ForEach-Object { $_.number })
}
}
$output | ConvertTo-Json -Depth 10 | Set-Content $OutputPath
Write-Host ""
Write-Host "Results saved to: $OutputPath"

View File

@@ -0,0 +1,188 @@
<#
.SYNOPSIS
Collects GitHub issues with activity since the last triage run.
.DESCRIPTION
Fetches open issues updated since the last run, closed issues with new comments,
and issues with pending follow-up actions.
.PARAMETER Since
ISO 8601 datetime string. Collect issues updated after this time.
If not specified, reads from triage-state.json.
.PARAMETER LookbackDays
For first run (no state), how many days to look back. Default: 7.
.PARAMETER OutputPath
Path to save collected issues JSON. Default: Generated Files/triage-issues/current-run/collected-issues.json
.PARAMETER Limit
Maximum issues to collect per query. Default: 500.
.EXAMPLE
.\collect-active-issues.ps1
.EXAMPLE
.\collect-active-issues.ps1 -Since "2026-01-29T00:00:00Z" -Limit 100
#>
param(
[Parameter()]
[string]$Since,
[Parameter()]
[int]$LookbackDays = 7,
[Parameter()]
[string]$OutputPath = "Generated Files/triage-issues/current-run/collected-issues.json",
[Parameter()]
[int]$Limit = 500
)
$ErrorActionPreference = "Stop"
# Determine the "since" timestamp
if (-not $Since) {
$statePath = "Generated Files/triage-issues/triage-state.json"
if (Test-Path $statePath) {
$state = Get-Content $statePath | ConvertFrom-Json
if ($state.lastRun) {
$Since = $state.lastRun
Write-Host "Using last run timestamp: $Since"
}
}
if (-not $Since) {
$Since = (Get-Date).AddDays(-$LookbackDays).ToUniversalTime().ToString("o")
Write-Host "First run - looking back $LookbackDays days to: $Since"
}
}
$sinceDate = [datetime]$Since
# Ensure output directory exists
$outputDir = Split-Path $OutputPath -Parent
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
}
$collectedIssues = @()
# 1. Collect open issues updated since last run
Write-Host "Fetching open issues updated since $Since..."
$openIssues = gh issue list `
--state open `
--json number,title,updatedAt `
--limit $Limit 2>$null | ConvertFrom-Json
$filteredOpen = $openIssues | Where-Object {
[datetime]$_.updatedAt -gt $sinceDate
}
Write-Host " Found $($filteredOpen.Count) open issues with recent activity"
foreach ($issue in $filteredOpen) {
$collectedIssues += @{
number = $issue.number
title = $issue.title
source = "open-updated"
updatedAt = $issue.updatedAt
}
}
# 2. Collect closed issues with recent activity (within tracking window)
Write-Host "Fetching closed issues with recent comments..."
$trackingDays = 30
$trackingCutoff = (Get-Date).AddDays(-$trackingDays)
$closedIssues = gh issue list `
--state closed `
--json number,title,updatedAt,closedAt `
--limit 200 2>$null | ConvertFrom-Json
$activeClosedIssues = $closedIssues | Where-Object {
$closedAt = [datetime]$_.closedAt
$updatedAt = [datetime]$_.updatedAt
# Closed within tracking window AND updated after being closed
($closedAt -gt $trackingCutoff) -and ($updatedAt -gt $closedAt)
}
Write-Host " Found $($activeClosedIssues.Count) closed issues with post-close activity"
foreach ($issue in $activeClosedIssues) {
$collectedIssues += @{
number = $issue.number
title = $issue.title
source = "closed-with-activity"
updatedAt = $issue.updatedAt
closedAt = $issue.closedAt
}
}
# 3. Check pending follow-ups from state
if (Test-Path $statePath) {
$state = Get-Content $statePath | ConvertFrom-Json
if ($state.pendingFollowUps) {
Write-Host "Checking $($state.pendingFollowUps.Count) pending follow-ups..."
foreach ($pending in $state.pendingFollowUps) {
if ($pending.status -eq "pending") {
if ($collectedIssues.number -notcontains $pending.issueNumber) {
$collectedIssues += @{
number = $pending.issueNumber
source = "pending-followup"
action = $pending.action
}
}
}
}
}
# Check unhandled issues from previous run
if ($state.issueSnapshots) {
$unhandled = $state.issueSnapshots.PSObject.Properties | Where-Object {
$snapshot = $_.Value
$snapshot.pendingAction -and -not $snapshot.actionTaken
}
if ($unhandled) {
Write-Host "Found $($unhandled.Count) unhandled issues from previous run"
foreach ($prop in $unhandled) {
$snapshot = $prop.Value
if ($collectedIssues.number -notcontains $snapshot.number) {
$collectedIssues += @{
number = $snapshot.number
title = $snapshot.title
source = "unhandled-previous"
previousCategory = $snapshot.category
}
}
}
}
}
}
# Deduplicate by issue number
$uniqueIssues = $collectedIssues | Group-Object number | ForEach-Object {
$_.Group | Select-Object -First 1
}
# Summary
Write-Host ""
Write-Host "=== Collection Summary ==="
Write-Host "Total unique issues: $($uniqueIssues.Count)"
Write-Host " - Open with activity: $(($uniqueIssues | Where-Object { $_.source -eq 'open-updated' }).Count)"
Write-Host " - Closed with activity: $(($uniqueIssues | Where-Object { $_.source -eq 'closed-with-activity' }).Count)"
Write-Host " - Pending follow-ups: $(($uniqueIssues | Where-Object { $_.source -eq 'pending-followup' }).Count)"
Write-Host " - Unhandled previous: $(($uniqueIssues | Where-Object { $_.source -eq 'unhandled-previous' }).Count)"
# Save results
$output = @{
collectedAt = (Get-Date).ToUniversalTime().ToString("o")
since = $Since
totalCount = $uniqueIssues.Count
issues = $uniqueIssues
}
$output | ConvertTo-Json -Depth 10 | Set-Content $OutputPath
Write-Host ""
Write-Host "Results saved to: $OutputPath"

View File

@@ -0,0 +1,210 @@
<#
.SYNOPSIS
Generates executive summary and category reports from categorized issues.
.DESCRIPTION
Creates markdown reports for each category and an executive summary
for the current triage run.
.PARAMETER InputPath
Path to categorized issues JSON. Default: Generated Files/triage-issues/current-run/categorized-issues.json
.PARAMETER OutputPath
Directory for generated reports. Default: Generated Files/triage-issues/current-run
.PARAMETER RepoUrl
GitHub repository URL for issue links. Default: https://github.com/microsoft/PowerToys/issues
.EXAMPLE
.\generate-summary.ps1
#>
param(
[Parameter()]
[string]$InputPath = "Generated Files/triage-issues/current-run/categorized-issues.json",
[Parameter()]
[string]$OutputPath = "Generated Files/triage-issues/current-run",
[Parameter()]
[string]$RepoUrl = "https://github.com/microsoft/PowerToys/issues"
)
$ErrorActionPreference = "Stop"
# Category display info
$CategoryInfo = @{
"trending" = @{ emoji = "🔥"; name = "Trending"; priority = 1 }
"needs-label" = @{ emoji = "🏷️"; name = "Needs-Label"; priority = 2 }
"ready-for-fix" = @{ emoji = ""; name = "Ready-for-Fix"; priority = 3 }
"needs-info" = @{ emoji = ""; name = "Needs-Info"; priority = 4 }
"needs-clarification" = @{ emoji = "💬"; name = "Needs-Clarification"; priority = 5 }
"closeable" = @{ emoji = "✔️"; name = "Closeable"; priority = 6 }
"stale-waiting" = @{ emoji = ""; name = "Stale-Waiting"; priority = 7 }
"duplicate-candidate" = @{ emoji = "🔁"; name = "Duplicate-Candidate"; priority = 8 }
"review-needed" = @{ emoji = "👀"; name = "Review-Needed"; priority = 9 }
}
# Load categorized issues
if (-not (Test-Path $InputPath)) {
Write-Error "Input file not found: $InputPath. Run categorize-issues.ps1 first."
exit 1
}
$data = Get-Content $InputPath | ConvertFrom-Json -AsHashtable
# Ensure output directories
New-Item -ItemType Directory -Force -Path $OutputPath | Out-Null
New-Item -ItemType Directory -Force -Path "$OutputPath/draft-replies" | Out-Null
# Group issues by category
$byCategory = @{}
foreach ($issueNum in $data.issues.Keys) {
$issue = $data.issues[$issueNum]
$cat = $issue.category
if (-not $byCategory[$cat]) {
$byCategory[$cat] = @()
}
$byCategory[$cat] += $issue
}
# Sort each category by priority
foreach ($cat in $byCategory.Keys) {
$byCategory[$cat] = $byCategory[$cat] | Sort-Object priorityScore -Descending
}
# Generate Executive Summary
$summaryLines = @()
$summaryLines += "# Issue Triage Summary - $(Get-Date -Format 'yyyy-MM-dd')"
$summaryLines += ""
$summaryLines += "**Run Time**: $(Get-Date -Format 'HH:mm UTC') | **Issues Analyzed**: $($data.totalCategorized)"
$summaryLines += ""
$summaryLines += "## ⚡ Action Required by Category"
$summaryLines += ""
$summaryLines += "| Category | Count | Top Priority | Suggested Action |"
$summaryLines += "|----------|-------|--------------|------------------|"
foreach ($catId in $CategoryInfo.Keys | Sort-Object { $CategoryInfo[$_].priority }) {
$info = $CategoryInfo[$catId]
$issues = $byCategory[$catId]
if ($issues -and $issues.Count -gt 0) {
$top = $issues[0]
$topLink = "[#$($top.number)]($RepoUrl/$($top.number))"
$topInfo = $top.categoryReason
if ($topInfo.Length -gt 40) { $topInfo = $topInfo.Substring(0, 37) + "..." }
$summaryLines += "| $($info.emoji) $($info.name) | $($issues.Count) | $topLink | $topInfo |"
}
}
$summaryLines += ""
$summaryLines += "## 🎯 Top 10 Priority Actions"
$summaryLines += ""
# Get top 10 across all categories
$allIssues = @()
foreach ($cat in $byCategory.Keys) {
$allIssues += $byCategory[$cat]
}
$topIssues = $allIssues | Sort-Object priorityScore -Descending | Select-Object -First 10
$priority = 1
foreach ($issue in $topIssues) {
$info = $CategoryInfo[$issue.category]
$urgency = if ($issue.priorityScore -ge 80) { "**[Urgent]**" }
elseif ($issue.priorityScore -ge 60) { "**[High]**" }
elseif ($issue.priorityScore -ge 40) { "[Medium]" }
else { "[Low]" }
$summaryLines += "$priority. $urgency $($info.emoji) [#$($issue.number)]($RepoUrl/$($issue.number)) - $($issue.categoryReason)"
$priority++
}
$summaryLines += ""
$summaryLines += "## 📁 Detailed Reports"
$summaryLines += ""
foreach ($catId in $CategoryInfo.Keys | Sort-Object { $CategoryInfo[$_].priority }) {
$info = $CategoryInfo[$catId]
$issues = $byCategory[$catId]
if ($issues -and $issues.Count -gt 0) {
$summaryLines += "- [$($info.emoji) $($info.name)](./$catId.md) ($($issues.Count) issues)"
}
}
$summaryLines += ""
$summaryLines += "---"
$summaryLines += "*Generated by continuous-issue-triage skill*"
$summaryLines -join "`n" | Set-Content "$OutputPath/summary.md"
Write-Host "Generated: summary.md"
# Generate individual category reports
foreach ($catId in $byCategory.Keys) {
$info = $CategoryInfo[$catId]
$issues = $byCategory[$catId]
if (-not $issues -or $issues.Count -eq 0) { continue }
$reportLines = @()
$reportLines += "# $($info.emoji) $($info.name) Issues"
$reportLines += ""
$reportLines += "**Total**: $($issues.Count) issues"
$reportLines += ""
$reportLines += "## Overview"
$reportLines += ""
$reportLines += "| # | Issue | Priority | Reason | Labels |"
$reportLines += "|---|-------|----------|--------|--------|"
foreach ($issue in $issues) {
$labelStr = ($issue.labels | Select-Object -First 3) -join ", "
if ($issue.labels.Count -gt 3) { $labelStr += "..." }
$reason = $issue.categoryReason
if ($reason.Length -gt 50) { $reason = $reason.Substring(0, 47) + "..." }
$reportLines += "| [#$($issue.number)]($RepoUrl/$($issue.number)) | $($issue.title.Substring(0, [Math]::Min(50, $issue.title.Length))) | $($issue.priorityScore)/100 | $reason | $labelStr |"
}
$reportLines += ""
$reportLines += "## Detailed Breakdown"
$reportLines += ""
foreach ($issue in $issues) {
$reportLines += "### [#$($issue.number)]($RepoUrl/$($issue.number)): $($issue.title)"
$reportLines += ""
$reportLines += "- **Priority Score**: $($issue.priorityScore)/100"
$reportLines += "- **Category Reason**: $($issue.categoryReason)"
$reportLines += "- **Suggested Action**: $($issue.suggestedAction)"
$reportLines += "- **Reactions**: 👍 $($issue.reactions.thumbsUp) | ❤️ $($issue.reactions.heart)"
$reportLines += "- **Comments**: $($issue.totalComments) total ($($issue.newComments) new)"
$reportLines += "- **Labels**: $($issue.labels -join ', ')"
if ($issue.additionalData) {
if ($issue.additionalData.suggestedLabels) {
$reportLines += "- **Suggested Labels**: $($issue.additionalData.suggestedLabels -join ', ') (confidence: $($issue.additionalData.labelConfidence)%)"
}
if ($issue.additionalData.missingItems) {
$reportLines += "- **Missing Info**: $($issue.additionalData.missingItems -join ', ')"
}
if ($issue.additionalData.mergedPRs) {
$reportLines += "- **Merged PRs**: #$($issue.additionalData.mergedPRs -join ', #')"
}
if ($issue.additionalData.daysWaiting) {
$reportLines += "- **Days Waiting**: $($issue.additionalData.daysWaiting)"
}
}
$reportLines += ""
$reportLines += "---"
$reportLines += ""
}
$reportLines -join "`n" | Set-Content "$OutputPath/$catId.md"
Write-Host "Generated: $catId.md"
}
Write-Host ""
Write-Host "All reports generated in: $OutputPath"
Write-Host "Start with: summary.md"

View File

@@ -0,0 +1,692 @@
<#
.SYNOPSIS
Runs continuous issue triage using GitHub Copilot CLI with parallel processing.
.DESCRIPTION
Orchestrates the full triage workflow:
1. Collects active issues
2. Analyzes issues in parallel using Copilot CLI
3. Categorizes results
4. Generates reports
5. Updates state for delta tracking
.PARAMETER RunType
Type of triage run: daily, twice-weekly, weekly. Default: weekly
.PARAMETER MaxParallel
Maximum parallel Copilot CLI invocations. Default: 5
.PARAMETER TimeoutMinutes
Timeout for each Copilot analysis. Default: 5
.PARAMETER MaxRetries
Maximum retries on timeout. Default: 3
.PARAMETER Model
Copilot model to use (optional).
.PARAMETER McpConfig
Path to MCP config file (optional).
.PARAMETER LookbackDays
For first run, days to look back. Default: 7
.PARAMETER Force
Force re-analysis of all issues, ignoring cache.
.EXAMPLE
.\run-triage.ps1
.EXAMPLE
.\run-triage.ps1 -RunType daily -MaxParallel 10 -Model "claude-sonnet-4"
#>
[CmdletBinding()]
param(
[Parameter()]
[ValidateSet("daily", "twice-weekly", "weekly")]
[string]$RunType = "weekly",
[Parameter()]
[int]$MaxParallel = 5,
[Parameter()]
[int]$TimeoutMinutes = 5,
[Parameter()]
[int]$MaxRetries = 3,
[Parameter()]
[string]$Model,
[Parameter()]
[string]$McpConfig,
[Parameter()]
[int]$LookbackDays = 7,
[Parameter()]
[switch]$Force
)
$ErrorActionPreference = "Stop"
$repoRoot = git rev-parse --show-toplevel 2>$null
# Resolve config directory name (.github or .claude) from script location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
if (-not $repoRoot) {
$repoRoot = (Get-Location).Path
}
# Paths
$triageRoot = Join-Path $repoRoot "Generated Files/triage-issues"
$currentRunPath = Join-Path $triageRoot "current-run"
$statePath = Join-Path $triageRoot "triage-state.json"
$issueCachePath = Join-Path $triageRoot "issue-cache"
$historyPath = Join-Path $triageRoot "history"
# Ensure directories exist
@($triageRoot, $currentRunPath, $issueCachePath, $historyPath) | ForEach-Object {
if (-not (Test-Path $_)) {
New-Item -ItemType Directory -Path $_ -Force | Out-Null
}
}
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " PowerToys Issue Triage - $RunType run" -ForegroundColor Cyan
Write-Host " Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host ""
#region State Management
Write-Host "[1/6] Loading previous state..." -ForegroundColor Yellow
$state = $null
if (Test-Path $statePath) {
$state = Get-Content $statePath -Raw | ConvertFrom-Json -AsHashtable
Write-Host " ✓ Loaded state from: $($state.lastRun)" -ForegroundColor Green
Write-Host " Previous run type: $($state.lastRunType)" -ForegroundColor Gray
Write-Host " Known issues: $($state.issueSnapshots.Count)" -ForegroundColor Gray
} else {
Write-Host " First run - initializing fresh state" -ForegroundColor Yellow
$state = @{
version = "1.0"
lastRun = $null
lastRunType = $null
issueSnapshots = @{}
pendingFollowUps = @()
closedWithActivity = @()
analysisResults = @{}
statistics = @{
totalRunCount = 0
issuesAnalyzed = 0
repliesPosted = 0
issuesClosed = 0
}
}
}
#endregion
#region Issue Collection
Write-Host ""
Write-Host "[2/6] Collecting active issues..." -ForegroundColor Yellow
$since = if ($state.lastRun) { $state.lastRun } else { (Get-Date).AddDays(-$LookbackDays).ToUniversalTime().ToString("o") }
Write-Host " Looking for issues updated since: $since" -ForegroundColor Gray
# Collect open issues with recent activity
$openIssuesJson = gh issue list --state open --json number,title,updatedAt,labels --limit 500 2>$null
$openIssues = $openIssuesJson | ConvertFrom-Json | Where-Object {
[datetime]$_.updatedAt -gt [datetime]$since
}
# Collect closed issues with post-close activity (within 30 days)
$closedIssuesJson = gh issue list --state closed --json number,title,updatedAt,closedAt --limit 200 2>$null
$closedIssues = $closedIssuesJson | ConvertFrom-Json | Where-Object {
$closedAt = [datetime]$_.closedAt
$updatedAt = [datetime]$_.updatedAt
$cutoff = (Get-Date).AddDays(-30)
($closedAt -gt $cutoff) -and ($updatedAt -gt $closedAt)
}
# Combine and dedupe
$allIssues = @()
$allIssues += $openIssues | ForEach-Object { @{ number = $_.number; title = $_.title; state = "open"; updatedAt = $_.updatedAt } }
$allIssues += $closedIssues | ForEach-Object { @{ number = $_.number; title = $_.title; state = "closed"; updatedAt = $_.updatedAt } }
# Add pending follow-ups from previous run
if ($state.pendingFollowUps) {
foreach ($pending in $state.pendingFollowUps) {
if ($pending.status -eq "pending" -and ($allIssues.number -notcontains $pending.issueNumber)) {
$allIssues += @{ number = $pending.issueNumber; title = "pending-followup"; state = "unknown" }
}
}
}
$uniqueIssues = $allIssues | Group-Object number | ForEach-Object { $_.Group | Select-Object -First 1 }
Write-Host " ✓ Found $($uniqueIssues.Count) issues to analyze" -ForegroundColor Green
Write-Host " - Open with activity: $(($uniqueIssues | Where-Object { $_.state -eq 'open' }).Count)" -ForegroundColor Gray
Write-Host " - Closed with activity: $(($uniqueIssues | Where-Object { $_.state -eq 'closed' }).Count)" -ForegroundColor Gray
#endregion
#region Filter for Analysis
Write-Host ""
Write-Host "[3/6] Filtering issues for analysis..." -ForegroundColor Yellow
$issuesToAnalyze = @()
foreach ($issue in $uniqueIssues) {
$issueNum = $issue.number
$cached = $state.analysisResults[$issueNum.ToString()]
$needsAnalysis = $false
$reason = ""
if ($Force) {
$needsAnalysis = $true
$reason = "forced"
}
elseif (-not $cached) {
$needsAnalysis = $true
$reason = "new"
}
elseif ($cached.analyzedAt) {
$daysSinceAnalysis = ((Get-Date) - [datetime]$cached.analyzedAt).Days
if ($daysSinceAnalysis -gt 7) {
$needsAnalysis = $true
$reason = "stale-cache"
}
elseif ($cached.commentCountAtAnalysis -and $state.issueSnapshots[$issueNum.ToString()]) {
$previousCount = $state.issueSnapshots[$issueNum.ToString()].commentCount
if ($cached.commentCountAtAnalysis -lt $previousCount) {
$needsAnalysis = $true
$reason = "new-comments"
}
}
}
if ($needsAnalysis) {
$issuesToAnalyze += @{
number = $issueNum
title = $issue.title
state = $issue.state
reason = $reason
}
}
}
Write-Host "$($issuesToAnalyze.Count) issues need analysis" -ForegroundColor Green
Write-Host "$($uniqueIssues.Count - $issuesToAnalyze.Count) issues using cached results" -ForegroundColor Gray
#endregion
#region Parallel Copilot Analysis
Write-Host ""
Write-Host "[4/6] Running parallel Copilot analysis..." -ForegroundColor Yellow
Write-Host " Max parallel: $MaxParallel | Timeout: ${TimeoutMinutes}m | Max retries: $MaxRetries" -ForegroundColor Gray
Write-Host ""
# Prepare the prompt template
$promptTemplate = @"
Analyze GitHub issue #ISSUE_NUMBER for PowerToys triage.
Use the review-issue prompt methodology from $_cfgDir/prompts/review-issue.prompt.md.
Output a JSON summary to stdout with this structure:
{
"issueNumber": ISSUE_NUMBER,
"category": "trending|needs-label|ready-for-fix|needs-info|needs-clarification|closeable|stale-waiting|duplicate-candidate|review-needed",
"categoryReason": "brief explanation",
"priorityScore": 0-100,
"suggestedAction": "what human should do",
"suggestedLabels": ["label1", "label2"],
"labelConfidence": 0-100,
"missingInfo": ["item1", "item2"],
"similarIssues": [12345, 12346],
"potentialAssignees": ["@user1", "@user2"],
"draftReply": "if needs-info or needs-clarification, draft the reply message here",
"clarityScore": 0-100,
"feasibilityScore": 0-100,
"newCommentsSummary": "brief summary of recent discussion if trending"
}
Focus on actionable triage. Be concise.
"@
# Thread-safe collections for results
$analysisResults = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new()
$analysisErrors = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
# Progress tracking
$totalIssues = $issuesToAnalyze.Count
$completedCount = [ref]0
$startTime = Get-Date
if ($totalIssues -gt 0) {
$issuesToAnalyze | ForEach-Object -ThrottleLimit $MaxParallel -Parallel {
$issue = $_
$issueNum = $issue.number
$results = $using:analysisResults
$errors = $using:analysisErrors
$completed = $using:completedCount
$total = $using:totalIssues
$timeoutMin = $using:TimeoutMinutes
$maxRetry = $using:MaxRetries
$model = $using:Model
$mcpCfg = $using:McpConfig
$template = $using:promptTemplate
$root = $using:repoRoot
$cachePath = $using:issueCachePath
$prompt = $template -replace 'ISSUE_NUMBER', $issueNum
$logDir = Join-Path $cachePath $issueNum
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$success = $false
$lastError = $null
$output = $null
for ($retry = 0; $retry -lt $maxRetry -and -not $success; $retry++) {
if ($retry -gt 0) {
Write-Host " ⟳ Retry $retry/$maxRetry for #$issueNum" -ForegroundColor Yellow
Start-Sleep -Seconds 10
}
try {
# Build Copilot CLI arguments
$copilotArgs = @()
if ($mcpCfg) {
$copilotArgs += @('--additional-mcp-config', $mcpCfg)
}
$copilotArgs += @('-p', $prompt, '--yolo', '--agent', 'ReviewIssue')
if ($model) {
$copilotArgs += @('--model', $model)
}
# Run with timeout
$job = Start-Job -ScriptBlock {
param($args)
& copilot @args 2>&1
} -ArgumentList (,$copilotArgs)
$timeoutSec = $timeoutMin * 60
$jobResult = $job | Wait-Job -Timeout $timeoutSec
if ($job.State -eq 'Running') {
# Timeout - kill the job
$job | Stop-Job -PassThru | Remove-Job -Force
$lastError = "Timeout after ${timeoutMin} minutes"
} else {
$output = $job | Receive-Job
$job | Remove-Job -Force
# Check for valid output
if ($output) {
$outputStr = $output -join "`n"
# Try to extract JSON from output
if ($outputStr -match '\{[\s\S]*"issueNumber"[\s\S]*\}') {
$success = $true
} else {
$lastError = "No valid JSON in output"
}
} else {
$lastError = "Empty output from Copilot"
}
}
}
catch {
$lastError = $_.Exception.Message
}
}
# Update progress
[System.Threading.Interlocked]::Increment($completed) | Out-Null
$pct = [math]::Round(($completed.Value / $total) * 100)
if ($success) {
# Save output and parse result
$outputStr = $output -join "`n"
$outputStr | Out-File -FilePath (Join-Path $logDir "analysis.log") -Force
# Try to extract JSON
try {
if ($outputStr -match '(\{[\s\S]*"issueNumber"[\s\S]*\})') {
$jsonStr = $Matches[1]
$parsed = $jsonStr | ConvertFrom-Json -AsHashtable
$results[$issueNum.ToString()] = @{
success = $true
data = $parsed
analyzedAt = (Get-Date).ToUniversalTime().ToString("o")
}
Write-Host " [$pct%] ✓ #$issueNum - $($parsed.category)" -ForegroundColor Green
}
}
catch {
$errors.Add(@{ issueNumber = $issueNum; error = "JSON parse error: $_" })
Write-Host " [$pct%] ⚠ #$issueNum - JSON parse failed" -ForegroundColor Yellow
}
} else {
# Log error
$lastError | Out-File -FilePath (Join-Path $logDir "error.log") -Force
$errors.Add(@{ issueNumber = $issueNum; error = $lastError; retries = $maxRetry })
Write-Host " [$pct%] ✗ #$issueNum - $lastError" -ForegroundColor Red
}
}
}
$elapsed = (Get-Date) - $startTime
Write-Host ""
Write-Host " Analysis complete in $([math]::Round($elapsed.TotalMinutes, 1)) minutes" -ForegroundColor Cyan
Write-Host " ✓ Successful: $($analysisResults.Count)" -ForegroundColor Green
Write-Host " ✗ Failed: $($analysisErrors.Count)" -ForegroundColor $(if ($analysisErrors.Count -gt 0) { 'Red' } else { 'Gray' })
#endregion
#region Merge Results & Categorize
Write-Host ""
Write-Host "[5/6] Merging results and updating state..." -ForegroundColor Yellow
# Merge new analysis with cached results
$allResults = @{}
# Add cached results
foreach ($key in $state.analysisResults.Keys) {
if (-not $analysisResults.ContainsKey($key)) {
$allResults[$key] = $state.analysisResults[$key]
}
}
# Add new results
foreach ($key in $analysisResults.Keys) {
$allResults[$key] = $analysisResults[$key]
}
# Categorize for reporting
$categorized = @{
trending = @()
"needs-label" = @()
"ready-for-fix" = @()
"needs-info" = @()
"needs-clarification" = @()
closeable = @()
"stale-waiting" = @()
"duplicate-candidate" = @()
"review-needed" = @()
}
foreach ($key in $allResults.Keys) {
$result = $allResults[$key]
if ($result.success -and $result.data) {
$data = $result.data
$category = $data.category
if ($categorized.ContainsKey($category)) {
$categorized[$category] += $data
} else {
$categorized["review-needed"] += $data
}
}
}
# Sort each category by priority
foreach ($cat in $categorized.Keys) {
$categorized[$cat] = $categorized[$cat] | Sort-Object { -[int]$_.priorityScore }
}
Write-Host " Categorization complete:" -ForegroundColor Green
foreach ($cat in $categorized.Keys | Sort-Object { $categorized[$_].Count } -Descending) {
if ($categorized[$cat].Count -gt 0) {
Write-Host " - $cat`: $($categorized[$cat].Count)" -ForegroundColor Gray
}
}
#endregion
#region Generate Reports
Write-Host ""
Write-Host "[6/6] Generating reports..." -ForegroundColor Yellow
# Archive previous run
$archiveDate = Get-Date -Format "yyyy-MM-dd_HHmm"
$archivePath = Join-Path $historyPath $archiveDate
if (Test-Path "$currentRunPath/summary.md") {
New-Item -ItemType Directory -Path $archivePath -Force | Out-Null
Copy-Item -Path "$currentRunPath/*" -Destination $archivePath -Recurse -Force
Write-Host " ✓ Archived previous run to: $archiveDate" -ForegroundColor Gray
}
# Clean current run
if (Test-Path $currentRunPath) {
Remove-Item -Path "$currentRunPath/*" -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Path "$currentRunPath/draft-replies" -Force | Out-Null
# Category info for display
$categoryInfo = @{
"trending" = @{ emoji = "🔥"; name = "Trending" }
"needs-label" = @{ emoji = "🏷️"; name = "Needs-Label" }
"ready-for-fix" = @{ emoji = ""; name = "Ready-for-Fix" }
"needs-info" = @{ emoji = ""; name = "Needs-Info" }
"needs-clarification" = @{ emoji = "💬"; name = "Needs-Clarification" }
"closeable" = @{ emoji = "✔️"; name = "Closeable" }
"stale-waiting" = @{ emoji = ""; name = "Stale-Waiting" }
"duplicate-candidate" = @{ emoji = "🔁"; name = "Duplicate-Candidate" }
"review-needed" = @{ emoji = "👀"; name = "Review-Needed" }
}
$repoUrl = "https://github.com/microsoft/PowerToys/issues"
# Generate summary.md
$summary = @"
# Issue Triage Summary - $(Get-Date -Format 'yyyy-MM-dd')
**Run Type**: $RunType | **Time**: $(Get-Date -Format 'HH:mm UTC') | **Duration**: $([math]::Round($elapsed.TotalMinutes, 1)) min
## 📊 Delta Since Last Run
| Metric | Value |
|--------|-------|
| Issues with new activity | $($uniqueIssues.Count) |
| Newly analyzed | $($analysisResults.Count) |
| Using cached analysis | $($allResults.Count - $analysisResults.Count) |
| Analysis failures | $($analysisErrors.Count) |
## Action Required by Category
| Category | Count | Top Priority | Score |
|----------|-------|--------------|-------|
"@
foreach ($cat in @("trending", "needs-label", "ready-for-fix", "needs-info", "needs-clarification", "closeable", "stale-waiting", "duplicate-candidate", "review-needed")) {
$info = $categoryInfo[$cat]
$issues = $categorized[$cat]
if ($issues.Count -gt 0) {
$top = $issues[0]
$summary += "| $($info.emoji) $($info.name) | $($issues.Count) | [#$($top.issueNumber)]($repoUrl/$($top.issueNumber)) | $($top.priorityScore)/100 |`n"
}
}
$summary += @"
## 🎯 Top 10 Priority Actions
"@
# Get top 10 across all categories
$allIssueData = @()
foreach ($cat in $categorized.Keys) {
$allIssueData += $categorized[$cat]
}
$topIssues = $allIssueData | Sort-Object { -[int]$_.priorityScore } | Select-Object -First 10
$priority = 1
foreach ($issue in $topIssues) {
$info = $categoryInfo[$issue.category]
$urgency = if ([int]$issue.priorityScore -ge 80) { "**[Urgent]**" }
elseif ([int]$issue.priorityScore -ge 60) { "**[High]**" }
elseif ([int]$issue.priorityScore -ge 40) { "[Medium]" }
else { "[Low]" }
$summary += "$priority. $urgency $($info.emoji) [#$($issue.issueNumber)]($repoUrl/$($issue.issueNumber)) - $($issue.categoryReason)`n"
$priority++
}
$summary += @"
## 📁 Detailed Reports
"@
foreach ($cat in @("trending", "needs-label", "ready-for-fix", "needs-info", "needs-clarification", "closeable", "stale-waiting", "duplicate-candidate")) {
$info = $categoryInfo[$cat]
if ($categorized[$cat].Count -gt 0) {
$summary += "- [$($info.emoji) $($info.name)](./$cat.md) ($($categorized[$cat].Count) issues)`n"
}
}
$summary += @"
## 📝 Draft Replies Ready
"@
$draftsWritten = 0
foreach ($cat in @("needs-info", "needs-clarification", "closeable", "stale-waiting")) {
foreach ($issue in $categorized[$cat]) {
if ($issue.draftReply) {
$draftPath = Join-Path "$currentRunPath/draft-replies" "issue-$($issue.issueNumber).md"
$draftContent = @"
---
issue: $($issue.issueNumber)
category: $($issue.category)
generated: $(Get-Date -Format "o")
---
$($issue.draftReply)
"@
$draftContent | Out-File -FilePath $draftPath -Force
$draftsWritten++
}
}
}
$summary += "**$draftsWritten** draft replies ready in ``draft-replies/```n`n"
if ($analysisErrors.Count -gt 0) {
$summary += @"
## Analysis Failures
| Issue | Error |
|-------|-------|
"@
foreach ($err in $analysisErrors) {
$summary += "| #$($err.issueNumber) | $($err.error) |`n"
}
}
$summary += @"
---
*Generated by continuous-issue-triage skill*
*Next suggested run: $(Get-Date (Get-Date).AddDays($(if ($RunType -eq 'daily') { 1 } elseif ($RunType -eq 'twice-weekly') { 3 } else { 7 })) -Format 'yyyy-MM-dd')*
"@
$summary | Out-File -FilePath "$currentRunPath/summary.md" -Force
Write-Host " ✓ Generated: summary.md" -ForegroundColor Green
# Generate category reports
foreach ($cat in $categorized.Keys) {
$issues = $categorized[$cat]
if ($issues.Count -eq 0) { continue }
$info = $categoryInfo[$cat]
$report = @"
# $($info.emoji) $($info.name) Issues
**Total**: $($issues.Count) issues
## Overview
| # | Issue | Priority | Reason | Suggested Action |
|---|-------|----------|--------|------------------|
"@
foreach ($issue in $issues) {
$reason = if ($issue.categoryReason.Length -gt 40) { $issue.categoryReason.Substring(0, 37) + "..." } else { $issue.categoryReason }
$action = if ($issue.suggestedAction.Length -gt 40) { $issue.suggestedAction.Substring(0, 37) + "..." } else { $issue.suggestedAction }
$report += "| [#$($issue.issueNumber)]($repoUrl/$($issue.issueNumber)) | $($issue.priorityScore)/100 | $reason | $action |`n"
}
$report += "`n## Detailed Breakdown`n`n"
foreach ($issue in $issues) {
$report += @"
### [#$($issue.issueNumber)]($repoUrl/$($issue.issueNumber))
- **Priority Score**: $($issue.priorityScore)/100
- **Category Reason**: $($issue.categoryReason)
- **Suggested Action**: $($issue.suggestedAction)
- **Clarity Score**: $($issue.clarityScore)/100
- **Feasibility Score**: $($issue.feasibilityScore)/100
"@
if ($issue.suggestedLabels -and $issue.suggestedLabels.Count -gt 0) {
$report += "- **Suggested Labels**: $($issue.suggestedLabels -join ', ') (confidence: $($issue.labelConfidence)%)`n"
}
if ($issue.missingInfo -and $issue.missingInfo.Count -gt 0) {
$report += "- **Missing Info**: $($issue.missingInfo -join ', ')`n"
}
if ($issue.potentialAssignees -and $issue.potentialAssignees.Count -gt 0) {
$report += "- **Potential Assignees**: $($issue.potentialAssignees -join ', ')`n"
}
if ($issue.similarIssues -and $issue.similarIssues.Count -gt 0) {
$report += "- **Similar Issues**: #$($issue.similarIssues -join ', #')`n"
}
if ($issue.draftReply) {
$report += "- **Draft Reply**: [View](./draft-replies/issue-$($issue.issueNumber).md)`n"
}
$report += "`n---`n`n"
}
$report | Out-File -FilePath "$currentRunPath/$cat.md" -Force
Write-Host " ✓ Generated: $cat.md ($($issues.Count) issues)" -ForegroundColor Green
}
#endregion
#region Save State
Write-Host ""
Write-Host "Saving state for next run..." -ForegroundColor Yellow
# Update issue snapshots
foreach ($issue in $uniqueIssues) {
$issueNum = $issue.number.ToString()
$result = $allResults[$issueNum]
$state.issueSnapshots[$issueNum] = @{
number = $issue.number
title = $issue.title
state = $issue.state
lastSeenAt = (Get-Date).ToUniversalTime().ToString("o")
category = if ($result.data) { $result.data.category } else { "unknown" }
priorityScore = if ($result.data) { $result.data.priorityScore } else { 0 }
}
}
$state.lastRun = (Get-Date).ToUniversalTime().ToString("o")
$state.lastRunType = $RunType
$state.analysisResults = $allResults
$state.statistics.totalRunCount++
$state.statistics.issuesAnalyzed += $analysisResults.Count
$state | ConvertTo-Json -Depth 10 | Out-File -FilePath $statePath -Force
Write-Host " ✓ State saved" -ForegroundColor Green
#endregion
Write-Host ""
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " Triage complete!" -ForegroundColor Cyan
Write-Host " Reports: $currentRunPath" -ForegroundColor Cyan
Write-Host " Start with: summary.md" -ForegroundColor Cyan
Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan

View File

@@ -1,6 +1,6 @@
The MIT License
MIT License
Copyright (c) Microsoft Corporation. All rights reserved.
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

220
.github/skills/issue-fix/SKILL.md vendored Normal file
View File

@@ -0,0 +1,220 @@
---
name: issue-fix
description: Automatically fix GitHub issues and create PRs. Use when asked to fix an issue, implement a feature from an issue, auto-fix an issue, apply implementation plan, create code changes for an issue, resolve a GitHub issue, or submit a PR for an issue. Creates isolated git worktree, applies AI-generated fixes, commits changes, and creates pull requests.
license: Complete terms in LICENSE.txt
---
# Issue Fix Skill
Automatically fix GitHub issues by creating isolated worktrees, applying AI-generated code changes, and creating pull requests - the complete issue-to-PR workflow.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/issue-fix/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
├── scripts/
│ ├── Start-IssueAutoFix.ps1 # Main fix script (creates worktree, applies fix)
│ ├── Start-IssueFixParallel.ps1 # Parallel runner (single terminal)
│ ├── Get-WorktreeStatus.ps1 # Worktree status helper
│ ├── Submit-IssueFix.ps1 # Commit and create PR
│ └── IssueReviewLib.ps1 # Shared helpers
└── references/
├── fix-issue.prompt.md # AI prompt for fixing
├── create-commit-title.prompt.md # AI prompt for commit messages
├── create-pr-summary.prompt.md # AI prompt for PR descriptions
└── mcp-config.json # MCP configuration
```
## Output
- **Worktrees**: Created at drive root level `Q:/PowerToys-xxxx/`
- **PRs**: Created on GitHub linking to the original issue
- **Signal file**: `Generated Files/issueFix/<issue>/.signal`
## Signal File
On completion, a `.signal` file is created for orchestrator coordination:
```json
{
"status": "success",
"issueNumber": 45363,
"timestamp": "2026-02-04T10:05:23Z",
"worktreePath": "Q:/PowerToys-ab12"
}
```
Status values: `success`, `failure`
## When to Use This Skill
- Fix a specific GitHub issue automatically
- Implement a feature described in an issue
- Apply an existing implementation plan
- Create code changes and submit PR for an issue
- Auto-fix high-confidence issues end-to-end
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- Issue must be reviewed first (use `issue-review` skill)
- PowerShell 7+ for running scripts
- Copilot CLI or Claude CLI installed
## Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `{{IssueNumber}}` | GitHub issue number to fix | `44044` |
## Workflow
### Step 1: Ensure Issue is Reviewed
If not already reviewed, use the `issue-review` skill first.
### Step 2: Run Auto-Fix
```powershell
# Create worktree and apply fix
.github/skills/issue-fix/scripts/Start-IssueAutoFix.ps1 -IssueNumber {{IssueNumber}} -CLIType copilot -Force
```
This will:
1. Create a new git worktree with branch `issue/{{IssueNumber}}`
2. Copy the review files to the worktree
3. Launch Copilot CLI to implement the fix
4. Build and verify the changes
### Step 3: Submit PR
```powershell
# Commit changes and create PR
.github/skills/issue-fix/scripts/Submit-IssueFix.ps1 -IssueNumber {{IssueNumber}} -CLIType copilot -Force
```
This will:
1. Generate AI commit message
2. Commit all changes
3. Push to origin
4. Create PR with AI-generated description
5. Link PR to issue with "Fixes #{{IssueNumber}}"
### One-Step Alternative
To fix AND submit in one command:
```powershell
.github/skills/issue-fix/scripts/Start-IssueAutoFix.ps1 -IssueNumber {{IssueNumber}} -CLIType copilot -CreatePR -Force
```
## CLI Options
### Start-IssueAutoFix.ps1
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-IssueNumber` | Issue to fix | Required |
| `-CLIType` | AI CLI: `copilot` or `claude` | `copilot` |
| `-Model` | Copilot model (e.g., `gpt-5.2-codex`) | (optional) |
| `-CreatePR` | Auto-create PR after fix | `false` |
| `-SkipWorktree` | Fix in current repo (no worktree) | `false` |
| `-Force` | Skip confirmation prompts | `false` |
### Submit-IssueFix.ps1
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-IssueNumber` | Issue to submit | Required |
| `-CLIType` | AI CLI: `copilot`, `claude`, `manual` | `copilot` |
| `-Draft` | Create as draft PR | `false` |
| `-SkipCommit` | Skip commit (changes already committed) | `false` |
| `-Force` | Skip confirmation prompts | `false` |
## Batch Processing
Fix multiple issues:
```powershell
# Fix multiple issues (creates worktrees, applies fixes)
.github/skills/issue-fix/scripts/Start-IssueAutoFix.ps1 -IssueNumbers 44044, 32950 -CLIType copilot -Force
# Submit all fixed issues as PRs
.github/skills/issue-fix/scripts/Submit-IssueFix.ps1 -CLIType copilot -Force
```
## Parallel Execution (IMPORTANT)
**DO NOT** spawn separate terminals for each issue. Use the dedicated scripts:
```powershell
# Run fixes in parallel (single terminal)
.github/skills/issue-fix/scripts/Start-IssueFixParallel.ps1 -IssueNumbers 28726,13336,27507,3054,37800 -CLIType copilot -ThrottleLimit 5 -Force
# Check worktree status
.github/skills/issue-fix/scripts/Get-WorktreeStatus.ps1
```
This allows:
- Tracking all jobs in one place
- Waiting for completion with proper synchronization
- Controlling parallelism with `-ThrottleLimit`
- Combined output visibility
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Worktree already exists | Use existing worktree or `git worktree remove <path>` |
| No implementation plan | Use `issue-review` skill first |
| Build failures | Check build logs, may need manual intervention |
| PR already exists | Script will skip, check existing PR |
| CLI not found | Install Copilot CLI |
## PR Creation Requirements (CRITICAL)
**NEVER create PRs with placeholder/stub code.** Every PR must have:
1. **Real implementation** - Actual working code that addresses the issue
2. **Proper title** - Follow `create-commit-title.prompt.md` (Conventional Commits)
3. **Full description** - Follow `create-pr-summary.prompt.md` based on actual diff
### PR Title Format (Conventional Commits)
```
feat(module): add feature description
fix(module): fix bug description
docs(module): update documentation
```
### PR Description Must Include
- Summary of changes (from actual diff)
- `Fixes #IssueNumber` link
- Checklist items marked appropriately
- Validation steps performed
**Example of BAD PR (never do this):**
```
Title: fix: address issue #12345
Body: Fixes #12345
Code: class Fix12345 { public void Apply() { } } // EMPTY STUB!
```
**Example of GOOD PR:**
```
Title: feat(peek): add symbolic link resolution for PDF/HTML files
Body: ## Summary
Adds SymlinkResolver helper to resolve symlinks...
[Full description based on create-pr-summary.prompt.md]
```
## Related Skills
| Skill | Purpose |
|-------|---------|
| `issue-review` | Review issues, generate implementation plans |
| `pr-review` | Review the created PR |
| `pr-fix` | Fix PR review comments |

View File

@@ -0,0 +1,49 @@
---
agent: 'agent'
description: 'Generate an 80-character git commit title for the local diff'
---
# Generate Commit Title
## Purpose
Provide a single-line, ready-to-paste git commit title (<= 80 characters) that reflects the most important local changes since `HEAD`.
## Input to collect
- Run exactly one command to view the local diff:
```@terminal
git diff HEAD
```
## How to decide the title
1. From the diff, find the dominant area (e.g., `src/modules/*`, `doc/devdocs/**`) and the change type (bug fix, docs update, config tweak).
2. Draft an imperative, plain-ASCII title that:
- Mentions the primary component when obvious (e.g., `FancyZones:` or `Docs:`)
- Stays within 80 characters and has no trailing punctuation
## Final output
- Reply with only the commit title on a single line—no extra text.
## PR title convention (when asked)
Use Conventional Commits style:
`<type>(<scope>): <summary>`
**Allowed types**
- feat, fix, docs, refactor, perf, test, build, ci, chore
**Scope rules**
- Use a short, PowerToys-focused scope (one word preferred). Common scopes:
- Core: `runner`, `settings-ui`, `common`, `docs`, `build`, `ci`, `installer`, `gpo`, `dsc`
- Modules: `fancyzones`, `powerrename`, `awake`, `colorpicker`, `imageresizer`, `keyboardmanager`, `mouseutils`, `peek`, `hosts`, `file-locksmith`, `screen-ruler`, `text-extractor`, `cropandlock`, `paste`, `powerlauncher`
- If unclear, pick the closest module or subsystem; omit only if unavoidable
**Summary rules**
- Imperative, present tense (“add”, “update”, “remove”, “fix”)
- Keep it <= 72 characters when possible; be specific, avoid “misc changes”
**Examples**
- `feat(fancyzones): add canvas template duplication`
- `fix(mouseutils): guard crosshair toggle when dpi info missing`
- `docs(runner): document tray icon states`
- `build(installer): align wix v5 suffix flag`
- `ci(ci): cache pipeline artifacts for x64`

View File

@@ -0,0 +1,24 @@
---
agent: 'agent'
description: 'Generate a PowerToys-ready pull request description from the local diff'
---
# Generate PR Summary
**Goal:** Produce a ready-to-paste PR title and description that follows PowerToys conventions by comparing the current branch against a user-selected target branch.
**Repo guardrails:**
- Treat `.github/pull_request_template.md` as the single source of truth; load it at runtime instead of embedding hardcoded content in this prompt.
- Preserve section order from the template but only surface checklist lines that are relevant for the detected changes, filling them with `[x]`/`[ ]` as appropriate.
- Cite touched paths with inline backticks, matching the guidance in `.github/copilot-instructions.md`.
- Call out test coverage explicitly: list automated tests run (unit/UI) or state why they are not applicable.
**Workflow:**
1. Determine the target branch from user context; default to `main` when no branch is supplied.
2. Run `git status --short` once to surface uncommitted files that may influence the summary.
3. Run `git diff <target-branch>...HEAD` a single time to review the detailed changes. Only when confidence stays low dig deeper with focused calls such as `git diff <target-branch>...HEAD -- <path>`.
4. From the diff, capture impacted areas, key file changes, behavioral risks, migrations, and noteworthy edge cases.
5. Confirm validation: list tests executed with results or state why tests were skipped in line with repo guidance.
6. Load `.github/pull_request_template.md`, mirror its section order, and populate it with the gathered facts. Include only relevant checklist entries, marking them `[x]/[ ]` and noting any intentional omissions as "N/A".
7. Present the filled template inside a fenced ```markdown code block with no extra commentary so it is ready to paste into a PR, clearly flagging any placeholders that still need user input.
8. Prepend the PR title above the filled template, applying the Conventional Commit type/scope rules from `.github/prompts/create-commit-title.prompt.md`; pick the dominant component from the diff and keep the title concise and imperative.

View File

@@ -0,0 +1,72 @@
---
agent: 'agent'
description: 'Execute the fix for a GitHub issue using the previously generated implementation plan'
---
# Fix GitHub Issue
## Dependencies
Source review prompt (for generating the implementation plan if missing):
- .github/prompts/review-issue.prompt.md
Required plan file (single source of truth):
- Generated Files/issueReview/{{issue_number}}/implementation-plan.md
## Dependency Handling
1) If `implementation-plan.md` exists → proceed.
2) If missing → run the review prompt:
- Invoke: `.github/prompts/review-issue.prompt.md`
- Pass: `issue_number={{issue_number}}`
- Then re-check for `implementation-plan.md`.
3) If still missing → stop and generate:
- `Generated Files/issueFix/{{issue_number}}/manual-steps.md` containing:
“implementation-plan.md not found; please run .github/prompts/review-issue.prompt.md for #{{issue_number}}.”
# GOAL
For **#{{issue_number}}**:
- Use implementation-plan.md as the single authority.
- Apply code and test changes directly in the repository.
- Produce a PR-ready description.
# OUTPUT FILES
1) Generated Files/issueFix/{{issue_number}}/pr-description.md
2) Generated Files/issueFix/{{issue_number}}/manual-steps.md # only if human interaction or external setup is required
# EXECUTION RULES
1) Read implementation-plan.md and execute:
- Layers & Files → edit/create as listed
- Pattern Choices → follow repository conventions
- Fundamentals (perf, security, compatibility, accessibility)
- Logging & Exceptions
- Telemetry (only if explicitly included in the plan)
- Risks & Mitigations
- Tests to Add
2) Locate affected files via `rg` or `git grep`.
3) Add/update tests to enforce the fixed behavior.
4) If any ambiguity exists, add:
// TODO(Human input needed): <clarification needed>
5) Verify locally: build & tests run successfully.
# pr-description.md should include:
- Title: `Fix: <short summary> (#{{issue_number}})`
- What changed and why the fix works
- Files or modules touched
- Risks & mitigations (implemented)
- Tests added/updated and how to run them
- Telemetry behavior (if applicable)
- Validation / reproduction steps
- `Closes #{{issue_number}}`
# manual-steps.md (only if needed)
- List required human actions: secrets, config, approvals, missing info, or code comments requiring human decisions.
# IMPORTANT
- Apply code and tests directly; do not produce patch files.
- Follow implementation-plan.md as the source of truth.
- Insert comments for human review where a decision or input is required.
- Use repository conventions and deterministic, minimal changes.
# FINALIZE
- Write pr-description.md
- Write manual-steps.md only if needed
- Print concise success message or note items requiring human interaction

View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"github-artifacts": {
"command": "cmd",
"args": ["/c", "for /f %i in ('git rev-parse --show-toplevel') do node %i/tools/mcp/github-artifacts/launch.js"],
"tools": ["*"]
}
}
}

View File

@@ -0,0 +1,22 @@
<#
.SYNOPSIS
Show commit/uncommitted status for issue/* worktrees.
#>
[CmdletBinding()]
param()
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')
Set-Location $repoRoot
git worktree list | Select-String "issue/" | ForEach-Object {
$path = ($_ -split "\s+")[0]
$branch = ($_ -split "\s+")[2] -replace "\[|\]",""
$ahead = (git -C $path rev-list main..HEAD --count 2>$null)
$uncommitted = (git -C $path status --porcelain 2>$null | Measure-Object).Count
[pscustomobject]@{
Branch = $branch
CommitsAhead = $ahead
Uncommitted = $uncommitted
Path = $path
}
}

View File

@@ -0,0 +1,644 @@
# IssueReviewLib.ps1 - Helpers for issue auto-fix workflow
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# This is a trimmed version with only what issue-fix needs
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
function Get-GeneratedFilesPath {
param([string]$RepoRoot)
return Join-Path $RepoRoot 'Generated Files'
}
function Get-IssueReviewPath {
param(
[string]$RepoRoot,
[int]$IssueNumber
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
return Join-Path $genFiles "issueReview/$IssueNumber"
}
function Ensure-DirectoryExists {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
#endregion
#region CLI Detection
function Get-AvailableCLI {
<#
.SYNOPSIS
Detect which AI CLI is available: GitHub Copilot CLI or Claude Code.
#>
# Check for standalone GitHub Copilot CLI
$copilotCLI = Get-Command 'copilot' -ErrorAction SilentlyContinue
if ($copilotCLI) {
return @{ Name = 'GitHub Copilot CLI'; Command = 'copilot'; Type = 'copilot' }
}
# Check for Claude Code CLI
$claudeCode = Get-Command 'claude' -ErrorAction SilentlyContinue
if ($claudeCode) {
return @{ Name = 'Claude Code CLI'; Command = 'claude'; Type = 'claude' }
}
# Check for GitHub Copilot CLI via gh extension
$ghCopilot = Get-Command 'gh' -ErrorAction SilentlyContinue
if ($ghCopilot) {
$copilotCheck = gh extension list 2>&1 | Select-String -Pattern 'copilot'
if ($copilotCheck) {
return @{ Name = 'GitHub Copilot CLI (gh extension)'; Command = 'gh'; Type = 'gh-copilot' }
}
}
# Check for VS Code CLI
$code = Get-Command 'code' -ErrorAction SilentlyContinue
if ($code) {
return @{ Name = 'VS Code (Copilot Chat)'; Command = 'code'; Type = 'vscode' }
}
return $null
}
#endregion
#region Issue Review Results Helpers
function Get-IssueReviewResult {
<#
.SYNOPSIS
Check if an issue has been reviewed and get its results.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot
)
$reviewPath = Get-IssueReviewPath -RepoRoot $RepoRoot -IssueNumber $IssueNumber
$result = @{
IssueNumber = $IssueNumber
Path = $reviewPath
HasOverview = $false
HasImplementationPlan = $false
OverviewPath = $null
ImplementationPlanPath = $null
}
$overviewPath = Join-Path $reviewPath 'overview.md'
$implPlanPath = Join-Path $reviewPath 'implementation-plan.md'
if (Test-Path $overviewPath) {
$result.HasOverview = $true
$result.OverviewPath = $overviewPath
}
if (Test-Path $implPlanPath) {
$result.HasImplementationPlan = $true
$result.ImplementationPlanPath = $implPlanPath
}
return $result
}
function Get-HighConfidenceIssues {
<#
.SYNOPSIS
Find issues with high confidence for auto-fix based on review results.
#>
param(
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int[]]$FilterIssueNumbers = @()
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
$reviewDir = Join-Path $genFiles 'issueReview'
if (-not (Test-Path $reviewDir)) {
return @()
}
$highConfidence = @()
Get-ChildItem -Path $reviewDir -Directory | ForEach-Object {
$issueNum = [int]$_.Name
if ($FilterIssueNumbers.Count -gt 0 -and $issueNum -notin $FilterIssueNumbers) {
return
}
$overviewPath = Join-Path $_.FullName 'overview.md'
$implPlanPath = Join-Path $_.FullName 'implementation-plan.md'
if (-not (Test-Path $overviewPath) -or -not (Test-Path $implPlanPath)) {
return
}
$overview = Get-Content $overviewPath -Raw
$feasibility = 0
$clarity = 0
$effortDays = 999
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$feasibility = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$clarity = [int]$Matches[1]
}
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
if ($Matches[1]) {
$effortDays = [int]$Matches[1]
} elseif ($overview -match 'Effort Estimate[^|]*\|\s*(\d+)\s*days?') {
$effortDays = [int]$Matches[1]
}
}
if ($overview -match 'Effort Estimate[^|]*\|[^|]*\|\s*(XS|S)\b') {
if ($Matches[1] -eq 'XS') { $effortDays = 1 } else { $effortDays = 2 }
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(XS\)') {
$effortDays = 1
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(S\)') {
$effortDays = 2
}
if ($feasibility -ge $MinFeasibilityScore -and
$clarity -ge $MinClarityScore -and
$effortDays -le $MaxEffortDays) {
$highConfidence += @{
IssueNumber = $issueNum
FeasibilityScore = $feasibility
ClarityScore = $clarity
EffortDays = $effortDays
OverviewPath = $overviewPath
ImplementationPlanPath = $implPlanPath
}
}
}
return $highConfidence | Sort-Object -Property FeasibilityScore -Descending
}
#endregion
#region Release & PR Status Helpers
function Get-PRReleaseStatus {
<#
.SYNOPSIS
Check if a PR has been merged and released.
.DESCRIPTION
Queries GitHub to determine:
1. If the PR is merged
2. What release (if any) contains the merge commit
.OUTPUTS
@{
PRNumber = <int>
IsMerged = $true | $false
MergeCommit = <commit sha or $null>
ReleasedIn = <version string or $null> # e.g., "v0.90.0"
IsReleased = $true | $false
}
#>
param(
[Parameter(Mandatory)]
[int]$PRNumber,
[string]$Repo = 'microsoft/PowerToys'
)
$result = @{
PRNumber = $PRNumber
IsMerged = $false
MergeCommit = $null
ReleasedIn = $null
IsReleased = $false
}
try {
# Get PR details from GitHub
$prJson = gh pr view $PRNumber --repo $Repo --json state,mergeCommit,mergedAt 2>$null
if (-not $prJson) {
return $result
}
$pr = $prJson | ConvertFrom-Json
if ($pr.state -eq 'MERGED' -and $pr.mergeCommit) {
$result.IsMerged = $true
$result.MergeCommit = $pr.mergeCommit.oid
# Check which release tags contain this commit
# Use git tag --contains to find tags that include the merge commit
$tags = git tag --contains $result.MergeCommit 2>$null
if ($tags) {
# Filter to release tags (v0.XX.X pattern) and get the earliest one
$releaseTags = $tags | Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } | Sort-Object
if ($releaseTags) {
$result.ReleasedIn = $releaseTags | Select-Object -First 1
$result.IsReleased = $true
}
}
}
}
catch {
# Silently fail - will return default "not merged" status
}
return $result
}
function Get-LatestRelease {
<#
.SYNOPSIS
Get the latest release version of PowerToys.
#>
param(
[string]$Repo = 'microsoft/PowerToys'
)
try {
$releaseJson = gh release view --repo $Repo --json tagName 2>$null
if ($releaseJson) {
$release = $releaseJson | ConvertFrom-Json
return $release.tagName
}
}
catch {
# Fallback: try to get from git tags
$latestTag = git describe --tags --abbrev=0 2>$null
if ($latestTag) {
return $latestTag
}
}
return $null
}
#endregion
#region Implementation Plan Analysis
function Get-ImplementationPlanStatus {
<#
.SYNOPSIS
Parse implementation-plan.md to determine the recommended action.
.DESCRIPTION
Reads the implementation plan and extracts the status/recommendation.
For "already resolved" issues, also checks if the fix has been released.
Returns an object indicating what action should be taken.
.OUTPUTS
@{
Status = 'AlreadyResolved' | 'FixedButUnreleased' | 'NeedsClarification' | 'Duplicate' | 'WontFix' | 'ReadyToImplement' | 'Unknown'
Action = 'CloseIssue' | 'AddComment' | 'LinkDuplicate' | 'ImplementFix' | 'Skip'
Reason = <string explaining why>
RelatedPR = <PR number if already fixed>
ReleasedIn = <version if released, e.g., "v0.90.0">
DuplicateOf = <issue number if duplicate>
CommentText = <suggested comment if applicable>
}
#>
param(
[Parameter(Mandatory)]
[string]$ImplementationPlanPath,
[switch]$SkipReleaseCheck
)
$result = @{
Status = 'Unknown'
Action = 'Skip'
Reason = 'Could not determine status from implementation plan'
RelatedPR = $null
ReleasedIn = $null
DuplicateOf = $null
CommentText = $null
}
if (-not (Test-Path $ImplementationPlanPath)) {
$result.Reason = 'Implementation plan file not found'
return $result
}
$content = Get-Content $ImplementationPlanPath -Raw
# Check for ALREADY RESOLVED status
if ($content -match '(?i)STATUS:\s*ALREADY\s+RESOLVED' -or
$content -match '(?i)⚠️\s*STATUS:\s*ALREADY\s+RESOLVED' -or
$content -match '(?i)This issue has been fixed by' -or
$content -match '(?i)No implementation work is needed') {
# Try to extract the PR number
$prNumber = $null
if ($content -match '\[PR #(\d+)\]' -or $content -match 'PR #(\d+)' -or $content -match '/pull/(\d+)') {
$prNumber = [int]$Matches[1]
$result.RelatedPR = $prNumber
}
# Check if the fix has been released
if ($prNumber -and -not $SkipReleaseCheck) {
$prStatus = Get-PRReleaseStatus -PRNumber $prNumber
if ($prStatus.IsReleased) {
# Fix is released - safe to close
$result.Status = 'AlreadyResolved'
$result.Action = 'CloseIssue'
$result.ReleasedIn = $prStatus.ReleasedIn
$result.Reason = "Issue fixed by PR #$prNumber, released in $($prStatus.ReleasedIn)"
$result.CommentText = @"
This issue has been fixed by PR #$prNumber and is available in **$($prStatus.ReleasedIn)**.
Please update to the latest version. If you're still experiencing this issue after updating, please reopen with additional details.
"@
}
elseif ($prStatus.IsMerged) {
# PR merged but not yet released - add comment but don't close
$result.Status = 'FixedButUnreleased'
$result.Action = 'AddComment'
$result.Reason = "Issue fixed by PR #$prNumber, but not yet released"
$result.CommentText = @"
This issue has been fixed by PR #$prNumber, which has been merged but **not yet released**.
The fix will be available in the next PowerToys release. You can:
- Wait for the next official release
- Build from source to get the fix immediately
We'll close this issue once the fix is released.
"@
}
else {
# PR exists but not merged - treat as ready to implement (PR might have been reverted)
$result.Status = 'ReadyToImplement'
$result.Action = 'ImplementFix'
$result.Reason = "PR #$prNumber exists but is not merged - may need reimplementation"
}
}
elseif ($prNumber) {
# Skip release check requested or no PR number - assume it's resolved
$result.Status = 'AlreadyResolved'
$result.Action = 'CloseIssue'
$result.Reason = 'Issue has already been fixed'
$result.CommentText = "This issue has been fixed by PR #$prNumber. Closing as resolved."
}
else {
# No PR number found - just mark as resolved with generic message
$result.Status = 'AlreadyResolved'
$result.Action = 'CloseIssue'
$result.Reason = 'Issue appears to have been resolved'
$result.CommentText = "Based on analysis, this issue appears to have already been resolved. Please verify and reopen if the issue persists."
}
return $result
}
# Check for DUPLICATE status
if ($content -match '(?i)STATUS:\s*DUPLICATE' -or
$content -match '(?i)This is a duplicate of' -or
$content -match '(?i)duplicate of #(\d+)') {
$result.Status = 'Duplicate'
$result.Action = 'LinkDuplicate'
$result.Reason = 'Issue is a duplicate'
# Try to extract the duplicate issue number
if ($content -match 'duplicate of #(\d+)' -or $content -match '#(\d+)') {
$result.DuplicateOf = [int]$Matches[1]
$result.CommentText = "This appears to be a duplicate of #$($result.DuplicateOf)."
}
return $result
}
# Check for NEEDS CLARIFICATION status
if ($content -match '(?i)STATUS:\s*NEEDS?\s+CLARIFICATION' -or
$content -match '(?i)STATUS:\s*NEEDS?\s+MORE\s+INFO' -or
$content -match '(?i)cannot proceed without' -or
$content -match '(?i)need(?:s)? more information') {
$result.Status = 'NeedsClarification'
$result.Action = 'AddComment'
$result.Reason = 'Issue needs more information from reporter'
# Try to extract what information is needed
if ($content -match '(?i)(?:need(?:s)?|require(?:s)?|missing)[:\s]+([^\n]+)') {
$result.CommentText = "Additional information is needed to proceed with this issue: $($Matches[1].Trim())"
} else {
$result.CommentText = "Could you please provide more details about this issue? Specifically, steps to reproduce and expected vs actual behavior would help."
}
return $result
}
# Check for WONT FIX / NOT FEASIBLE status
if ($content -match '(?i)STATUS:\s*(?:WONT?\s+FIX|NOT\s+FEASIBLE|REJECTED)' -or
$content -match '(?i)(?:not|cannot be) (?:feasible|implemented)' -or
$content -match '(?i)recommend(?:ed)?\s+(?:to\s+)?close') {
$result.Status = 'WontFix'
$result.Action = 'AddComment'
$result.Reason = 'Issue is not feasible or recommended to close'
# Try to extract the reason
if ($content -match '(?i)(?:because|reason|due to)[:\s]+([^\n]+)') {
$result.CommentText = "After analysis, this issue cannot be implemented: $($Matches[1].Trim())"
}
return $result
}
# Check for external dependency / blocked status
if ($content -match '(?i)STATUS:\s*BLOCKED' -or
$content -match '(?i)blocked by' -or
$content -match '(?i)depends on external' -or
$content -match '(?i)waiting for upstream') {
$result.Status = 'Blocked'
$result.Action = 'AddComment'
$result.Reason = 'Issue is blocked by external dependency'
return $result
}
# Check for READY TO IMPLEMENT (positive signals)
if ($content -match '(?i)## \d+\)\s*Task Breakdown' -or
$content -match '(?i)implementation steps' -or
$content -match '(?i)## Layers & Files' -or
($content -match '(?i)Feasibility' -and $content -notmatch '(?i)not\s+feasible')) {
$result.Status = 'ReadyToImplement'
$result.Action = 'ImplementFix'
$result.Reason = 'Implementation plan is ready'
return $result
}
# Default: if we have a detailed plan, assume it's ready
if ($content.Length -gt 500 -and $content -match '(?i)##') {
$result.Status = 'ReadyToImplement'
$result.Action = 'ImplementFix'
$result.Reason = 'Implementation plan appears complete'
}
return $result
}
function Invoke-ImplementationPlanAction {
<#
.SYNOPSIS
Execute the recommended action from the implementation plan analysis.
.DESCRIPTION
Based on the status from Get-ImplementationPlanStatus, takes appropriate action:
- CloseIssue: Closes the issue with a comment
- AddComment: Adds a comment to the issue
- LinkDuplicate: Marks as duplicate
- ImplementFix: Returns $true to indicate code fix should proceed
- Skip: Returns $false
.OUTPUTS
@{
ActionTaken = <string describing what was done>
ShouldProceedWithFix = $true | $false
Success = $true | $false
}
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[hashtable]$PlanStatus,
[switch]$DryRun
)
$result = @{
ActionTaken = 'None'
ShouldProceedWithFix = $false
Success = $true
}
switch ($PlanStatus.Action) {
'ImplementFix' {
$result.ActionTaken = 'Proceeding with code fix'
$result.ShouldProceedWithFix = $true
Info "[Issue #$IssueNumber] Status: $($PlanStatus.Status) - $($PlanStatus.Reason)"
}
'CloseIssue' {
$result.ActionTaken = "Closing issue: $($PlanStatus.Reason)"
Info "[Issue #$IssueNumber] $($PlanStatus.Status): $($PlanStatus.Reason)"
if (-not $DryRun) {
$comment = $PlanStatus.CommentText
if (-not $comment) {
$comment = "Closing based on automated analysis: $($PlanStatus.Reason)"
}
try {
# Check if issue is already closed
$issueState = gh issue view $IssueNumber --json state 2>$null | ConvertFrom-Json
if ($issueState.state -eq 'CLOSED') {
Info "[Issue #$IssueNumber] Already closed, skipping"
$result.ActionTaken = "Already closed"
return $result
}
# Close the issue with comment (single operation to avoid duplicates)
gh issue close $IssueNumber --reason "completed" --comment $comment 2>&1 | Out-Null
Success "[Issue #$IssueNumber] ✓ Closed with comment"
}
catch {
Err "[Issue #$IssueNumber] Failed to close: $($_.Exception.Message)"
$result.Success = $false
}
} else {
Info "[Issue #$IssueNumber] (DryRun) Would close with: $($PlanStatus.CommentText)"
}
}
'AddComment' {
$result.ActionTaken = "Adding comment: $($PlanStatus.Reason)"
Info "[Issue #$IssueNumber] $($PlanStatus.Status): $($PlanStatus.Reason)"
if (-not $DryRun -and $PlanStatus.CommentText) {
try {
gh issue comment $IssueNumber --body $PlanStatus.CommentText 2>&1 | Out-Null
Success "[Issue #$IssueNumber] ✓ Comment added"
}
catch {
Err "[Issue #$IssueNumber] Failed to add comment: $($_.Exception.Message)"
$result.Success = $false
}
} else {
Info "[Issue #$IssueNumber] (DryRun) Would comment: $($PlanStatus.CommentText)"
}
}
'LinkDuplicate' {
$result.ActionTaken = "Marking as duplicate of #$($PlanStatus.DuplicateOf)"
Info "[Issue #$IssueNumber] Duplicate of #$($PlanStatus.DuplicateOf)"
if (-not $DryRun -and $PlanStatus.DuplicateOf) {
try {
gh issue close $IssueNumber --reason "not_planned" --comment "Closing as duplicate of #$($PlanStatus.DuplicateOf)" 2>&1 | Out-Null
Success "[Issue #$IssueNumber] ✓ Closed as duplicate"
}
catch {
Err "[Issue #$IssueNumber] Failed to close as duplicate: $($_.Exception.Message)"
$result.Success = $false
}
}
}
'Skip' {
$result.ActionTaken = "Skipped: $($PlanStatus.Reason)"
Warn "[Issue #$IssueNumber] Skipping: $($PlanStatus.Reason)"
}
}
return $result
}
#endregion
#region Worktree Integration
function Copy-IssueReviewToWorktree {
<#
.SYNOPSIS
Copy the Generated Files for an issue to a worktree.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$SourceRepoRoot,
[Parameter(Mandatory)]
[string]$WorktreePath
)
$sourceReviewPath = Get-IssueReviewPath -RepoRoot $SourceRepoRoot -IssueNumber $IssueNumber
$destReviewPath = Get-IssueReviewPath -RepoRoot $WorktreePath -IssueNumber $IssueNumber
if (-not (Test-Path $sourceReviewPath)) {
throw "Issue review files not found at: $sourceReviewPath"
}
Ensure-DirectoryExists -Path $destReviewPath
Copy-Item -Path "$sourceReviewPath\*" -Destination $destReviewPath -Recurse -Force
Info "Copied issue review files to: $destReviewPath"
return $destReviewPath
}
#endregion

View File

@@ -0,0 +1,581 @@
<#!
.SYNOPSIS
Auto-fix high-confidence issues using worktrees and AI CLI.
.DESCRIPTION
Finds issues with high confidence scores from the review results, creates worktrees
for each, copies the Generated Files, and kicks off the FixIssue agent to implement fixes.
.PARAMETER IssueNumber
Specific issue number to fix. If not specified, finds high-confidence issues automatically.
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score (0-100). Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score (0-100). Default: 60.
.PARAMETER MaxEffortDays
Maximum effort estimate in days. Default: 2 (Small fixes).
.PARAMETER MaxConcurrent
Maximum parallel fix jobs. Default: 5 (worktrees are resource-intensive).
.PARAMETER CLIType
AI CLI to use: claude, gh-copilot, or vscode. Auto-detected if not specified.
.PARAMETER Model
Copilot CLI model to use (e.g., gpt-5.2-codex).
.PARAMETER DryRun
List issues without starting fixes.
.PARAMETER SkipWorktree
Fix in the current repository instead of creating worktrees (useful for single issue).
.PARAMETER VSCodeProfile
VS Code profile to use when opening worktrees. Default: Default.
.PARAMETER AutoCommit
Automatically commit changes after successful fix.
.PARAMETER CreatePR
Automatically create a pull request after successful fix.
.EXAMPLE
# Fix a specific issue
./Start-IssueAutoFix.ps1 -IssueNumber 12345
.EXAMPLE
# Find and fix all high-confidence issues (dry run)
./Start-IssueAutoFix.ps1 -DryRun
.EXAMPLE
# Fix issues with very high confidence
./Start-IssueAutoFix.ps1 -MinFeasibilityScore 80 -MinClarityScore 70 -MaxEffortDays 1
.EXAMPLE
# Fix single issue in current repo (no worktree)
./Start-IssueAutoFix.ps1 -IssueNumber 12345 -SkipWorktree
.NOTES
Prerequisites:
- Run Start-BulkIssueReview.ps1 first to generate review files
- GitHub CLI (gh) authenticated
- Claude Code CLI or VS Code with Copilot
Results:
- Worktrees created at ../<RepoName>-<hash>/
- Generated Files copied to each worktree
- Fix agent invoked in each worktree
#>
[CmdletBinding()]
param(
[int]$IssueNumber,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int]$MaxConcurrent = 5,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode', 'auto')]
[string]$CLIType = 'auto',
[string]$Model,
[switch]$DryRun,
[switch]$SkipWorktree,
[Alias('Profile')]
[string]$VSCodeProfile = 'Default',
[switch]$AutoCommit,
[switch]$CreatePR,
[switch]$Force,
[switch]$Help
)
# Load libraries
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir/IssueReviewLib.ps1"
# Resolve config directory name (.github or .claude) from script location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
# Load worktree library from tools/build
$repoRoot = Get-RepoRoot
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
# Show help
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
function Start-IssueFixInWorktree {
<#
.SYNOPSIS
Analyze implementation plan and either take action or create worktree for fix.
.DESCRIPTION
First analyzes the implementation plan to determine if:
- Issue is already resolved (close it)
- Issue needs clarification (add comment)
- Issue is a duplicate (close as duplicate)
- Issue is ready to implement (create worktree and fix)
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$SourceRepoRoot,
[string]$CLIType = 'claude',
[string]$Model,
[string]$VSCodeProfile = 'Default',
[switch]$SkipWorktree,
[switch]$DryRun
)
$issueReviewPath = Get-IssueReviewPath -RepoRoot $SourceRepoRoot -IssueNumber $IssueNumber
$overviewPath = Join-Path $issueReviewPath 'overview.md'
$implPlanPath = Join-Path $issueReviewPath 'implementation-plan.md'
# Verify review files exist
if (-not (Test-Path $overviewPath)) {
throw "No overview.md found for issue #$IssueNumber. Run Start-BulkIssueReview.ps1 first."
}
if (-not (Test-Path $implPlanPath)) {
throw "No implementation-plan.md found for issue #$IssueNumber. Run Start-BulkIssueReview.ps1 first."
}
# =====================================
# STEP 1: Analyze the implementation plan
# =====================================
Info "Analyzing implementation plan for issue #$IssueNumber..."
$planStatus = Get-ImplementationPlanStatus -ImplementationPlanPath $implPlanPath
# =====================================
# STEP 2: Execute the recommended action
# =====================================
$actionResult = Invoke-ImplementationPlanAction -IssueNumber $IssueNumber -PlanStatus $planStatus -DryRun:$DryRun
# If we shouldn't proceed with fix, return early
if (-not $actionResult.ShouldProceedWithFix) {
return @{
IssueNumber = $IssueNumber
WorktreePath = $null
Success = $actionResult.Success
ActionTaken = $actionResult.ActionTaken
SkippedCodeFix = $true
}
}
# =====================================
# STEP 3: Proceed with code fix
# =====================================
$workingDir = $SourceRepoRoot
if (-not $SkipWorktree) {
# Use the simplified New-WorktreeFromIssue.cmd which only needs issue number
$worktreeCmd = Join-Path $SourceRepoRoot 'tools/build/New-WorktreeFromIssue.cmd'
Info "Creating worktree for issue #$IssueNumber..."
# Call the cmd script with issue number and -NoVSCode for automation
& cmd /c $worktreeCmd $IssueNumber -NoVSCode
if ($LASTEXITCODE -ne 0) {
throw "Failed to create worktree for issue #$IssueNumber"
}
# Find the created worktree
$entries = Get-WorktreeEntries
$worktreeEntry = $entries | Where-Object { $_.Branch -like "issue/$IssueNumber*" } | Select-Object -First 1
if (-not $worktreeEntry) {
throw "Failed to find worktree for issue #$IssueNumber"
}
$workingDir = $worktreeEntry.Path
Info "Worktree created at: $workingDir"
# Copy Generated Files to worktree
Info "Copying review files to worktree..."
$destReviewPath = Copy-IssueReviewToWorktree -IssueNumber $IssueNumber -SourceRepoRoot $SourceRepoRoot -WorktreePath $workingDir
Info "Review files copied to: $destReviewPath"
# Copy config dirs to worktree (agents, skills, instructions, prompts, top-level md)
# These aren't on the issue branch so the CLI can't find them without this.
$sourceCfg = Join-Path $SourceRepoRoot $_cfgDir
$destCfg = Join-Path $workingDir $_cfgDir
if (Test-Path $sourceCfg) {
if (-not (Test-Path $destCfg)) {
New-Item -ItemType Directory -Path $destCfg -Force | Out-Null
}
foreach ($sub in @('agents', 'skills', 'instructions', 'prompts')) {
$src = Join-Path $sourceCfg $sub
$dst = Join-Path $destCfg $sub
if ((Test-Path $src) -and -not (Test-Path $dst)) {
Copy-Item -Path $src -Destination $dst -Recurse -Force
Info "Copied $_cfgDir/$sub to worktree"
}
}
foreach ($mdFile in @('copilot-instructions.md', 'CLAUDE.md')) {
$src = Join-Path $sourceCfg $mdFile
$dst = Join-Path $destCfg $mdFile
if ((Test-Path $src) -and -not (Test-Path $dst)) {
Copy-Item -Path $src -Destination $dst -Force
Info "Copied $_cfgDir/$mdFile to worktree"
}
}
}
}
# Build the prompt for the fix agent
$prompt = @"
You are the FixIssue agent. Fix GitHub issue #$IssueNumber.
The implementation plan is at: Generated Files/issueReview/$IssueNumber/implementation-plan.md
The overview is at: Generated Files/issueReview/$IssueNumber/overview.md
Follow the implementation plan exactly. Build and verify after each change.
"@
# Start the fix agent
Info "Starting fix agent for issue #$IssueNumber in $workingDir..."
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = "@$_cfgDir/skills/issue-fix/references/mcp-config.json"
switch ($CLIType) {
'copilot' {
# GitHub Copilot CLI (standalone copilot command)
# -p: Non-interactive prompt mode (exits after completion)
# --yolo: Enable all permissions for automated execution
# -s: Silent mode - output only agent response
# --additional-mcp-config: Load github-artifacts MCP for image/attachment analysis
$copilotArgs = @(
'--additional-mcp-config', $mcpConfig,
'-p', $prompt,
'--yolo',
'-s',
'--agent', 'FixIssue'
)
if ($Model) {
$copilotArgs += @('--model', $Model)
}
Info "Running: copilot $($copilotArgs -join ' ')"
Push-Location $workingDir
try {
& copilot @copilotArgs
if ($LASTEXITCODE -ne 0) {
Warn "Copilot exited with code $LASTEXITCODE"
}
} finally {
Pop-Location
}
}
'claude' {
$claudeArgs = @(
'--print',
'--dangerously-skip-permissions',
'--agent', 'FixIssue',
'--prompt', $prompt
)
Start-Process -FilePath 'claude' -ArgumentList $claudeArgs -WorkingDirectory $workingDir -Wait -NoNewWindow
}
'gh-copilot' {
# Use GitHub Copilot CLI via gh extension
# gh copilot suggest requires interactive mode, so we open VS Code with the prompt
Info "GitHub Copilot CLI detected. Opening VS Code with prompt..."
# Create a prompt file in the worktree for easy access
$promptFile = Join-Path $workingDir "Generated Files/issueReview/$IssueNumber/fix-prompt.md"
$promptContent = @"
# Fix Issue #$IssueNumber
## Instructions
$prompt
## Quick Start
1. Read the implementation plan: ``Generated Files/issueReview/$IssueNumber/implementation-plan.md``
2. Read the overview: ``Generated Files/issueReview/$IssueNumber/overview.md``
3. Follow the plan step by step
4. Build and test after each change
"@
Set-Content -Path $promptFile -Value $promptContent -Force
# Open VS Code with the worktree
code --new-window $workingDir --profile $VSCodeProfile
Info "VS Code opened at $workingDir"
Info "Prompt file created at: $promptFile"
Info "Use GitHub Copilot in VS Code to implement the fix."
}
'vscode' {
# Open VS Code and let user manually trigger the fix
code --new-window $workingDir --profile $VSCodeProfile
Info "VS Code opened at $workingDir. Use Copilot to implement the fix."
}
default {
Warn "CLI type '$CLIType' not fully supported for auto-fix. Opening VS Code..."
code --new-window $workingDir --profile $VSCodeProfile
}
}
# Check if any changes were actually made
$hasChanges = $false
Push-Location $workingDir
try {
$uncommitted = git status --porcelain 2>$null
$commitsAhead = git rev-list main..HEAD --count 2>$null
if ($uncommitted -or ($commitsAhead -gt 0)) {
$hasChanges = $true
}
} finally {
Pop-Location
}
return @{
IssueNumber = $IssueNumber
WorktreePath = $workingDir
Success = $true
ActionTaken = 'CodeFixAttempted'
SkippedCodeFix = $false
HasChanges = $hasChanges
}
}
#region Main Script
try {
Info "Repository root: $repoRoot"
# Detect or validate CLI
if ($CLIType -eq 'auto') {
$cli = Get-AvailableCLI
if ($cli) {
$CLIType = $cli.Type
Info "Auto-detected CLI: $($cli.Name)"
} else {
$CLIType = 'vscode'
Info "No CLI detected, will use VS Code"
}
}
# Find issues to fix
$issuesToFix = @()
if ($IssueNumber) {
# Single issue specified
$reviewResult = Get-IssueReviewResult -IssueNumber $IssueNumber -RepoRoot $repoRoot
if (-not $reviewResult.HasOverview -or -not $reviewResult.HasImplementationPlan) {
throw "Issue #$IssueNumber does not have review files. Run Start-BulkIssueReview.ps1 first."
}
$issuesToFix += @{
IssueNumber = $IssueNumber
OverviewPath = $reviewResult.OverviewPath
ImplementationPlanPath = $reviewResult.ImplementationPlanPath
}
} else {
# Find high-confidence issues
Info "`nSearching for high-confidence issues..."
Info " Min Feasibility Score: $MinFeasibilityScore"
Info " Min Clarity Score: $MinClarityScore"
Info " Max Effort: $MaxEffortDays days"
$highConfidence = Get-HighConfidenceIssues `
-RepoRoot $repoRoot `
-MinFeasibilityScore $MinFeasibilityScore `
-MinClarityScore $MinClarityScore `
-MaxEffortDays $MaxEffortDays
if ($highConfidence.Count -eq 0) {
Warn "No high-confidence issues found matching criteria."
Info "Try lowering the score thresholds or increasing MaxEffortDays."
return
}
$issuesToFix = $highConfidence
}
Info "`nIssues ready for auto-fix: $($issuesToFix.Count)"
Info ("-" * 80)
foreach ($issue in $issuesToFix) {
$scores = ""
if ($issue.FeasibilityScore) {
$scores = " [Feasibility: $($issue.FeasibilityScore), Clarity: $($issue.ClarityScore), Effort: $($issue.EffortDays)d]"
}
Info ("#{0,-6}{1}" -f $issue.IssueNumber, $scores)
}
Info ("-" * 80)
# In DryRun mode, still analyze plans but don't take action
if ($DryRun) {
Info "`nAnalyzing implementation plans (dry run)..."
foreach ($issue in $issuesToFix) {
$implPlanPath = Join-Path (Get-IssueReviewPath -RepoRoot $repoRoot -IssueNumber $issue.IssueNumber) 'implementation-plan.md'
if (Test-Path $implPlanPath) {
$planStatus = Get-ImplementationPlanStatus -ImplementationPlanPath $implPlanPath
$color = switch ($planStatus.Action) {
'ImplementFix' { 'Green' }
'CloseIssue' { 'Yellow' }
'AddComment' { 'Cyan' }
'LinkDuplicate' { 'Magenta' }
default { 'Gray' }
}
Write-Host (" #{0,-6} [{1,-20}] -> {2}" -f $issue.IssueNumber, $planStatus.Status, $planStatus.Action) -ForegroundColor $color
if ($planStatus.RelatedPR) {
$prInfo = "PR #$($planStatus.RelatedPR)"
if ($planStatus.ReleasedIn) {
$prInfo += " (released in $($planStatus.ReleasedIn))"
} elseif ($planStatus.Status -eq 'FixedButUnreleased') {
$prInfo += " (merged, awaiting release)"
}
Write-Host " $prInfo" -ForegroundColor DarkGray
}
if ($planStatus.DuplicateOf) {
Write-Host " Duplicate of #$($planStatus.DuplicateOf)" -ForegroundColor DarkGray
}
}
}
Warn "`nDry run mode - no actions taken."
return
}
# Confirm before proceeding (skip if -Force)
if (-not $Force) {
$confirm = Read-Host "`nProceed with fixing $($issuesToFix.Count) issues? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
# Process issues
$results = @{
Succeeded = @()
Failed = @()
AlreadyResolved = @()
AwaitingRelease = @()
NeedsClarification = @()
Duplicates = @()
NoChanges = @()
}
foreach ($issue in $issuesToFix) {
try {
Info "`n" + ("=" * 60)
Info "PROCESSING ISSUE #$($issue.IssueNumber)"
Info ("=" * 60)
$result = Start-IssueFixInWorktree `
-IssueNumber $issue.IssueNumber `
-SourceRepoRoot $repoRoot `
-CLIType $CLIType `
-Model $Model `
-VSCodeProfile $VSCodeProfile `
-SkipWorktree:$SkipWorktree `
-DryRun:$DryRun
if ($result.SkippedCodeFix) {
# Action was taken but no code fix (e.g., closed issue, added comment)
switch -Wildcard ($result.ActionTaken) {
'*Closing*' { $results.AlreadyResolved += $issue.IssueNumber }
'*clarification*' { $results.NeedsClarification += $issue.IssueNumber }
'*duplicate*' { $results.Duplicates += $issue.IssueNumber }
'*merged*awaiting*' { $results.AwaitingRelease += $issue.IssueNumber }
'*merged but not yet released*' { $results.AwaitingRelease += $issue.IssueNumber }
default { $results.Succeeded += $issue.IssueNumber }
}
Success "✓ Issue #$($issue.IssueNumber) handled: $($result.ActionTaken)"
}
elseif ($result.HasChanges) {
$results.Succeeded += $issue.IssueNumber
Success "✓ Issue #$($issue.IssueNumber) fix completed with changes"
}
else {
$results.NoChanges += $issue.IssueNumber
Warn "⚠ Issue #$($issue.IssueNumber) fix ran but no code changes were made"
}
}
catch {
Err "✗ Issue #$($issue.IssueNumber) failed: $($_.Exception.Message)"
$results.Failed += $issue.IssueNumber
}
}
# Summary
Info "`n" + ("=" * 80)
Info "AUTO-FIX COMPLETE"
Info ("=" * 80)
Info "Total issues: $($issuesToFix.Count)"
if ($results.Succeeded.Count -gt 0) {
Success "Code fixes: $($results.Succeeded.Count)"
}
if ($results.AlreadyResolved.Count -gt 0) {
Success "Already resolved: $($results.AlreadyResolved.Count) (issues closed)"
}
if ($results.AwaitingRelease.Count -gt 0) {
Info "Awaiting release: $($results.AwaitingRelease.Count) (fix merged, pending release)"
}
if ($results.NeedsClarification.Count -gt 0) {
Warn "Need clarification: $($results.NeedsClarification.Count) (comments added)"
}
if ($results.Duplicates.Count -gt 0) {
Warn "Duplicates: $($results.Duplicates.Count) (issues closed)"
}
if ($results.NoChanges.Count -gt 0) {
Warn "No changes made: $($results.NoChanges.Count)"
}
if ($results.Failed.Count -gt 0) {
Err "Failed: $($results.Failed.Count)"
Err "Failed issues: $($results.Failed -join ', ')"
}
Info ("=" * 80)
if (-not $SkipWorktree -and ($results.Succeeded.Count -gt 0 -or $results.NoChanges.Count -gt 0)) {
Info "`nWorktrees created. Use 'git worktree list' to see all worktrees."
Info "To clean up: Delete-Worktree.ps1 -Branch issue/<number>"
}
# Write signal files for orchestrator
$genFiles = Get-GeneratedFilesPath -RepoRoot $repoRoot
foreach ($issueNum in $results.Succeeded) {
$signalDir = Join-Path $genFiles "issueFix/$issueNum"
if (-not (Test-Path $signalDir)) { New-Item -ItemType Directory -Path $signalDir -Force | Out-Null }
@{
status = "success"
issueNumber = $issueNum
timestamp = (Get-Date).ToString("o")
worktreePath = (git worktree list --porcelain | Select-String "worktree.*issue.$issueNum" | ForEach-Object { $_.Line -replace 'worktree ', '' })
} | ConvertTo-Json | Set-Content "$signalDir/.signal" -Force
}
foreach ($issueNum in $results.Failed) {
$signalDir = Join-Path $genFiles "issueFix/$issueNum"
if (-not (Test-Path $signalDir)) { New-Item -ItemType Directory -Path $signalDir -Force | Out-Null }
@{
status = "failure"
issueNumber = $issueNum
timestamp = (Get-Date).ToString("o")
} | ConvertTo-Json | Set-Content "$signalDir/.signal" -Force
}
return $results
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

View File

@@ -0,0 +1,86 @@
<#
.SYNOPSIS
Run issue-fix in parallel from a single terminal.
.PARAMETER IssueNumbers
Issue numbers to fix.
.PARAMETER ThrottleLimit
Maximum parallel tasks.
.PARAMETER CLIType
AI CLI type (copilot/claude/gh-copilot/vscode/auto).
.PARAMETER Model
Copilot CLI model to use (e.g., gpt-5.2-codex).
.PARAMETER Force
Skip confirmation prompts in Start-IssueAutoFix.ps1.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int[]]$IssueNumbers,
[int]$ThrottleLimit = 5,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode', 'auto')]
[string]$CLIType = 'copilot',
[string]$Model,
[switch]$Force
)
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')
# Resolve config directory name (.github or .claude) from script location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
$scriptPath = Join-Path $repoRoot "$_cfgDir\skills\issue-fix\scripts\Start-IssueAutoFix.ps1"
$results = $IssueNumbers | ForEach-Object -Parallel {
$issue = $PSItem
$repoRoot = $using:repoRoot
$scriptPath = $using:scriptPath
$cliType = $using:CLIType
$model = $using:Model
$force = $using:Force
Set-Location $repoRoot
if (-not $issue) {
return [pscustomobject]@{
IssueNumber = $issue
ExitCode = 1
Error = 'Issue number is empty.'
}
}
$params = @{
IssueNumber = [int]$issue
CLIType = $cliType
}
if ($model) {
$params.Model = $model
}
if ($force) {
$params.Force = $true
}
try {
& $scriptPath @params | Out-Default
[pscustomobject]@{
IssueNumber = $issue
ExitCode = $LASTEXITCODE
}
}
catch {
[pscustomobject]@{
IssueNumber = $issue
ExitCode = 1
Error = $_.Exception.Message
}
}
} -ThrottleLimit $ThrottleLimit
$results

View File

@@ -0,0 +1,562 @@
<#!
.SYNOPSIS
Commit and create PRs for completed issue fixes in worktrees.
.DESCRIPTION
For each specified issue (or all issue worktrees), commits changes using AI-generated
commit messages and creates PRs with AI-generated summaries, linking to the original issue.
.PARAMETER IssueNumbers
Array of issue numbers to submit. If not specified, processes all issue/* worktrees.
.PARAMETER DryRun
Show what would be done without actually committing or creating PRs.
.PARAMETER SkipCommit
Skip the commit step (assume changes are already committed).
.PARAMETER SkipPush
Skip pushing to remote (useful for testing).
.PARAMETER TargetBranch
Target branch for the PR. Default: main.
.PARAMETER CLIType
AI CLI to use for generating messages: copilot, claude, or manual. Default: copilot.
.PARAMETER Draft
Create PRs as drafts.
.EXAMPLE
# Submit all issue worktrees
./Submit-IssueFixes.ps1
.EXAMPLE
# Submit specific issues
./Submit-IssueFixes.ps1 -IssueNumbers 44044, 44480
.EXAMPLE
# Dry run to see what would happen
./Submit-IssueFixes.ps1 -DryRun
.EXAMPLE
# Create draft PRs
./Submit-IssueFixes.ps1 -Draft
.NOTES
Prerequisites:
- Worktrees created by Start-IssueAutoFix.ps1
- Changes made in the worktrees
- GitHub CLI (gh) authenticated
- Copilot CLI or Claude Code CLI
#>
[CmdletBinding()]
param(
[int[]]$IssueNumbers,
[switch]$DryRun,
[switch]$SkipCommit,
[switch]$SkipPush,
[string]$TargetBranch = 'main',
[ValidateSet('copilot', 'claude', 'manual')]
[string]$CLIType = 'copilot',
[switch]$Draft,
[switch]$Force,
[switch]$Help
)
# Load libraries
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir/IssueReviewLib.ps1"
# Load worktree library
$repoRoot = Get-RepoRoot
# Resolve config directory name (.github or .claude) from script location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
function Get-AIGeneratedCommitTitle {
<#
.SYNOPSIS
Generate commit title using AI CLI with create-commit-title prompt.
#>
param(
[Parameter(Mandatory)]
[string]$WorktreePath,
[string]$CLIType = 'copilot'
)
$promptFile = Join-Path $repoRoot "$_cfgDir/prompts/create-commit-title.prompt.md"
if (-not (Test-Path $promptFile)) {
throw "Prompt file not found: $promptFile"
}
$prompt = "Follow the instructions in $_cfgDir/prompts/create-commit-title.prompt.md to generate a commit title for the current changes. Output ONLY the commit title, nothing else."
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = "@$_cfgDir/skills/issue-fix/references/mcp-config.json"
Push-Location $WorktreePath
try {
switch ($CLIType) {
'copilot' {
$result = & copilot --additional-mcp-config $mcpConfig -p $prompt --yolo -s --agent FixIssue 2>&1
# Extract just the title line (last non-empty line that looks like a title)
$lines = $result -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch '^\s*```' -and $_ -notmatch '^\s*#' }
$title = $lines | Select-Object -Last 1
return $title.Trim()
}
'claude' {
$result = & claude --print --dangerously-skip-permissions --agent FixIssue --prompt $prompt 2>&1
$lines = $result -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch '^\s*```' }
$title = $lines | Select-Object -Last 1
return $title.Trim()
}
'manual' {
# Show diff and ask user for title
git diff HEAD --stat
return Read-Host "Enter commit title"
}
}
} finally {
Pop-Location
}
}
function Get-AIGeneratedPRSummary {
<#
.SYNOPSIS
Generate PR summary using AI CLI with create-pr-summary prompt.
#>
param(
[Parameter(Mandatory)]
[string]$WorktreePath,
[Parameter(Mandatory)]
[int]$IssueNumber,
[string]$TargetBranch = 'main',
[string]$CLIType = 'copilot'
)
$prompt = @"
Follow the instructions in $_cfgDir/prompts/create-pr-summary.prompt.md to generate a PR summary.
Target branch: $TargetBranch
This PR fixes issue #$IssueNumber.
IMPORTANT:
1. Output the PR title on the first line
2. Then output the PR body in markdown format
3. Make sure to include "Fixes #$IssueNumber" in the body to auto-link the issue
"@
# MCP config for github-artifacts tools (relative to repo root)
$mcpConfig = "@$_cfgDir/skills/issue-fix/references/mcp-config.json"
Push-Location $WorktreePath
try {
switch ($CLIType) {
'copilot' {
$result = & copilot --additional-mcp-config $mcpConfig -p $prompt --yolo -s --agent FixIssue 2>&1
return $result -join "`n"
}
'claude' {
$result = & claude --print --dangerously-skip-permissions --agent FixIssue --prompt $prompt 2>&1
return $result -join "`n"
}
'manual' {
git diff "$TargetBranch...HEAD" --stat
$title = Read-Host "Enter PR title"
$body = Read-Host "Enter PR body (or press Enter for default)"
if (-not $body) {
$body = "Fixes #$IssueNumber"
}
return "$title`n`n$body"
}
}
} finally {
Pop-Location
}
}
function Parse-PRContent {
<#
.SYNOPSIS
Parse AI output to extract PR title and body.
Expected format:
Line 1: feat(scope): title text
Line 2+: ```markdown
## Summary...
```
#>
param(
[Parameter(Mandatory)]
[string]$Content,
[int]$IssueNumber
)
$lines = $Content -split "`n"
# Title is the FIRST line that looks like a conventional commit
# Body is the content INSIDE the ```markdown ... ``` block
$title = $null
$body = $null
# Find title - first line matching conventional commit format
foreach ($line in $lines) {
$trimmed = $line.Trim()
if ($trimmed -match '^(feat|fix|docs|refactor|perf|test|build|ci|chore)(\([^)]+\))?:') {
$title = $trimmed -replace '^#+\s*', ''
break
}
}
# Fallback title
if (-not $title) {
$title = "fix: address issue #$IssueNumber"
}
# Extract body from markdown code block
$fullContent = $Content
if ($fullContent -match '```markdown\r?\n([\s\S]*?)\r?\n```') {
$body = $Matches[1].Trim()
} else {
# No markdown block - use everything after the title line
$titleIndex = [array]::IndexOf($lines, ($lines | Where-Object { $_.Trim() -eq $title } | Select-Object -First 1))
if ($titleIndex -ge 0 -and $titleIndex -lt $lines.Count - 1) {
$body = ($lines[($titleIndex + 1)..($lines.Count - 1)] -join "`n").Trim()
# Clean up any remaining code fences
$body = $body -replace '^```\w*\r?\n', '' -replace '\r?\n```\s*$', ''
} else {
$body = ""
}
}
# Ensure issue link is present
if ($body -notmatch "Fixes\s*#$IssueNumber" -and $body -notmatch "Closes\s*#$IssueNumber" -and $body -notmatch "Resolves\s*#$IssueNumber") {
$body = "$body`n`nFixes #$IssueNumber"
}
return @{
Title = $title
Body = $body
}
}
function Submit-IssueFix {
<#
.SYNOPSIS
Commit changes, push, and create PR for a single issue.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$WorktreePath,
[Parameter(Mandatory)]
[string]$Branch,
[string]$TargetBranch = 'main',
[string]$CLIType = 'copilot',
[switch]$DryRun,
[switch]$SkipCommit,
[switch]$SkipPush,
[switch]$Draft
)
Push-Location $WorktreePath
try {
# Check for changes
$status = git status --porcelain
$hasUncommitted = $status.Count -gt 0
# Check for commits ahead of target
git fetch origin $TargetBranch 2>$null
$commitsAhead = git rev-list --count "origin/$TargetBranch..$Branch" 2>$null
if (-not $commitsAhead) { $commitsAhead = 0 }
Info "Issue #$IssueNumber in $WorktreePath"
Info " Branch: $Branch"
Info " Uncommitted changes: $hasUncommitted"
Info " Commits ahead of $TargetBranch`: $commitsAhead"
if (-not $hasUncommitted -and $commitsAhead -eq 0) {
Warn " No changes to submit for issue #$IssueNumber"
return @{ IssueNumber = $IssueNumber; Status = 'NoChanges' }
}
# Step 1: Commit if there are uncommitted changes
if ($hasUncommitted -and -not $SkipCommit) {
Info " Generating commit title..."
if ($DryRun) {
Info " [DRY RUN] Would generate commit title and commit changes"
} else {
$commitTitle = Get-AIGeneratedCommitTitle -WorktreePath $WorktreePath -CLIType $CLIType
if (-not $commitTitle) {
throw "Failed to generate commit title"
}
Info " Commit title: $commitTitle"
# Stage all changes and commit
git add -A
git commit -m $commitTitle
if ($LASTEXITCODE -ne 0) {
throw "Git commit failed"
}
Success " ✓ Changes committed"
}
}
# Step 2: Push to remote
if (-not $SkipPush) {
if ($DryRun) {
Info " [DRY RUN] Would push branch $Branch to origin"
} else {
Info " Pushing to origin..."
git push -u origin $Branch 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
# Try force push if normal push fails (branch might have been reset)
Warn " Normal push failed, trying force push..."
git push -u origin $Branch --force-with-lease 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Git push failed"
}
}
Success " ✓ Pushed to origin"
}
}
# Step 3: Create PR
Info " Generating PR summary..."
if ($DryRun) {
Info " [DRY RUN] Would generate PR summary and create PR"
Info " [DRY RUN] PR would link to issue #$IssueNumber"
return @{ IssueNumber = $IssueNumber; Status = 'DryRun' }
}
# Check if PR already exists
$existingPR = gh pr list --head $Branch --json number,url 2>$null | ConvertFrom-Json
if ($existingPR -and $existingPR.Count -gt 0) {
Warn " PR already exists: $($existingPR[0].url)"
return @{ IssueNumber = $IssueNumber; Status = 'PRExists'; PRUrl = $existingPR[0].url }
}
$prContent = Get-AIGeneratedPRSummary -WorktreePath $WorktreePath -IssueNumber $IssueNumber -TargetBranch $TargetBranch -CLIType $CLIType
$parsed = Parse-PRContent -Content $prContent -IssueNumber $IssueNumber
if (-not $parsed.Title) {
throw "Failed to generate PR title"
}
Info " PR Title: $($parsed.Title)"
# Create PR using gh CLI
$ghArgs = @(
'pr', 'create',
'--base', $TargetBranch,
'--head', $Branch,
'--title', $parsed.Title,
'--body', $parsed.Body
)
if ($Draft) {
$ghArgs += '--draft'
}
$prResult = & gh @ghArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to create PR: $prResult"
}
# Extract PR URL from result
$prUrl = $prResult | Select-String -Pattern 'https://github.com/[^\s]+' | ForEach-Object { $_.Matches[0].Value }
Success " ✓ PR created: $prUrl"
return @{
IssueNumber = $IssueNumber
Status = 'Success'
PRUrl = $prUrl
CommitTitle = $commitTitle
PRTitle = $parsed.Title
}
}
catch {
Err " ✗ Failed: $($_.Exception.Message)"
return @{
IssueNumber = $IssueNumber
Status = 'Failed'
Error = $_.Exception.Message
}
}
finally {
Pop-Location
}
}
#region Main Script
try {
Info "Repository root: $repoRoot"
Info "Target branch: $TargetBranch"
Info "CLI type: $CLIType"
# Get all issue worktrees
$allWorktrees = Get-WorktreeEntries | Where-Object { $_.Branch -like 'issue/*' }
if ($allWorktrees.Count -eq 0) {
Warn "No issue worktrees found. Run Start-IssueAutoFix.ps1 first."
return
}
# Filter to specified issues if provided
$worktreesToProcess = @()
if ($IssueNumbers -and $IssueNumbers.Count -gt 0) {
foreach ($issueNum in $IssueNumbers) {
$wt = $allWorktrees | Where-Object { $_.Branch -match "issue/$issueNum\b" }
if ($wt) {
$worktreesToProcess += $wt
} else {
Warn "No worktree found for issue #$issueNum"
}
}
} else {
$worktreesToProcess = $allWorktrees
}
if ($worktreesToProcess.Count -eq 0) {
Warn "No worktrees to process."
return
}
# Display worktrees to process
Info "`nWorktrees to submit:"
Info ("-" * 80)
foreach ($wt in $worktreesToProcess) {
# Extract issue number from branch name
if ($wt.Branch -match 'issue/(\d+)') {
$issueNum = $Matches[1]
Info " #$issueNum -> $($wt.Path) [$($wt.Branch)]"
}
}
Info ("-" * 80)
if ($DryRun) {
Warn "`nDry run mode - no changes will be made."
}
# Confirm before proceeding
if (-not $Force -and -not $DryRun) {
$confirm = Read-Host "`nProceed with submitting $($worktreesToProcess.Count) fixes? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
# Process each worktree
$results = @{
Success = @()
Failed = @()
NoChanges = @()
PRExists = @()
DryRun = @()
}
foreach ($wt in $worktreesToProcess) {
if ($wt.Branch -match 'issue/(\d+)') {
$issueNum = [int]$Matches[1]
Info "`n" + ("=" * 60)
Info "SUBMITTING ISSUE #$issueNum"
Info ("=" * 60)
$result = Submit-IssueFix `
-IssueNumber $issueNum `
-WorktreePath $wt.Path `
-Branch $wt.Branch `
-TargetBranch $TargetBranch `
-CLIType $CLIType `
-DryRun:$DryRun `
-SkipCommit:$SkipCommit `
-SkipPush:$SkipPush `
-Draft:$Draft
switch ($result.Status) {
'Success' { $results.Success += $result }
'Failed' { $results.Failed += $result }
'NoChanges' { $results.NoChanges += $result }
'PRExists' { $results.PRExists += $result }
'DryRun' { $results.DryRun += $result }
}
}
}
# Summary
Info "`n" + ("=" * 80)
Info "SUBMISSION COMPLETE"
Info ("=" * 80)
Info "Total worktrees: $($worktreesToProcess.Count)"
if ($results.Success.Count -gt 0) {
Success "PRs created: $($results.Success.Count)"
foreach ($r in $results.Success) {
Success " #$($r.IssueNumber): $($r.PRUrl)"
}
}
if ($results.PRExists.Count -gt 0) {
Warn "PRs already exist: $($results.PRExists.Count)"
foreach ($r in $results.PRExists) {
Warn " #$($r.IssueNumber): $($r.PRUrl)"
}
}
if ($results.NoChanges.Count -gt 0) {
Warn "No changes: $($results.NoChanges.Count)"
Warn " Issues: $($results.NoChanges.IssueNumber -join ', ')"
}
if ($results.Failed.Count -gt 0) {
Err "Failed: $($results.Failed.Count)"
foreach ($r in $results.Failed) {
Err " #$($r.IssueNumber): $($r.Error)"
}
}
if ($results.DryRun.Count -gt 0) {
Info "Dry run: $($results.DryRun.Count)"
}
Info ("=" * 80)
return $results
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,184 @@
---
name: issue-review-review
description: Meta-review of issue-review outputs to validate scoring accuracy and implementation plan quality. Use when asked to verify an issue review, validate review scores, check if implementation plan is sound, audit issue analysis quality, second-opinion on issue feasibility, or ensure review consistency. Outputs a quality score (0-100) and corrective feedback that feeds back into issue-review for re-analysis.
license: Complete terms in LICENSE.txt
---
# Issue Review Review Skill
Validate the quality of `issue-review` outputs by cross-checking scores against evidence, verifying implementation plan correctness, and producing actionable feedback. When the quality score is below 90, the feedback is fed back into `issue-review` to re-run the analysis with corrections.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/issue-review-review/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
├── scripts/
│ ├── Start-IssueReviewReview.ps1 # Main review-review script
│ ├── Start-IssueReviewReviewParallel.ps1 # Parallel runner
│ └── IssueReviewLib.ps1 # Shared library functions
└── references/
├── review-the-review.prompt.md # AI prompt for meta-review
└── mcp-config.json # MCP configuration
```
## Output Directory
All generated artifacts are placed under `Generated Files/issueReviewReview/<issue-number>/` at the repository root (gitignored).
```
Generated Files/issueReviewReview/
└── <issue-number>/
├── reviewTheReview.md # Meta-review with quality score and feedback
├── .signal # Completion signal for orchestrator
└── iteration-<N>/ # Previous iteration outputs (if looped)
└── reviewTheReview.md
```
## Signal File
On completion, a `.signal` file is created for orchestrator coordination:
```json
{
"status": "success",
"issueNumber": 45363,
"timestamp": "2026-02-04T10:05:23Z",
"qualityScore": 85,
"iteration": 1,
"outputs": ["reviewTheReview.md"],
"needsReReview": true
}
```
Status values: `success`, `failure`
Key fields:
- `qualityScore` (0-100): Overall quality of the original review
- `iteration`: Which review-review pass this is (1, 2, 3...)
- `needsReReview`: `true` if score < 90, meaning `issue-review` should re-run with feedback
## When to Use This Skill
- Validate that an issue review's scores match the evidence
- Check if an implementation plan is technically sound
- Verify that short-term and long-term fix strategies are correct
- Audit review quality before sending issues to `issue-fix`
- Second-opinion on feasibility and clarity assessments
- Quality gate in the issue-to-PR cycle automation
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- PowerShell 7+ for running scripts
- Issue must be reviewed first (use `issue-review` skill)
- Copilot CLI or Claude CLI installed
## Required Variables
⚠️ **Before starting**, confirm `{{IssueNumber}}` with the user. If not provided, **ASK**: "What issue number should I review-review?"
| Variable | Description | Example |
|----------|-------------|---------|
| `{{IssueNumber}}` | GitHub issue number whose review to validate | `44044` |
## Workflow
### Step 1: Ensure Issue Is Reviewed
The issue must already have `Generated Files/issueReview/{{IssueNumber}}/overview.md` and `implementation-plan.md`. If not, run `issue-review` first.
### Step 2: Run Review-Review
```powershell
# From repo root
.github/skills/issue-review-review/scripts/Start-IssueReviewReview.ps1 -IssueNumber {{IssueNumber}}
```
This will:
1. Read the original issue from GitHub
2. Read the existing `overview.md` and `implementation-plan.md`
3. Cross-check scores against evidence in the issue
4. Validate implementation plan against codebase
5. Generate `reviewTheReview.md` with quality score and feedback
### Step 3: Check Quality Score
Read the signal file at `Generated Files/issueReviewReview/{{IssueNumber}}/.signal`:
| Quality Score | Action |
|---------------|--------|
| 90-100 | ✅ Review is high quality — proceed to `issue-fix` |
| 70-89 | ⚠️ Review needs improvement — re-run `issue-review` with feedback |
| 50-69 | 🔶 Review has significant issues — re-run with feedback, may need 2 iterations |
| 0-49 | 🔴 Review is poor — re-run with feedback, consider manual review |
### Step 4: Feed Back to Issue-Review (if score < 90)
If `needsReReview` is `true`, re-run issue-review with the feedback file:
```powershell
# Re-run issue-review with feedback from review-review
.github/skills/issue-review/scripts/Start-BulkIssueReview.ps1 -IssueNumber {{IssueNumber}} -FeedbackFile "Generated Files/issueReviewReview/{{IssueNumber}}/reviewTheReview.md" -Force
```
Then re-run the review-review to check if quality improved:
```powershell
.github/skills/issue-review-review/scripts/Start-IssueReviewReview.ps1 -IssueNumber {{IssueNumber}} -Force
```
### Step 5: Loop Until Quality ≥ 90
The orchestrator (`issue-to-pr-cycle`) will loop Steps 2-4 until either:
- Quality score ≥ 90, OR
- Maximum iterations reached (default: 3)
## Batch Review-Review
To review-review multiple issues at once:
```powershell
.github/skills/issue-review-review/scripts/Start-IssueReviewReviewParallel.ps1 -IssueNumbers 44044,32950,45029 -ThrottleLimit 5 -Force
```
## CLI Options
### Start-IssueReviewReview.ps1
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-IssueNumber` | Issue number to review-review | (required) |
| `-CLIType` | AI CLI: `copilot` or `claude` | `copilot` |
| `-Model` | Copilot model to use | (auto) |
| `-Force` | Skip confirmation prompts | `$false` |
| `-DryRun` | Show what would be done | `$false` |
### Start-IssueReviewReviewParallel.ps1
| Parameter | Description | Default |
|-----------|-------------|---------|
| `-IssueNumbers` | Array of issue numbers | (required) |
| `-ThrottleLimit` | Max parallel tasks | `5` |
| `-CLIType` | AI CLI type | `copilot` |
| `-Model` | Copilot model to use | (auto) |
| `-Force` | Skip confirmation prompts | `$false` |
## Quality Dimensions Checked
The meta-review evaluates these dimensions:
| Dimension | What It Checks | Weight |
|-----------|---------------|--------|
| Score Accuracy | Do scores match the evidence cited? | 30% |
| Implementation Correctness | Are the right files/patterns identified? | 25% |
| Risk Assessment | Are risks properly identified and mitigated? | 15% |
| Completeness | Are all aspects covered (perf, security, a11y, i18n)? | 15% |
| Actionability | Can an AI agent execute the plan as written? | 15% |
## AI Prompt Reference
The full prompt template is at [references/review-the-review.prompt.md](./references/review-the-review.prompt.md).

View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"github-artifacts": {
"command": "cmd",
"args": ["/c", "for /f %i in ('git rev-parse --show-toplevel') do node %i/tools/mcp/github-artifacts/launch.js"],
"tools": ["*"]
}
}
}

View File

@@ -0,0 +1,194 @@
---
agent: 'agent'
description: 'Meta-review of issue-review outputs: validate scores, check implementation plan quality, produce feedback'
---
# Review the Review — Meta-Analysis of Issue Review Quality
## Goal
For issue **#{{issue_number}}**, validate the existing `issue-review` outputs and produce:
1) `Generated Files/issueReviewReview/{{issue_number}}/reviewTheReview.md`
## Inputs
You MUST have these files available before starting:
- `Generated Files/issueReview/{{issue_number}}/overview.md` — The original review scores and assessment
- `Generated Files/issueReview/{{issue_number}}/implementation-plan.md` — The original implementation plan
- The original GitHub issue data (fetch via `gh issue view {{issue_number}}`)
If a feedback file from a previous iteration exists, also read it:
- `Generated Files/issueReviewReview/{{issue_number}}/reviewTheReview.md` — Previous meta-review feedback (check if iteration > 1)
## Process
### Step 1: Gather Context
1. **Read the original issue**: `gh issue view {{issue_number}} --json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments,linkedPullRequests`
2. **Read overview.md**: Parse all scores (Business Importance, Community Excitement, Technical Feasibility, Requirement Clarity, Overall Priority, Effort Estimate)
3. **Read implementation-plan.md**: Parse all sections (Problem Framing, Layers & Files, Pattern Choices, Fundamentals, Task Breakdown)
4. **Examine the actual codebase**: Use `rg`/`git grep`/`find` to verify file paths mentioned in the implementation plan actually exist
5. **Check for similar past fixes**: Search for related PRs and how they were implemented
### Step 2: Validate Scores
For EACH score dimension, evaluate whether the score matches the evidence:
#### A) Business Importance Score Validation
- Does the score align with the issue's labels (priority/security/regression)?
- Is the milestone/roadmap impact correctly assessed?
- Are customer/contract impacts properly weighted?
#### B) Community Excitement Score Validation
- Count actual 👍/❤️ reactions and compare against the score
- Verify comment count and unique participant count
- Check if recent activity assessment is accurate
- Verify duplicate/related issue count
#### C) Technical Feasibility Score Validation
- **CRITICAL**: Verify that files mentioned in the plan actually exist in the repo
- Check if the proposed changes follow existing patterns (use `rg` to find similar patterns)
- Assess whether risk factors (perf/security/compat) are properly identified
- Verify testability claims by checking if test infrastructure exists for the affected module
#### D) Requirement Clarity Score Validation
- Does the issue actually contain clear repro steps?
- Are non-functional requirements (perf/security/i18n/a11y) addressed?
- Are acceptance criteria defined or at least inferable?
### Step 3: Validate Implementation Plan
For EACH section of the implementation plan:
#### Problem Framing
- Is the problem correctly understood?
- Are scope boundaries reasonable?
- Is current vs expected behavior accurately described?
#### Layers & Files
- **CRITICAL**: Do ALL referenced files/directories exist? Run `test -f <path>` or `ls <path>` for each one
- Are the file paths using correct casing and separators?
- Are all affected layers identified (UI/domain/data/infra/build)?
- Are any files missing that should be modified?
#### Pattern Choices
- Do the suggested patterns match what the repo actually uses?
- Use `rg` to find 2-3 examples of the suggested pattern in the codebase
- If a new pattern is suggested, is the justification sound?
#### Fundamentals
- Are performance concerns addressed for the specific module?
- Are security implications properly assessed?
- Is i18n/l10n handled (check for hardcoded strings)?
- Is accessibility considered (keyboard nav, screen readers)?
#### Task Breakdown
- Can an AI agent actually execute each task as written?
- Are the steps in the right order (dependencies respected)?
- Are test requirements specified for each task?
- Is the human-vs-agent ownership realistic?
### Step 4: Check for Red Flags
Flag these issues if found:
- 🔴 **Ghost files**: Implementation plan references files that don't exist
- 🔴 **Wrong patterns**: Suggested approach contradicts existing codebase patterns
- 🔴 **Missing tests**: No test plan for behavior changes
- 🔴 **Score inflation**: Scores are ≥20 points higher than evidence supports
- 🔴 **Score deflation**: Scores are ≥20 points lower than evidence supports
- 🟡 **Incomplete coverage**: Missing fundamentals (security, i18n, a11y)
- 🟡 **Vague tasks**: Task breakdown has steps that are too broad to execute
- 🟡 **Missing dependencies**: Task order doesn't respect build/import dependencies
## Output: reviewTheReview.md
Generate the following structure:
```markdown
# Review-Review: Issue #{{issue_number}}
**Review Quality Score: X/100**
**Iteration: N**
**Verdict: PASS / NEEDS_IMPROVEMENT / FAIL**
## Executive Summary
Brief (2-3 sentences) on whether the original review is trustworthy and actionable.
## Score Validation
| Dimension | Original Score | Validated Score | Delta | Assessment |
|-----------|---------------|-----------------|-------|------------|
| Business Importance | X/100 | Y/100 | ±Z | ✅ Accurate / ⚠️ Inflated / ⚠️ Deflated |
| Community Excitement | X/100 | Y/100 | ±Z | ✅ / ⚠️ |
| Technical Feasibility | X/100 | Y/100 | ±Z | ✅ / ⚠️ |
| Requirement Clarity | X/100 | Y/100 | ±Z | ✅ / ⚠️ |
| Overall Priority | X/100 | Y/100 | ±Z | ✅ / ⚠️ |
### Score Details
For each dimension where delta ≥ 10 points:
- What evidence was missed or misinterpreted
- What the correct assessment should be
- Specific data points supporting the correction
## Implementation Plan Validation
### Files Verification
| File Path | Exists? | Correct? | Notes |
|-----------|---------|----------|-------|
| `src/modules/...` | ✅/❌ | ✅/⚠️ | ... |
### Pattern Verification
| Suggested Pattern | Used in Repo? | Examples Found | Assessment |
|-------------------|---------------|----------------|------------|
| ... | ✅/❌ | `src/...`, `src/...` | ✅ Correct / ⚠️ Wrong pattern |
### Task Breakdown Assessment
| Task # | Executable by Agent? | Issues | Corrective Action |
|--------|---------------------|--------|-------------------|
| 1 | ✅/⚠️/❌ | ... | ... |
## Red Flags Found
List any 🔴 or 🟡 flags with evidence.
## Corrective Feedback for Re-Review
**IF quality score < 90, provide specific instructions for issue-review to fix:**
### Scores to Adjust
- Dimension X: Change from Y to Z because [evidence]
### Implementation Plan Corrections
- File path corrections: [list]
- Missing files to add: [list]
- Pattern corrections: [list]
- Task breakdown fixes: [list]
### Missing Coverage
- Add section on: [topic]
- Expand analysis of: [topic]
## Quality Score Breakdown
| Dimension | Score | Weight | Weighted |
|-----------|-------|--------|----------|
| Score Accuracy | X/100 | 30% | X |
| Implementation Correctness | X/100 | 25% | X |
| Risk Assessment | X/100 | 15% | X |
| Completeness | X/100 | 15% | X |
| Actionability | X/100 | 15% | X |
| **Total** | | | **X/100** |
```
## Important Rules
1. **Be evidence-based**: Every correction must cite specific files, lines, or data
2. **Verify file existence**: ALWAYS run `test -f` or `ls` for paths in the implementation plan
3. **Check patterns**: Use `rg` to find at least 2 examples of any suggested pattern
4. **Don't be a rubber stamp**: If the review looks perfect, still verify the top 3 most impactful claims
5. **Actionable feedback**: Every issue found must include a specific correction, not just "this is wrong"
6. **Score honestly**: The quality score should reflect real issues found, not just gut feeling

View File

@@ -0,0 +1,777 @@
# IssueReviewLib.ps1 - Shared helpers for bulk issue review automation
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# Resolve config directory name (.github or .claude) from this script's location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
function Get-GeneratedFilesPath {
param([string]$RepoRoot)
return Join-Path $RepoRoot 'Generated Files'
}
function Get-IssueReviewPath {
param(
[string]$RepoRoot,
[int]$IssueNumber
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
return Join-Path $genFiles "issueReview/$IssueNumber"
}
function Get-IssueTitleFromOverview {
<#
.SYNOPSIS
Extract issue title from existing overview.md file.
.DESCRIPTION
Parses the overview.md to get the issue title without requiring GitHub CLI.
#>
param(
[Parameter(Mandatory)]
[string]$OverviewPath
)
if (-not (Test-Path $OverviewPath)) {
return $null
}
$content = Get-Content $OverviewPath -Raw
# Try to match title from Summary table: | **Title** | <title> |
if ($content -match '\*\*Title\*\*\s*\|\s*([^|]+)\s*\|') {
return $Matches[1].Trim()
}
# Try to match from header: # Issue #XXXX: <title>
if ($content -match '# Issue #\d+[:\s]+(.+)$' ) {
return $Matches[1].Trim()
}
# Try to match: # Issue #XXXX Review: <title>
if ($content -match '# Issue #\d+ Review[:\s]+(.+)$') {
return $Matches[1].Trim()
}
return $null
}
function Ensure-DirectoryExists {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
#endregion
#region GitHub Issue Query Helpers
function Get-GitHubIssues {
<#
.SYNOPSIS
Query GitHub issues by label, state, and sort order.
.PARAMETER Labels
Comma-separated list of labels to filter by (e.g., "bug,help wanted").
.PARAMETER State
Issue state: open, closed, or all. Default: open.
.PARAMETER Sort
Sort field: created, updated, comments, reactions. Default: created.
.PARAMETER Order
Sort order: asc or desc. Default: desc.
.PARAMETER Limit
Maximum number of issues to return. Default: 100.
.PARAMETER Repository
Repository in owner/repo format. Default: microsoft/PowerToys.
#>
param(
[string]$Labels,
[ValidateSet('open', 'closed', 'all')]
[string]$State = 'open',
[ValidateSet('created', 'updated', 'comments', 'reactions')]
[string]$Sort = 'created',
[ValidateSet('asc', 'desc')]
[string]$Order = 'desc',
[int]$Limit = 100,
[string]$Repository = 'microsoft/PowerToys'
)
$ghArgs = @('issue', 'list', '--repo', $Repository, '--state', $State, '--limit', $Limit)
if ($Labels) {
foreach ($label in ($Labels -split ',')) {
$ghArgs += @('--label', $label.Trim())
}
}
# Build JSON fields (use reactionGroups instead of reactions)
$jsonFields = 'number,title,state,labels,createdAt,updatedAt,author,reactionGroups,comments'
$ghArgs += @('--json', $jsonFields)
Info "Querying issues: gh $($ghArgs -join ' ')"
$result = & gh @ghArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to query issues: $result"
}
$issues = $result | ConvertFrom-Json
# Sort by reactions if requested (gh CLI doesn't support this natively)
if ($Sort -eq 'reactions') {
$issues = $issues | ForEach-Object {
# reactionGroups is an array of {content, users} - sum up user counts
$totalReactions = ($_.reactionGroups | ForEach-Object { $_.users.totalCount } | Measure-Object -Sum).Sum
if (-not $totalReactions) { $totalReactions = 0 }
$_ | Add-Member -NotePropertyName 'totalReactions' -NotePropertyValue $totalReactions -PassThru
}
if ($Order -eq 'desc') {
$issues = $issues | Sort-Object -Property totalReactions -Descending
} else {
$issues = $issues | Sort-Object -Property totalReactions
}
}
return $issues
}
function Get-IssueDetails {
<#
.SYNOPSIS
Get detailed information about a specific issue.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[string]$Repository = 'microsoft/PowerToys'
)
$jsonFields = 'number,title,body,state,labels,createdAt,updatedAt,author,reactions,comments,linkedPullRequests,milestone'
$result = gh issue view $IssueNumber --repo $Repository --json $jsonFields 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to get issue #$IssueNumber`: $result"
}
return $result | ConvertFrom-Json
}
#endregion
#region CLI Detection and Execution
function Get-AvailableCLI {
<#
.SYNOPSIS
Detect which AI CLI is available: GitHub Copilot CLI or Claude Code.
.OUTPUTS
Returns object with: Name, Command, PromptArg
#>
# Check for standalone GitHub Copilot CLI (copilot command)
$copilotCLI = Get-Command 'copilot' -ErrorAction SilentlyContinue
if ($copilotCLI) {
return @{
Name = 'GitHub Copilot CLI'
Command = 'copilot'
Args = @('-p') # Non-interactive prompt mode
Type = 'copilot'
}
}
# Check for Claude Code CLI
$claudeCode = Get-Command 'claude' -ErrorAction SilentlyContinue
if ($claudeCode) {
return @{
Name = 'Claude Code CLI'
Command = 'claude'
Args = @()
Type = 'claude'
}
}
# Check for GitHub Copilot CLI via gh extension
$ghCopilot = Get-Command 'gh' -ErrorAction SilentlyContinue
if ($ghCopilot) {
$copilotCheck = gh extension list 2>&1 | Select-String -Pattern 'copilot'
if ($copilotCheck) {
return @{
Name = 'GitHub Copilot CLI (gh extension)'
Command = 'gh'
Args = @('copilot', 'suggest')
Type = 'gh-copilot'
}
}
}
# Check for VS Code CLI with Copilot
$code = Get-Command 'code' -ErrorAction SilentlyContinue
if ($code) {
return @{
Name = 'VS Code (Copilot Chat)'
Command = 'code'
Args = @()
Type = 'vscode'
}
}
return $null
}
function Invoke-AIReview {
<#
.SYNOPSIS
Invoke AI CLI to review a single issue.
.PARAMETER IssueNumber
The issue number to review.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER CLIType
CLI type: 'claude', 'copilot', 'gh-copilot', or 'vscode'.
.PARAMETER WorkingDirectory
Working directory for the CLI command.
.PARAMETER FeedbackContext
Optional feedback from review-the-review to incorporate into the re-review.
.PARAMETER Model
Optional model override for Copilot CLI (e.g., claude-sonnet-4).
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode')]
[string]$CLIType = 'copilot',
[string]$WorkingDirectory,
[string]$FeedbackContext,
[string]$Model
)
if (-not $WorkingDirectory) {
$WorkingDirectory = $RepoRoot
}
$promptFile = Join-Path $RepoRoot "$_cfgDir/prompts/review-issue.prompt.md"
if (-not (Test-Path $promptFile)) {
throw "Prompt file not found: $promptFile"
}
# Prepare the prompt with issue number substitution
$promptContent = Get-Content $promptFile -Raw
$promptContent = $promptContent -replace '\{\{issue_number\}\}', $IssueNumber
# Create temp prompt file
$tempPromptDir = Join-Path $env:TEMP "issue-review-$IssueNumber"
Ensure-DirectoryExists -Path $tempPromptDir
$tempPromptFile = Join-Path $tempPromptDir "prompt.md"
$promptContent | Set-Content -Path $tempPromptFile -Encoding UTF8
# Build the prompt text for CLI
$promptText = "Review GitHub issue #$IssueNumber following the template in $_cfgDir/prompts/review-issue.prompt.md. Generate overview.md and implementation-plan.md in 'Generated Files/issueReview/$IssueNumber/'"
# Inject feedback from review-the-review if available
if ($FeedbackContext) {
$promptText += @"
IMPORTANT: This is a RE-REVIEW. A previous review was rejected by the quality gate. You MUST address ALL the corrective feedback below. Read the feedback carefully and fix every issue identified.
=== CORRECTIVE FEEDBACK FROM REVIEW-THE-REVIEW ===
$FeedbackContext
=== END FEEDBACK ===
Pay special attention to:
1. Score corrections adjust scores to match the evidence cited in the feedback
2. File path corrections verify all paths exist before including them
3. Pattern corrections use the patterns identified as correct in the feedback
4. Missing coverage add any sections flagged as missing
5. Task breakdown fixes make tasks specific and executable
"@
}
switch ($CLIType) {
'copilot' {
# GitHub Copilot CLI (standalone copilot command)
# Use --yolo for full permissions (--allow-all-tools --allow-all-paths --allow-all-urls)
# Use -s (silent) for cleaner output in batch mode
# Enable ALL GitHub MCP tools (issues, PRs, repos, etc.) + github-artifacts for images/attachments
# MCP config path relative to repo root for github-artifacts tools
$mcpConfig = "@$_cfgDir/skills/issue-review/references/mcp-config.json"
$args = @(
'--additional-mcp-config', $mcpConfig, # Load github-artifacts MCP for image/attachment analysis
'-p', $promptText, # Non-interactive prompt mode (exits after completion)
'--yolo', # Enable all permissions for automated execution
'-s', # Silent mode - output only agent response
'--enable-all-github-mcp-tools', # Enable ALL GitHub MCP tools (issues, PRs, search, etc.)
'--allow-tool', 'github-artifacts', # Also enable our custom github-artifacts MCP
'--agent', 'ReviewIssue'
)
if ($Model) {
$args += @('--model', $Model)
}
return @{
Command = 'copilot'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'claude' {
# Claude Code CLI
$args = @(
'--print', # Non-interactive mode
'--dangerously-skip-permissions',
'--agent', 'ReviewIssue',
'--prompt', $promptText
)
return @{
Command = 'claude'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'gh-copilot' {
# GitHub Copilot CLI via gh
$args = @(
'copilot', 'suggest',
'-t', 'shell',
"Review GitHub issue #$IssueNumber and generate analysis files"
)
return @{
Command = 'gh'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'vscode' {
# VS Code with Copilot - open with prompt
$args = @(
'--new-window',
$WorkingDirectory,
'--goto', $tempPromptFile
)
return @{
Command = 'code'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
}
}
#endregion
#region Parallel Job Management
function Start-ParallelIssueReviews {
<#
.SYNOPSIS
Start parallel issue reviews with throttling.
.PARAMETER Issues
Array of issue objects to review.
.PARAMETER MaxConcurrent
Maximum number of parallel jobs. Default: 20.
.PARAMETER CLIType
CLI type to use for reviews.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER TimeoutMinutes
Timeout per issue in minutes. Default: 30.
.PARAMETER MaxRetryCount
Maximum number of retries for failed issues. Default: 2.
.PARAMETER RetryDelaySeconds
Delay between retries in seconds. Default: 10.
#>
param(
[Parameter(Mandatory)]
[array]$Issues,
[int]$MaxConcurrent = 20,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode')]
[string]$CLIType = 'copilot',
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$TimeoutMinutes = 30,
[int]$MaxRetryCount = 2,
[int]$RetryDelaySeconds = 10,
[string]$FeedbackContext,
[string]$Model
)
$totalIssues = $Issues.Count
$completed = 0
$failed = @()
$succeeded = @()
$retryQueue = [System.Collections.Queue]::new()
Info "Starting parallel review of $totalIssues issues (max $MaxConcurrent concurrent, $MaxRetryCount retries)"
# Use PowerShell jobs for parallelization
$jobs = @()
$issueQueue = [System.Collections.Queue]::new($Issues)
while ($issueQueue.Count -gt 0 -or $jobs.Count -gt 0 -or $retryQueue.Count -gt 0) {
# Process retry queue when main queue is empty
if ($issueQueue.Count -eq 0 -and $retryQueue.Count -gt 0 -and $jobs.Count -lt $MaxConcurrent) {
$retryItem = $retryQueue.Dequeue()
Warn "🔄 Retrying issue #$($retryItem.IssueNumber) (attempt $($retryItem.Attempt + 1)/$($MaxRetryCount + 1))"
Start-Sleep -Seconds $RetryDelaySeconds
$issueQueue.Enqueue(@{ number = $retryItem.IssueNumber; _retryAttempt = $retryItem.Attempt + 1 })
}
# Start new jobs up to MaxParallel
while ($jobs.Count -lt $MaxConcurrent -and $issueQueue.Count -gt 0) {
$issue = $issueQueue.Dequeue()
$issueNum = $issue.number
$retryAttempt = if ($issue._retryAttempt) { $issue._retryAttempt } else { 0 }
$attemptInfo = if ($retryAttempt -gt 0) { " (retry $retryAttempt)" } else { "" }
Info "Starting review for issue #$issueNum$attemptInfo ($($totalIssues - $issueQueue.Count)/$totalIssues)"
$job = Start-Job -Name "Issue-$issueNum" -ScriptBlock {
param($IssueNumber, $RepoRoot, $CLIType, $FeedbackCtx, $ModelOverride)
Set-Location $RepoRoot
# Import the library in the job context
. "$RepoRoot/.github/review-tools/IssueReviewLib.ps1"
try {
$reviewParams = @{
IssueNumber = $IssueNumber
RepoRoot = $RepoRoot
CLIType = $CLIType
}
if ($FeedbackCtx) {
$reviewParams.FeedbackContext = $FeedbackCtx
}
if ($ModelOverride) {
$reviewParams.Model = $ModelOverride
}
$reviewCmd = Invoke-AIReview @reviewParams
# Execute the command using invocation operator (works for .ps1 scripts and executables)
Set-Location $reviewCmd.WorkingDirectory
$argList = $reviewCmd.Arguments
# Capture both stdout and stderr for better error reporting
$output = & $reviewCmd.Command @argList 2>&1
$exitCode = $LASTEXITCODE
# Get last 20 lines of output for error context
$outputLines = $output | Out-String
$lastLines = ($outputLines -split "`n" | Select-Object -Last 20) -join "`n"
# Check if output files were created (success indicator)
$overviewPath = Join-Path $RepoRoot "Generated Files/issueReview/$IssueNumber/overview.md"
$implPlanPath = Join-Path $RepoRoot "Generated Files/issueReview/$IssueNumber/implementation-plan.md"
$filesCreated = (Test-Path $overviewPath) -and (Test-Path $implPlanPath)
return @{
IssueNumber = $IssueNumber
Success = ($exitCode -eq 0) -or $filesCreated
ExitCode = $exitCode
FilesCreated = $filesCreated
Output = $lastLines
Error = if ($exitCode -ne 0 -and -not $filesCreated) { "Exit code: $exitCode`n$lastLines" } else { $null }
}
}
catch {
return @{
IssueNumber = $IssueNumber
Success = $false
ExitCode = -1
FilesCreated = $false
Output = $null
Error = $_.Exception.Message
}
}
} -ArgumentList $issueNum, $RepoRoot, $CLIType, $FeedbackContext, $Model
$jobs += @{
Job = $job
IssueNumber = $issueNum
StartTime = Get-Date
RetryAttempt = $retryAttempt
}
}
# Check for completed jobs
$completedJobs = @()
foreach ($jobInfo in $jobs) {
$job = $jobInfo.Job
$issueNum = $jobInfo.IssueNumber
$startTime = $jobInfo.StartTime
$retryAttempt = $jobInfo.RetryAttempt
if ($job.State -eq 'Completed') {
$result = Receive-Job -Job $job
Remove-Job -Job $job -Force
if ($result.Success) {
Success "✓ Issue #$issueNum completed (files created: $($result.FilesCreated))"
$succeeded += $issueNum
$completed++
} else {
# Check if we should retry
if ($retryAttempt -lt $MaxRetryCount) {
$errorPreview = if ($result.Error) { ($result.Error -split "`n" | Select-Object -First 3) -join " | " } else { "Unknown error" }
Warn "⚠ Issue #$issueNum failed (will retry): $errorPreview"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = $result.Error })
} else {
$errorMsg = if ($result.Error) { $result.Error } else { "Exit code: $($result.ExitCode)" }
Err "✗ Issue #$issueNum failed after $($retryAttempt + 1) attempts:"
Err " Error: $errorMsg"
$failed += @{ IssueNumber = $issueNum; Error = $errorMsg; Attempts = $retryAttempt + 1 }
$completed++
}
}
$completedJobs += $jobInfo
}
elseif ($job.State -eq 'Failed') {
$jobError = $job.ChildJobs[0].JobStateInfo.Reason.Message
Remove-Job -Job $job -Force
if ($retryAttempt -lt $MaxRetryCount) {
Warn "⚠ Issue #$issueNum job crashed (will retry): $jobError"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = $jobError })
} else {
Err "✗ Issue #$issueNum job failed after $($retryAttempt + 1) attempts: $jobError"
$failed += @{ IssueNumber = $issueNum; Error = $jobError; Attempts = $retryAttempt + 1 }
$completed++
}
$completedJobs += $jobInfo
}
elseif ((Get-Date) - $startTime -gt [TimeSpan]::FromMinutes($TimeoutMinutes)) {
Stop-Job -Job $job -ErrorAction SilentlyContinue
Remove-Job -Job $job -Force
if ($retryAttempt -lt $MaxRetryCount) {
Warn "⏱ Issue #$issueNum timed out after $TimeoutMinutes min (will retry)"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = "Timeout after $TimeoutMinutes minutes" })
} else {
Err "⏱ Issue #$issueNum timed out after $($retryAttempt + 1) attempts"
$failed += @{ IssueNumber = $issueNum; Error = "Timeout after $TimeoutMinutes minutes"; Attempts = $retryAttempt + 1 }
$completed++
}
$completedJobs += $jobInfo
}
}
# Remove completed jobs from active list
$jobs = $jobs | Where-Object { $_ -notin $completedJobs }
# Brief pause to avoid tight loop
if ($jobs.Count -gt 0) {
Start-Sleep -Seconds 2
}
}
# Extract just issue numbers for the failed list
$failedNumbers = $failed | ForEach-Object { $_.IssueNumber }
return @{
Total = $totalIssues
Succeeded = $succeeded
Failed = $failedNumbers
FailedDetails = $failed
}
}
#endregion
#region Issue Review Results Helpers
function Get-IssueReviewResult {
<#
.SYNOPSIS
Check if an issue has been reviewed and get its results.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot
)
$reviewPath = Get-IssueReviewPath -RepoRoot $RepoRoot -IssueNumber $IssueNumber
$result = @{
IssueNumber = $IssueNumber
Path = $reviewPath
HasOverview = $false
HasImplementationPlan = $false
OverviewPath = $null
ImplementationPlanPath = $null
}
$overviewPath = Join-Path $reviewPath 'overview.md'
$implPlanPath = Join-Path $reviewPath 'implementation-plan.md'
if (Test-Path $overviewPath) {
$result.HasOverview = $true
$result.OverviewPath = $overviewPath
}
if (Test-Path $implPlanPath) {
$result.HasImplementationPlan = $true
$result.ImplementationPlanPath = $implPlanPath
}
return $result
}
function Get-HighConfidenceIssues {
<#
.SYNOPSIS
Find issues with high confidence for auto-fix based on review results.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score (0-100). Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score (0-100). Default: 60.
.PARAMETER MaxEffortDays
Maximum effort estimate in days. Default: 2 (S = Small).
.PARAMETER FilterIssueNumbers
Optional array of issue numbers to filter to. If specified, only these issues are considered.
#>
param(
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int[]]$FilterIssueNumbers = @()
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
$reviewDir = Join-Path $genFiles 'issueReview'
if (-not (Test-Path $reviewDir)) {
return @()
}
$highConfidence = @()
Get-ChildItem -Path $reviewDir -Directory | ForEach-Object {
$issueNum = [int]$_.Name
# Skip if filter is specified and this issue is not in the filter list
if ($FilterIssueNumbers.Count -gt 0 -and $issueNum -notin $FilterIssueNumbers) {
return
}
$overviewPath = Join-Path $_.FullName 'overview.md'
$implPlanPath = Join-Path $_.FullName 'implementation-plan.md'
if (-not (Test-Path $overviewPath) -or -not (Test-Path $implPlanPath)) {
return
}
# Parse overview.md to extract scores
$overview = Get-Content $overviewPath -Raw
# Extract scores using regex (looking for score table or inline scores)
$feasibility = 0
$clarity = 0
$effortDays = 999
# Try to extract from At-a-Glance Score Table
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$feasibility = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$clarity = [int]$Matches[1]
}
# Match effort formats like "0.5-1 day", "1-2 days", "2-3 days" - extract the upper bound
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
if ($Matches[1]) {
$effortDays = [int]$Matches[1]
} elseif ($overview -match 'Effort Estimate[^|]*\|\s*(\d+)\s*days?') {
$effortDays = [int]$Matches[1]
}
}
# Also check for XS/S sizing in the table (e.g., "| XS |" or "| S |" or "(XS)" or "(S)")
if ($overview -match 'Effort Estimate[^|]*\|[^|]*\|\s*(XS|S)\b') {
# XS = 1 day, S = 2 days
if ($Matches[1] -eq 'XS') {
$effortDays = 1
} else {
$effortDays = 2
}
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(XS\)') {
$effortDays = 1
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(S\)') {
$effortDays = 2
}
if ($feasibility -ge $MinFeasibilityScore -and
$clarity -ge $MinClarityScore -and
$effortDays -le $MaxEffortDays) {
$highConfidence += @{
IssueNumber = $issueNum
FeasibilityScore = $feasibility
ClarityScore = $clarity
EffortDays = $effortDays
OverviewPath = $overviewPath
ImplementationPlanPath = $implPlanPath
}
}
}
return $highConfidence | Sort-Object -Property FeasibilityScore -Descending
}
#endregion
#region Worktree Integration
function Copy-IssueReviewToWorktree {
<#
.SYNOPSIS
Copy the Generated Files for an issue to a worktree.
.PARAMETER IssueNumber
The issue number.
.PARAMETER SourceRepoRoot
Source repository root (main repo).
.PARAMETER WorktreePath
Destination worktree path.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$SourceRepoRoot,
[Parameter(Mandatory)]
[string]$WorktreePath
)
$sourceReviewPath = Get-IssueReviewPath -RepoRoot $SourceRepoRoot -IssueNumber $IssueNumber
$destReviewPath = Get-IssueReviewPath -RepoRoot $WorktreePath -IssueNumber $IssueNumber
if (-not (Test-Path $sourceReviewPath)) {
throw "Issue review files not found at: $sourceReviewPath"
}
Ensure-DirectoryExists -Path $destReviewPath
# Copy all files from the issue review folder
Copy-Item -Path "$sourceReviewPath\*" -Destination $destReviewPath -Recurse -Force
Info "Copied issue review files to: $destReviewPath"
return $destReviewPath
}
#endregion
# Note: This script is dot-sourced, not imported as a module.
# All functions above are available after: . "path/to/IssueReviewLib.ps1"

View File

@@ -0,0 +1,298 @@
<#
.SYNOPSIS
Orchestrate the feedback loop: re-run issue-review with corrections, then re-review.
.DESCRIPTION
For each issue whose review-review score is below the threshold:
1. Re-run issue-review with the corrective feedback from reviewTheReview.md
2. Re-run review-review on the updated review files
3. Repeat up to MaxIterations times or until the score passes
.PARAMETER ThrottleLimit
Maximum parallel tasks. Default: 3.
.PARAMETER QualityThreshold
Score threshold for PASS. Default: 90.
.PARAMETER MaxIterations
Maximum feedback loop iterations per issue. Default: 3.
.PARAMETER CLIType
AI CLI type (copilot/claude). Default: copilot.
.PARAMETER Model
Copilot CLI model override (e.g., claude-sonnet-4).
.PARAMETER IssueNumbers
Optional: specific issue numbers to process. If omitted, processes all issues with needsReReview=true.
.PARAMETER Force
Skip confirmation prompts.
.EXAMPLE
./Start-FeedbackLoop.ps1 -CLIType copilot -Model claude-sonnet-4 -ThrottleLimit 3 -Force
.EXAMPLE
# Process specific issues only
./Start-FeedbackLoop.ps1 -IssueNumbers @(1929, 1934) -CLIType copilot -Model claude-sonnet-4 -Force
#>
[CmdletBinding()]
param(
[int]$ThrottleLimit = 3,
[int]$QualityThreshold = 90,
[int]$MaxIterations = 3,
[ValidateSet('copilot', 'claude')]
[string]$CLIType = 'copilot',
[string]$Model,
[int[]]$IssueNumbers,
[switch]$Force
)
$ErrorActionPreference = 'Continue'
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')
# Resolve config directory name (.github or .claude) from script location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
$genFiles = Join-Path $repoRoot 'Generated Files'
$reviewReviewDir = Join-Path $genFiles 'issueReviewReview'
$issueReviewDir = Join-Path $genFiles 'issueReview'
$bulkReviewScript = Join-Path $repoRoot "$_cfgDir\skills\issue-review\scripts\Start-BulkIssueReview.ps1"
$reviewReviewScript = Join-Path $repoRoot "$_cfgDir\skills\issue-review-review\scripts\Start-IssueReviewReview.ps1"
Write-Host "=== FEEDBACK LOOP ORCHESTRATOR ===" -ForegroundColor Cyan
Write-Host "Repository root: $repoRoot"
Write-Host "Quality threshold: $QualityThreshold"
Write-Host "Max iterations: $MaxIterations"
Write-Host "Throttle limit: $ThrottleLimit"
Write-Host "CLI: $CLIType $(if ($Model) { "(model: $Model)" })"
Write-Host ""
# ------------------------------------------------------------------
# Step 1: Identify issues that need re-review
# ------------------------------------------------------------------
if ($IssueNumbers -and $IssueNumbers.Count -gt 0) {
# Use explicit list
$needsWork = $IssueNumbers | ForEach-Object {
$signalPath = Join-Path $reviewReviewDir "$_\.signal"
if (Test-Path $signalPath) {
$signal = Get-Content $signalPath -Raw | ConvertFrom-Json
[PSCustomObject]@{
IssueNumber = $_
CurrentScore = [int]$signal.qualityScore
Iteration = [int]$signal.iteration
FeedbackFile = Join-Path $reviewReviewDir "$_\reviewTheReview.md"
}
}
else {
Write-Host " Warning: No signal for issue #$_ — skipping" -ForegroundColor Yellow
}
} | Where-Object { $_ }
}
else {
# Auto-discover from signals with needsReReview = true
$needsWork = Get-ChildItem $reviewReviewDir -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName '.signal') } |
ForEach-Object {
$signal = Get-Content (Join-Path $_.FullName '.signal') -Raw | ConvertFrom-Json
if ($signal.needsReReview -eq $true -and [int]$signal.iteration -lt $MaxIterations) {
[PSCustomObject]@{
IssueNumber = [int]$signal.issueNumber
CurrentScore = [int]$signal.qualityScore
Iteration = [int]$signal.iteration
FeedbackFile = Join-Path $_.FullName 'reviewTheReview.md'
}
}
} | Sort-Object IssueNumber
}
if (-not $needsWork -or $needsWork.Count -eq 0) {
Write-Host "No issues need re-review. All passed or reached max iterations." -ForegroundColor Green
return
}
Write-Host "Issues needing feedback loop: $($needsWork.Count)" -ForegroundColor Yellow
Write-Host ("-" * 70)
$needsWork | Format-Table IssueNumber, CurrentScore, Iteration -AutoSize | Out-String | Write-Host
Write-Host ("-" * 70)
if (-not $Force) {
$confirm = Read-Host "Proceed with feedback loop for $($needsWork.Count) issues? (y/N)"
if ($confirm -notmatch '^[yY]') {
Write-Host "Cancelled."
return
}
}
# ------------------------------------------------------------------
# Step 2: Run feedback loop in parallel
# ------------------------------------------------------------------
$startTime = Get-Date
$results = $needsWork | ForEach-Object -Parallel {
$item = $PSItem
$repoRoot = $using:repoRoot
$bulkScript = $using:bulkReviewScript
$reviewScript = $using:reviewReviewScript
$cliType = $using:CLIType
$model = $using:Model
$qualityThreshold = $using:QualityThreshold
$maxIter = $using:MaxIterations
Set-Location $repoRoot
$issueNum = $item.IssueNumber
$currentScore = $item.CurrentScore
$currentIter = $item.Iteration
$feedbackFile = $item.FeedbackFile
Write-Host "[#$issueNum] Starting feedback loop (current score: $currentScore, iteration: $currentIter)" -ForegroundColor Cyan
# Phase A: Re-run issue-review with corrective feedback
Write-Host "[#$issueNum] Phase A: Re-running issue-review with feedback..." -ForegroundColor Yellow
$bulkParams = @{
IssueNumber = $issueNum
CLIType = $cliType
Force = $true
}
if ($model) { $bulkParams.Model = $model }
if (Test-Path $feedbackFile) {
$bulkParams.FeedbackFile = $feedbackFile
}
try {
& $bulkScript @bulkParams 2>&1 | ForEach-Object { Write-Host "[#$issueNum] $_" }
}
catch {
Write-Host "[#$issueNum] Phase A error: $($_.Exception.Message)" -ForegroundColor Red
return [PSCustomObject]@{
IssueNumber = $issueNum
OldScore = $currentScore
NewScore = 0
Iteration = $currentIter
Status = 'FAILED_REVIEW'
Error = $_.Exception.Message
}
}
# Phase B: Re-run review-review on the updated files
Write-Host "[#$issueNum] Phase B: Re-running review-review..." -ForegroundColor Yellow
$rrParams = @{
IssueNumber = $issueNum
CLIType = $cliType
Force = $true
}
if ($model) { $rrParams.Model = $model }
try {
& $reviewScript @rrParams 2>&1 | ForEach-Object { Write-Host "[#$issueNum] $_" }
}
catch {
Write-Host "[#$issueNum] Phase B error: $($_.Exception.Message)" -ForegroundColor Red
return [PSCustomObject]@{
IssueNumber = $issueNum
OldScore = $currentScore
NewScore = 0
Iteration = $currentIter + 1
Status = 'FAILED_REVIEW_REVIEW'
Error = $_.Exception.Message
}
}
# Read updated signal
$signalPath = Join-Path $using:reviewReviewDir "$issueNum\.signal"
if (Test-Path $signalPath) {
$newSignal = Get-Content $signalPath -Raw | ConvertFrom-Json
$newScore = [int]$newSignal.qualityScore
$newIter = [int]$newSignal.iteration
$verdict = $newSignal.verdict
$status = if ($newScore -ge $qualityThreshold) { 'IMPROVED_TO_PASS' }
elseif ($newScore -gt $currentScore) { 'IMPROVED' }
elseif ($newScore -eq $currentScore) { 'NO_CHANGE' }
else { 'REGRESSED' }
Write-Host "[#$issueNum] Done: $currentScore$newScore ($status)" -ForegroundColor $(
if ($status -eq 'IMPROVED_TO_PASS') { 'Green' }
elseif ($status -eq 'IMPROVED') { 'Yellow' }
else { 'Red' }
)
[PSCustomObject]@{
IssueNumber = $issueNum
OldScore = $currentScore
NewScore = $newScore
Iteration = $newIter
Status = $status
Verdict = $verdict
}
}
else {
[PSCustomObject]@{
IssueNumber = $issueNum
OldScore = $currentScore
NewScore = 0
Iteration = $currentIter + 1
Status = 'NO_SIGNAL'
Error = 'No signal file after review-review'
}
}
} -ThrottleLimit $ThrottleLimit
$duration = (Get-Date) - $startTime
# ------------------------------------------------------------------
# Step 3: Summary
# ------------------------------------------------------------------
Write-Host ""
Write-Host ("=" * 70) -ForegroundColor Cyan
Write-Host " FEEDBACK LOOP SUMMARY" -ForegroundColor Cyan
Write-Host ("=" * 70) -ForegroundColor Cyan
$improved = @($results | Where-Object Status -eq 'IMPROVED_TO_PASS')
$partial = @($results | Where-Object Status -eq 'IMPROVED')
$noChange = @($results | Where-Object Status -eq 'NO_CHANGE')
$regressed = @($results | Where-Object Status -eq 'REGRESSED')
$errors = @($results | Where-Object { $_.Status -like 'FAILED*' -or $_.Status -eq 'NO_SIGNAL' })
Write-Host "Total processed: $($results.Count)"
Write-Host "Improved to PASS: $($improved.Count)" -ForegroundColor Green
Write-Host "Improved (below): $($partial.Count)" -ForegroundColor Yellow
Write-Host "No change: $($noChange.Count)" -ForegroundColor DarkYellow
Write-Host "Regressed: $($regressed.Count)" -ForegroundColor Red
Write-Host "Errors: $($errors.Count)" -ForegroundColor Red
Write-Host "Duration: $($duration.ToString('hh\:mm\:ss'))"
Write-Host ("=" * 70) -ForegroundColor Cyan
# Show details
if ($results.Count -gt 0) {
Write-Host ""
Write-Host "Details:" -ForegroundColor White
$results | Sort-Object NewScore -Descending | Format-Table IssueNumber, OldScore, NewScore, Status, Iteration -AutoSize | Out-String | Write-Host
}
# Count remaining issues that still need work
$stillNeedsWork = Get-ChildItem $reviewReviewDir -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName '.signal') } |
ForEach-Object {
$signal = Get-Content (Join-Path $_.FullName '.signal') -Raw | ConvertFrom-Json
if ($signal.needsReReview -eq $true -and [int]$signal.iteration -lt $MaxIterations) { $signal }
}
if ($stillNeedsWork.Count -gt 0) {
Write-Host "`nStill needs improvement: $($stillNeedsWork.Count) issues" -ForegroundColor Yellow
Write-Host "Run this script again for another iteration." -ForegroundColor Yellow
}
else {
Write-Host "`nAll issues have either passed or reached max iterations!" -ForegroundColor Green
}
# Return results for pipeline
return $results

View File

@@ -0,0 +1,327 @@
<#
.SYNOPSIS
Meta-review of issue-review outputs to validate scoring and implementation plan quality.
.DESCRIPTION
Reads the existing overview.md and implementation-plan.md from issue-review,
cross-checks scores against evidence, validates file paths and patterns,
and produces a reviewTheReview.md with a quality score (0-100).
If the quality score is < 90, the signal file indicates that issue-review
should re-run with the feedback.
.PARAMETER IssueNumber
GitHub issue number whose review to validate.
.PARAMETER CLIType
AI CLI to use: copilot or claude. Default: copilot.
.PARAMETER Model
Copilot CLI model to use (e.g., gpt-5.2-codex).
.PARAMETER Force
Skip confirmation prompts.
.PARAMETER DryRun
Show what would be done without executing.
.EXAMPLE
./Start-IssueReviewReview.ps1 -IssueNumber 44044
.EXAMPLE
./Start-IssueReviewReview.ps1 -IssueNumber 44044 -CLIType copilot -Model gpt-5.2-codex -Force
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[ValidateSet('copilot', 'claude')]
[string]$CLIType = 'copilot',
[string]$Model,
[switch]$Force,
[switch]$DryRun,
[switch]$Help
)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $scriptDir 'IssueReviewLib.ps1')
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
#region Main
try {
$repoRoot = Get-RepoRoot
$genFiles = Get-GeneratedFilesPath -RepoRoot $repoRoot
Info "Repository root: $repoRoot"
#region Validate prerequisites
$reviewDir = Join-Path $genFiles "issueReview/$IssueNumber"
$overviewPath = Join-Path $reviewDir 'overview.md'
$implPlanPath = Join-Path $reviewDir 'implementation-plan.md'
if (-not (Test-Path $overviewPath)) {
throw "overview.md not found for issue #$IssueNumber at: $overviewPath. Run issue-review first."
}
if (-not (Test-Path $implPlanPath)) {
throw "implementation-plan.md not found for issue #$IssueNumber at: $implPlanPath. Run issue-review first."
}
Info "Found review files for issue #$IssueNumber"
Info " Overview: $overviewPath"
Info " Implementation plan: $implPlanPath"
#endregion
#region Determine iteration
$outputDir = Join-Path $genFiles "issueReviewReview/$IssueNumber"
Ensure-DirectoryExists -Path $outputDir
$existingSignalPath = Join-Path $outputDir '.signal'
$iteration = 1
if (Test-Path $existingSignalPath) {
try {
$existingSignal = Get-Content $existingSignalPath -Raw | ConvertFrom-Json
$iteration = ([int]$existingSignal.iteration) + 1
Info "Previous review-review found (iteration $($existingSignal.iteration), score: $($existingSignal.qualityScore))"
# Archive previous output
$archiveDir = Join-Path $outputDir "iteration-$($existingSignal.iteration)"
Ensure-DirectoryExists -Path $archiveDir
$prevReviewPath = Join-Path $outputDir 'reviewTheReview.md'
if (Test-Path $prevReviewPath) {
Copy-Item $prevReviewPath (Join-Path $archiveDir 'reviewTheReview.md') -Force
Info "Archived previous review to: $archiveDir"
}
}
catch {
Warn "Could not parse existing signal, starting fresh"
}
}
Info "Starting review-review iteration $iteration for issue #$IssueNumber"
#endregion
if ($DryRun) {
Warn "Dry run mode - would review-review issue #$IssueNumber (iteration $iteration)"
return
}
if (-not $Force) {
$confirm = Read-Host "Proceed with review-review for issue #$IssueNumber? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
#region Build and run AI prompt
$promptText = @"
TASK: Write a meta-review file to 'Generated Files/issueReviewReview/$IssueNumber/reviewTheReview.md'.
You MUST create this file before finishing. This is your primary deliverable.
Issue number: $IssueNumber
Iteration: $iteration
STEP 1 - Read these inputs:
- Run: gh issue view $IssueNumber --json number,title,body,state,labels,comments
- Read file: Generated Files/issueReview/$IssueNumber/overview.md
- Read file: Generated Files/issueReview/$IssueNumber/implementation-plan.md
$(if ($iteration -gt 1) { "- Read file: Generated Files/issueReviewReview/$IssueNumber/iteration-$($iteration - 1)/reviewTheReview.md" })
STEP 2 - Verify file paths from the implementation plan exist using test -f or ls.
STEP 3 - Verify code patterns from the implementation plan using rg.
STEP 4 - Write the file 'Generated Files/issueReviewReview/$IssueNumber/reviewTheReview.md' with this structure:
# Meta-Review: Issue #$IssueNumber
## Score Validation
| Dimension | Original Score | Verified Score | Evidence |
|-----------|---------------|----------------|----------|
(validate each score dimension from overview.md against actual codebase evidence)
## Implementation Plan Verification
- File paths: which exist, which don't
- Patterns: which are correct, which are wrong
- Task breakdown: are tasks specific and executable?
## Quality Score Breakdown
| Dimension | Weight | Score | Weighted |
|-----------|--------|-------|----------|
| Score Accuracy | 30% | X/100 | X |
| Implementation Correctness | 25% | X/100 | X |
| Risk Assessment | 15% | X/100 | X |
| Completeness | 15% | X/100 | X |
| Actionability | 15% | X/100 | X |
| **Total** | | | **X/100** |
## Review Quality Score: X/100
## Verdict: PASS/NEEDS_IMPROVEMENT/FAIL
## Corrective Feedback
(specific items the review should fix, if any)
CRITICAL: You MUST write the output file. Do NOT just describe what you would do. Actually create the file.
"@
$mcpConfig = "@$_cfgDir/skills/issue-review-review/references/mcp-config.json"
switch ($CLIType) {
'copilot' {
$cliArgs = @(
'--additional-mcp-config', $mcpConfig,
'-p', $promptText,
'--yolo',
'-s',
'--enable-all-github-mcp-tools',
'--allow-tool', 'github-artifacts',
'--agent', 'ReviewTheReview'
)
if ($Model) {
$cliArgs += @('--model', $Model)
}
Info "Running Copilot CLI for review-review..."
& copilot @cliArgs 2>&1 | Out-Default
$exitCode = $LASTEXITCODE
}
'claude' {
$cliArgs = @(
'--print',
'--dangerously-skip-permissions',
'--agent', 'ReviewTheReview',
'--prompt', $promptText
)
Info "Running Claude CLI for review-review..."
& claude @cliArgs 2>&1 | Out-Default
$exitCode = $LASTEXITCODE
}
}
#endregion
#region Parse result and write signal
$reviewTheReviewPath = Join-Path $outputDir 'reviewTheReview.md'
if (-not (Test-Path $reviewTheReviewPath)) {
# CLI may have failed
Err "reviewTheReview.md was not generated for issue #$IssueNumber"
@{
status = 'failure'
issueNumber = $IssueNumber
timestamp = (Get-Date).ToString('o')
qualityScore = 0
iteration = $iteration
outputs = @()
needsReReview = $true
error = "Output file not generated (exit code: $exitCode)"
} | ConvertTo-Json | Set-Content $existingSignalPath -Force
return @{
IssueNumber = $IssueNumber
Status = 'failure'
QualityScore = 0
Iteration = $iteration
NeedsReReview = $true
Error = "Output file not generated"
}
}
# Parse quality score from the generated reviewTheReview.md
$content = Get-Content $reviewTheReviewPath -Raw
$qualityScore = 0
# Try to extract "Review Quality Score: X/100"
if ($content -match 'Review Quality Score:\s*(\d+)/100') {
$qualityScore = [int]$Matches[1]
}
# Also try total from breakdown table: "| **Total** | | | **X/100** |"
elseif ($content -match '\*\*Total\*\*[^|]*\|[^|]*\|[^|]*\|\s*\*\*(\d+)/100\*\*') {
$qualityScore = [int]$Matches[1]
}
# Fallback: any line with "Quality Score" and a number
elseif ($content -match 'Quality Score[^\d]*(\d+)') {
$qualityScore = [int]$Matches[1]
}
$needsReReview = $qualityScore -lt 90
# Determine verdict
$verdict = if ($qualityScore -ge 90) { 'PASS' }
elseif ($qualityScore -ge 50) { 'NEEDS_IMPROVEMENT' }
else { 'FAIL' }
# Write signal
$signal = @{
status = 'success'
issueNumber = $IssueNumber
timestamp = (Get-Date).ToString('o')
qualityScore = $qualityScore
iteration = $iteration
verdict = $verdict
outputs = @('reviewTheReview.md')
needsReReview = $needsReReview
}
$signal | ConvertTo-Json | Set-Content $existingSignalPath -Force
if ($needsReReview) {
Warn "Review-review score: $qualityScore/100 (iteration $iteration) — NEEDS RE-REVIEW"
Warn "Feedback written to: $reviewTheReviewPath"
Warn "Re-run issue-review with: -FeedbackFile `"$reviewTheReviewPath`""
}
else {
Success "Review-review score: $qualityScore/100 (iteration $iteration) — PASS"
Success "Review quality is sufficient. Proceed to issue-fix."
}
Info "Signal: $existingSignalPath"
#endregion
return @{
IssueNumber = $IssueNumber
Status = 'success'
QualityScore = $qualityScore
Iteration = $iteration
Verdict = $verdict
NeedsReReview = $needsReReview
}
}
catch {
Err "Error: $($_.Exception.Message)"
# Write failure signal
$outputDir = Join-Path (Get-GeneratedFilesPath -RepoRoot (Get-RepoRoot)) "issueReviewReview/$IssueNumber"
Ensure-DirectoryExists -Path $outputDir
$signalPath = Join-Path $outputDir '.signal'
@{
status = 'failure'
issueNumber = $IssueNumber
timestamp = (Get-Date).ToString('o')
qualityScore = 0
iteration = 1
outputs = @()
needsReReview = $true
error = $_.Exception.Message
} | ConvertTo-Json | Set-Content $signalPath -Force
return @{
IssueNumber = $IssueNumber
Status = 'failure'
QualityScore = 0
Iteration = 1
NeedsReReview = $true
Error = $_.Exception.Message
}
}
#endregion

View File

@@ -0,0 +1,111 @@
<#
.SYNOPSIS
Run issue-review-review in parallel from a single terminal.
.PARAMETER IssueNumbers
Issue numbers to review-review.
.PARAMETER ThrottleLimit
Maximum parallel tasks.
.PARAMETER CLIType
AI CLI type (copilot/claude).
.PARAMETER Model
Copilot CLI model to use (e.g., gpt-5.2-codex).
.PARAMETER Force
Skip confirmation prompts.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int[]]$IssueNumbers,
[int]$ThrottleLimit = 5,
[ValidateSet('copilot', 'claude')]
[string]$CLIType = 'copilot',
[string]$Model,
[switch]$Force
)
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')
# Resolve config directory name (.github or .claude) from script location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
$scriptPath = Join-Path $repoRoot "$_cfgDir\skills\issue-review-review\scripts\Start-IssueReviewReview.ps1"
$results = $IssueNumbers | ForEach-Object -Parallel {
$issue = $PSItem
$repoRoot = $using:repoRoot
$scriptPath = $using:scriptPath
$cliType = $using:CLIType
$model = $using:Model
$force = $using:Force
Set-Location $repoRoot
if (-not $issue) {
return [pscustomobject]@{
IssueNumber = $issue
ExitCode = 1
QualityScore = 0
Error = 'Issue number is empty.'
}
}
$params = @{
IssueNumber = [int]$issue
CLIType = $cliType
}
if ($model) {
$params.Model = $model
}
if ($force) {
$params.Force = $true
}
try {
$result = & $scriptPath @params
[pscustomobject]@{
IssueNumber = $issue
ExitCode = $LASTEXITCODE
QualityScore = $result.QualityScore
NeedsReReview = $result.NeedsReReview
Iteration = $result.Iteration
Verdict = $result.Verdict
}
}
catch {
[pscustomobject]@{
IssueNumber = $issue
ExitCode = 1
QualityScore = 0
NeedsReReview = $true
Error = $_.Exception.Message
}
}
} -ThrottleLimit $ThrottleLimit
# Summary
$passed = @($results | Where-Object { $_.QualityScore -ge 90 })
$needsWork = @($results | Where-Object { $_.QualityScore -gt 0 -and $_.QualityScore -lt 90 })
$failed = @($results | Where-Object { $_.QualityScore -eq 0 -or $_.Error })
Write-Host "`n=== REVIEW-REVIEW SUMMARY ===" -ForegroundColor Cyan
Write-Host "Total: $($results.Count)"
Write-Host "Passed (>=90): $($passed.Count)" -ForegroundColor Green
Write-Host "Needs work: $($needsWork.Count)" -ForegroundColor Yellow
Write-Host "Failed: $($failed.Count)" -ForegroundColor Red
if ($needsWork.Count -gt 0) {
Write-Host "`nIssues needing re-review:" -ForegroundColor Yellow
foreach ($r in $needsWork) {
Write-Host " #$($r.IssueNumber) — score: $($r.QualityScore)/100 (iteration $($r.Iteration))"
}
}
$results

21
.github/skills/issue-review/LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

148
.github/skills/issue-review/SKILL.md vendored Normal file
View File

@@ -0,0 +1,148 @@
---
name: issue-review
description: Analyze GitHub issues for feasibility and implementation planning. Use when asked to review an issue, analyze if an issue is fixable, evaluate issue complexity, create implementation plan for an issue, triage issues, assess technical feasibility, or estimate effort for an issue. Outputs structured analysis including feasibility score, clarity score, effort estimate, and detailed implementation plan.
license: Complete terms in LICENSE.txt
---
# Issue Review Skill
Analyze GitHub issues to determine technical feasibility, requirement clarity, and create detailed implementation plans for PowerToys.
## Skill Contents
This skill is **self-contained** with all required resources:
```
.github/skills/issue-review/
├── SKILL.md # This file
├── LICENSE.txt # MIT License
├── scripts/
│ ├── IssueReviewLib.ps1 # Shared library functions
│ └── Start-BulkIssueReview.ps1 # Main review script
└── references/
└── review-issue.prompt.md # Full AI prompt template
```
## Output Directory
All generated artifacts are placed under `Generated Files/issueReview/<issue-number>/` at the repository root (gitignored).
```
Generated Files/issueReview/
└── <issue-number>/
├── overview.md # High-level assessment with scores
├── implementation-plan.md # Detailed step-by-step fix plan
├── _raw-issue.json # Cached issue data from GitHub
└── .signal # Completion signal for orchestrator
```
## Signal File
On completion, a `.signal` file is created for orchestrator coordination:
```json
{
"status": "success",
"issueNumber": 45363,
"timestamp": "2026-02-04T10:05:23Z",
"outputs": ["overview.md", "implementation-plan.md"]
}
```
Status values: `success`, `failure`
## When to Use This Skill
- Review a specific GitHub issue for feasibility
- Analyze whether an issue can be fixed by AI
- Create an implementation plan for an issue
- Triage issues by complexity and clarity
- Estimate effort for fixing an issue
- Evaluate technical requirements of an issue
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- PowerShell 7+ for running scripts
## Required Variables
⚠️ **Before starting**, confirm `{{IssueNumber}}` with the user. If not provided, **ASK**: "What issue number should I review?"
| Variable | Description | Example |
|----------|-------------|---------|
| `{{IssueNumber}}` | GitHub issue number to analyze | `44044` |
## Workflow
### Step 1: Run Issue Review
Execute the review script (use paths relative to this skill folder):
```powershell
# From repo root
.github/skills/issue-review/scripts/Start-BulkIssueReview.ps1 -IssueNumber {{IssueNumber}}
```
This will:
1. Fetch issue details from GitHub
2. Analyze the codebase for relevant files
3. Generate `overview.md` with feasibility assessment
4. Generate `implementation-plan.md` with detailed steps
### Step 2: Review Output
Check the generated files at `Generated Files/issueReview/{{IssueNumber}}/`:
| File | Contains |
|------|----------|
| `overview.md` | Feasibility score (0-100), Clarity score (0-100), Effort estimate, Risk assessment |
| `implementation-plan.md` | Step-by-step implementation with file paths, code snippets, test requirements |
### Step 3: Interpret Scores
| Score Range | Interpretation |
|-------------|----------------|
| 80-100 | High confidence - straightforward fix |
| 60-79 | Medium confidence - some complexity |
| 40-59 | Low confidence - significant challenges |
| 0-39 | Very low - may need human intervention |
## Batch Review
To review multiple issues at once:
```powershell
.github/skills/issue-review/scripts/Start-BulkIssueReview.ps1 -IssueNumbers 44044, 32950, 45029
```
## AI Prompt Reference
For manual AI invocation, the full prompt is at:
- `references/review-issue.prompt.md` (relative to this skill folder)
## Re-Review with Feedback
When the `issue-review-review` skill identifies quality issues, re-run with feedback:
```powershell
.github/skills/issue-review/scripts/Start-BulkIssueReview.ps1 -IssueNumber {{IssueNumber}} -FeedbackFile "Generated Files/issueReviewReview/{{IssueNumber}}/reviewTheReview.md" -Force
```
The `-FeedbackFile` parameter injects corrective feedback into the AI prompt so the review addresses specific issues found by the meta-review.
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Issue not found | Verify issue number exists: `gh issue view {{IssueNumber}}` |
| No implementation plan | Issue may be unclear - check `overview.md` for clarity score |
| Script errors | Ensure you're in the PowerToys repo root |
## Related Skills
| Skill | Purpose |
|-------|---------|
| `issue-review-review` | Validate review quality, loop until score ≥ 90 |
| `issue-fix` | Fix issues after review, create PRs |
| `issue-to-pr-cycle` | Full orchestration (review → fix → PR → review loop) |

View File

@@ -0,0 +1,9 @@
{
"mcpServers": {
"github-artifacts": {
"command": "cmd",
"args": ["/c", "for /f %i in ('git rev-parse --show-toplevel') do node %i/tools/mcp/github-artifacts/launch.js"],
"tools": ["*"]
}
}
}

View File

@@ -0,0 +1,165 @@
---
agent: 'agent'
description: 'Review a GitHub issue, score it (0-100), and generate an implementation plan'
---
# Review GitHub Issue
## Goal
For **#{{issue_number}}** produce:
1) `Generated Files/issueReview/{{issue_number}}/overview.md`
2) `Generated Files/issueReview/{{issue_number}}/implementation-plan.md`
## Inputs
Figure out required inputs {{issue_number}} from the invocation context; if anything is missing, ask for the value or note it as a gap.
# CONTEXT (brief)
Ground evidence using `gh issue view {{issue_number}} --json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments,linkedPullRequests`, download images via MCP `github_issue_images` to better understand the issue context. Finally, use MCP `github_issue_attachments` to download logs with parameter `extractFolder` as `Generated Files/issueReview/{{issue_number}}/logs`, and analyze the downloaded logs if available to identify relevant issues. Locate the source code in the current workspace (use `rg`/`git grep` as needed). Link related issues and PRs.
## When to call MCP tools
If the following MCP "github-artifacts" tools are available in the environment, use them:
- `github_issue_images`: use when the issue/PR likely contains screenshots or other visual evidence (UI bugs, glitches, design problems).
- `github_issue_attachments`: use when the issue/PR mentions attached ZIPs (PowerToysReport_*.zip, logs.zip, debug.zip) or asks to analyze logs/diagnostics. Always provide `extractFolder` as `Generated Files/issueReview/{{issue_number}}/logs`
If these tools are not available (not listed by the runtime), start the MCP server "github-artifacts" first.
# OVERVIEW.MD
## Summary
Issue, state, milestone, labels. **Signals**: 👍/❤️/👎, comment count, last activity, linked PRs.
## At-a-Glance Score Table
Present all ratings in a compact table for quick scanning:
| Dimension | Score | Assessment | Key Drivers |
|-----------|-------|------------|-------------|
| **A) Business Importance** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **B) Community Excitement** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **C) Technical Feasibility** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **D) Requirement Clarity** | X/100 | Low/Medium/High | Top 2 factors with scores |
| **Overall Priority** | X/100 | Low/Medium/High/Critical | Average or weighted summary |
| **Effort Estimate** | X days (T-shirt) | XS/S/M/L/XL/XXL/Epic | Type: bug/feature/chore |
| **Similar Issues Found** | X open, Y closed | — | Quick reference to related work |
| **Potential Assignees** | @username, @username | — | Top contributors to module |
**Assessment bands**: 0-25 Low, 26-50 Medium, 51-75 High, 76-100 Critical
## Ratings (0100) — add evidence & short rationale
### A) Business Importance
- Labels (priority/security/regression): **≤35**
- Milestone/roadmap: **≤25**
- Customer/contract impact: **≤20**
- Unblocks/platform leverage: **≤20**
### B) Community Excitement
- 👍+❤️ normalized: **≤45**
- Comment volume & unique participants: **≤25**
- Recent activity (≤30d): **≤15**
- Duplicates/related issues: **≤15**
### C) Technical Feasibility
- Contained surface/clear seams: **≤30**
- Existing patterns/utilities: **≤25**
- Risk (perf/sec/compat) manageable: **≤25**
- Testability & CI support: **≤20**
### D) Requirement Clarity
- Behavior/repro/constraints: **≤60**
- Non-functionals (perf/sec/i18n/a11y): **≤25**
- Decision owners/acceptance signals: **≤15**
## Effort
Days + **T-shirt** (XS 0.51d, S 12, M 24, L 47, XL 714, XXL 1430, Epic >30).
Type/level: bug/feature/chore/docs/refactor/test-only; severity/value tier.
## Suggested Actions
Provide actionable recommendations for issue triage and assignment:
### A) Requirement Clarification (if Clarity score <50)
**When Requirement Clarity (Dimension D) is Medium or Low:**
- Identify specific gaps in issue description: missing repro steps, unclear expected behavior, undefined acceptance criteria, missing non-functional requirements
- Draft 3-5 clarifying questions to post as issue comment
- Suggest additional information needed: screenshots, logs, environment details, OS version, PowerToys version, error messages
- If behavior is ambiguous, propose 2-3 interpretation scenarios and ask reporter to confirm
- Example questions:
- "Can you provide exact steps to reproduce this issue?"
- "What is the expected behavior vs. what you're actually seeing?"
- "Does this happen on Windows 10, 11, or both?"
- "Can you attach a screenshot or screen recording?"
### B) Correct Label Suggestions
- Analyze issue type, module, and severity to suggest missing or incorrect labels
- Recommend labels from: `Issue-Bug`, `Issue-Feature`, `Issue-Docs`, `Issue-Task`, `Priority-High`, `Priority-Medium`, `Priority-Low`, `Needs-Triage`, `Needs-Author-Feedback`, `Product-<ModuleName>`, etc.
- If Requirement Clarity is low (<50), add `Needs-Author-Feedback` label
- If current labels are incorrect or incomplete, provide specific label changes with rationale
### C) Find Similar Issues & Past Fixes
- Search for similar issues using `gh issue list --search "keywords" --state all --json number,title,state,closedAt`
- Identify patterns: duplicate issues, related bugs, or similar feature requests
- For closed issues, find linked PRs that fixed them: check `linkedPullRequests` in issue data
- Provide 3-5 examples of similar issues with format: `#<number> - <title> (closed by PR #<pr>)` or `(still open)`
### D) Identify Subject Matter Experts
- Use git blame/log to find who fixed similar issues in the past
- Search for PR authors who touched relevant files: `git log --all --format='%aN' -- <file_paths> | sort | uniq -c | sort -rn | head -5`
- Check issue/PR history for frequent contributors to the affected module
- Suggest 2-3 potential assignees with context: `@<username> - <reason>` (e.g., "fixed similar rendering bug in #12345", "maintains FancyZones module")
### E) Semantic Search for Related Work
- Use semantic_search tool to find similar issues, code patterns, or past discussions
- Search queries should include: issue keywords, module names, error messages, feature descriptions
- Cross-reference semantic results with GitHub issue search for comprehensive coverage
**Output format for Suggested Actions section in overview.md:**
```markdown
## Suggested Actions
### Clarifying Questions (if Clarity <50)
Post these questions as issue comment to gather missing information:
1. <question>
2. <question>
3. <question>
**Recommended label**: `Needs-Author-Feedback`
### Label Recommendations
- Add: `<label>` - <reason>
- Remove: `<label>` - <reason>
- Current labels are appropriate ✓
### Similar Issues Found
1. #<number> - <title> (<state>, closed by PR #<pr> on <date>)
2. #<number> - <title> (<state>)
...
### Potential Assignees
- @<username> - <reason>
- @<username> - <reason>
### Related Code/Discussions
- <semantic search findings>
```
# IMPLEMENTATION-PLAN.MD
1) **Problem Framing** — restate problem; current vs expected; scope boundaries.
2) **Layers & Files** — layers (UI/domain/data/infra/build). For each, list **files/dirs to modify** and **new files** (exact paths + why). Prefer repo patterns; cite examples/PRs.
3) **Pattern Choices** — reuse existing; if new, justify trade-offs & transition.
4) **Fundamentals** (brief plan or N/A + reason):
- Performance (hot paths, allocs, caching/streaming)
- Security (validation, authN/Z, secrets, SSRF/XSS/CSRF)
- G11N/L10N (resources, number/date, pluralization)
- Compatibility (public APIs, formats, OS/runtime/toolchain)
- Extensibility (DI seams, options/flags, plugin points)
- Accessibility (roles, labels, focus, keyboard, contrast)
- SOLID & repo conventions (naming, folders, dependency direction)
5) **Logging & Exception Handling**
- Where to log; levels; structured fields; correlation/traces.
- What to catch vs rethrow; retries/backoff; user-visible errors.
- **Privacy**: never log secrets/PII; redaction policy.
6) **Telemetry (optional — business metrics only)**
- Events/metrics (name, when, props); success signal; privacy/sampling; dashboards/alerts.
7) **Risks & Mitigations** — flags/canary/shadow-write/config guards.
8) **Task Breakdown (agent-ready)** — table (leave a blank line before the header so Markdown renders correctly):
| Task | Intent | Files/Areas | Steps | Tests (brief) | Owner (Agent/Human) | Human interaction needed? (why) |
|---|---|---|---|---|---|---|
9) **Tests to Add (only)**
- **Unit**: targets, cases (success/edge/error), mocks/fixtures, path, notes.
- **UI** (if applicable): flows, locator strategy, env/data/flags, path, flake mitigation.

View File

@@ -0,0 +1,777 @@
# IssueReviewLib.ps1 - Shared helpers for bulk issue review automation
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# Resolve config directory name (.github or .claude) from this script's location
$_cfgDir = if ($PSScriptRoot -match '[\\/](\.github|\.claude)[\\/]') { $Matches[1] } else { '.github' }
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
function Get-GeneratedFilesPath {
param([string]$RepoRoot)
return Join-Path $RepoRoot 'Generated Files'
}
function Get-IssueReviewPath {
param(
[string]$RepoRoot,
[int]$IssueNumber
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
return Join-Path $genFiles "issueReview/$IssueNumber"
}
function Get-IssueTitleFromOverview {
<#
.SYNOPSIS
Extract issue title from existing overview.md file.
.DESCRIPTION
Parses the overview.md to get the issue title without requiring GitHub CLI.
#>
param(
[Parameter(Mandatory)]
[string]$OverviewPath
)
if (-not (Test-Path $OverviewPath)) {
return $null
}
$content = Get-Content $OverviewPath -Raw
# Try to match title from Summary table: | **Title** | <title> |
if ($content -match '\*\*Title\*\*\s*\|\s*([^|]+)\s*\|') {
return $Matches[1].Trim()
}
# Try to match from header: # Issue #XXXX: <title>
if ($content -match '# Issue #\d+[:\s]+(.+)$' ) {
return $Matches[1].Trim()
}
# Try to match: # Issue #XXXX Review: <title>
if ($content -match '# Issue #\d+ Review[:\s]+(.+)$') {
return $Matches[1].Trim()
}
return $null
}
function Ensure-DirectoryExists {
param([string]$Path)
if (-not (Test-Path $Path)) {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
#endregion
#region GitHub Issue Query Helpers
function Get-GitHubIssues {
<#
.SYNOPSIS
Query GitHub issues by label, state, and sort order.
.PARAMETER Labels
Comma-separated list of labels to filter by (e.g., "bug,help wanted").
.PARAMETER State
Issue state: open, closed, or all. Default: open.
.PARAMETER Sort
Sort field: created, updated, comments, reactions. Default: created.
.PARAMETER Order
Sort order: asc or desc. Default: desc.
.PARAMETER Limit
Maximum number of issues to return. Default: 100.
.PARAMETER Repository
Repository in owner/repo format. Default: microsoft/PowerToys.
#>
param(
[string]$Labels,
[ValidateSet('open', 'closed', 'all')]
[string]$State = 'open',
[ValidateSet('created', 'updated', 'comments', 'reactions')]
[string]$Sort = 'created',
[ValidateSet('asc', 'desc')]
[string]$Order = 'desc',
[int]$Limit = 100,
[string]$Repository = 'microsoft/PowerToys'
)
$ghArgs = @('issue', 'list', '--repo', $Repository, '--state', $State, '--limit', $Limit)
if ($Labels) {
foreach ($label in ($Labels -split ',')) {
$ghArgs += @('--label', $label.Trim())
}
}
# Build JSON fields (use reactionGroups instead of reactions)
$jsonFields = 'number,title,state,labels,createdAt,updatedAt,author,reactionGroups,comments'
$ghArgs += @('--json', $jsonFields)
Info "Querying issues: gh $($ghArgs -join ' ')"
$result = & gh @ghArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to query issues: $result"
}
$issues = $result | ConvertFrom-Json
# Sort by reactions if requested (gh CLI doesn't support this natively)
if ($Sort -eq 'reactions') {
$issues = $issues | ForEach-Object {
# reactionGroups is an array of {content, users} - sum up user counts
$totalReactions = ($_.reactionGroups | ForEach-Object { $_.users.totalCount } | Measure-Object -Sum).Sum
if (-not $totalReactions) { $totalReactions = 0 }
$_ | Add-Member -NotePropertyName 'totalReactions' -NotePropertyValue $totalReactions -PassThru
}
if ($Order -eq 'desc') {
$issues = $issues | Sort-Object -Property totalReactions -Descending
} else {
$issues = $issues | Sort-Object -Property totalReactions
}
}
return $issues
}
function Get-IssueDetails {
<#
.SYNOPSIS
Get detailed information about a specific issue.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[string]$Repository = 'microsoft/PowerToys'
)
$jsonFields = 'number,title,body,state,labels,createdAt,updatedAt,author,reactions,comments,linkedPullRequests,milestone'
$result = gh issue view $IssueNumber --repo $Repository --json $jsonFields 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to get issue #$IssueNumber`: $result"
}
return $result | ConvertFrom-Json
}
#endregion
#region CLI Detection and Execution
function Get-AvailableCLI {
<#
.SYNOPSIS
Detect which AI CLI is available: GitHub Copilot CLI or Claude Code.
.OUTPUTS
Returns object with: Name, Command, PromptArg
#>
# Check for standalone GitHub Copilot CLI (copilot command)
$copilotCLI = Get-Command 'copilot' -ErrorAction SilentlyContinue
if ($copilotCLI) {
return @{
Name = 'GitHub Copilot CLI'
Command = 'copilot'
Args = @('-p') # Non-interactive prompt mode
Type = 'copilot'
}
}
# Check for Claude Code CLI
$claudeCode = Get-Command 'claude' -ErrorAction SilentlyContinue
if ($claudeCode) {
return @{
Name = 'Claude Code CLI'
Command = 'claude'
Args = @()
Type = 'claude'
}
}
# Check for GitHub Copilot CLI via gh extension
$ghCopilot = Get-Command 'gh' -ErrorAction SilentlyContinue
if ($ghCopilot) {
$copilotCheck = gh extension list 2>&1 | Select-String -Pattern 'copilot'
if ($copilotCheck) {
return @{
Name = 'GitHub Copilot CLI (gh extension)'
Command = 'gh'
Args = @('copilot', 'suggest')
Type = 'gh-copilot'
}
}
}
# Check for VS Code CLI with Copilot
$code = Get-Command 'code' -ErrorAction SilentlyContinue
if ($code) {
return @{
Name = 'VS Code (Copilot Chat)'
Command = 'code'
Args = @()
Type = 'vscode'
}
}
return $null
}
function Invoke-AIReview {
<#
.SYNOPSIS
Invoke AI CLI to review a single issue.
.PARAMETER IssueNumber
The issue number to review.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER CLIType
CLI type: 'claude', 'copilot', 'gh-copilot', or 'vscode'.
.PARAMETER WorkingDirectory
Working directory for the CLI command.
.PARAMETER FeedbackContext
Optional feedback from review-the-review to incorporate into the re-review.
.PARAMETER Model
Optional model override for Copilot CLI (e.g., claude-sonnet-4).
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode')]
[string]$CLIType = 'copilot',
[string]$WorkingDirectory,
[string]$FeedbackContext,
[string]$Model
)
if (-not $WorkingDirectory) {
$WorkingDirectory = $RepoRoot
}
$promptFile = Join-Path $RepoRoot "$_cfgDir/prompts/review-issue.prompt.md"
if (-not (Test-Path $promptFile)) {
throw "Prompt file not found: $promptFile"
}
# Prepare the prompt with issue number substitution
$promptContent = Get-Content $promptFile -Raw
$promptContent = $promptContent -replace '\{\{issue_number\}\}', $IssueNumber
# Create temp prompt file
$tempPromptDir = Join-Path $env:TEMP "issue-review-$IssueNumber"
Ensure-DirectoryExists -Path $tempPromptDir
$tempPromptFile = Join-Path $tempPromptDir "prompt.md"
$promptContent | Set-Content -Path $tempPromptFile -Encoding UTF8
# Build the prompt text for CLI
$promptText = "Review GitHub issue #$IssueNumber following the template in $_cfgDir/prompts/review-issue.prompt.md. Generate overview.md and implementation-plan.md in 'Generated Files/issueReview/$IssueNumber/'"
# Inject feedback from review-the-review if available
if ($FeedbackContext) {
$promptText += @"
IMPORTANT: This is a RE-REVIEW. A previous review was rejected by the quality gate. You MUST address ALL the corrective feedback below. Read the feedback carefully and fix every issue identified.
=== CORRECTIVE FEEDBACK FROM REVIEW-THE-REVIEW ===
$FeedbackContext
=== END FEEDBACK ===
Pay special attention to:
1. Score corrections adjust scores to match the evidence cited in the feedback
2. File path corrections verify all paths exist before including them
3. Pattern corrections use the patterns identified as correct in the feedback
4. Missing coverage add any sections flagged as missing
5. Task breakdown fixes make tasks specific and executable
"@
}
switch ($CLIType) {
'copilot' {
# GitHub Copilot CLI (standalone copilot command)
# Use --yolo for full permissions (--allow-all-tools --allow-all-paths --allow-all-urls)
# Use -s (silent) for cleaner output in batch mode
# Enable ALL GitHub MCP tools (issues, PRs, repos, etc.) + github-artifacts for images/attachments
# MCP config path relative to repo root for github-artifacts tools
$mcpConfig = "@$_cfgDir/skills/issue-review/references/mcp-config.json"
$args = @(
'--additional-mcp-config', $mcpConfig, # Load github-artifacts MCP for image/attachment analysis
'-p', $promptText, # Non-interactive prompt mode (exits after completion)
'--yolo', # Enable all permissions for automated execution
'-s', # Silent mode - output only agent response
'--enable-all-github-mcp-tools', # Enable ALL GitHub MCP tools (issues, PRs, search, etc.)
'--allow-tool', 'github-artifacts', # Also enable our custom github-artifacts MCP
'--agent', 'ReviewIssue'
)
if ($Model) {
$args += @('--model', $Model)
}
return @{
Command = 'copilot'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'claude' {
# Claude Code CLI
$args = @(
'--print', # Non-interactive mode
'--dangerously-skip-permissions',
'--agent', 'ReviewIssue',
'--prompt', $promptText
)
return @{
Command = 'claude'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'gh-copilot' {
# GitHub Copilot CLI via gh
$args = @(
'copilot', 'suggest',
'-t', 'shell',
"Review GitHub issue #$IssueNumber and generate analysis files"
)
return @{
Command = 'gh'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
'vscode' {
# VS Code with Copilot - open with prompt
$args = @(
'--new-window',
$WorkingDirectory,
'--goto', $tempPromptFile
)
return @{
Command = 'code'
Arguments = $args
WorkingDirectory = $WorkingDirectory
IssueNumber = $IssueNumber
}
}
}
}
#endregion
#region Parallel Job Management
function Start-ParallelIssueReviews {
<#
.SYNOPSIS
Start parallel issue reviews with throttling.
.PARAMETER Issues
Array of issue objects to review.
.PARAMETER MaxConcurrent
Maximum number of parallel jobs. Default: 20.
.PARAMETER CLIType
CLI type to use for reviews.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER TimeoutMinutes
Timeout per issue in minutes. Default: 30.
.PARAMETER MaxRetryCount
Maximum number of retries for failed issues. Default: 2.
.PARAMETER RetryDelaySeconds
Delay between retries in seconds. Default: 10.
#>
param(
[Parameter(Mandatory)]
[array]$Issues,
[int]$MaxConcurrent = 20,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode')]
[string]$CLIType = 'copilot',
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$TimeoutMinutes = 30,
[int]$MaxRetryCount = 2,
[int]$RetryDelaySeconds = 10,
[string]$FeedbackContext,
[string]$Model
)
$totalIssues = $Issues.Count
$completed = 0
$failed = @()
$succeeded = @()
$retryQueue = [System.Collections.Queue]::new()
Info "Starting parallel review of $totalIssues issues (max $MaxConcurrent concurrent, $MaxRetryCount retries)"
# Use PowerShell jobs for parallelization
$jobs = @()
$issueQueue = [System.Collections.Queue]::new($Issues)
while ($issueQueue.Count -gt 0 -or $jobs.Count -gt 0 -or $retryQueue.Count -gt 0) {
# Process retry queue when main queue is empty
if ($issueQueue.Count -eq 0 -and $retryQueue.Count -gt 0 -and $jobs.Count -lt $MaxConcurrent) {
$retryItem = $retryQueue.Dequeue()
Warn "🔄 Retrying issue #$($retryItem.IssueNumber) (attempt $($retryItem.Attempt + 1)/$($MaxRetryCount + 1))"
Start-Sleep -Seconds $RetryDelaySeconds
$issueQueue.Enqueue(@{ number = $retryItem.IssueNumber; _retryAttempt = $retryItem.Attempt + 1 })
}
# Start new jobs up to MaxParallel
while ($jobs.Count -lt $MaxConcurrent -and $issueQueue.Count -gt 0) {
$issue = $issueQueue.Dequeue()
$issueNum = $issue.number
$retryAttempt = if ($issue._retryAttempt) { $issue._retryAttempt } else { 0 }
$attemptInfo = if ($retryAttempt -gt 0) { " (retry $retryAttempt)" } else { "" }
Info "Starting review for issue #$issueNum$attemptInfo ($($totalIssues - $issueQueue.Count)/$totalIssues)"
$job = Start-Job -Name "Issue-$issueNum" -ScriptBlock {
param($IssueNumber, $RepoRoot, $CLIType, $FeedbackCtx, $ModelOverride)
Set-Location $RepoRoot
# Import the library in the job context
. "$RepoRoot/.github/review-tools/IssueReviewLib.ps1"
try {
$reviewParams = @{
IssueNumber = $IssueNumber
RepoRoot = $RepoRoot
CLIType = $CLIType
}
if ($FeedbackCtx) {
$reviewParams.FeedbackContext = $FeedbackCtx
}
if ($ModelOverride) {
$reviewParams.Model = $ModelOverride
}
$reviewCmd = Invoke-AIReview @reviewParams
# Execute the command using invocation operator (works for .ps1 scripts and executables)
Set-Location $reviewCmd.WorkingDirectory
$argList = $reviewCmd.Arguments
# Capture both stdout and stderr for better error reporting
$output = & $reviewCmd.Command @argList 2>&1
$exitCode = $LASTEXITCODE
# Get last 20 lines of output for error context
$outputLines = $output | Out-String
$lastLines = ($outputLines -split "`n" | Select-Object -Last 20) -join "`n"
# Check if output files were created (success indicator)
$overviewPath = Join-Path $RepoRoot "Generated Files/issueReview/$IssueNumber/overview.md"
$implPlanPath = Join-Path $RepoRoot "Generated Files/issueReview/$IssueNumber/implementation-plan.md"
$filesCreated = (Test-Path $overviewPath) -and (Test-Path $implPlanPath)
return @{
IssueNumber = $IssueNumber
Success = ($exitCode -eq 0) -or $filesCreated
ExitCode = $exitCode
FilesCreated = $filesCreated
Output = $lastLines
Error = if ($exitCode -ne 0 -and -not $filesCreated) { "Exit code: $exitCode`n$lastLines" } else { $null }
}
}
catch {
return @{
IssueNumber = $IssueNumber
Success = $false
ExitCode = -1
FilesCreated = $false
Output = $null
Error = $_.Exception.Message
}
}
} -ArgumentList $issueNum, $RepoRoot, $CLIType, $FeedbackContext, $Model
$jobs += @{
Job = $job
IssueNumber = $issueNum
StartTime = Get-Date
RetryAttempt = $retryAttempt
}
}
# Check for completed jobs
$completedJobs = @()
foreach ($jobInfo in $jobs) {
$job = $jobInfo.Job
$issueNum = $jobInfo.IssueNumber
$startTime = $jobInfo.StartTime
$retryAttempt = $jobInfo.RetryAttempt
if ($job.State -eq 'Completed') {
$result = Receive-Job -Job $job
Remove-Job -Job $job -Force
if ($result.Success) {
Success "✓ Issue #$issueNum completed (files created: $($result.FilesCreated))"
$succeeded += $issueNum
$completed++
} else {
# Check if we should retry
if ($retryAttempt -lt $MaxRetryCount) {
$errorPreview = if ($result.Error) { ($result.Error -split "`n" | Select-Object -First 3) -join " | " } else { "Unknown error" }
Warn "⚠ Issue #$issueNum failed (will retry): $errorPreview"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = $result.Error })
} else {
$errorMsg = if ($result.Error) { $result.Error } else { "Exit code: $($result.ExitCode)" }
Err "✗ Issue #$issueNum failed after $($retryAttempt + 1) attempts:"
Err " Error: $errorMsg"
$failed += @{ IssueNumber = $issueNum; Error = $errorMsg; Attempts = $retryAttempt + 1 }
$completed++
}
}
$completedJobs += $jobInfo
}
elseif ($job.State -eq 'Failed') {
$jobError = $job.ChildJobs[0].JobStateInfo.Reason.Message
Remove-Job -Job $job -Force
if ($retryAttempt -lt $MaxRetryCount) {
Warn "⚠ Issue #$issueNum job crashed (will retry): $jobError"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = $jobError })
} else {
Err "✗ Issue #$issueNum job failed after $($retryAttempt + 1) attempts: $jobError"
$failed += @{ IssueNumber = $issueNum; Error = $jobError; Attempts = $retryAttempt + 1 }
$completed++
}
$completedJobs += $jobInfo
}
elseif ((Get-Date) - $startTime -gt [TimeSpan]::FromMinutes($TimeoutMinutes)) {
Stop-Job -Job $job -ErrorAction SilentlyContinue
Remove-Job -Job $job -Force
if ($retryAttempt -lt $MaxRetryCount) {
Warn "⏱ Issue #$issueNum timed out after $TimeoutMinutes min (will retry)"
$retryQueue.Enqueue(@{ IssueNumber = $issueNum; Attempt = $retryAttempt; LastError = "Timeout after $TimeoutMinutes minutes" })
} else {
Err "⏱ Issue #$issueNum timed out after $($retryAttempt + 1) attempts"
$failed += @{ IssueNumber = $issueNum; Error = "Timeout after $TimeoutMinutes minutes"; Attempts = $retryAttempt + 1 }
$completed++
}
$completedJobs += $jobInfo
}
}
# Remove completed jobs from active list
$jobs = $jobs | Where-Object { $_ -notin $completedJobs }
# Brief pause to avoid tight loop
if ($jobs.Count -gt 0) {
Start-Sleep -Seconds 2
}
}
# Extract just issue numbers for the failed list
$failedNumbers = $failed | ForEach-Object { $_.IssueNumber }
return @{
Total = $totalIssues
Succeeded = $succeeded
Failed = $failedNumbers
FailedDetails = $failed
}
}
#endregion
#region Issue Review Results Helpers
function Get-IssueReviewResult {
<#
.SYNOPSIS
Check if an issue has been reviewed and get its results.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$RepoRoot
)
$reviewPath = Get-IssueReviewPath -RepoRoot $RepoRoot -IssueNumber $IssueNumber
$result = @{
IssueNumber = $IssueNumber
Path = $reviewPath
HasOverview = $false
HasImplementationPlan = $false
OverviewPath = $null
ImplementationPlanPath = $null
}
$overviewPath = Join-Path $reviewPath 'overview.md'
$implPlanPath = Join-Path $reviewPath 'implementation-plan.md'
if (Test-Path $overviewPath) {
$result.HasOverview = $true
$result.OverviewPath = $overviewPath
}
if (Test-Path $implPlanPath) {
$result.HasImplementationPlan = $true
$result.ImplementationPlanPath = $implPlanPath
}
return $result
}
function Get-HighConfidenceIssues {
<#
.SYNOPSIS
Find issues with high confidence for auto-fix based on review results.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score (0-100). Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score (0-100). Default: 60.
.PARAMETER MaxEffortDays
Maximum effort estimate in days. Default: 2 (S = Small).
.PARAMETER FilterIssueNumbers
Optional array of issue numbers to filter to. If specified, only these issues are considered.
#>
param(
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int[]]$FilterIssueNumbers = @()
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
$reviewDir = Join-Path $genFiles 'issueReview'
if (-not (Test-Path $reviewDir)) {
return @()
}
$highConfidence = @()
Get-ChildItem -Path $reviewDir -Directory | ForEach-Object {
$issueNum = [int]$_.Name
# Skip if filter is specified and this issue is not in the filter list
if ($FilterIssueNumbers.Count -gt 0 -and $issueNum -notin $FilterIssueNumbers) {
return
}
$overviewPath = Join-Path $_.FullName 'overview.md'
$implPlanPath = Join-Path $_.FullName 'implementation-plan.md'
if (-not (Test-Path $overviewPath) -or -not (Test-Path $implPlanPath)) {
return
}
# Parse overview.md to extract scores
$overview = Get-Content $overviewPath -Raw
# Extract scores using regex (looking for score table or inline scores)
$feasibility = 0
$clarity = 0
$effortDays = 999
# Try to extract from At-a-Glance Score Table
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$feasibility = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$clarity = [int]$Matches[1]
}
# Match effort formats like "0.5-1 day", "1-2 days", "2-3 days" - extract the upper bound
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
if ($Matches[1]) {
$effortDays = [int]$Matches[1]
} elseif ($overview -match 'Effort Estimate[^|]*\|\s*(\d+)\s*days?') {
$effortDays = [int]$Matches[1]
}
}
# Also check for XS/S sizing in the table (e.g., "| XS |" or "| S |" or "(XS)" or "(S)")
if ($overview -match 'Effort Estimate[^|]*\|[^|]*\|\s*(XS|S)\b') {
# XS = 1 day, S = 2 days
if ($Matches[1] -eq 'XS') {
$effortDays = 1
} else {
$effortDays = 2
}
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(XS\)') {
$effortDays = 1
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(S\)') {
$effortDays = 2
}
if ($feasibility -ge $MinFeasibilityScore -and
$clarity -ge $MinClarityScore -and
$effortDays -le $MaxEffortDays) {
$highConfidence += @{
IssueNumber = $issueNum
FeasibilityScore = $feasibility
ClarityScore = $clarity
EffortDays = $effortDays
OverviewPath = $overviewPath
ImplementationPlanPath = $implPlanPath
}
}
}
return $highConfidence | Sort-Object -Property FeasibilityScore -Descending
}
#endregion
#region Worktree Integration
function Copy-IssueReviewToWorktree {
<#
.SYNOPSIS
Copy the Generated Files for an issue to a worktree.
.PARAMETER IssueNumber
The issue number.
.PARAMETER SourceRepoRoot
Source repository root (main repo).
.PARAMETER WorktreePath
Destination worktree path.
#>
param(
[Parameter(Mandatory)]
[int]$IssueNumber,
[Parameter(Mandatory)]
[string]$SourceRepoRoot,
[Parameter(Mandatory)]
[string]$WorktreePath
)
$sourceReviewPath = Get-IssueReviewPath -RepoRoot $SourceRepoRoot -IssueNumber $IssueNumber
$destReviewPath = Get-IssueReviewPath -RepoRoot $WorktreePath -IssueNumber $IssueNumber
if (-not (Test-Path $sourceReviewPath)) {
throw "Issue review files not found at: $sourceReviewPath"
}
Ensure-DirectoryExists -Path $destReviewPath
# Copy all files from the issue review folder
Copy-Item -Path "$sourceReviewPath\*" -Destination $destReviewPath -Recurse -Force
Info "Copied issue review files to: $destReviewPath"
return $destReviewPath
}
#endregion
# Note: This script is dot-sourced, not imported as a module.
# All functions above are available after: . "path/to/IssueReviewLib.ps1"

View File

@@ -0,0 +1,291 @@
<#!
.SYNOPSIS
Bulk review GitHub issues using AI CLI (Claude Code or GitHub Copilot).
.DESCRIPTION
Queries GitHub issues by labels, state, and sort order, then kicks off parallel
AI-powered reviews for each issue. Results are stored in Generated Files/issueReview/<number>/.
.PARAMETER Labels
Comma-separated list of labels to filter issues (e.g., "bug,help wanted").
.PARAMETER State
Issue state: open, closed, or all. Default: open.
.PARAMETER Sort
Sort field: created, updated, comments, reactions. Default: created.
.PARAMETER Order
Sort order: asc or desc. Default: desc.
.PARAMETER Limit
Maximum number of issues to process. Default: 100.
.PARAMETER MaxConcurrent
Maximum parallel review jobs. Default: 20.
.PARAMETER CLIType
AI CLI to use: claude, gh-copilot, or vscode. Auto-detected if not specified.
.PARAMETER DryRun
List issues without starting reviews.
.PARAMETER SkipExisting
Skip issues that already have review files.
.PARAMETER Repository
Repository in owner/repo format. Default: microsoft/PowerToys.
.PARAMETER TimeoutMinutes
Timeout per issue review in minutes. Default: 30.
.EXAMPLE
# Review all open bugs sorted by reactions
./Start-BulkIssueReview.ps1 -Labels "bug" -Sort reactions -Order desc
.EXAMPLE
# Dry run to see which issues would be reviewed
./Start-BulkIssueReview.ps1 -Labels "help wanted" -DryRun
.EXAMPLE
# Review top 50 issues with Claude Code, max 10 parallel
./Start-BulkIssueReview.ps1 -Labels "Issue-Bug" -Limit 50 -MaxConcurrent 10 -CLIType claude
.EXAMPLE
# Skip already-reviewed issues
./Start-BulkIssueReview.ps1 -Labels "Issue-Feature" -SkipExisting
.NOTES
Requires: GitHub CLI (gh) authenticated, and either Claude Code CLI or VS Code with Copilot.
Results: Generated Files/issueReview/<issue_number>/overview.md and implementation-plan.md
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$Labels,
[ValidateSet('open', 'closed', 'all')]
[string]$State = 'open',
[ValidateSet('created', 'updated', 'comments', 'reactions')]
[string]$Sort = 'created',
[ValidateSet('asc', 'desc')]
[string]$Order = 'desc',
[int]$Limit = 1000,
[int]$MaxConcurrent = 20,
[ValidateSet('claude', 'copilot', 'gh-copilot', 'vscode', 'auto')]
[string]$CLIType = 'auto',
[switch]$DryRun,
[switch]$SkipExisting,
[string]$Repository = 'microsoft/PowerToys',
[int]$TimeoutMinutes = 30,
[int]$MaxRetryCount = 2,
[int]$RetryDelaySeconds = 10,
[switch]$Force,
[int]$IssueNumber,
[int[]]$IssueNumbers,
[string]$FeedbackFile,
[string]$Model,
[switch]$Help
)
# Load library
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. "$scriptDir/IssueReviewLib.ps1"
# Show help
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
#region Main Script
try {
# Get repo root
$repoRoot = Get-RepoRoot
Info "Repository root: $repoRoot"
# Detect or validate CLI
if ($CLIType -eq 'auto') {
$cli = Get-AvailableCLI
if (-not $cli) {
throw "No AI CLI found. Please install Claude Code CLI or GitHub Copilot CLI extension."
}
$CLIType = $cli.Type
Info "Auto-detected CLI: $($cli.Name)"
}
# Load feedback context if provided
$feedbackContext = $null
if ($FeedbackFile -and (Test-Path $FeedbackFile)) {
$feedbackContext = Get-Content $FeedbackFile -Raw
Info "Loaded feedback from: $FeedbackFile"
}
elseif ($FeedbackFile) {
Warn "Feedback file not found: $FeedbackFile (proceeding without feedback)"
}
# Determine issue list: explicit IssueNumber(s) take priority over label query
if ($IssueNumber -gt 0) {
Info "`nUsing single issue: #$IssueNumber"
$issues = @(@{ number = $IssueNumber })
}
elseif ($IssueNumbers -and $IssueNumbers.Count -gt 0) {
Info "`nUsing explicit issue list: $($IssueNumbers -join ', ')"
$issues = $IssueNumbers | ForEach-Object { @{ number = $_ } }
}
else {
# Query issues from GitHub
Info "`nQuerying issues with filters:"
Info " Labels: $(if ($Labels) { $Labels } else { '(none)' })"
Info " State: $State"
Info " Sort: $Sort $Order"
Info " Limit: $Limit"
$issues = Get-GitHubIssues -Labels $Labels -State $State -Sort $Sort -Order $Order -Limit $Limit -Repository $Repository
}
if ($issues.Count -eq 0) {
Warn "No issues found matching the criteria."
return
}
Info "`nFound $($issues.Count) issues"
# Filter out existing reviews if requested
if ($SkipExisting) {
$originalCount = $issues.Count
$issues = $issues | Where-Object {
$result = Get-IssueReviewResult -IssueNumber $_.number -RepoRoot $repoRoot
-not ($result.HasOverview -and $result.HasImplementationPlan)
}
$skipped = $originalCount - $issues.Count
if ($skipped -gt 0) {
Info "Skipping $skipped issues with existing reviews"
}
}
if ($issues.Count -eq 0) {
Warn "All issues already have reviews. Nothing to do."
return
}
# Display issue list
Info "`nIssues to review:"
Info ("-" * 80)
foreach ($issue in $issues) {
$labels = ($issue.labels | ForEach-Object { $_.name }) -join ', '
$reactions = if ($issue.reactions) { $issue.reactions.totalCount } else { 0 }
Info ("#{0,-6} {1,-50} [👍{2}] [{3}]" -f $issue.number, ($issue.title.Substring(0, [Math]::Min(50, $issue.title.Length))), $reactions, $labels)
}
Info ("-" * 80)
if ($DryRun) {
Warn "`nDry run mode - no reviews started."
Info "Would review $($issues.Count) issues with CLI: $CLIType"
return
}
# Confirm before proceeding (skip if -Force)
if (-not $Force) {
$confirm = Read-Host "`nProceed with reviewing $($issues.Count) issues using $CLIType? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
} else {
Info "`nProceeding with $($issues.Count) issues (Force mode)"
}
# Create output directory
$genFiles = Get-GeneratedFilesPath -RepoRoot $repoRoot
Ensure-DirectoryExists -Path (Join-Path $genFiles 'issueReview')
# Start parallel reviews
Info "`nStarting bulk review..."
Info " Max retries: $MaxRetryCount (delay: ${RetryDelaySeconds}s)"
$startTime = Get-Date
$results = Start-ParallelIssueReviews `
-Issues $issues `
-MaxConcurrent $MaxConcurrent `
-CLIType $CLIType `
-RepoRoot $repoRoot `
-TimeoutMinutes $TimeoutMinutes `
-MaxRetryCount $MaxRetryCount `
-RetryDelaySeconds $RetryDelaySeconds `
-FeedbackContext $feedbackContext `
-Model $Model
$duration = (Get-Date) - $startTime
# Summary
Info "`n" + ("=" * 80)
Info "BULK REVIEW COMPLETE"
Info ("=" * 80)
Info "Total issues: $($results.Total)"
Success "Succeeded: $($results.Succeeded.Count)"
if ($results.Failed.Count -gt 0) {
Err "Failed: $($results.Failed.Count)"
Err "Failed issues: $($results.Failed -join ', ')"
Info ""
Info "Failed Issue Details:"
Info ("-" * 40)
foreach ($failedItem in $results.FailedDetails) {
Err " #$($failedItem.IssueNumber) (attempts: $($failedItem.Attempts)):"
$errorLines = ($failedItem.Error -split "`n" | Select-Object -First 5) -join "`n "
Err " $errorLines"
}
Info ("-" * 40)
}
Info "Duration: $($duration.ToString('hh\:mm\:ss'))"
Info "Output: $genFiles/issueReview/"
Info ("=" * 80)
# Write signal file for each issue processed
foreach ($issueNum in $results.Succeeded) {
$signalPath = Join-Path $genFiles "issueReview/$issueNum/.signal"
@{
status = "success"
issueNumber = $issueNum
timestamp = (Get-Date).ToString("o")
outputs = @("overview.md", "implementation-plan.md")
} | ConvertTo-Json | Set-Content $signalPath -Force
Info "Signal: $signalPath"
}
foreach ($issueNum in $results.Failed) {
$signalPath = Join-Path $genFiles "issueReview/$issueNum/.signal"
$failDetail = $results.FailedDetails | Where-Object { $_.IssueNumber -eq $issueNum }
@{
status = "failure"
issueNumber = $issueNum
timestamp = (Get-Date).ToString("o")
error = $failDetail.Error
} | ConvertTo-Json | Set-Content $signalPath -Force
}
# Return results for pipeline
return $results
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,287 @@
---
name: issue-to-pr-cycle
description: End-to-end orchestration from issue analysis to PR creation and review. This skill is the ORCHESTRATION BRAIN that invokes other skills via CLI and performs VS Code MCP operations directly.
license: Complete terms in LICENSE.txt
---
# Issue-to-PR Full Cycle Skill
**ORCHESTRATION BRAIN** - coordinates other skills and performs VS Code MCP operations.
## Skill Contents
```
.github/skills/issue-to-pr-cycle/
├── SKILL.md # This file (orchestration brain)
├── LICENSE.txt # MIT License
└── scripts/
├── Get-CycleStatus.ps1 # Check status of issues/PRs
├── IssueReviewLib.ps1 # Shared helpers
└── Start-FullIssueCycle.ps1 # Legacy script (phases A-C)
```
**Orchestrates these skills:**
| Skill | Purpose |
|-------|---------|
| `issue-review` | Analyze issues, generate implementation plans |
| `issue-review-review` | Validate review quality, loop until score ≥ 90 |
| `issue-fix` | Create worktrees, apply fixes, create PRs |
| `pr-review` | Comprehensive PR review (13 steps) |
| `pr-fix` | Fix review comments, resolve threads |
## Prerequisites
- GitHub CLI (`gh`) installed and authenticated
- Copilot CLI or Claude CLI installed
- PowerShell 7+
- VS Code with MCP tools (for write operations)
## Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `{{IssueNumbers}}` | Issue numbers to process | `45363, 45364` |
| (or) `{{PRNumbers}}` | PR numbers for review/fix loop | `45365, 45366` |
## How This Skill Works
The orchestrator:
1. **Invokes skills via CLI** - kicks off `copilot` CLI (not `gh copilot`) to run each skill
2. **Runs in parallel** - use PowerShell 7 `ForEach-Object -Parallel` in SINGLE terminal
3. **Waits for signals** - polls for `.signal` files indicating completion
4. **Performs VS Code MCP directly** - for operations that require write access (request reviewer, resolve threads)
## Quality Gates (CRITICAL)
**Every PR must pass these quality checks before creation:**
1. **Real Implementation** - NO placeholder/stub code
- Files must contain actual working code
- Empty classes like `class FixXXX { }` are FORBIDDEN
2. **Proper PR Title** - Follow Conventional Commits
- Use `.github/prompts/create-commit-title.prompt.md`
- Format: `feat(module): description` or `fix(module): description`
- NEVER use generic titles like "fix: address issue #12345"
3. **Full PR Description** - Based on actual diff
- Use `.github/prompts/create-pr-summary.prompt.md`
- Run `git diff main...HEAD` to analyze changes
- Fill PR template with real information
4. **Build Verification** - Code must compile
- Run `tools/build/build.cmd` in worktree
- Exit code 0 = success
### Checking Worktree Quality
```powershell
# Check if worktree has real implementation (not stubs)
$files = git diff main --name-only
foreach ($file in $files) {
if ($file -match "src/common/fixes/Fix\d+\.cs") {
Write-Error "STUB FILE DETECTED: $file - Need real implementation"
}
}
```
## Signal Files
Each skill produces a `.signal` file when complete:
| Skill | Signal Location | Status Values |
|-------|-----------------|---------------|
| `issue-review` | `Generated Files/issueReview/<issue>/.signal` | `success`, `failure` |
| `issue-review-review` | `Generated Files/issueReviewReview/<issue>/.signal` | `success`, `failure` |
| `issue-fix` | `Generated Files/issueFix/<issue>/.signal` | `success`, `failure` |
| `pr-review` | `Generated Files/prReview/<pr>/.signal` | `success`, `failure` |
| `pr-fix` | `Generated Files/prFix/<pr>/.signal` | `success`, `partial`, `failure` |
Signal format:
```json
{
"status": "success",
"issueNumber": 45363,
"timestamp": "2026-02-04T10:05:23Z"
}
```
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ ORCHESTRATOR (this skill, VS Code agent) │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │
│ │ issue-review│◄─┤issue-review- │ │ issue-fix │ │
│ │ (CLI) │ │review (CLI) │ │ (CLI) │ │
│ └──────┬──────┘ │ loop until ≥90 │ └──────┬──────┘ │
│ │ └────────┬─────────┘ │ │
│ └────────►─────────┘ │ │
│ │ │
│ ┌─────────────┐ ┌─────────────┐ │ │
│ │ pr-review │ │ pr-fix │ │ │
│ │ (CLI) │ │ (CLI) │ │ │
│ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Signal Files (Generated Files/*/.signal) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ VS Code MCP Operations (orchestrator executes directly): │
│ - mcp_github_request_copilot_review │
│ - gh api graphql (resolve threads) │
│ - Post review comments │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Workflow
### Phase A: Issue Review
Use the orchestration script instead of inline commands:
```powershell
.github/skills/issue-to-pr-cycle/scripts/Start-FullIssueCycle.ps1 -IssueNumbers 45363,45364
```
### Phase A2: Review-Review Loop (Quality Gate)
After issue-review completes, validate the review quality. Loop until quality score ≥ 90 or max iterations reached.
**A2.1: Run review-review**
```powershell
.github/skills/issue-review-review/scripts/Start-IssueReviewReviewParallel.ps1 -IssueNumbers 45363,45364 -CLIType copilot -ThrottleLimit 5 -Force
```
**A2.2: Check signals**
```powershell
# For each issue, check the review-review signal
$signal = Get-Content "Generated Files/issueReviewReview/45363/.signal" | ConvertFrom-Json
# If signal.needsReReview is true (qualityScore < 90), re-run issue-review with feedback
```
**A2.3: Re-run issue-review with feedback (if needed)**
```powershell
# Re-run issue-review, passing the reviewTheReview.md feedback file
.github/skills/issue-review/scripts/Start-BulkIssueReview.ps1 -IssueNumber 45363 -FeedbackFile "Generated Files/issueReviewReview/45363/reviewTheReview.md" -Force
```
**A2.4: Loop** — Go back to A2.1 until:
- All issues have quality score ≥ 90, OR
- Maximum 3 iterations reached per issue
### Phase B: Issue Fix
Use the parallel runner script:
```powershell
.github/skills/issue-fix/scripts/Start-IssueFixParallel.ps1 -IssueNumbers 45363,45364 -CLIType copilot -ThrottleLimit 5 -Force
```
### Phase C: PR Review
Use the pr-review script for each PR, or run the full cycle script to orchestrate:
```powershell
.github/skills/pr-review/scripts/Start-PRReviewWorkflow.ps1 -PRNumber 45392
```
### Phase D: Review/Fix Loop (VS Code Agent Orchestrated)
This phase requires the VS Code agent to:
**D1: Request Copilot review (VS Code MCP)**
```
mcp_github_request_copilot_review:
owner: microsoft
repo: PowerToys
pullNumber: {{PRNumber}}
```
**D2: Invoke pr-review skill (CLI, parallel)**
```powershell
gh copilot -p "Run skill pr-review for PR #{{PRNumber}}"
# Wait for: Generated Files/prReview/{{PRNumber}}/.signal
```
**D3: Check results**
- Read `Generated Files/prReview/{{PRNumber}}/00-OVERVIEW.md`
- Query unresolved threads via GraphQL
**D4: Post comments (VS Code MCP) - if medium+ severity**
**D5: Invoke pr-fix skill in WORKTREE (CLI)**
```powershell
# Find worktree for this PR's branch
$branch = (gh pr view {{PRNumber}} --json headRefName -q .headRefName)
$worktree = git worktree list --porcelain | Select-String "worktree.*$branch" | ...
# Run fix in worktree
cd $worktreePath
gh copilot -p "Run skill pr-fix for PR #{{PRNumber}}"
# Wait for: Generated Files/prFix/{{PRNumber}}/.signal
```
**D6: Resolve threads (VS Code MCP)**
```powershell
# Get thread IDs
gh api graphql -f query='query { repository(owner:"microsoft",name:"PowerToys") {
pullRequest(number:{{PRNumber}}) { reviewThreads(first:50) { nodes { id isResolved } } }
} }'
# Resolve each (VS Code agent executes this)
gh api graphql -f query='mutation { resolveReviewThread(input:{threadId:"{{ID}}"}) { thread { isResolved } } }'
```
**D7: Loop**
- If unresolved issues remain → go to D2
- If all clear → done
## Timeout Handling
Default timeout: 10 minutes per skill invocation.
If no signal file appears within timeout:
1. Check if the skill process is still running
2. If hung, terminate and mark as `timeout`
3. Log failure and continue with other items
## Parallel Execution (CRITICAL)
**DO NOT spawn separate terminals for each operation.** Use the dedicated scripts to run parallel work from a single terminal:
```powershell
# Issue fixes in parallel
.github/skills/issue-fix/scripts/Start-IssueFixParallel.ps1 -IssueNumbers 28726,13336,27507,3054,37800 -CLIType copilot -Model gpt-5.2-codex -ThrottleLimit 5 -Force
# PR fixes in parallel
.github/skills/pr-fix/scripts/Start-PRFixParallel.ps1 -PRNumbers 45256,45257,45285,45286 -CLIType copilot -Model gpt-5.2-codex -ThrottleLimit 3 -Force
```
## Worktree Mapping
The orchestrator must track which worktree belongs to which issue/PR:
```powershell
# Get all worktrees
$worktrees = git worktree list --porcelain | Select-String "worktree|branch" |
ForEach-Object { $_.Line }
# Parse into mapping
# Q:\PowerToys-ab12 → issue/44044
# Q:\PowerToys-cd34 → issue/32950
# Find worktree for issue
$issueNum = 45363
$worktreeLine = git worktree list | Select-String "issue/$issueNum"
$worktreePath = ($worktreeLine -split '\s+')[0]
```
## When to Use This Skill
- Process multiple issues end-to-end
- Automate the full issue → PR → review → fix cycle
- Batch process high-confidence issues
- Run continuous review/fix loops until clean

View File

@@ -0,0 +1,370 @@
<#
.SYNOPSIS
Get the current status of issues/PRs in the issue-to-PR cycle.
.DESCRIPTION
Checks the status of:
- Issue review completion (has overview.md + implementation-plan.md)
- Issue fix completion (has worktree + commits)
- PR creation status (has open PR)
- PR review status (has review files)
- PR active comments count
.PARAMETER IssueNumbers
Array of issue numbers to check status for.
.PARAMETER PRNumbers
Array of PR numbers to check status for.
.PARAMETER CheckAll
Check all issues with review data and all open PRs with issue/* branches.
.EXAMPLE
./Get-CycleStatus.ps1 -IssueNumbers 44044, 32950
.EXAMPLE
./Get-CycleStatus.ps1 -PRNumbers 45234, 45235
.EXAMPLE
./Get-CycleStatus.ps1 -CheckAll
#>
[CmdletBinding()]
param(
[int[]]$IssueNumbers = @(),
[int[]]$PRNumbers = @(),
[switch]$CheckAll,
[switch]$JsonOutput
)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $scriptDir 'IssueReviewLib.ps1')
$repoRoot = Get-RepoRoot
$genFiles = Get-GeneratedFilesPath -RepoRoot $repoRoot
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
function Get-IssueStatus {
param([int]$IssueNumber)
$status = @{
IssueNumber = $IssueNumber
HasReview = $false
HasImplementationPlan = $false
FeasibilityScore = 0
ClarityScore = 0
EffortDays = 0
HasWorktree = $false
WorktreePath = $null
HasCommits = $false
CommitCount = 0
HasPR = $false
PRNumber = 0
PRState = $null
PRUrl = $null
ReviewSignalStatus = $null
ReviewSignalTimestamp = $null
ReviewReviewSignalStatus = $null
ReviewReviewQualityScore = 0
ReviewReviewIteration = 0
ReviewReviewNeedsReReview = $false
FixSignalStatus = $null
FixSignalTimestamp = $null
}
# Check review status
$reviewDir = Join-Path $genFiles "issueReview/$IssueNumber"
$overviewPath = Join-Path $reviewDir 'overview.md'
$implPlanPath = Join-Path $reviewDir 'implementation-plan.md'
if (Test-Path $overviewPath) {
$status.HasReview = $true
$overview = Get-Content $overviewPath -Raw
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$status.FeasibilityScore = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$status.ClarityScore = [int]$Matches[1]
}
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
$status.EffortDays = if ($Matches[1]) { [int]$Matches[1] } else { 1 }
}
}
if (Test-Path $implPlanPath) {
$status.HasImplementationPlan = $true
}
# Check review signal
$reviewSignalPath = Join-Path $reviewDir '.signal'
if (Test-Path $reviewSignalPath) {
try {
$reviewSignal = Get-Content $reviewSignalPath -Raw | ConvertFrom-Json
$status.ReviewSignalStatus = $reviewSignal.status
$status.ReviewSignalTimestamp = $reviewSignal.timestamp
}
catch {}
}
# Check review-review signal
$reviewReviewSignalPath = Join-Path $genFiles "issueReviewReview/$IssueNumber/.signal"
if (Test-Path $reviewReviewSignalPath) {
try {
$rrSignal = Get-Content $reviewReviewSignalPath -Raw | ConvertFrom-Json
$status.ReviewReviewSignalStatus = $rrSignal.status
$status.ReviewReviewQualityScore = [int]$rrSignal.qualityScore
$status.ReviewReviewIteration = [int]$rrSignal.iteration
$status.ReviewReviewNeedsReReview = [bool]$rrSignal.needsReReview
}
catch {}
}
# Check worktree status
$worktrees = Get-WorktreeEntries | Where-Object { $_.Branch -like "issue/$IssueNumber*" }
if ($worktrees) {
$status.HasWorktree = $true
$status.WorktreePath = $worktrees[0].Path
# Check for commits
Push-Location $status.WorktreePath
try {
$commits = git log --oneline "main..HEAD" 2>$null
if ($commits) {
$status.HasCommits = $true
$status.CommitCount = @($commits).Count
}
}
finally {
Pop-Location
}
}
# Check fix signal
$fixSignalPath = Join-Path $genFiles "issueFix/$IssueNumber/.signal"
if (Test-Path $fixSignalPath) {
try {
$fixSignal = Get-Content $fixSignalPath -Raw | ConvertFrom-Json
$status.FixSignalStatus = $fixSignal.status
$status.FixSignalTimestamp = $fixSignal.timestamp
}
catch {}
}
# Check PR status
$prs = gh pr list --head "issue/$IssueNumber" --state all --json number,url,state 2>$null | ConvertFrom-Json
if (-not $prs -or $prs.Count -eq 0) {
# Try searching by issue reference
$prs = gh pr list --search "fixes #$IssueNumber OR closes #$IssueNumber" --state all --json number,url,state --limit 1 2>$null | ConvertFrom-Json
}
if ($prs -and $prs.Count -gt 0) {
$status.HasPR = $true
$status.PRNumber = $prs[0].number
$status.PRState = $prs[0].state
$status.PRUrl = $prs[0].url
}
return $status
}
function Get-PRStatus {
param([int]$PRNumber)
$status = @{
PRNumber = $PRNumber
State = $null
IssueNumber = 0
Branch = $null
HasReviewFiles = $false
ReviewStepCount = 0
HighSeverityCount = 0
MediumSeverityCount = 0
ActiveCommentCount = 0
UnresolvedThreadCount = 0
CopilotReviewRequested = $false
ReviewSignalStatus = $null
ReviewSignalTimestamp = $null
FixSignalStatus = $null
FixSignalTimestamp = $null
}
# Get PR info
$prInfo = gh pr view $PRNumber --json state,headRefName,number 2>$null | ConvertFrom-Json
if (-not $prInfo) {
return $status
}
$status.State = $prInfo.state
$status.Branch = $prInfo.headRefName
# Extract issue number from branch
if ($status.Branch -match 'issue/(\d+)') {
$status.IssueNumber = [int]$Matches[1]
}
# Check review files
$reviewDir = Join-Path $genFiles "prReview/$PRNumber"
if (Test-Path $reviewDir) {
$status.HasReviewFiles = $true
$stepFiles = Get-ChildItem -Path $reviewDir -Filter "*.md" -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^\d{2}-' }
$status.ReviewStepCount = $stepFiles.Count
# Count severity issues
foreach ($stepFile in $stepFiles) {
$content = Get-Content $stepFile.FullName -Raw -ErrorAction SilentlyContinue
if ($content) {
$status.HighSeverityCount += ([regex]::Matches($content, '\*\*Severity:\s*high\*\*', 'IgnoreCase')).Count
$status.HighSeverityCount += ([regex]::Matches($content, '🔴\s*High', 'IgnoreCase')).Count
$status.MediumSeverityCount += ([regex]::Matches($content, '\*\*Severity:\s*medium\*\*', 'IgnoreCase')).Count
$status.MediumSeverityCount += ([regex]::Matches($content, '🟡\s*Medium', 'IgnoreCase')).Count
}
}
}
# Check review signal
$reviewSignalPath = Join-Path $reviewDir '.signal'
if (Test-Path $reviewSignalPath) {
try {
$reviewSignal = Get-Content $reviewSignalPath -Raw | ConvertFrom-Json
$status.ReviewSignalStatus = $reviewSignal.status
$status.ReviewSignalTimestamp = $reviewSignal.timestamp
}
catch {}
}
# Check fix signal
$fixSignalPath = Join-Path $genFiles "prFix/$PRNumber/.signal"
if (Test-Path $fixSignalPath) {
try {
$fixSignal = Get-Content $fixSignalPath -Raw | ConvertFrom-Json
$status.FixSignalStatus = $fixSignal.status
$status.FixSignalTimestamp = $fixSignal.timestamp
}
catch {}
}
# Get active comments (not in reply to another comment)
try {
$commentCount = gh api "repos/microsoft/PowerToys/pulls/$PRNumber/comments" --jq '[.[] | select(.in_reply_to_id == null)] | length' 2>$null
$status.ActiveCommentCount = [int]$commentCount
}
catch {
$status.ActiveCommentCount = 0
}
# Get unresolved thread count
try {
$threads = gh api graphql -f query="query { repository(owner: `"microsoft`", name: `"PowerToys`") { pullRequest(number: $PRNumber) { reviewThreads(first: 100) { nodes { isResolved } } } } }" --jq '.data.repository.pullRequest.reviewThreads.nodes | map(select(.isResolved == false)) | length' 2>$null
$status.UnresolvedThreadCount = [int]$threads
}
catch {
$status.UnresolvedThreadCount = 0
}
# Check if Copilot review was requested
try {
$reviewers = gh pr view $PRNumber --json reviewRequests --jq '.reviewRequests[].login' 2>$null
if ($reviewers -contains 'copilot' -or $reviewers -contains 'github-copilot') {
$status.CopilotReviewRequested = $true
}
}
catch {}
return $status
}
# Main execution
$results = @{
Issues = @()
PRs = @()
Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
# Gather issue numbers to check
$issuesToCheck = @()
$prsToCheck = @()
if ($CheckAll) {
# Get all reviewed issues
$reviewDir = Join-Path $genFiles 'issueReview'
if (Test-Path $reviewDir) {
$issuesToCheck = Get-ChildItem -Path $reviewDir -Directory |
Where-Object { $_.Name -match '^\d+$' } |
ForEach-Object { [int]$_.Name }
}
# Get all open PRs with issue/* branches
$openPRs = gh pr list --state open --json number,headRefName 2>$null | ConvertFrom-Json |
Where-Object { $_.headRefName -like 'issue/*' }
$prsToCheck = @($openPRs | ForEach-Object { $_.number })
}
else {
$issuesToCheck = $IssueNumbers
$prsToCheck = $PRNumbers
}
# Get issue statuses
foreach ($issueNum in $issuesToCheck) {
$status = Get-IssueStatus -IssueNumber $issueNum
$results.Issues += $status
}
# Get PR statuses
foreach ($prNum in $prsToCheck) {
$status = Get-PRStatus -PRNumber $prNum
$results.PRs += $status
}
# Output
if ($JsonOutput) {
$results | ConvertTo-Json -Depth 5
return
}
else {
if ($results.Issues.Count -gt 0) {
Write-Host "`n=== ISSUE STATUS ===" -ForegroundColor Cyan
Write-Host ("-" * 120)
Write-Host ("{0,-8} {1,-8} {2,-8} {3,-5} {4,-5} {5,-8} {6,-8} {7,-8} {8,-8} {9,-8} {10,-8}" -f "Issue", "Review", "Plan", "Feas", "Clar", "RR Scr", "Worktree", "PR", "RevSig", "RRSig", "FixSig")
Write-Host ("-" * 120)
foreach ($issue in $results.Issues | Sort-Object IssueNumber) {
$reviewMark = if ($issue.HasReview) { "" } else { "-" }
$planMark = if ($issue.HasImplementationPlan) { "" } else { "-" }
$wtMark = if ($issue.HasWorktree) { "" } else { "-" }
$commitMark = if ($issue.HasCommits) { $issue.CommitCount } else { "-" }
$prMark = if ($issue.HasPR) { "#$($issue.PRNumber) ($($issue.PRState))" } else { "-" }
$reviewSignalMark = if ($issue.ReviewSignalStatus) { $issue.ReviewSignalStatus } else { "-" }
$fixSignalMark = if ($issue.FixSignalStatus) { $issue.FixSignalStatus } else { "-" }
$rrScoreMark = if ($issue.ReviewReviewSignalStatus) { "$($issue.ReviewReviewQualityScore)" } else { "-" }
$rrSignalMark = if ($issue.ReviewReviewSignalStatus) {
if ($issue.ReviewReviewNeedsReReview) { "redo" } else { "pass" }
} else { "-" }
Write-Host ("{0,-8} {1,-8} {2,-8} {3,-5} {4,-5} {5,-8} {6,-8} {7,-8} {8,-8} {9,-8} {10,-8}" -f
"#$($issue.IssueNumber)", $reviewMark, $planMark, $issue.FeasibilityScore, $issue.ClarityScore, $rrScoreMark, $wtMark, $prMark, $reviewSignalMark, $rrSignalMark, $fixSignalMark)
}
}
if ($results.PRs.Count -gt 0) {
Write-Host "`n=== PR STATUS ===" -ForegroundColor Cyan
Write-Host ("-" * 120)
Write-Host ("{0,-8} {1,-10} {2,-10} {3,-8} {4,-8} {5,-10} {6,-12} {7,-10} {8,-8} {9,-8}" -f "PR", "State", "Issue", "Reviews", "High", "Medium", "Comments", "Unresolved", "RevSig", "FixSig")
Write-Host ("-" * 120)
foreach ($pr in $results.PRs | Sort-Object PRNumber) {
$reviewMark = if ($pr.HasReviewFiles) { "$($pr.ReviewStepCount) steps" } else { "-" }
$issueMark = if ($pr.IssueNumber -gt 0) { "#$($pr.IssueNumber)" } else { "-" }
$reviewSignalMark = if ($pr.ReviewSignalStatus) { $pr.ReviewSignalStatus } else { "-" }
$fixSignalMark = if ($pr.FixSignalStatus) { $pr.FixSignalStatus } else { "-" }
Write-Host ("{0,-8} {1,-10} {2,-10} {3,-8} {4,-8} {5,-10} {6,-12} {7,-10} {8,-8} {9,-8}" -f
"#$($pr.PRNumber)", $pr.State, $issueMark, $reviewMark, $pr.HighSeverityCount, $pr.MediumSeverityCount, $pr.ActiveCommentCount, $pr.UnresolvedThreadCount, $reviewSignalMark, $fixSignalMark)
}
}
Write-Host "`nTimestamp: $($results.Timestamp)" -ForegroundColor Gray
}
return $results

View File

@@ -0,0 +1,123 @@
# IssueReviewLib.ps1 - Helpers for full issue-to-PR cycle workflow
# Part of the PowerToys GitHub Copilot/Claude Code issue review system
# This is a trimmed version with only what issue-to-pr-cycle needs
#region Console Output Helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Success { param([string]$Message) Write-Host $Message -ForegroundColor Green }
#endregion
#region Repository Helpers
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return (Resolve-Path $root).Path
}
function Get-GeneratedFilesPath {
param([string]$RepoRoot)
return Join-Path $RepoRoot 'Generated Files'
}
#endregion
#region Issue Review Results Helpers
function Get-HighConfidenceIssues {
<#
.SYNOPSIS
Find issues with high confidence for auto-fix based on review results.
.PARAMETER RepoRoot
Repository root path.
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score (0-100). Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score (0-100). Default: 60.
.PARAMETER MaxEffortDays
Maximum effort estimate in days. Default: 2 (S = Small).
.PARAMETER FilterIssueNumbers
Optional array of issue numbers to filter to. If specified, only these issues are considered.
#>
param(
[Parameter(Mandatory)]
[string]$RepoRoot,
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 60,
[int]$MaxEffortDays = 2,
[int[]]$FilterIssueNumbers = @()
)
$genFiles = Get-GeneratedFilesPath -RepoRoot $RepoRoot
$reviewDir = Join-Path $genFiles 'issueReview'
if (-not (Test-Path $reviewDir)) {
return @()
}
$highConfidence = @()
Get-ChildItem -Path $reviewDir -Directory | ForEach-Object {
$issueNum = [int]$_.Name
# Skip if filter is specified and this issue is not in the filter list
if ($FilterIssueNumbers.Count -gt 0 -and $issueNum -notin $FilterIssueNumbers) {
return
}
$overviewPath = Join-Path $_.FullName 'overview.md'
$implPlanPath = Join-Path $_.FullName 'implementation-plan.md'
if (-not (Test-Path $overviewPath) -or -not (Test-Path $implPlanPath)) {
return
}
# Parse overview.md to extract scores
$overview = Get-Content $overviewPath -Raw
# Extract scores using regex (looking for score table or inline scores)
$feasibility = 0
$clarity = 0
$effortDays = 999
# Try to extract from At-a-Glance Score Table
if ($overview -match 'Technical Feasibility[^\d]*(\d+)/100') {
$feasibility = [int]$Matches[1]
}
if ($overview -match 'Requirement Clarity[^\d]*(\d+)/100') {
$clarity = [int]$Matches[1]
}
# Match effort formats like "0.5-1 day", "1-2 days", "2-3 days" - extract the upper bound
if ($overview -match 'Effort Estimate[^|]*\|\s*[\d.]+(?:-(\d+))?\s*days?') {
if ($Matches[1]) {
$effortDays = [int]$Matches[1]
} elseif ($overview -match 'Effort Estimate[^|]*\|\s*(\d+)\s*days?') {
$effortDays = [int]$Matches[1]
}
}
# Also check for XS/S sizing in the table
if ($overview -match 'Effort Estimate[^|]*\|[^|]*\|\s*(XS|S)\b') {
if ($Matches[1] -eq 'XS') { $effortDays = 1 } else { $effortDays = 2 }
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(XS\)') {
$effortDays = 1
} elseif ($overview -match 'Effort Estimate[^|]*\|[^|]*\(S\)') {
$effortDays = 2
}
if ($feasibility -ge $MinFeasibilityScore -and
$clarity -ge $MinClarityScore -and
$effortDays -le $MaxEffortDays) {
$highConfidence += @{
IssueNumber = $issueNum
FeasibilityScore = $feasibility
ClarityScore = $clarity
EffortDays = $effortDays
OverviewPath = $overviewPath
ImplementationPlanPath = $implPlanPath
}
}
}
return $highConfidence | Sort-Object -Property FeasibilityScore -Descending
}
#endregion

View File

@@ -0,0 +1,679 @@
<#!
.SYNOPSIS
Run the complete issue-to-PR cycle: fix issues, create PRs, review, and fix comments.
.DESCRIPTION
Orchestrates the full workflow:
1. Find high-confidence issues matching criteria
2. Create worktrees and run auto-fix for each issue
3. Commit changes and create PRs
4. Run PR review workflow in a loop until no issues remain:
a. Review PR and post comments
b. Fix PR comments
c. Re-review to check for remaining issues
d. Repeat until clean or max iterations reached
.PARAMETER MinFeasibilityScore
Minimum Technical Feasibility score. Default: 70.
.PARAMETER MinClarityScore
Minimum Requirement Clarity score. Default: 70.
.PARAMETER MaxEffortDays
Maximum effort in days. Default: 10.
.PARAMETER MaxReviewIterations
Maximum review/fix iterations per PR before giving up. Default: 3.
.PARAMETER ExcludeIssues
Array of issue numbers to exclude (already processed).
.PARAMETER CLIType
AI CLI to use: copilot or claude. Default: copilot.
.PARAMETER DryRun
Show what would be done without executing.
.PARAMETER SkipExisting
Skip issues that already have worktrees or PRs.
.EXAMPLE
./Start-FullIssueCycle.ps1 -MinFeasibilityScore 70 -MinClarityScore 70 -MaxEffortDays 10
.EXAMPLE
./Start-FullIssueCycle.ps1 -ExcludeIssues 44044,45029,32950,35703,44480 -DryRun
#>
[CmdletBinding()]
param(
[string]$Labels = '',
[int]$Limit = 500, # GitHub API max is 1000, default to 500 to get most issues
[int]$MinFeasibilityScore = 70,
[int]$MinClarityScore = 70,
[int]$MaxEffortDays = 10,
[int]$MaxReviewIterations = 3,
[int[]]$ExcludeIssues = @(),
[ValidateSet('copilot', 'claude')]
[string]$CLIType = 'copilot',
[int]$FixThrottleLimit = 5,
[int]$PRThrottleLimit = 5,
[int]$ReviewMaxConcurrent = 3,
[ValidateSet('high', 'medium', 'low', 'info')]
[string]$MinSeverityForLoop = 'medium',
[switch]$DryRun,
[switch]$SkipExisting,
[switch]$SkipReview,
[switch]$Force,
[switch]$Help
)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$skillsDir = Split-Path -Parent (Split-Path -Parent $scriptDir) # <configRoot>/skills (e.g. .github/skills or .claude/skills)
. (Join-Path $scriptDir 'IssueReviewLib.ps1')
# Paths to other skills' scripts
$issueFixScript = Join-Path $skillsDir 'issue-fix/scripts/Start-IssueAutoFix.ps1'
$submitPRScript = Join-Path $skillsDir 'issue-fix/scripts/Submit-IssueFix.ps1'
$prReviewScript = Join-Path $skillsDir 'pr-review/scripts/Start-PRReviewWorkflow.ps1'
$prFixScript = Join-Path $skillsDir 'pr-fix/scripts/Start-PRFix.ps1'
$repoRoot = Get-RepoRoot
$worktreeLib = Join-Path $repoRoot 'tools/build/WorktreeLib.ps1'
if (Test-Path $worktreeLib) {
. $worktreeLib
}
if ($Help) {
Get-Help $MyInvocation.MyCommand.Path -Full
return
}
#region Helper Functions
function Get-ExistingIssuePRs {
<#
.SYNOPSIS
Get ALL issues that already have PRs (open, closed, or merged) - checking GitHub directly.
#>
param(
[int[]]$IssueNumbers
)
$existingPRs = @{}
foreach ($issueNum in $IssueNumbers) {
# Check if there's a PR that mentions this issue (any state: open, closed, merged)
$prs = gh pr list --search "fixes #$issueNum OR closes #$issueNum OR resolves #$issueNum" --state all --json number,url,headRefName,state 2>$null | ConvertFrom-Json
if ($prs -and $prs.Count -gt 0) {
$existingPRs[$issueNum] = @{
PRNumber = $prs[0].number
PRUrl = $prs[0].url
Branch = $prs[0].headRefName
State = $prs[0].state
}
continue
}
# Also check for branch pattern issue/<number>* (any state)
$branchPrs = gh pr list --head "issue/$issueNum" --state all --json number,url,headRefName,state 2>$null | ConvertFrom-Json
if (-not $branchPrs -or $branchPrs.Count -eq 0) {
# Try with wildcard search via gh api
$branchPrs = gh pr list --state all --json number,url,headRefName,state 2>$null | ConvertFrom-Json | Where-Object { $_.headRefName -like "issue/$issueNum*" }
}
if ($branchPrs -and $branchPrs.Count -gt 0) {
$existingPRs[$issueNum] = @{
PRNumber = $branchPrs[0].number
PRUrl = $branchPrs[0].url
Branch = $branchPrs[0].headRefName
State = $branchPrs[0].state
}
}
}
return $existingPRs
}
function Get-ExistingWorktrees {
<#
.SYNOPSIS
Get issues that already have worktrees.
#>
$existingWorktrees = @{}
$worktrees = Get-WorktreeEntries | Where-Object { $_.Branch -like 'issue/*' }
foreach ($wt in $worktrees) {
if ($wt.Branch -match 'issue/(\d+)') {
$issueNum = [int]$Matches[1]
$existingWorktrees[$issueNum] = $wt.Path
}
}
return $existingWorktrees
}
function Get-PRReviewIssueCount {
<#
.SYNOPSIS
Count high/medium severity issues from the review overview file.
#>
param(
[int]$PRNumber,
[string]$MinSeverity = 'medium'
)
$overviewPath = Join-Path $repoRoot "Generated Files/prReview/$PRNumber/00-OVERVIEW.md"
if (-not (Test-Path $overviewPath)) {
return -1 # No review yet
}
$content = Get-Content $overviewPath -Raw
# Parse "High severity issues: <count>" from the overview
$highCount = 0
$mediumCount = 0
if ($content -match 'High severity issues:\s*(\d+)') {
$highCount = [int]$Matches[1]
}
# Also check step files for medium severity
$stepFiles = Get-ChildItem -Path (Split-Path $overviewPath) -Filter "*.md" | Where-Object { $_.Name -match '^\d{2}-' }
foreach ($stepFile in $stepFiles) {
$stepContent = Get-Content $stepFile.FullName -Raw
# Count severity markers
$mediumCount += ([regex]::Matches($stepContent, '\*\*Severity:\s*medium\*\*', 'IgnoreCase')).Count
$mediumCount += ([regex]::Matches($stepContent, '🟡\s*Medium', 'IgnoreCase')).Count
}
switch ($MinSeverity) {
'high' { return $highCount }
'medium' { return $highCount + $mediumCount }
default { return $highCount + $mediumCount }
}
}
function Get-PRActiveCommentCount {
<#
.SYNOPSIS
Count active (unresolved) review comments on a PR.
#>
param(
[int]$PRNumber
)
try {
# Get all review comments
$comments = gh api "repos/microsoft/PowerToys/pulls/$PRNumber/comments" --jq '[.[] | select(.in_reply_to_id == null)] | length' 2>$null
if ($comments) {
return [int]$comments
}
return 0
}
catch {
return 0
}
}
function Clear-PRReviewCache {
<#
.SYNOPSIS
Clear the review cache to force a fresh review.
#>
param(
[int]$PRNumber
)
$reviewPath = Join-Path $repoRoot "Generated Files/prReview/$PRNumber"
if (Test-Path $reviewPath) {
# Keep logs but remove review files
Get-ChildItem $reviewPath -Filter "*.md" | Remove-Item -Force
}
}
function Invoke-PRReviewFixLoop {
<#
.SYNOPSIS
Run the review/fix loop until no issues remain or max iterations reached.
#>
param(
[int]$PRNumber,
[int]$IssueNumber,
[string]$WorktreePath,
[string]$CLIType = 'copilot',
[string]$MinSeverity = 'medium',
[int]$MaxIterations = 3
)
$iteration = 0
$issuesRemaining = $true
while ($issuesRemaining -and $iteration -lt $MaxIterations) {
$iteration++
Info " [PR #$PRNumber] Review/Fix iteration $iteration of $MaxIterations"
# Step 1: Run PR review (assign Copilot, review, post comments)
Info " [PR #$PRNumber] Running review..."
try {
# Clear previous review to force fresh analysis
if ($iteration -gt 1) {
Clear-PRReviewCache -PRNumber $PRNumber
}
& $prReviewScript -PRNumbers $PRNumber -CLIType $CLIType -Force 2>&1 | Out-Null
}
catch {
Warn " [PR #$PRNumber] Review failed: $($_.Exception.Message)"
break
}
# Step 2: Check if there are issues found
$issueCount = Get-PRReviewIssueCount -PRNumber $PRNumber -MinSeverity $MinSeverity
$activeComments = Get-PRActiveCommentCount -PRNumber $PRNumber
Info " [PR #$PRNumber] Found $issueCount issues (severity >= $MinSeverity), $activeComments active comments"
if ($issueCount -le 0 -and $activeComments -le 0) {
Info " [PR #$PRNumber] ✓ No issues remaining!"
$issuesRemaining = $false
break
}
# Step 3: Run fix for active comments
if ($activeComments -gt 0 -or $issueCount -gt 0) {
Info " [PR #$PRNumber] Fixing $activeComments active comments..."
try {
# Run fix via pr-fix skill (review script only does reviews)
& $prFixScript -PRNumber $PRNumber -CLIType $CLIType -Force 2>&1 | Out-Null
}
catch {
Warn " [PR #$PRNumber] Fix failed: $($_.Exception.Message)"
}
}
# Brief pause to let GitHub sync
Start-Sleep -Seconds 2
}
if ($issuesRemaining) {
Warn " [PR #$PRNumber] Max iterations reached, some issues may remain"
}
return @{
PRNumber = $PRNumber
IssueNumber = $IssueNumber
Iterations = $iteration
IssuesRemaining = $issuesRemaining
FinalIssueCount = (Get-PRReviewIssueCount -PRNumber $PRNumber -MinSeverity $MinSeverity)
}
}
#endregion
#region Main Script
try {
$startTime = Get-Date
Info "=" * 80
Info "FULL ISSUE-TO-PR CYCLE"
Info "=" * 80
Info "Repository root: $repoRoot"
Info "CLI type: $CLIType"
if ($Labels) {
Info "Labels filter: $Labels"
}
Info "Criteria: Feasibility >= $MinFeasibilityScore, Clarity >= $MinClarityScore, Effort <= $MaxEffortDays days"
# Step 0: Review issues first (if labels specified and not skipping review)
if ($Labels -and -not $SkipReview) {
Info "`n" + ("=" * 60)
Info "STEP 0: Reviewing issues with label '$Labels'"
Info ("=" * 60)
$reviewScript = Join-Path $scriptDir '../../issue-review/scripts/Start-BulkIssueReview.ps1'
if (Test-Path $reviewScript) {
$reviewArgs = @{
Labels = $Labels
Limit = $Limit
CLIType = $CLIType
Force = $Force
}
if ($DryRun) {
Info "[DRY RUN] Would run: Start-BulkIssueReview.ps1 -Labels '$Labels' -Limit $Limit -CLIType $CLIType -Force"
} else {
Info "Running bulk issue review..."
& $reviewScript @reviewArgs
}
} else {
Warn "Review script not found at: $reviewScript"
Warn "Proceeding with existing review data..."
}
}
# Step 1: Find high-confidence issues
Info "`n" + ("=" * 60)
Info "STEP 1: Finding high-confidence issues"
Info ("=" * 60)
# If labels specified, get the list of issue numbers with that label first
# This ensures we ONLY look at issues with the specified label, not all reviewed issues
$filterIssueNumbers = @()
if ($Labels) {
Info "Fetching issues with label '$Labels' from GitHub..."
$labeledIssues = gh issue list --repo microsoft/PowerToys --label "$Labels" --state open --limit $Limit --json number 2>$null | ConvertFrom-Json
$filterIssueNumbers = @($labeledIssues | ForEach-Object { $_.number })
Info "Found $($filterIssueNumbers.Count) issues with label '$Labels'"
}
$highConfidence = Get-HighConfidenceIssues `
-RepoRoot $repoRoot `
-MinFeasibilityScore $MinFeasibilityScore `
-MinClarityScore $MinClarityScore `
-MaxEffortDays $MaxEffortDays `
-FilterIssueNumbers $filterIssueNumbers
Info "Found $($highConfidence.Count) high-confidence issues matching criteria"
if ($highConfidence.Count -eq 0) {
Warn "No issues found matching criteria."
return
}
# Get issue numbers for checking
$issueNumbers = $highConfidence | ForEach-Object { $_.IssueNumber }
# Get existing PRs to skip (check GitHub directly)
Info "Checking for existing PRs..."
$existingPRs = Get-ExistingIssuePRs -IssueNumbers $issueNumbers
Info "Found $($existingPRs.Count) issues with existing PRs"
# Filter out excluded issues and those with existing PRs
$issuesToProcess = $highConfidence | Where-Object {
$issueNum = $_.IssueNumber
$excluded = $issueNum -in $ExcludeIssues
$hasPR = $existingPRs.ContainsKey($issueNum)
if ($excluded) {
Info " Excluding #$issueNum (in exclude list)"
}
if ($hasPR -and $SkipExisting) {
$prState = $existingPRs[$issueNum].State
Info " Skipping #$issueNum (has $prState PR #$($existingPRs[$issueNum].PRNumber))"
}
-not $excluded -and (-not $hasPR -or -not $SkipExisting)
}
if ($issuesToProcess.Count -eq 0) {
Warn "No new issues to process after filtering."
return
}
Info "`nIssues to process: $($issuesToProcess.Count)"
Info ("-" * 80)
foreach ($issue in $issuesToProcess) {
$prInfo = if ($existingPRs.ContainsKey($issue.IssueNumber)) {
$state = $existingPRs[$issue.IssueNumber].State
" [has $state PR #$($existingPRs[$issue.IssueNumber].PRNumber)]"
} else { "" }
Info ("#{0,-6} [F:{1}, C:{2}, E:{3}d]{4}" -f $issue.IssueNumber, $issue.FeasibilityScore, $issue.ClarityScore, $issue.EffortDays, $prInfo)
}
Info ("-" * 80)
if ($DryRun) {
Warn "`nDry run mode - showing what would be done:"
Info " 1. Create worktrees for $($issuesToProcess.Count) issues (parallel)"
Info " 2. Run Copilot auto-fix in each worktree (parallel)"
Info " 3. Commit and create PRs (parallel)"
Info " 4. Run PR review/fix loop (up to $MaxReviewIterations iterations per PR)"
Info " - Review PR and post comments (severity >= $MinSeverityForLoop)"
Info " - Fix active comments"
Info " - Repeat until clean or max iterations"
return
}
# Confirm
if (-not $Force) {
$confirm = Read-Host "`nProceed with full cycle for $($issuesToProcess.Count) issues? (y/N)"
if ($confirm -notmatch '^[yY]') {
Info "Cancelled."
return
}
}
# Track results
$results = @{
FixSucceeded = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
FixFailed = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
PRCreated = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
PRFailed = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
PRSkipped = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
ReviewSucceeded = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
ReviewFailed = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
}
# ========================================
# PHASE 1: Create worktrees and fix issues (PARALLEL)
# ========================================
Info "`n" + ("=" * 60)
Info "PHASE 1: Auto-Fix Issues (Parallel)"
Info ("=" * 60)
$issuesNeedingFix = $issuesToProcess | Where-Object { -not $existingPRs.ContainsKey($_.IssueNumber) }
$issuesWithPR = $issuesToProcess | Where-Object { $existingPRs.ContainsKey($_.IssueNumber) }
Info "Issues needing fix: $($issuesNeedingFix.Count)"
Info "Issues with existing PR (skip to review): $($issuesWithPR.Count)"
if ($issuesNeedingFix.Count -gt 0) {
$issuesNeedingFix | ForEach-Object -ThrottleLimit $FixThrottleLimit -Parallel {
$issue = $_
$issueNum = $issue.IssueNumber
$issueFixScript = $using:issueFixScript
$CLIType = $using:CLIType
$results = $using:results
try {
Write-Host "[Issue #$issueNum] Starting auto-fix..." -ForegroundColor Cyan
& $issueFixScript -IssueNumber $issueNum -CLIType $CLIType -Force 2>&1 | Out-Null
$results.FixSucceeded.Add($issueNum)
Write-Host "[Issue #$issueNum] ✓ Fix completed" -ForegroundColor Green
}
catch {
$results.FixFailed.Add(@{ IssueNumber = $issueNum; Error = $_.Exception.Message })
Write-Host "[Issue #$issueNum] ✗ Fix failed: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
Info "`nPhase 1 complete: $($results.FixSucceeded.Count) succeeded, $($results.FixFailed.Count) failed"
# ========================================
# PHASE 2: Commit and create PRs (PARALLEL)
# ========================================
Info "`n" + ("=" * 60)
Info "PHASE 2: Submit PRs (Parallel)"
Info ("=" * 60)
$fixedIssues = $results.FixSucceeded.ToArray()
if ($fixedIssues.Count -gt 0) {
$fixedIssues | ForEach-Object -ThrottleLimit $PRThrottleLimit -Parallel {
$issueNum = $_
$submitPRScript = $using:submitPRScript
$CLIType = $using:CLIType
$results = $using:results
try {
Write-Host "[Issue #$issueNum] Creating PR..." -ForegroundColor Cyan
$submitResult = & $submitPRScript -IssueNumbers $issueNum -CLIType $CLIType -Force 2>&1
# Parse output to find PR URL
$prUrl = $null
$prNum = 0
if ($submitResult -match 'https://github.com/[^/]+/[^/]+/pull/(\d+)') {
$prUrl = $Matches[0]
$prNum = [int]$Matches[1]
}
if ($prNum -gt 0) {
$results.PRCreated.Add(@{ IssueNumber = $issueNum; PRNumber = $prNum; PRUrl = $prUrl })
Write-Host "[Issue #$issueNum] ✓ PR #$prNum created" -ForegroundColor Green
} else {
# Check if PR was already created
$existingPr = gh pr list --head "issue/$issueNum" --state open --json number,url 2>$null | ConvertFrom-Json
if ($existingPr -and $existingPr.Count -gt 0) {
$results.PRSkipped.Add(@{ IssueNumber = $issueNum; PRNumber = $existingPr[0].number; PRUrl = $existingPr[0].url; Reason = "Already exists" })
Write-Host "[Issue #$issueNum] PR already exists: #$($existingPr[0].number)" -ForegroundColor Yellow
} else {
$results.PRFailed.Add(@{ IssueNumber = $issueNum; Error = "No PR created" })
Write-Host "[Issue #$issueNum] ✗ PR creation failed" -ForegroundColor Red
}
}
}
catch {
$results.PRFailed.Add(@{ IssueNumber = $issueNum; Error = $_.Exception.Message })
Write-Host "[Issue #$issueNum] ✗ PR failed: $($_.Exception.Message)" -ForegroundColor Red
}
}
}
Info "`nPhase 2 complete: $($results.PRCreated.Count) created, $($results.PRSkipped.Count) skipped, $($results.PRFailed.Count) failed"
# ========================================
# PHASE 3: Review and Fix PRs (ITERATIVE LOOP)
# ========================================
Info "`n" + ("=" * 60)
Info "PHASE 3: Review & Fix PRs (Iterative Loop)"
Info ("=" * 60)
Info "Max iterations per PR: $MaxReviewIterations"
Info "Min severity to fix: $MinSeverityForLoop"
# Collect all PRs to review (newly created + existing)
$prsToReview = @()
foreach ($pr in $results.PRCreated.ToArray()) {
$prsToReview += @{ IssueNumber = $pr.IssueNumber; PRNumber = $pr.PRNumber }
}
foreach ($pr in $results.PRSkipped.ToArray()) {
$prsToReview += @{ IssueNumber = $pr.IssueNumber; PRNumber = $pr.PRNumber }
}
foreach ($issue in $issuesWithPR) {
$prInfo = $existingPRs[$issue.IssueNumber]
# Only include open PRs
if ($prInfo.State -eq 'OPEN') {
$prsToReview += @{ IssueNumber = $issue.IssueNumber; PRNumber = $prInfo.PRNumber }
}
}
Info "PRs to review: $($prsToReview.Count)"
# Track review loop results
$reviewLoopResults = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
if ($prsToReview.Count -gt 0) {
# Process sequentially to avoid overwhelming the AI CLI
foreach ($pr in $prsToReview) {
$issueNum = $pr.IssueNumber
$prNum = $pr.PRNumber
Info "`n [PR #$prNum for Issue #$issueNum] Starting review/fix loop..."
try {
$loopResult = Invoke-PRReviewFixLoop `
-PRNumber $prNum `
-IssueNumber $issueNum `
-CLIType $CLIType `
-MinSeverity $MinSeverityForLoop `
-MaxIterations $MaxReviewIterations
$reviewLoopResults.Add($loopResult)
if (-not $loopResult.IssuesRemaining) {
$results.ReviewSucceeded.Add(@{ IssueNumber = $issueNum; PRNumber = $prNum; Iterations = $loopResult.Iterations })
Success " [PR #$prNum] ✓ Clean after $($loopResult.Iterations) iteration(s)"
} else {
$results.ReviewFailed.Add(@{ IssueNumber = $issueNum; PRNumber = $prNum; Iterations = $loopResult.Iterations; RemainingIssues = $loopResult.FinalIssueCount })
Warn " [PR #$prNum] ⚠ $($loopResult.FinalIssueCount) issues remain after $($loopResult.Iterations) iterations"
}
}
catch {
$results.ReviewFailed.Add(@{ IssueNumber = $issueNum; PRNumber = $prNum; Error = $_.Exception.Message })
Err " [PR #$prNum] ✗ Review loop failed: $($_.Exception.Message)"
}
}
}
Info "`nPhase 3 complete: $($results.ReviewSucceeded.Count) clean, $($results.ReviewFailed.Count) with remaining issues"
# Final Summary
$duration = (Get-Date) - $startTime
Info "`n" + ("=" * 80)
Info "FULL CYCLE COMPLETE"
Info ("=" * 80)
Info "Duration: $($duration.ToString('hh\:mm\:ss'))"
Info ""
Info "Issues processed: $($issuesToProcess.Count)"
Success "Fixes succeeded: $($results.FixSucceeded.Count)"
if ($results.FixFailed.Count -gt 0) {
Err "Fixes failed: $($results.FixFailed.Count)"
}
Success "PRs created: $($results.PRCreated.Count)"
if ($results.PRSkipped.Count -gt 0) {
Warn "PRs skipped: $($results.PRSkipped.Count) (already existed)"
}
if ($results.PRFailed.Count -gt 0) {
Err "PRs failed: $($results.PRFailed.Count)"
}
Success "PRs clean (no issues): $($results.ReviewSucceeded.Count)"
if ($results.ReviewFailed.Count -gt 0) {
Warn "PRs with remaining issues: $($results.ReviewFailed.Count)"
}
Info ""
Info "Summary by issue:"
foreach ($issue in $issuesToProcess) {
$issueNum = $issue.IssueNumber
$prInfo = $results.PRCreated.ToArray() | Where-Object { $_.IssueNumber -eq $issueNum } | Select-Object -First 1
if (-not $prInfo) {
$prInfo = $results.PRSkipped.ToArray() | Where-Object { $_.IssueNumber -eq $issueNum } | Select-Object -First 1
}
if (-not $prInfo -and $existingPRs.ContainsKey($issueNum)) {
$prInfo = @{ PRNumber = $existingPRs[$issueNum].PRNumber }
}
$prNum = if ($prInfo) { "PR #$($prInfo.PRNumber)" } else { "No PR" }
$fixStatus = if ($results.FixSucceeded.ToArray() -contains $issueNum) { "" } elseif ($results.FixFailed.ToArray().IssueNumber -contains $issueNum) { "" } else { "-" }
# Check review status with iteration count
$reviewResult = $results.ReviewSucceeded.ToArray() | Where-Object { $_.IssueNumber -eq $issueNum -or $_.PRNumber -eq $prInfo.PRNumber } | Select-Object -First 1
$reviewFailResult = $results.ReviewFailed.ToArray() | Where-Object { $_.IssueNumber -eq $issueNum -or $_.PRNumber -eq $prInfo.PRNumber } | Select-Object -First 1
if ($reviewResult) {
$reviewStatus = "✓($($reviewResult.Iterations))"
} elseif ($reviewFailResult) {
$reviewStatus = "⚠($($reviewFailResult.RemainingIssues) left)"
} else {
$reviewStatus = "-"
}
Info (" Issue #{0,-6} [{1}Fix] [{2}Review] -> {3}" -f $issueNum, $fixStatus, $reviewStatus, $prNum)
}
Info ("=" * 80)
return @{
FixSucceeded = $results.FixSucceeded.ToArray()
FixFailed = $results.FixFailed.ToArray()
PRCreated = $results.PRCreated.ToArray()
PRSkipped = $results.PRSkipped.ToArray()
PRFailed = $results.PRFailed.ToArray()
ReviewSucceeded = $results.ReviewSucceeded.ToArray()
ReviewFailed = $results.ReviewFailed.ToArray()
}
}
catch {
Err "Error: $($_.Exception.Message)"
exit 1
}
#endregion

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,151 @@
---
name: parallel-job-orchestrator
description: Generic parallel job orchestrator for running copilot, claude, or any CLI tool concurrently with queuing, monitoring, retry, and cleanup. Use when asked to run multiple jobs in parallel, batch process PRs or issues with copilot/claude, orchestrate concurrent CLI executions, run parallel reviews, run parallel triage, or execute any batch of shell commands concurrently. ALL skills that need parallel execution MUST use this orchestrator — do NOT use Start-Job, ForEach-Object -Parallel, or Start-Process directly.
license: Complete terms in LICENSE.txt
---
# Parallel Job Orchestrator
The **single, canonical way** to run multiple jobs concurrently in this repository. Every skill that needs to run copilot, claude, or any CLI tool in parallel **MUST** use this orchestrator. Do NOT use `Start-Job`, `ForEach-Object -Parallel`, or `Start-Process` directly — those approaches have known PowerShell 7 crash bugs that took 48 hours to diagnose and fix.
## When to Use This Skill
- Running copilot or claude CLI on multiple PRs/issues simultaneously
- Any batch processing that spawns multiple CLI processes
- Parallel review, triage, fix, or rework workflows
- Any skill that needs concurrent execution with retry and monitoring
## Why This Orchestrator Exists
PowerShell 7 has **silent host-process crash bugs** triggered by:
1. `[CmdletBinding()]`, `[Parameter(Mandatory)]`, `[ValidateSet()]` attributes propagating `ErrorActionPreference='Stop'` through child scopes
2. `Start-Job` called from within functions inside `while` loops — crashes after ~10-15 jobs
3. Accumulated completed `Job` objects consuming runspace resources
4. `ForEach-Object -Parallel` swallowing errors and losing context
This orchestrator avoids all of these by:
- **No advanced-function attributes** on the script itself
- **Inlined** all `Start-Job`/`Stop-Job`/`Remove-Job` calls (never in functions)
- **Immediately** `Receive-Job` + `Remove-Job` on completion
- **`$ErrorActionPreference = 'Continue'`** in the monitoring loop
- **Write-Host on every iteration** (PS7 kills the host if no output for ~8s in child-script loops)
## Quick Start
### Step 1: Build Job Definitions
Each job is a hashtable with this exact structure:
```powershell
$jobDef = @{
Label = 'copilot-pr-12345' # unique human-readable label
ExecutionParameters = @{
JobName = 'copilot-pr-12345' # PS job name
Command = 'copilot' # executable to run
Arguments = @('-p', 'Review PR #12345', '--yolo') # argument array
WorkingDir = 'C:\repo' # working directory
OutputDir = 'C:\repo\output\copilot\12345' # output directory (auto-created)
LogPath = 'C:\repo\output\copilot\12345\review.log' # stdout+stderr log
}
MonitorFiles = @('C:\repo\output\copilot\12345\review.log') # files to watch for activity
CleanupTask = $null # optional scriptblock: { param($Tracker) ... }
}
```
### Step 2: Call the Orchestrator
```powershell
# CRITICAL: Set ErrorActionPreference to Continue before calling
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$results = & '.github/skills/parallel-job-orchestrator/scripts/Invoke-SimpleJobOrchestrator.ps1' `
-JobDefinitions $jobDefs `
-MaxConcurrent 4 `
-InactivityTimeoutSeconds 60 `
-MaxRetryCount 3 `
-PollIntervalSeconds 5 `
-LogDir 'C:\repo\output'
$ErrorActionPreference = $savedEAP
```
### Step 3: Process Results
The orchestrator returns an array of result objects:
```powershell
$results | Format-Table Label, Status, JobState, ExitCode, RetryCount -AutoSize
```
| Property | Type | Description |
|----------|------|-------------|
| `Label` | string | Job label from definition |
| `JobId` | int | Last PowerShell job ID |
| `Status` | string | `Completed`, `Failed`, `Abandoned` |
| `JobState` | string | PowerShell job state |
| `ExitCode` | int | Process exit code |
| `RetryCount` | int | Number of retries performed |
| `OutputDir` | string | Output directory path |
| `LogPath` | string | Log file path |
## Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `-JobDefinitions` | hashtable[] | **(required)** | Array of job definition hashtables |
| `-MaxConcurrent` | int | 4 | Maximum simultaneous jobs |
| `-InactivityTimeoutSeconds` | int | 60 | Seconds of zero log-file growth before stale |
| `-MaxRetryCount` | int | 3 | Retry attempts before abandoning |
| `-PollIntervalSeconds` | int | 5 | Health-check interval |
| `-LogDir` | string | `$env:TEMP` | Directory for orchestrator's own log |
## Job Definition Schema
See [references/job-definition-schema.md](./references/job-definition-schema.md) for the complete schema, copilot/claude examples, and the CleanupTask API.
## Critical Rules for Callers
1. **Set `$ErrorActionPreference = 'Continue'`** before calling the orchestrator
2. **Do NOT** wrap the orchestrator call in a `try/catch` that re-throws
3. **Do NOT** use `[CmdletBinding()]` or `[Parameter(Mandatory)]` on your runner script
4. **Do NOT** use `Start-Job`, `ForEach-Object -Parallel`, or `Start-Process` for parallel work — use this orchestrator
5. **Do** use manual validation (`if (-not $param) { Write-Error ...; return }`) instead of parameter attributes
## Scripts
| Script | Purpose |
|--------|---------|
| [Invoke-SimpleJobOrchestrator.ps1](./scripts/Invoke-SimpleJobOrchestrator.ps1) | The orchestrator — the ONLY parallel execution engine |
| [Test-OrchestratorEdgeCases.ps1](./scripts/Test-OrchestratorEdgeCases.ps1) | 28-scenario stress test suite |
## Execution & Monitoring Rules
The orchestrator is a long-running poll loop. The agent calling it MUST:
1. **Never exit early** — monitor the orchestrator log until it prints "All N jobs finished."
2. **For VS Code terminal usage**, launch the parent script as a detached process (`Start-Process -WindowStyle Hidden`) with `Tee-Object` to a log file. VS Code kills idle background terminals after ~60s.
3. **Poll the log every 30120 seconds** and report concise progress (done/total, running jobs, retries).
4. **On unexpected termination**, check the orchestrator log's last entries, diagnose the failure, and relaunch.
5. **Only report done** after the orchestrator returns results and all downstream processing is complete.
## Post-Execution Review
After using the orchestrator:
1. Check the orchestrator log in `$LogDir/orchestrator-*.log` for errors
2. Verify all expected jobs show `Completed` status in results
3. Check `RetryCount` — high retries may indicate CLI instability
4. Review `Abandoned` jobs — these hit `MaxRetryCount` and need manual attention
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| PS7 crashes silently | Advanced-function attributes on caller | Remove `[CmdletBinding()]`, `[Parameter()]` from runner script |
| PS7 crashes after ~10 jobs | `Start-Job` inside functions in while loops | Already fixed in orchestrator; don't re-introduce functions |
| Jobs stuck as "Running" | `InactivityTimeoutSeconds` too high | Lower timeout or check CLI isn't hanging |
| All jobs `Abandoned` | CLI tool not installed or auth expired | Test CLI manually: `copilot -p "hello" --yolo` |
| Orchestrator itself crashes at iter ~9 | Too many VS Code terminals open | Kill all terminals, restart VS Code, run in single terminal |

View File

@@ -0,0 +1,166 @@
# Job Definition Schema
This document defines the exact hashtable structure required by the
`Invoke-SimpleJobOrchestrator.ps1` script. Every skill that needs parallel
execution builds an array of these hashtables and passes them to the
orchestrator.
## Schema
```powershell
@{
Label = [string] # REQUIRED: unique human-readable label (e.g. 'copilot-pr-12345')
ExecutionParameters = @{
JobName = [string] # REQUIRED: PowerShell background job name
Command = [string] # REQUIRED: executable to run (e.g. 'copilot', 'claude', 'gh')
Arguments = [string[]] # REQUIRED: argument array splatted to Command
WorkingDir = [string] # REQUIRED: working directory for the job
OutputDir = [string] # REQUIRED: output directory (auto-created by orchestrator)
LogPath = [string] # REQUIRED: path for stdout+stderr capture
}
MonitorFiles = [string[]] # REQUIRED: files to watch for activity (typically LogPath or a debug log)
CleanupTask = [scriptblock] # OPTIONAL: runs after job finishes or is abandoned
}
```
## Field Details
### Label
A unique string identifying the job in logs and results. Convention:
`{cli-type}-{skill}-{id}` — e.g. `copilot-pr-45601`, `claude-issue-1234`.
### ExecutionParameters
| Field | Description |
|-------|-------------|
| `JobName` | Name for `Start-Job -Name`. Should match `Label`. |
| `Command` | The executable. Must be in `$PATH` or an absolute path. |
| `Arguments` | Array of arguments. Splatted via `@ArgList`. |
| `WorkingDir` | The job sets `Set-Location` to this before running. |
| `OutputDir` | The orchestrator creates this directory automatically. |
| `LogPath` | All stdout+stderr is redirected here via `*> $LogFile`. |
### MonitorFiles
Array of file paths the orchestrator watches for growth. If none of these
files grow for `InactivityTimeoutSeconds`, the job is considered stale and
retried.
**For copilot CLI**: Monitor the `LogPath` (stdout/stderr).
**For claude CLI**: Monitor the debug log (`--debug-file` path) — claude
writes progress there more frequently than to stdout.
### CleanupTask
Optional scriptblock that receives the tracker hashtable as its single
parameter. Runs after the job completes, fails, or is abandoned. Use for
cleaning up large temporary files.
```powershell
CleanupTask = {
param($Tracker)
$debugLog = Join-Path $Tracker.ExecutionParameters.OutputDir '_debug.log'
if (Test-Path $debugLog) { Remove-Item $debugLog -Force }
}
```
## Examples
### Copilot CLI Job
```powershell
@{
Label = 'copilot-pr-45601'
ExecutionParameters = @{
JobName = 'copilot-pr-45601'
Command = 'copilot'
Arguments = @('-p', 'Review PR #45601 in microsoft/PowerToys...', '--yolo')
WorkingDir = 'C:\s\PowerToys'
OutputDir = 'C:\s\PowerToys\output\copilot\45601'
LogPath = 'C:\s\PowerToys\output\copilot\45601\_copilot-review.log'
}
MonitorFiles = @('C:\s\PowerToys\output\copilot\45601\_copilot-review.log')
CleanupTask = $null
}
```
### Claude CLI Job
```powershell
@{
Label = 'claude-pr-45601'
ExecutionParameters = @{
JobName = 'claude-pr-45601'
Command = 'claude'
Arguments = @('-p', 'Review PR #45601 in microsoft/PowerToys...',
'--dangerously-skip-permissions',
'--debug', 'all', '--debug-file', 'C:\output\claude\45601\_claude-debug.log')
WorkingDir = 'C:\s\PowerToys'
OutputDir = 'C:\s\PowerToys\output\claude\45601'
LogPath = 'C:\s\PowerToys\output\claude\45601\_claude-review.log'
}
MonitorFiles = @('C:\s\PowerToys\output\claude\45601\_claude-debug.log')
CleanupTask = {
param($Tracker)
$dbg = Join-Path $Tracker.ExecutionParameters.OutputDir '_claude-debug.log'
if (Test-Path $dbg) {
$fi = [System.IO.FileInfo]::new($dbg)
if ($fi.Length -gt 0) {
$sizeMB = [math]::Round($fi.Length / 1MB, 1)
Remove-Item $dbg -Force
Write-Host "[$($Tracker.Label)] Cleaned debug log (${sizeMB} MB)"
}
}
}
}
```
### Generic Shell Command Job
```powershell
@{
Label = 'lint-module-fancyzones'
ExecutionParameters = @{
JobName = 'lint-fancyzones'
Command = 'dotnet'
Arguments = @('build', '--no-restore', '-warnaserror')
WorkingDir = 'C:\s\PowerToys\src\modules\fancyzones'
OutputDir = 'C:\s\PowerToys\output\lint\fancyzones'
LogPath = 'C:\s\PowerToys\output\lint\fancyzones\build.log'
}
MonitorFiles = @('C:\s\PowerToys\output\lint\fancyzones\build.log')
CleanupTask = $null
}
```
## Caller Template
Every skill that builds job definitions and calls the orchestrator should
follow this pattern:
```powershell
# Build definitions
$jobDefs = @(foreach ($item in $items) {
@{
Label = "myskill-$($item.Id)"
ExecutionParameters = @{ ... }
MonitorFiles = @(...)
CleanupTask = $null
}
})
# Resolve orchestrator path
$orchestratorPath = Join-Path $PSScriptRoot '..\..\parallel-job-orchestrator\scripts\Invoke-SimpleJobOrchestrator.ps1'
# CRITICAL: Lower ErrorActionPreference before calling
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$results = & $orchestratorPath `
-JobDefinitions $jobDefs `
-MaxConcurrent 4 `
-LogDir $outputPath
$ErrorActionPreference = $savedEAP
# Process results
$results | Format-Table Label, Status, ExitCode, RetryCount -AutoSize
```

View File

@@ -0,0 +1,358 @@
<#
.SYNOPSIS
Generic job orchestrator: queues, starts, monitors, retries, and cleans up
PowerShell background jobs with configurable concurrency.
.DESCRIPTION
Accepts an array of job definitions (created via New-JobDefinition), queues
them in memory, and runs up to MaxConcurrent at a time. Jobs are retried
up to MaxRetryCount times when they:
- Exit with a non-zero exit code
- Finish with a Failed or NotFound job state
- Stall (log-file inactivity exceeds InactivityTimeoutSeconds)
When a job finishes or is abandoned, its optional CleanupTask scriptblock
runs.
Returns an array of result objects with final state, exit code, retry count,
and output directory for every definition.
This is the CANONICAL parallel execution engine for this repository.
ALL skills that need to run copilot, claude, or any CLI tool in parallel
MUST use this orchestrator. Do NOT use Start-Job, ForEach-Object -Parallel,
or Start-Process directly — those approaches have known PowerShell 7 crash
bugs.
Part of the parallel-job-orchestrator skill:
<configRoot>/skills/parallel-job-orchestrator/SKILL.md
.PARAMETER JobDefinitions
Array of job-definition hashtables created by New-JobDefinition.
.PARAMETER MaxConcurrent
Maximum number of jobs running simultaneously. Default 4.
.PARAMETER InactivityTimeoutSeconds
Seconds of zero log-file growth before a job is considered stale. Default 60.
.PARAMETER MaxRetryCount
How many times to restart a stale job before giving up. Default 3.
.PARAMETER PollIntervalSeconds
How often (seconds) to check job health. Default 5.
.PARAMETER LogDir
Directory for the orchestrator's own progress log. Default: TEMP.
#>
# NOTE: Do NOT use [CmdletBinding()] here. When a caller sets
# $ErrorActionPreference='Stop', CmdletBinding propagates that as the implicit
# -ErrorAction common parameter, overriding any local assignment. A monitoring
# loop must be resilient, so we intentionally stay as a simple script.
# IMPORTANT: Do not use [Parameter()], [ValidateSet()] or any attribute on params
# either — those ALSO implicitly enable advanced-script behaviour.
param(
[hashtable[]]$JobDefinitions,
[int]$MaxConcurrent = 4,
[int]$InactivityTimeoutSeconds = 60,
[int]$MaxRetryCount = 3,
[int]$PollIntervalSeconds = 5,
[string]$LogDir
)
# Manual mandatory check (replacing [Parameter(Mandatory)] which makes this
# an advanced script and re-enables ErrorActionPreference propagation).
if (-not $JobDefinitions -or $JobDefinitions.Count -eq 0) {
Write-Error 'Invoke-SimpleJobOrchestrator: -JobDefinitions is required and must not be empty.'
return @()
}
# Orchestrator must be resilient — individual operations handle their own errors.
$ErrorActionPreference = 'Continue'
# ── logging ──────────────────────────────────────────────────────────────
# Verbose progress goes to a log file to avoid terminal-output issues that
# can silently terminate the script when run inside VS Code / IDE terminals.
# Only summary-level messages go to Write-Host (console).
if (-not $LogDir) { $LogDir = $env:TEMP }
New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
$script:_orchestratorLog = Join-Path $LogDir "orchestrator-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
function Write-Log {
param([string]$Message)
$ts = Get-Date -Format 'HH:mm:ss'
$line = "[$ts] $Message"
try { Add-Content -Path $script:_orchestratorLog -Value $line -ErrorAction SilentlyContinue }
catch { }
}
function Write-ProgressMessage {
<# Write to both console and log file. Use sparingly. #>
param([string]$Message)
Write-Log $Message
Write-Host $Message
}
# ── helpers ──────────────────────────────────────────────────────────────
# IMPORTANT: Start-TrackedJob is deliberately NOT a function. PowerShell 7
# silently crashes the host process when Start-Job is called from within a
# function that is invoked inside a while loop in a .ps1 script file (~10-15
# jobs triggers it). Inline the Start-Job call at every call site instead.
# Shared scriptblock for all tracked jobs (defined once, reused).
$_jobScriptBlock = {
param($Cmd, $ArgList, $WorkDir, $LogFile)
Set-Location $WorkDir
if (Test-Path $LogFile) { Remove-Item $LogFile -Force }
& $Cmd @ArgList *> $LogFile
[PSCustomObject]@{
Command = $Cmd
ExitCode = $LASTEXITCODE
LogPath = $LogFile
}
}
# NOTE: Test-MonitorFilesActive, Stop-TrackedJob, and Invoke-CleanupTask
# are deliberately NOT functions. PowerShell 7 silently crashes the host when
# certain cmdlets (Stop-Job, Remove-Job, Get-Job, Get-Item) are called from
# within a function in a while loop inside a .ps1 script. Their logic is
# inlined at every call site below.
function Get-TrackerResult {
param([hashtable]$Tracker)
# Job output was collected and stored in _ReceivedOutput / _FinalJobState
# at completion time (before Remove-Job). Fall back to live query only if
# the tracker somehow missed the collection step.
$received = $Tracker._ReceivedOutput
$state = $Tracker._FinalJobState
if (-not $state) {
$jobObj = Get-Job -Id $Tracker.JobId -ErrorAction SilentlyContinue
$received = if ($jobObj) {
Receive-Job -Id $Tracker.JobId -Keep -ErrorAction SilentlyContinue
}
else { $null }
$state = if ($jobObj) { $jobObj.State } else { 'Removed' }
}
[PSCustomObject]@{
Label = $Tracker.Label
JobId = $Tracker.JobId
Status = $Tracker.Status
JobState = $state
ExitCode = if ($received) { $received.ExitCode } else { $null }
RetryCount = $Tracker.RetryCount
OutputDir = $Tracker.ExecutionParameters.OutputDir
LogPath = $Tracker.ExecutionParameters.LogPath
}
}
# ── build tracker list ───────────────────────────────────────────────────
$queue = [System.Collections.Generic.Queue[hashtable]]::new()
$running = [System.Collections.Generic.List[hashtable]]::new()
$finished = [System.Collections.Generic.List[hashtable]]::new()
foreach ($def in $JobDefinitions) {
$tracker = @{
Label = $def.Label
ExecutionParameters = $def.ExecutionParameters
MonitorFiles = $def.MonitorFiles
CleanupTask = $def.CleanupTask
Status = 'Queued'
JobId = $null
RetryCount = 0
LastFileSizes = @{}
LastChangeTime = [DateTime]::UtcNow
}
$queue.Enqueue($tracker)
}
Write-ProgressMessage "Orchestrator: $($queue.Count) jobs queued, max $MaxConcurrent concurrent. Log: $script:_orchestratorLog"
# ── main loop ────────────────────────────────────────────────────────────
$loopIteration = 0
while ($queue.Count -gt 0 -or $running.Count -gt 0) {
$loopIteration++
try {
$ErrorActionPreference = 'Continue'
# fill slots from queue
while ($running.Count -lt $MaxConcurrent -and $queue.Count -gt 0) {
$t = $queue.Dequeue()
Write-Log "Dequeued $($t.Label); about to start job (running=$($running.Count), queue=$($queue.Count))"
try {
# ── inline Start-TrackedJob (see note above about PS7 crash) ──
$ep = $t.ExecutionParameters
New-Item -ItemType Directory -Path $ep.OutputDir -Force | Out-Null
$job = Start-Job -Name $ep.JobName -ScriptBlock $_jobScriptBlock `
-ArgumentList $ep.Command, $ep.Arguments, $ep.WorkingDir, $ep.LogPath
$t.JobId = $job.Id
$t.Status = 'Running'
$t.LastFileSizes = @{}
$t.LastChangeTime = [DateTime]::UtcNow
foreach ($f in $t.MonitorFiles) { $t.LastFileSizes[$f] = 0L }
Write-Log "[$($t.Label)] Started job $($job.Id)"
}
catch {
Write-Log "Start job FAILED for $($t.Label): $_"
$t.Status = 'Failed'
$finished.Add($t)
continue
}
$running.Add($t)
}
Write-Log "Sleeping ${PollIntervalSeconds}s..."
Start-Sleep -Seconds $PollIntervalSeconds
# evaluate every running tracker
$toRemove = [System.Collections.Generic.List[hashtable]]::new()
foreach ($t in $running) {
$jobObj = Get-Job -Id $t.JobId -ErrorAction SilentlyContinue
$jobState = if ($jobObj) { $jobObj.State } else { 'NotFound' }
# ── finished naturally ────────────────────────────────────
if ($jobState -in 'Completed', 'Failed', 'NotFound') {
# Collect job output before deciding whether to retry.
$received = $null
if ($jobObj) {
$received = Receive-Job -Id $t.JobId -ErrorAction SilentlyContinue
}
Remove-Job -Id $t.JobId -Force -ErrorAction SilentlyContinue
$exitCode = if ($received) { $received.ExitCode } else { $null }
$isFailedExit = ($jobState -in 'Failed', 'NotFound') -or
($null -ne $exitCode -and $exitCode -ne 0)
# ── retry if the process exited with failure ──────────
if ($isFailedExit -and $t.RetryCount -lt $MaxRetryCount) {
$t.RetryCount++
Write-Log "[$($t.Label)] Exited with failure (state=$jobState, exit=$exitCode) — retry $($t.RetryCount)/$MaxRetryCount"
# ── inline cleanup before retry (no function — see PS7 crash note) ──
if ($t.CleanupTask) {
try { & $t.CleanupTask $t }
catch { Write-Log "[$($t.Label)] Cleanup failed: $_" }
}
# ── inline Start-TrackedJob for retry (see note about PS7 crash) ──
$ep = $t.ExecutionParameters
New-Item -ItemType Directory -Path $ep.OutputDir -Force | Out-Null
$job = Start-Job -Name $ep.JobName -ScriptBlock $_jobScriptBlock `
-ArgumentList $ep.Command, $ep.Arguments, $ep.WorkingDir, $ep.LogPath
$t.JobId = $job.Id
$t.Status = 'Running'
$t.LastFileSizes = @{}
$t.LastChangeTime = [DateTime]::UtcNow
foreach ($f in $t.MonitorFiles) { $t.LastFileSizes[$f] = 0L }
Write-Log "[$($t.Label)] Retry started job $($job.Id)"
continue
}
$t.Status = if ($isFailedExit) { 'Failed' } else { $jobState }
Write-Log "[$($t.Label)] Finished (state=$jobState, exit=$exitCode) after $($t.RetryCount) retries."
$t._ReceivedOutput = $received
$t._FinalJobState = $jobState
# ── inline cleanup (no function — see PS7 crash note) ──
if ($t.CleanupTask) {
try { & $t.CleanupTask $t }
catch { Write-Log "[$($t.Label)] Cleanup failed: $_" }
}
$toRemove.Add($t)
continue
}
# ── still running — check monitor files ──────────────────
$active = $false
try {
# ── inline file-activity check (no function — see PS7 crash note) ──
$_anyGrew = $false
foreach ($_f in $t.MonitorFiles) {
$_sz = 0L
if (Test-Path $_f) { $_sz = ([System.IO.FileInfo]::new($_f)).Length }
if ($_sz -ne $t.LastFileSizes[$_f]) {
$t.LastFileSizes[$_f] = $_sz
$_anyGrew = $true
}
}
$active = $_anyGrew
}
catch { $active = $true }
if ($active) {
$t.LastChangeTime = [DateTime]::UtcNow
continue
}
$staleSecs = [math]::Round(([DateTime]::UtcNow - $t.LastChangeTime).TotalSeconds)
if ($staleSecs -lt $InactivityTimeoutSeconds) { continue }
# ── stale — retry or give up ─────────────────────────────
if ($t.RetryCount -ge $MaxRetryCount) {
Write-Log "[$($t.Label)] Max retries ($MaxRetryCount) after ${staleSecs}s stale. Giving up."
$t.Status = 'Abandoned'
# ── inline stop + cleanup (no function — see PS7 crash note) ──
Stop-Job -Id $t.JobId -ErrorAction SilentlyContinue
Remove-Job -Id $t.JobId -Force -ErrorAction SilentlyContinue
if ($t.CleanupTask) {
try { & $t.CleanupTask $t }
catch { Write-Log "[$($t.Label)] Cleanup failed: $_" }
}
$toRemove.Add($t)
continue
}
$t.RetryCount++
Write-Log "[$($t.Label)] Stale ${staleSecs}s — retry $($t.RetryCount)/$MaxRetryCount"
# ── inline stop (no function — see PS7 crash note) ──
Stop-Job -Id $t.JobId -ErrorAction SilentlyContinue
Remove-Job -Id $t.JobId -Force -ErrorAction SilentlyContinue
# ── inline Start-TrackedJob for retry (see note about PS7 crash) ──
$ep = $t.ExecutionParameters
New-Item -ItemType Directory -Path $ep.OutputDir -Force | Out-Null
$job = Start-Job -Name $ep.JobName -ScriptBlock $_jobScriptBlock `
-ArgumentList $ep.Command, $ep.Arguments, $ep.WorkingDir, $ep.LogPath
$t.JobId = $job.Id
$t.Status = 'Running'
$t.LastFileSizes = @{}
$t.LastChangeTime = [DateTime]::UtcNow
foreach ($f in $t.MonitorFiles) { $t.LastFileSizes[$f] = 0L }
Write-Log "[$($t.Label)] Retry started job $($job.Id)"
}
foreach ($r in $toRemove) {
$running.Remove($r) | Out-Null
$finished.Add($r)
}
}
catch {
Write-Log "Loop error (iter $loopIteration): $_ | $($_.Exception.GetType().FullName)"
}
# log every iteration; console progress every iteration (REQUIRED:
# PowerShell 7 silently kills the host process when a child-script
# while loop produces no Write-Host output for ~8+ seconds).
$qc = $queue.Count; $rc = $running.Count; $fc = $finished.Count
Write-Log "queue=$qc running=$rc done=$fc (iter=$loopIteration)"
$runLabels = ($running | ForEach-Object { $_.Label }) -join ', '
Write-Host " [$((Get-Date).ToString('HH:mm:ss'))] queue=$qc running=$rc done=$fc (iter=$loopIteration) [$runLabels]"
}
# ── results ──────────────────────────────────────────────────────────────
Write-ProgressMessage "All $($finished.Count) jobs finished. Log: $script:_orchestratorLog"
$results = foreach ($t in $finished) { Get-TrackerResult $t }
return $results

View File

@@ -0,0 +1,352 @@
<#
.SYNOPSIS
Stress-tests Invoke-SimpleJobOrchestrator.ps1 with edge-case scenarios.
.DESCRIPTION
Creates job definitions that simulate various failure modes:
1. Happy-path jobs (should complete normally)
2. Jobs that throw exceptions (should be detected as Failed)
3. Jobs that hang with no log output (stale → retry → abandon)
4. Jobs that write to the log once then hang (stale after initial burst)
5. Jobs with a cleanup task (verify cleanup runs on completion)
6. Jobs with a cleanup task that itself throws
7. Concurrency pressure: many fast jobs queued beyond MaxConcurrent
8. Mixed bag: all of the above in one run
Each scenario prints PASS / FAIL and the script exits with the total
failure count so CI can gate on it.
.PARAMETER Scenario
Which scenario to run. Default 'All' runs every scenario sequentially.
.PARAMETER OutputRoot
Base directory for test artefacts. Cleaned before each scenario.
#>
# NOTE: Do NOT use [CmdletBinding()] or parameter attributes such as
# [ValidateSet()] / [Parameter()] here. Any of those make this an "advanced
# script", which propagates the caller's ErrorActionPreference via the implicit
# -ErrorAction common parameter — silently terminating the entire script when
# stray non-terminating errors bubble up from Stop-Job, Remove-Job, or file
# locks between scenarios.
param(
[string]$Scenario = 'All',
[string]$OutputRoot = 'Generated Files/orch-stress-test'
)
# Manual validation instead of [ValidateSet()] to keep this a simple script.
$validScenarios = @('All', 'HappyPath', 'ThrowException', 'StaleNoLog',
'StaleThenHang', 'CleanupRuns', 'CleanupThrows', 'ConcurrencyPressure', 'MixedBag')
if ($Scenario -notin $validScenarios) {
Write-Error "Invalid -Scenario '$Scenario'. Valid values: $($validScenarios -join ', ')"
return
}
# Test scripts use 'Continue' globally. Individual assertions use try/catch.
# Using 'Stop' causes stray non-terminating errors from completed-job cleanup,
# file locks, Start-Job, etc. to silently terminate the whole script.
$ErrorActionPreference = 'Continue'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path
$orchPath = Join-Path $PSScriptRoot 'Invoke-SimpleJobOrchestrator.ps1'
if (-not [System.IO.Path]::IsPathRooted($OutputRoot)) {
$OutputRoot = Join-Path $repoRoot $OutputRoot
}
# ── helper: build a single synthetic job definition ──────────────────────
function New-TestJob {
param(
[string]$Label,
[string]$InlineScript, # PowerShell code to run inside the job
[string]$OutDir,
[scriptblock]$CleanupTask = $null
)
$logPath = Join-Path $OutDir "$Label.log"
return @{
Label = $Label
ExecutionParameters = @{
JobName = $Label
Command = 'powershell'
Arguments = @('-NoProfile', '-Command', $InlineScript)
WorkingDir = $repoRoot
OutputDir = $OutDir
LogPath = $logPath
}
MonitorFiles = @($logPath)
CleanupTask = $CleanupTask
}
}
# ── helper: run one scenario ─────────────────────────────────────────────
$script:passCount = 0
$script:failCount = 0
function Invoke-Scenario {
param(
[string]$Name,
[hashtable[]]$Defs,
[int]$MaxConcurrent = 10,
[int]$InactivityTimeout = 8,
[int]$MaxRetry = 1,
[int]$PollInterval = 2,
[scriptblock]$Assertions # receives $results array
)
Write-Host "`n╔══════════════════════════════════════════════════════╗" -ForegroundColor Yellow
Write-Host "║ Scenario: $Name" -ForegroundColor Yellow
Write-Host "╚══════════════════════════════════════════════════════╝" -ForegroundColor Yellow
$scenarioDir = Join-Path $OutputRoot $Name
# ── aggressive cleanup ───────────────────────────────────────────────
# Previous stale-job scenarios may leave background processes with file
# locks. Stop ALL jobs (not just the current scenario's) and wait a
# moment for handles to release before wiping the directory.
Get-Job | Stop-Job -ErrorAction SilentlyContinue
Get-Job | Remove-Job -Force -ErrorAction SilentlyContinue
if (Test-Path $scenarioDir) {
Start-Sleep -Milliseconds 500
Remove-Item $scenarioDir -Recurse -Force -ErrorAction SilentlyContinue
# Retry once if first attempt failed (file lock race)
if (Test-Path $scenarioDir) {
Start-Sleep -Seconds 1
Remove-Item $scenarioDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
$results = & $orchPath `
-JobDefinitions $Defs `
-MaxConcurrent $MaxConcurrent `
-InactivityTimeoutSeconds $InactivityTimeout `
-MaxRetryCount $MaxRetry `
-PollIntervalSeconds $PollInterval `
-LogDir $scenarioDir
# run caller assertions
try {
& $Assertions $results
}
catch {
Write-Host " FAIL (assertion error): $_" -ForegroundColor Red
$script:failCount++
}
}
function Assert-True {
param([bool]$Condition, [string]$Message)
if ($Condition) {
Write-Host " PASS: $Message" -ForegroundColor Green
$script:passCount++
}
else {
Write-Host " FAIL: $Message" -ForegroundColor Red
$script:failCount++
}
}
# ── scenario definitions ─────────────────────────────────────────────────
$scenarios = @{}
# 1. Happy path — 3 jobs that complete quickly
$scenarios['HappyPath'] = {
$dir = Join-Path $OutputRoot 'HappyPath'
$defs = @(1..3 | ForEach-Object {
New-TestJob -Label "happy-$_" -OutDir $dir `
-InlineScript "Write-Output 'hello from $_'; Start-Sleep -Milliseconds 500; Write-Output 'done $_'"
})
Invoke-Scenario -Name 'HappyPath' -Defs $defs -Assertions {
param($r)
Assert-True ($r.Count -eq 3) 'Got 3 results'
Assert-True (($r | Where-Object Status -eq 'Completed').Count -eq 3) 'All 3 completed'
Assert-True (($r | Where-Object RetryCount -eq 0).Count -eq 3) 'Zero retries'
}
}
# 2. Throw exception — the command errors out immediately
$scenarios['ThrowException'] = {
$dir = Join-Path $OutputRoot 'ThrowException'
$defs = @(
(New-TestJob -Label 'throw-1' -OutDir $dir `
-InlineScript "throw 'Simulated fatal error'"),
(New-TestJob -Label 'good-1' -OutDir $dir `
-InlineScript "Write-Output 'I am fine'; Start-Sleep -Milliseconds 300")
)
Invoke-Scenario -Name 'ThrowException' -Defs $defs -Assertions {
param($r)
Assert-True ($r.Count -eq 2) 'Got 2 results'
Assert-True (($r | Where-Object Label -eq 'throw-1').Status -in 'Completed','Failed') 'Throw job detected as finished'
Assert-True (($r | Where-Object Label -eq 'good-1').Status -eq 'Completed') 'Good job completed'
}
}
# 3. Stale — no log output, job sleeps forever (beyond timeout)
$scenarios['StaleNoLog'] = {
$dir = Join-Path $OutputRoot 'StaleNoLog'
$defs = @(
(New-TestJob -Label 'stale-nolog' -OutDir $dir `
-InlineScript "Start-Sleep -Seconds 120")
)
# Timeout 8 s, poll 2 s, max retry 1 → should retry once then abandon
Invoke-Scenario -Name 'StaleNoLog' -Defs $defs `
-InactivityTimeout 8 -MaxRetry 1 -PollInterval 2 `
-Assertions {
param($r)
Assert-True ($r.Count -eq 1) 'Got 1 result'
Assert-True ($r[0].Status -eq 'Abandoned') 'Marked as Abandoned'
Assert-True ($r[0].RetryCount -eq 1) 'Retried once before giving up'
}
}
# 4. Writes once then hangs — log grows initially then stops
$scenarios['StaleThenHang'] = {
$dir = Join-Path $OutputRoot 'StaleThenHang'
$defs = @(
(New-TestJob -Label 'burst-hang' -OutDir $dir `
-InlineScript "Write-Output 'initial burst'; Start-Sleep -Seconds 120")
)
Invoke-Scenario -Name 'StaleThenHang' -Defs $defs `
-InactivityTimeout 8 -MaxRetry 1 -PollInterval 2 `
-Assertions {
param($r)
Assert-True ($r.Count -eq 1) 'Got 1 result'
Assert-True ($r[0].Status -eq 'Abandoned') 'Marked as Abandoned'
Assert-True ($r[0].RetryCount -ge 1) 'Retried at least once'
}
}
# 5. Cleanup task runs on completion
$scenarios['CleanupRuns'] = {
$dir = Join-Path $OutputRoot 'CleanupRuns'
$marker = Join-Path $dir 'cleanup-ran.marker'
$cleanupBlock = [scriptblock]::Create(
"param(`$Tracker); New-Item -ItemType File -Path '$($marker -replace "'","''")' -Force | Out-Null"
)
$defs = @(
(New-TestJob -Label 'cleanup-ok' -OutDir $dir `
-InlineScript "Write-Output 'will be cleaned'" `
-CleanupTask $cleanupBlock)
)
Invoke-Scenario -Name 'CleanupRuns' -Defs $defs -Assertions {
param($r)
Assert-True ($r.Count -eq 1) 'Got 1 result'
Assert-True ($r[0].Status -eq 'Completed') 'Job completed'
Assert-True (Test-Path $marker) 'Cleanup marker file exists'
}
}
# 6. Cleanup task that itself throws — should not crash the orchestrator
$scenarios['CleanupThrows'] = {
$dir = Join-Path $OutputRoot 'CleanupThrows'
$badCleanup = { param($Tracker); throw 'Cleanup explosion!' }
$defs = @(
(New-TestJob -Label 'cleanup-boom' -OutDir $dir `
-InlineScript "Write-Output 'boom prep'" `
-CleanupTask $badCleanup),
(New-TestJob -Label 'after-boom' -OutDir $dir `
-InlineScript "Write-Output 'I should still finish'")
)
Invoke-Scenario -Name 'CleanupThrows' -Defs $defs -Assertions {
param($r)
Assert-True ($r.Count -eq 2) 'Got 2 results'
Assert-True (($r | Where-Object Label -eq 'cleanup-boom').Status -eq 'Completed') 'Boom job completed despite bad cleanup'
Assert-True (($r | Where-Object Label -eq 'after-boom').Status -eq 'Completed') 'Next job also completed'
}
}
# 7. Concurrency pressure — 20 fast jobs, MaxConcurrent=5
$scenarios['ConcurrencyPressure'] = {
$dir = Join-Path $OutputRoot 'ConcurrencyPressure'
$defs = @(1..20 | ForEach-Object {
New-TestJob -Label "conc-$_" -OutDir $dir `
-InlineScript "Write-Output 'job $_ at $(Get-Date -f s)'; Start-Sleep -Milliseconds $(Get-Random -Min 200 -Max 1500)"
})
Invoke-Scenario -Name 'ConcurrencyPressure' -Defs $defs `
-MaxConcurrent 5 -InactivityTimeout 15 -PollInterval 2 `
-Assertions {
param($r)
Assert-True ($r.Count -eq 20) 'Got 20 results'
Assert-True (($r | Where-Object Status -eq 'Completed').Count -eq 20) 'All 20 completed'
# Verify logs have content
$withContent = ($r | Where-Object {
(Test-Path $_.LogPath) -and (Get-Item $_.LogPath).Length -gt 0
}).Count
Assert-True ($withContent -eq 20) 'All 20 logs have content'
}
}
# 8. Mixed bag — happy + throw + stale + cleanup in one run
$scenarios['MixedBag'] = {
$dir = Join-Path $OutputRoot 'MixedBag'
$marker = Join-Path $dir 'mixed-cleanup.marker'
$cleanupOk = [scriptblock]::Create(
"param(`$Tracker); New-Item -ItemType File -Path '$($marker -replace "'","''")' -Force | Out-Null"
)
$defs = @(
(New-TestJob -Label 'mix-happy' -OutDir $dir -InlineScript "Write-Output 'happy'; Start-Sleep -Milliseconds 500"),
(New-TestJob -Label 'mix-throw' -OutDir $dir -InlineScript "throw 'kaboom'"),
(New-TestJob -Label 'mix-stale' -OutDir $dir -InlineScript "Start-Sleep -Seconds 120"),
(New-TestJob -Label 'mix-cleanup' -OutDir $dir -InlineScript "Write-Output 'with cleanup'" -CleanupTask $cleanupOk)
)
Invoke-Scenario -Name 'MixedBag' -Defs $defs `
-MaxConcurrent 10 -InactivityTimeout 8 -MaxRetry 1 -PollInterval 2 `
-Assertions {
param($r)
Assert-True ($r.Count -eq 4) 'Got 4 results'
Assert-True (($r | Where-Object Label -eq 'mix-happy').Status -eq 'Completed') 'Happy completed'
Assert-True (($r | Where-Object Label -eq 'mix-throw').Status -in 'Completed','Failed') 'Throw detected'
Assert-True (($r | Where-Object Label -eq 'mix-stale').Status -eq 'Abandoned') 'Stale abandoned'
Assert-True (($r | Where-Object Label -eq 'mix-stale').RetryCount -ge 1) 'Stale retried'
Assert-True (($r | Where-Object Label -eq 'mix-cleanup').Status -eq 'Completed') 'Cleanup job completed'
Assert-True (Test-Path $marker) 'Mixed cleanup marker exists'
}
}
# ── run selected scenarios ───────────────────────────────────────────────
$toRun = if ($Scenario -eq 'All') { $scenarios.Keys | Sort-Object } else { @($Scenario) }
$sw = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($name in $toRun) {
& $scenarios[$name]
# ── inter-scenario cleanup ─────────────────────────────────
# Kill any leftover jobs (especially long-running stale-sim sleeps),
# force garbage collection, and pause briefly so handles release.
Get-Job | Stop-Job -ErrorAction SilentlyContinue
Get-Job | Remove-Job -Force -ErrorAction SilentlyContinue
[System.GC]::Collect()
Start-Sleep -Seconds 2
}
$sw.Stop()
# ── summary ──────────────────────────────────────────────────────────────
Write-Host "`n════════════════════════════════════════════════════════" -ForegroundColor Cyan
Write-Host " RESULTS: $($script:passCount) passed, $($script:failCount) failed ($([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor Cyan
Write-Host "════════════════════════════════════════════════════════" -ForegroundColor Cyan
# clean up jobs
Get-Job | Remove-Job -Force -ErrorAction SilentlyContinue
exit $script:failCount

View File

@@ -1,302 +0,0 @@
---
name: powertoys-module-verification
description: "Verify a single PowerToys module's release checklist items end-to-end. Drive each checkbox via UIA / Named Events / settings.json edits / clipboard inspection / GPO / SendInput. Output a structured PASS / FAIL / BLOCKED verdict per item with evidence (FAIL distinguishes product defects from stale/ambiguous checklist items). Combine standard winapp ui mechanics (see references/winapp-ui-testing.md) with PT-specific recipes and the helper .ps1 files shipped with this skill."
license: Complete terms in LICENSE.txt
---
## When to use this skill
Use this skill when you need to **verify every checklist item for a single PowerToys module** for a release sign-off — e.g. "verify all 18 Color Picker items", "verify all 88 Command Palette items". Each item produces a PASS / FAIL / BLOCKED verdict with evidence (UIA enumeration, log line, settings.json diff, screenshot, etc.).
The **checklist to verify is supplied with the task** (the calling prompt points you at the module's checklist file). This skill is the *how* — the drive techniques, helpers, taxonomy, and reporting format — independent of any specific checklist.
## Required reads (in order)
1. **`references/winapp-ui-testing.md`** — the **prerequisite** UIA mechanics doc (winapp ui verbs, scripted batch testing, file pickers, accessibility audits, screenshots, click-vs-invoke, PostMessage, SendInput cb=40, stunted-UIA recovery, settings-mutation safety contract). **Read this first** — this skill assumes you know its content and only adds PT-specific extensions.
2. **This `SKILL.md`** — the PT-specific playbook: the 3-bucket drive-technique selector (Step 2), classification taxonomy, critical pitfalls, helper-script catalog.
3. **`references/modules/<module>.md` IF IT EXISTS** — per-module entry-paths, item-by-item recipes, common BLOCKED traps, fixture lists, source citations. **Always check `references/modules/` first.** If no profile exists, fall back to this SKILL.md and create one after you finish (template in `references/modules/README.md`).
4. **`references/explorer-context-menu-flow.md` IF your module registers an Explorer right-click entry** (PowerRename, File Locksmith, Image Resizer, New+, Preview Pane, RegistryPreview) — shared synthetic-right-click + UIA-invoke + multi-file-selection flow + module-caption table. Helper: `scripts/pt-explorer-contextmenu.ps1`.
5. **`references/pre-flight.md`** — pre-flight checks, bootstrap snippet, state-hygiene cleanup, final wrap-up, hard rules.
6. **`references/reporting-format.md`** — per-item table template, top-of-report summary, step-table rules, anti-patterns, worked example.
7. **`references/environment-setup.md`** — RDP/sleep/screensaver/session-attachment gotchas. Cite in BLK-ENV verdicts.
8. **`references/release-checklist/<module>.md`** — the checklist for the module under test (one file per module; see `references/release-checklist/index.md` for the full list). Each item carries `[ADMIN: …]` + `[CLARITY: …]` metadata. **This file IS the set of items to verify.**
## Helper scripts shipped with this skill
| File | Purpose |
|---|---|
| `scripts/pt-shared-events.ps1` | `Invoke-PtSharedEvent`, `Test-PtSharedEvent`, `Get-PtSharedEventCatalog` — 56-entry friendly-name map for PT Named Events (CmdPal.Show, AOT.Pin, PowerLauncher.Invoke, LightSwitch.Toggle, ZoomIt.Draw, ...). The deterministic, foreground-free, UIPI-immune way to trigger a module. |
| `scripts/pt-sendinput-chord.ps1` | `Send-PtChord`, `Wait-PtHotkeyAccepted` — last-resort SendInput hotkey injection with the cb=40 fix. Use only when the module has no Named Event and the hotkey itself is the test subject. |
| `scripts/pt-foreground-guard.ps1` | `Test-PtForeground`, `Force-PtForeground`, `Assert-PtForegroundOrAbort` — guard helpers to ensure target window IS foreground before SendInput, so keys don't leak to caller's terminal. |
| `scripts/pt-cmdpal-recycle.ps1` | `Reset-CmdPalAppX`, `Reset-CmdPalToHome`, `Test-CmdPalDegraded`, `Invoke-CmdPalQuery` — CmdPal-specific lifecycle (handles TextChanged-broken state, BackButton navigation, AppX recycle). |
| `scripts/pt-admin-probe.ps1` | `Test-PtAdmin`, `Test-ProcessElevated`, `Test-PtRunnerAdmin` — TokenElevation probes to verify your session and the PT runner have the right elevation for the test. |
| `scripts/pt-clipboard-diff.ps1` | `Get-PtClipboardFormats`, `Compare-PtClipboardFormatDiff`, `Set-PtClipboardRich` — multi-format clipboard inspection for Advanced Paste tests. |
| `scripts/pt-explorer-com.ps1` | `Get-PtExplorerWindows`, `Open-PtExplorerAtPath`, `Select-PtExplorerFiles`, `Invoke-PtPeekWithExplorerSelection`, `Test-PtInteractiveDesktop` — drive Explorer via Shell COM to set up multi-file selections, then trigger Peek/FZ/PowerRename/Image Resizer/Workspaces via their hotkeys. **Use this for Peek L706-L709, L719-L720 and any test that needs an Explorer file selection.** |
| `scripts/pt-explorer-contextmenu.ps1` | `Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems` — open Win11's real context menu via synthetic right-click (with retry), then UIA-invoke a menu item by name. **Canonical user-flow path for File Locksmith / Image Resizer / PowerRename / New+ menu-presence + launch tests.** Needs an unlocked interactive desktop. See `references/explorer-context-menu-flow.md` for the full write-up, stability notes, and per-module captions. |
| `scripts/pt-shell-verbs.ps1` | `Get-PtShellVerbs`, `Invoke-PtShellVerb`, `Reset-PtShellComCache` — enumerate + invoke CLASSIC HKCR shell verbs via Shell.Application COM. **NOT for PT context-menu modules on Win11** (PT registers via IExplorerCommand, not classic — use `pt-explorer-contextmenu.ps1` for those). Useful for non-PT verbs (Open/Edit/Send-to/third-party) and as a negative check that PT verbs are NOT classic-shadowed. |
| `scripts/pt-state.ps1` | `Get-PtSettings`, `Get-PtModuleSettings`, `Get-CmdPalSettings`, `Get-PtRunnerLogTail`, `Test-PtModuleEnabled`, `Test-PtModuleProcess`, `Restart-PtRunner`, `Backup-PtModuleSettings`, `Restore-PtModuleSettings` — common state checks. |
| `scripts/pt-nonelevated.ps1` | `Start-PtNonElevated`, `Invoke-PtNonElevatedCapture` — launch an exe at **Medium IL (non-elevated)** from an elevated agent shell via a one-shot `RunLevel Limited` scheduled task. Required for elevation-visibility tests (a non-elevated module must NOT see higher-integrity processes; e.g. File Locksmith L649/L650). Verify the result with `Test-ProcessElevated`. |
Dot-source them **all** at once in your bootstrap (the `Get-ChildItem` loop loads every helper — see **Step 1 — Bootstrap**):
```powershell
$skill = '<this skill folder>' # the folder containing SKILL.md, e.g. <PT-repo>\.github\skills\powertoys-module-verification
Get-ChildItem "$skill\scripts" -Filter '*.ps1' | ForEach-Object { . $_.FullName }
```
## Step 1 — Bootstrap
```powershell
$module = 'AdvancedPaste' # or 'CmdPal', 'FZ', 'Peek', ...
# Work out of %TEMP% during the run (keeps screenshots/scratch off OneDrive); move to the
# sign-off archive at the very end (see Step 7).
$workspace = "$env:TEMP\verify-$module-$(Get-Date -Format yyyyMMdd-HHmmss)"
New-Item -ItemType Directory -Path $workspace, "$workspace\artifacts" -Force | Out-Null
$report = "$workspace\verify-$module.md"
# Dot-source helpers
$skill = '<this skill folder>' # set once at top of your script (the folder containing SKILL.md)
Get-ChildItem "$skill\scripts" -Filter '*.ps1' | ForEach-Object { . $_.FullName }
# Verify environment
"=== Environment ===" | Tee-Object $report -Append
"IsAdmin: $(Test-PtAdmin)" | Tee-Object $report -Append
$rn = Test-PtRunnerAdmin
"PT runner: PID=$($rn.Pid) Elevated=$($rn.Elevated)" | Tee-Object $report -Append
# The checklist items to verify are supplied with the task (see the calling prompt).
# Read that module's checklist file and iterate its items (see Step 6 — Verifier loop).
```
## Step 2 — Drive techniques
Every checklist item boils down to ONE of three intents. **Pick the bucket from the verb in the item, then use the best technique inside it.** Stop at the first technique that works.
| Intent | Verb-cues in the checklist item | Bucket |
|---|---|---|
| Change a setting | "default is X", "setting persists", "is enabled/disabled by default", "value Y is accepted" | §2.A |
| Interact with a UI element | "click X", "toggle X", "type into Y", "X button is visible", "selecting Z does W" | §2.B |
| Trigger a module action | "pressing hotkey X opens Y", "module launches", "Z happens when invoked" | §2.C |
### §2.A — Change a setting (single technique)
Edit the JSON file the module reads, wait for the file-watcher debounce, assert, then restore from backup. Zero external tools.
```powershell
$bk = Backup-PtModuleSettings -ModuleDir AdvancedPaste
try {
$j = Get-PtModuleSettings -ModuleDir AdvancedPaste
$j.properties.IsAdvancedAIEnabled.value = $false
$j | ConvertTo-Json -Depth 12 | Set-Content "$env:LOCALAPPDATA\Microsoft\PowerToys\AdvancedPaste\settings.json"
Start-Sleep -Seconds 4 # debounce — runner re-reads via file-watcher
# ... assertion ...
} finally {
Restore-PtModuleSettings -ModuleDir AdvancedPaste -BackupPath $bk
}
```
> For shell-extension modules (PowerRename, File Locksmith, Image Resizer, New+) edit the **module-owned** file under `%LOCALAPPDATA%\Microsoft\PowerToys\<Module>\`, then `Restart-PtRunner` (and on stubborn handlers, restart Explorer). See pitfall #18 below.
>
> If you need to flip the *enabled* bit for a whole module, debounce isn't enough — call `Restart-PtRunner` after the write.
### §2.B — Interact with a UI element (2 techniques, most-reliable first)
#### B1. UIA invoke / set-value — **always try first**
```powershell
winapp ui invoke 'SubmitButton' -a PowerToys.Settings
winapp ui set-value 'QueryTextBox' '=2+3*4' -a PowerToys.PowerLauncher
Start-Sleep -Milliseconds 600
winapp ui inspect -a PowerToys.PowerLauncher --depth 7 -i 2>$null
```
Invoke goes through UIA InvokePattern COM IPC — no foreground steal, no UIPI. See references/winapp-ui-testing.md §CRITICAL — invoke vs click.
#### B2. PostMessage WM_KEYDOWN/CHAR — when UIA can't reach the target
For elevated targets, AppX windows with stunted UIA trees, or keystrokes that UIA `set-value` can't dispatch (arrow-key ListView nav, Enter to commit). See references/winapp-ui-testing.md §CRITICAL — Keystroke input that bypasses UIPI (PostMessage). Esc is often filtered by WinUI 3 raw-input hook — use BackButton invoke instead.
### §2.C — Trigger a module action (2 techniques, most-reliable first)
| | C1 Named Event | C2 SendInput chord |
|---|---|---|
| **Proves** | The action fires (the path *downstream* of the hotkey). **Not** that the chord is bound. | The full path: real keys → runner hook → action. The **only** method that proves the chord binding itself. |
| **Robustness** | Highest — no foreground, no input desktop, UIPI-immune; works headless / RDP-minimized. | Lowest — needs an attached input desktop (else `BLK-ENV`), steals foreground, can't inject OS-reserved chords (Win+L / Win+Tab). |
| **Precondition** | Owning module process is running (the event only exists while it is). | Attached input desktop + foreground. |
**Pick by what the item asserts:** for "does action Y happen" use C1; for "pressing chord X triggers Y" or "the rebind takes effect", C1 is insufficient (it bypasses the chord) — use C2, or C1 *plus* a runner-log line proving the chord was accepted.
#### C1. Named Event signal — preferred
```powershell
Invoke-PtSharedEvent -Name 'CmdPal.Show' # opens CmdPal without keyboard
Invoke-PtSharedEvent -Name 'AOT.Pin' # pins foreground window via AOT
Invoke-PtSharedEvent -Name 'PowerLauncher.Invoke' # opens PT Run
Invoke-PtSharedEvent -Name 'LightSwitch.Toggle' # toggles theme
Get-PtSharedEventCatalog | Format-Table # full list
```
No synthetic input — it's a `SetEvent` on the kernel event the module waits on, the same downstream path the runner's hotkey handler signals. Verify the side effect via UIA (`winapp ui list-windows -a <module>`), a log line (`Get-PtRunnerLogTail`), or settings.json diff (`Get-PtModuleSettings`). The event only exists while the owning process runs, so `Test-PtSharedEvent` doubles as an "is the module alive" check.
#### C2. SendInput chord — last resort / chord-binding verification
Real synthetic keys. Loud (steals foreground) and fragile, but the only way to prove the activation chord is actually bound. The runner's global keyboard hook catches the chord regardless of focus, so the precondition is just an **attached input desktop** (pitfall #13; on a detached desktop `SendInput` returns `ACCESS_DENIED` and the keys vanish → mark `BLK-ENV`).
```powershell
# Precondition: input desktop attached? 0 = detached → don't bother sending, mark BLK-ENV (pitfall #13)
if ([PtFg]::GetForegroundWindow() -eq [IntPtr]::Zero) { throw 'No input desktop — BLK-ENV (pitfall #13)' }
Send-PtChord -Mods 0x5B,0x10 -Key 0x43 # Win+Shift+C → Color Picker (cb=40 fix is inside the helper)
$line = Wait-PtHotkeyAccepted -ModuleHint 'Color' -TimeoutSec 3
if (-not $line) { throw 'Runner did not log hotkey invocation' }
```
> **Rare fallback — a module that uses its own `RegisterHotKey` and exposes no Named Event.** Post `WM_HOTKEY` (`0x312`) straight to its message window (find the HWND via `EnumWindows`+`GetClassName` through `Add-Type` — same P/Invoke pattern as `pt-foreground-guard.ps1`). **No current PT module needs this:** ZoomIt — the obvious candidate — also waits on Named Events (`ZoomIt.Zoom`, `ZoomIt.Draw`, …; source: `Zoomit.cpp` `CreateEventW(ZOOMIT_ZOOM_EVENT)`), so drive it with C1.
> **Different case — sending keys *into* a specific focused window** (e.g. a CmdPal alias like `=` / `<` / `>` that `winapp ui set-value` can't trigger because it bypasses TextChanged; see pitfalls #4 and #6). Here the keystrokes go to whatever currently has focus, so you must bring the target window foreground first:
> ```powershell
> Assert-PtForegroundOrAbort -AppId Microsoft.CmdPal.UI # -AppId = the window you're typing INTO
> Send-PtChord -Key 0xBB # '=' (no modifiers) to trigger the calculator alias
> ```
> The `-AppId` is whatever window you're targeting — it's **not** CmdPal-specific. CmdPal is just the worst offender: its AppX foreground-lock drops focus after the first `SetForegroundWindow`, so without the guard the keys silently leak to your terminal.
> Verdict decisions (PASS if behavior matches spec; **FAIL** if the product is wrong *or* the checklist item is stale/ambiguous; BLOCKED if you couldn't run the check after ≥2 entry-paths) live in **Step 3 — Classification taxonomy** below. Don't put verdict logic in Step 2.
## Step 3 — Classification taxonomy
### Verdicts (assign exactly ONE per item)
| Verdict | Meaning |
|---|---|
| **PASS** | You drove/observed the behavior and it matched the spec. **A pass is a pass — there is no PASS sub-type.** Record *how* you verified in the item's **Category** field as free text, e.g. "full UIA flow + asserted popup", "settings.json round-trip", "runner-log line", "Shell COM / IExplorerCommand", "screenshot pixel-diff", "output matches fixture", "process spawn/exit", "module CLI", "admin GPO write". |
| **FAIL** | The item is **red** — something is wrong and action is required. Treat the checklist as test code: a test fails because **the product is wrong** *or* **the test/checklist is wrong**. Record the **cause** in the **Category** field: <br>• **product** — behavior contradicts a valid spec → file a product bug (repro + expected-vs-actual + screenshot/log + build version). <br>• **checklist** — the item itself is broken: *stale* (feature was removed/deprecated — cite the source grep proving it's gone) or *ambiguous* (`[CLARITY: VAGUE-*]`, no definable pass/fail criterion — quote the original wording). Fix the checklist, not the product. |
| **BLOCKED** | Couldn't run the check in this environment / with this toolset *after ≥2 entry-paths* — inconclusive, like a skipped test. **Not red against the product.** Tag exactly one concrete reason below. |
### BLOCKED reasons
Different failure reasons stay distinct because each drives a different remediation.
| Reason | When |
|---|---|
| `BLK-ENV` | This specific shell can't drive it (non-interactive / Session 0, RDP-minimized, missing Explorer windows) but a normal interactive desktop CAN. Triggers a "re-run on an interactive desktop" recommendation. Cite `references/environment-setup.md`. |
| `BLK-HARDWARE` | Needs hardware this session lacks — multi-monitor, 2 physical PCs (MWB), real camera / battery / game-mode, or live screen/device capture. State the specific shortfall in **Category**. |
| `BLK-DRAG-REQUIRED` | Needs a real mouse-drag gesture; synthetic drag is insufficient (e.g. FancyZones snap). |
| `BLK-DESTRUCTIVE` | Reboot, hibernate, install/uninstall, or mid-session AppX uninstall — would damage the run environment. |
| `BLK-VISUAL-RENDER` | The thing to verify is a rendered surface UIA can't see — WinUI3 islands, WebView2, or Explorer-side context-menu rendering/localization. Needs pixel/OCR or a manual eyeball. |
| `BLK-OVERLAY-INPUT-BLOCK` | Overlay both blocks input and excludes itself from capture (`BlockInput` + `WDA_EXCLUDEFROMCAPTURE`, e.g. ZoomIt draw mode) — can neither drive nor screenshot it. |
| `BLK-EXTERNAL-APP` | Needs a 3rd-party tool, a real API key, or a system locale change. |
**Rule of thumb**: in your report, separate the two FAIL causes — *product* FAILs are bugs to file; *checklist* FAILs are items to rewrite or prune. `BLOCKED` is only for a concrete, named obstacle (cite it), never a substitute for effort. If a large share of a module's items are checklist-FAILs, the checklist needs an overhaul before re-verifying.
## Step 4 — Report format
**See `references/reporting-format.md` for the full template** (per-item table, summary, step-table rules, anti-patterns, worked example). Don't paraphrase; copy the templates literally. This includes a mandatory **§G Retrospective** — a self-reflection on the *run itself*: list every friction encountered (classified by source — `SKILL-UNCLEAR` / `WINAPP-TOOL-BUG` / `WINAPP-DOC-UNCLEAR` / `HELPER-FLAW` / `PT-PRODUCT` / `ENVIRONMENT` — with severity + minutes/attempts cost + a suggested fix), or write `Everything was smooth — no friction encountered.` if there was none. This is how the skill improves run over run, so don't skip it.
## Step 5 — State hygiene (CRITICAL)
**See `references/pre-flight.md` §State hygiene** for the backup/restore pattern and cleanup commands. Always wrap mutations in `try { ... } finally { Restore-* }`.
## Module-specific quick reference
**Look for `references/modules/<module>.md` FIRST.** Each per-module profile contains paths, entry-paths, item-by-item recipes, common BLOCKED traps, fixture lists, and source citations specific to that module.
Catalog: see `references/modules/README.md`. Currently authored: `peek.md`, `power-rename.md`, `file-locksmith.md`, `image-resizer.md`.
If your module has NO profile yet:
1. Fall back to the generic drive-stack in §2 above.
2. **For Explorer-context-menu modules** (PowerRename / File Locksmith / Image Resizer / New+ / Preview Pane / RegistryPreview): read **`references/explorer-context-menu-flow.md`** first — it has the synthetic-right-click + UIA-invoke pattern with stability rules and module-caption table. Per-module profiles cite it and only document module-specific quirks. The canonical helper is `scripts/pt-explorer-contextmenu.ps1`.
3. After finishing the verification, **create the profile** using the template in `references/modules/README.md` so the next agent benefits from what you learned.
Quick one-liners for modules without dedicated profiles (will be moved to per-module files as they're authored):
- **Advanced Paste**: `Invoke-PtSharedEvent -Name 'AdvancedPaste.ShowUI'` + `Set-PtClipboardRich` + `Compare-PtClipboardFormatDiff` (see `scripts/pt-clipboard-diff.ps1`).
- **Command Palette**: `Invoke-PtSharedEvent -Name 'CmdPal.Show'` + `Invoke-CmdPalQuery` (auto-handles degraded state via `scripts/pt-cmdpal-recycle.ps1`). Settings file via `Get-CmdPalSettings`.
- **PowerToys Run**: `Invoke-PtSharedEvent -Name 'PowerLauncher.Invoke'` + `winapp ui set-value QueryTextBox`. Window has 2 HWNDs — filter by width ≥ 800.
- **FancyZones**: `Invoke-PtSharedEvent -Name 'FancyZones.ToggleEditor'`. Snap-drag tests are usually `BLK-DRAG-REQUIRED`; settings verify via settings.json round-trip.
- **Light Switch**: `Invoke-PtSharedEvent -Name 'LightSwitch.Toggle' | LightSwitch.Light | LightSwitch.Dark`. Verify via `HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme`.
- **Always on Top**: `Invoke-PtSharedEvent -Name 'AOT.Pin'`. Verify `WS_EX_TOPMOST` on pinned HWND.
- **Hosts File Editor** (admin): `Invoke-PtSharedEvent -Name 'Hosts.Show' | Hosts.ShowAdmin`.
- **GPO** (admin): write `HKLM:\Software\Policies\PowerToys` + `Restart-PtRunner` + `Get-PtRunnerLogTail -Pattern 'GPO sets'`. Cleanup: `Remove-Item HKLM:\Software\Policies\PowerToys -Recurse -Force`.
- **Mouse Without Borders**: most items `BLK-HARDWARE` (need 2 physical PCs).
- **ZoomIt**: most modes inside `BlockInput + WDA_EXCLUDEFROMCAPTURE` overlay → `BLK-OVERLAY-INPUT-BLOCK`. Mode triggers: `ZoomIt.Zoom`, `ZoomIt.Draw`, `ZoomIt.Break`, etc.
- **Peek**: see `references/modules/peek.md` for the full recipe (CLI back-door + Shell.Application + Ctrl+Space).
## Step 6 — Verifier loop per checkbox
```
For each item in module:
1. Pick a bucket from the verb in the item (§2.A change a setting / §2.B interact with UI / §2.C trigger an action)
2. Walk that bucket's techniques top-to-bottom; stop at the first one that drives the item
3. Compare observed behavior to the spec:
• matches the spec → PASS (note the method in Category)
• product behaves wrong → FAIL, cause=product (repro + expected/actual + screenshot/log + build)
4. Checklist item itself is broken — feature removed from source, or spec too ambiguous to judge → FAIL, cause=checklist (cite the source proof / quote the wording)
5. Couldn't drive it after ≥2 entry-paths → BLOCKED with a concrete reason (§3)
6. Record verdict + evidence + cleanup
7. Next item
```
When done, run state hygiene cleanup, write the report **including the §G retrospective**, archive the workspace (Step 7), and exit.
## Step 7 — Archive the workspace to the sign-off folder (do this LAST)
The live run works out of `%TEMP%`, but the **final deliverable must live in the module sign-off archive** so reports persist and sync via OneDrive:
```powershell
# After the report is written AND the artifact-existence check passes:
$signoff = "$env:OneDrive\PowerToys\Module-Signoff" # e.g. C:\Users\<you>\OneDrive - Microsoft\PowerToys\Module-Signoff
New-Item -ItemType Directory -Path $signoff -Force | Out-Null
$final = Join-Path $signoff (Split-Path $workspace -Leaf)
Move-Item -Path $workspace -Destination $final -Force
# Report uses RELATIVE artifacts/… paths, so all links stay valid after the move.
Write-Host "Final report: $(Join-Path $final (Split-Path $report -Leaf))"
```
Print the **moved** report path (under `…\PowerToys\Module-Signoff\`) as the last line — never the `%TEMP%` path.
## Invocation & placeholders
This skill auto-activates when you ask to verify a PowerToys module's checklist (e.g. "verify all Color Picker items"). **One module per run** — never chain multiple modules into one report. Resolve these placeholders for the module under test:
| Placeholder | Substitute with |
|---|---|
| `<Module>` | Exact display name, e.g. `Color Picker`, `Command Palette`, `PowerToys Run`, `FancyZones` (see `references/release-checklist/index.md`). |
| `<module>` | Lowercase-kebab-case for file lookup, e.g. `color-picker`, `command-palette`, `power-rename` — used for BOTH `references/release-checklist/<module>.md` (checklist) and `references/modules/<module>.md` (profile, if any). |
| `<ModuleDir>` | settings.json sub-dir under `%LOCALAPPDATA%\Microsoft\PowerToys\` (e.g. `AdvancedPaste`, `FancyZones`, `PowerToys Run` (with space)). |
| `<N>` | Total item count for this module. |
**Execution order:** `references/pre-flight.md` → per item, the §2 drive-stack (this file) → `references/reporting-format.md` per-item table → Step 6 verifier loop → `references/pre-flight.md` §Final wrap-up → Step 7 archive → print the final report path.
## What NOT to do
- Do NOT chain multiple modules in one report — one module per run.
- Do NOT mark an item BLOCKED without a concrete, named obstacle (see §3 and `references/pre-flight.md` §Hard rules).
- Do NOT invent steps for a VAGUE checklist item — if the spec is too ambiguous to judge, that is FAIL (cause=checklist), not a guess.
- All other rules (foreground guard, always restore mutated state, etc.) live in `references/pre-flight.md` §Hard rules — follow them.
## Critical pitfalls (PT-specific)
*Reference, not a sequential step — skim before you start and consult while driving. Numbered for cross-reference only.*
1. **PT runner does NOT auto-pickup edits to master `settings.json`** (top-level `enabled.<Module>` flags). Call `Restart-PtRunner`.
2. **Each module's own `<Module>\settings.json` IS hot-reloaded** via per-module file watcher (~3s debounce). **EXCEPTION — shell-extension/context-menu modules do NOT read this file; see pitfall #18.**
3. **PT Run setting key has a space**: `"PowerToys Run"` not `PowerToysRun`.
4. **CmdPal AppX foreground from external CLI is unreliable** — Windows foreground-lock blocks `SetForegroundWindow` after the first call. SendInput keys silently leak to your terminal. **Always `Assert-PtForegroundOrAbort` before SendInput.**
5. **CmdPal AppX enters TextChanged-broken state** every ~30 probes — `Test-CmdPalDegraded` + `Reset-CmdPalAppX` to recover.
6. **CmdPal alias detection (`=`, `<`, `>`, `:`, `$`, `??`, `)`) requires real keystrokes**`winapp ui set-value` bypasses TextChanged and the alias never fires. Use Send-PtChord + `Assert-PtForegroundOrAbort` for aliases; use set-value for plain queries.
7. **CmdPal Esc handler is filtered** by WinUI 3 raw-input hook — use `winapp ui invoke BackButton` instead (see `Reset-CmdPalToHome`).
8. **GPO HKLM vs HKCU**: HKLM wins when both are set with conflicting values.
9. **HKLM `Software\Policies\PowerToys` writes require admin** — verify with `Test-PtAdmin`.
10. **`Stop-Process` is policy-blocked in this session unless you pass `-Id <int>` literally**. Always inline the PID.
11. **WinUI 3 islands are largely invisible to UIA** (QuickAccess flyout, RegistryPreview Monaco editor, Peek WebView2). For these, fall back to screenshot + OCR or settings.json diff.
12. **OS-reserved chords (Win+L, Win+Tab)** are consumed by Windows before any hook and cannot be injected via SendInput at all.
13. **RDP minimized = `SendInput` denied.** Even though `quser` shows the remote session State=Active, minimizing the mstsc client detaches the session's input desktop. `GetForegroundWindow()` returns 0; `SendInput` returns `ACCESS_DENIED (5)`; tests that need synthetic input fail. **Same applies to: closed mstsc with X (Disconnected), local PC sleep (RDP TCP drops), remote screensaver/workstation lock, remote machine sleep.** Run `scripts/pt-session-diagnose.ps1` in pre-flight to detect, and see `references/environment-setup.md` for the full per-scenario table + `powercfg` setup commands the user should run before starting the agent. The agent should call `Test-PtForeground` mid-run before each input-injection-dependent item; if it returns False, mark `BLK-ENV` with mitigation citation (an environment block — not a product FAIL).
14. **`winapp ui` arg-order quirk**: `winapp ui inspect --depth N -w $hwnd` may intermittently fail to parse `--depth` as Int64 if `-w` precedes it. **Put `-w $hwnd` AFTER `--depth N`** or as the first arg before any flag. If you see "Cannot bind argument" or numeric parse errors, swap the order and retry.
15. **`winapp ui list-windows` line wrapping**: when window titles or process names are long, output may wrap a single window's `HWND <id>: "<title>" ... (proc, PID N)` across multiple lines, breaking single-line regexes. Either pipe through `Out-String` and use a multi-line regex, or use `--json` (when supported) and parse structured output.
16. **De-elevation: launching a NON-elevated (Medium IL) child from an elevated agent shell.** The drive-stack only covers gaining *more* privilege; some items need the opposite. From a High-IL shell you cannot `Start-Process` a Medium-IL child directly. Use `scripts/pt-nonelevated.ps1` (`Start-PtNonElevated` / `Invoke-PtNonElevatedCapture`) — a one-shot `RunLevel Limited` + `LogonType Interactive` scheduled task that lands on the user's desktop at their filtered token. Confirm with `Test-ProcessElevated`. Needed for elevation-visibility pairs (File Locksmith L649/L650: non-elevated FL must not see the elevated runner; elevated FL must).
17. **Win11 packaged context menus are not observable without real Explorer.** Modern PT context-menu entries are packaged `IExplorerCommand`s (sparse MSIX, e.g. File Locksmith CLSID `{AAF1E27D-…}`). They are **NOT** enumerable via classic `Shell.Application … FolderItem.Verbs()` and **NOT** `CoCreate`-able from a non-Explorer host (`REGDB_E_CLASSNOTREGISTERED`). So "verify the entry appears / no longer appears" cannot be pixel-verified by API. Verify instead via the gate flag the entry's `GetState` reads (e.g. general `enabled.<Module>`) + a source citation that maps it to `ECS_HIDDEN`; treat the literal render as `BLK-VISUAL-RENDER` and recommend a 5-second manual right-click. (Disabling does NOT unregister the package — it stays `Status Ok`; the entry is hidden dynamically.)
18. **Shell-extension modules read a module-OWNED settings file, NOT the PT-store `<Module>\settings.json`.** PowerRename, File Locksmith, Image Resizer, and New+ context-menu handlers and exes run *outside* the runner (hosted by Explorer / launched on demand) and cannot use the PT-Settings IPC. Each reads its **own** json in `%LOCALAPPDATA%\Microsoft\PowerToys\<Module>\` *at process/handler launch* (registry-migrated `CSettings`/`Settings` classes — `lib/Settings.cpp` `Load→ParseJson`). The PT-Settings UI writes the *PT-store* `settings.json` (the `bool_*`/`int_*` file `Get-PtModuleSettings` reads); the runner's module DLL syncs PT-store→module-store **only on a Settings-UI change event** — so the PT-store file can be **stale for days** and editing it has **no effect** on the running shell handler. **To drive a settings item on these modules, edit the module-owned file directly (drive-stack §2.A) and relaunch the module (or restart runner+Explorer for the menu handlers), then restore.**
**Pitfall #18 — module-owned files + their key style** (verified 2026-06-10 against `<PT-repo>\src`):
| Module | Module-owned file (under `…\PowerToys\<Module>\`) | Key style | PT-store `settings.json` keys (UI/`Get-PtModuleSettings`) |
|---|---|---|---|
| PowerRename | `power-rename-settings.json` (+ `power-rename-last-run-data.json`, `search-mru.json`, `replace-mru.json`) | `ShowIcon`, `ExtendedContextMenuOnly`, `PersistState`, `MRUEnabled`, `MaxMRUSize`, `UseBoostLib` | `bool_show_icon_on_menu`, `bool_show_extended_menu`, `bool_persist_input`, `bool_mru_enabled`, `int_max_mru_size`, `bool_use_boost_lib` |
| File Locksmith | `file-locksmith-settings.json` | `ShowInExtendedContextMenu` | `bool_show_extended_context_menu` |
| Image Resizer | `image-resizer-settings.json` | (resize sizes/encoder/etc.) | mirrored `imageresizer*` keys |
| New+ | `NewPlus\settings.json` (sub-folder **`NewPlus`**, verified on disk + `constants.h` `powertoy_name=L"NewPlus"`) | `HideFileExtension`, `HideStartingDigits`, `TemplateLocation`, `ReplaceVariables`, `BuiltInNewHidePreference` | mirrored `newplus*` keys |
Confirm which file actually drives behavior with a quick A/B: edit the module-owned file → relaunch → observe; if behavior follows, that's the source of truth (PowerRename L394/L395/L396/L397/L409 were all driven this way).
If you find another gap during verification, update this skill (add a recipe) AND consider proposing the addition to references/winapp-ui-testing.md if it's generic enough.

View File

@@ -1,143 +0,0 @@
# Environment setup for PowerToys verification
**Audience**: human user preparing a test machine before running a verification agent.
**One-time** (per test session) — restore afterward.
## Why this matters
PowerToys release checklists test real user interactions: pressing hotkeys, dragging files, switching windows. Many tests use `SendInput` to inject keystrokes. Windows refuses `SendInput` when the calling session has **no attached input desktop** — and several common Windows states cause exactly that to happen:
- RDP client minimized
- Workstation locked (screensaver kicked in, idle timeout)
- Remote machine asleep
- Local machine asleep (RDP TCP drops)
If any of these happens mid-verification, items that need synthetic input fail with `BLK-ENV` even though the feature itself works fine. This guide eliminates the env causes so the only BLOCKED verdicts you see are real test/framework limitations.
## Per-scenario reference table
| Scenario | Remote session State | `GetForegroundWindow()` | `SendInput` | Verdict for input-injection tests |
|---|---|---|---|---|
| mstsc window focused | Active | Real HWND | Works | ✅ Drivable |
| mstsc visible but not focused (covered or alt-tabbed) | Active | Real HWND | Works | ✅ Drivable |
| **mstsc MINIMIZED** | Active | **0** | **ACCESS_DENIED (5)** | ❌ BLK-ENV |
| Local machine sleeps / RDP TCP drops | **Disconnected** | 0 | ACCESS_DENIED | ❌ BLK-ENV |
| User closes mstsc with X (no signout) | **Disconnected** | 0 | ACCESS_DENIED | ❌ BLK-ENV |
| Sign out from the remote | Session destroyed | — | — | ❌ Agent killed |
| Remote machine sleeps | Suspended | — | — | ❌ Catastrophic — timing corruption |
| Remote screensaver / auto-lock kicks in | Active but desktop locked | 0 | ACCESS_DENIED | ❌ BLK-ENV |
| **2nd RDP login as the SAME user** (you reconnect from another client) | the OLD session flips to **Disconnected** | 0 (in the old session) | ACCESS_DENIED | ❌ BLK-ENV — your running test's session got taken over |
**Key insight**: "Active" in `quser` ≠ "can inject input". Always check `GetForegroundWindow()` first (the diagnostic script `scripts/pt-session-diagnose.ps1` does this).
## Can I verify two modules at once in two RDP sessions?
Short answer on a **client edition of Windows (Windows 10/11, ProductType=1)**: **no — not as the same user, and effectively not at all.** This was investigated live on this machine (Windows 11 Enterprise, build 26200, `fSingleSessionPerUser=1` default):
- **Two monitors ≠ two sessions.** A multi-monitor setup is **one** session spanning both screens — it shares a single input desktop, foreground window, and `SendInput` queue across the monitors. Monitor count has nothing to do with session count, so "I have two monitors" does not give you two sessions to run two modules in.
- **Sessions are isolated** — each Windows session has its own input desktop, its own foreground window, and its own `SendInput` queue. So *typing in session B genuinely does NOT disturb session A's foreground or input.* Cross-session interference is **not** the problem (so if you somehow DID have two live sessions — Server/RDS — they could run in parallel without colliding).
- **The real blocker is session takeover.** Client Windows allows only **one interactive (console/owning) session at a time**, and `fSingleSessionPerUser=1` (the default) means one user gets **one** session. When you open the *second* RDP connection (as the same user), Windows **disconnects the first session** — it flips to `Disconnected`, its input desktop detaches, `GetForegroundWindow()` → 0, and any in-flight UI test there fails with `ACCESS_DENIED` → BLK-ENV. It's not your *typing* that breaks the test; it's the act of logging in the second session that evicts the first.
- A different *user* account doesn't rescue it either: client Windows still permits only one connected interactive session, so the second login still disconnects the first.
- Therefore, on client Windows, **run modules serially in one session.** True concurrent multi-session needs Windows Server + the RDS (Remote Desktop Session Host) role; unofficial multi-session patches exist but are out of scope here.
> **Verdict on the common assumption "I can run two modules in two RDP sessions because I have two monitors":** the *conclusion* (can't run two at once on client Windows) is correct, but the *reasoning* is wrong on two counts — two monitors is still one session, and you can't get two simultaneously-Active sessions on client Windows at all (the 2nd login disconnects the 1st). The limit is "can't open a 2nd Active session", not "the two sessions fight each other".
**Practical guidance:** keep a single RDP session for the whole run; don't reconnect/relogin mid-run; if you must check something elsewhere, alt-tab inside the *same* session rather than opening a new RDP connection. To detect a takeover after the fact, `qwinsta` will show your former session as `Disconnected`.
## Pre-run setup checklist
Run these BEFORE starting the verification agent.
### On the test machine (the one being verified)
```powershell
# Snapshot current power settings so you can restore after
$bk = "$env:TEMP\powercfg-backup-$(Get-Date -f yyyyMMdd-HHmmss).txt"
powercfg /query SCHEME_CURRENT SUB_SLEEP > $bk
powercfg /query SCHEME_CURRENT SUB_VIDEO >> $bk
"# Restore later with the values from $bk" | Set-Content "$bk.note"
# Disable sleep + display-off + hibernate (AC and battery)
powercfg /change standby-timeout-ac 0
powercfg /change standby-timeout-dc 0
powercfg /change monitor-timeout-ac 0
powercfg /change monitor-timeout-dc 0
powercfg /change hibernate-timeout-ac 0
powercfg /change hibernate-timeout-dc 0
# Disable screensaver
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '0'
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '0'
# Disable workstation lock-on-idle (requires admin)
# 0 = never lock. Restore your original value (commonly 600 = 10 min) afterward.
$origLock = (Get-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' -Name InactivityTimeoutSecs -EA SilentlyContinue).InactivityTimeoutSecs
"$origLock" | Out-File "$bk.lock"
Set-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' -Name InactivityTimeoutSecs -Value 0 -EA SilentlyContinue
# Confirm
powercfg /query SCHEME_CURRENT SUB_SLEEP | Select-String 'Power Setting GUID|Current AC Power Setting Index'
```
### On the local machine (the one with the RDP client)
```powershell
# Disable local sleep so RDP TCP stays alive
powercfg /change standby-timeout-ac 0
# Practical habit: put mstsc on a monitor you're NOT actively working on.
# Don't minimize. Alt-tab is fine; minimize is not.
```
## Mid-run discipline
While the agent is running:
- **Don't minimize mstsc.** Visible-but-unfocused is OK; minimized is not.
- **Don't close mstsc with the X.** If you have to step away, fine — leave it open.
- **Don't disconnect or reconnect RDP.** Stay continuously connected for the duration of the run.
- **Don't sign out** on either end.
- If you do step away and the screen locks (despite the setup above), reconnect/unlock and the agent's `Test-PtSessionStillInteractive` guard (if used) will resume; otherwise items mid-execution will be BLK-ENV.
## Post-run cleanup (restore)
```powershell
# Restore the values you captured to $bk before starting
# (e.g. typical defaults: standby 30min, monitor 15min, screensaver 600s, lock 600s)
powercfg /change standby-timeout-ac 30
powercfg /change standby-timeout-dc 15
powercfg /change monitor-timeout-ac 15
powercfg /change monitor-timeout-dc 10
powercfg /change hibernate-timeout-ac 0 # often default
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -Value '1'
Set-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveTimeOut -Value '600'
$origLock = Get-Content "$bk.lock" -EA SilentlyContinue
if ($origLock) {
Set-ItemProperty 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System' `
-Name InactivityTimeoutSecs -Value ([int]$origLock) -EA SilentlyContinue
}
```
(Values above are typical; adjust to your environment policy.)
## Diagnostic before you start
Run `scripts/pt-session-diagnose.ps1` from the agent shell. Expected output for a GO:
```
PASS - this shell can drive interactive PowerToys tests.
```
If it prints FAIL with a `psexec -i <consoleSession> -s pwsh.exe` hint, you're in a non-console session — relaunch the agent shell as suggested before starting verification.
## Why this isn't in the global SKILL.md
These are **human prep steps**, not agent instructions. The agent needs to *detect* a bad environment (via `Test-PtInteractiveDesktop` in pre-flight + `Test-PtSessionStillInteractive` mid-run); the user needs to *prevent* one. Different audiences, different docs.
## Related
- `scripts/pt-session-diagnose.ps1` — one-shot session diagnostic
- `scripts/pt-foreground-guard.ps1``Test-PtForeground` / `Force-PtForeground` / `Assert-PtForegroundOrAbort` used by agent
- `SKILL.md` pitfall #13 — short pointer to this doc
- `references/pre-flight.md` pre-flight check #4 — agent reads this doc when it detects a bad env

View File

@@ -1,99 +0,0 @@
# Explorer context-menu flow — driving PowerToys shell-menu modules end-to-end
**Audience**: agents verifying any PowerToys module whose entry point is the **Windows Explorer right-click context menu** — i.e. **File Locksmith, Image Resizer, PowerRename, New+ (NewPlus)**, and similar.
This is the *true user flow*: open Explorer → select file(s) → right-click → click the module's menu item. Use it when an item's assertion is specifically about the **context menu** (e.g. "the entry appears / no longer appears", "right-click → X launches the module on the selection"). For the module's *internal* behavior you can still prefer a faster back-door (CLI / `last-run.log` / Named Event) — see each module profile — but the menu presence/launch itself can only be observed this way.
Helper: `scripts/pt-explorer-contextmenu.ps1` (`Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems`).
## Which approach first? (CLI / back-door vs synthetic menu)
**Pick the tool by what the item ASSERTS — not "always synthetic" or "always CLI".**
| The item asserts… | First approach | Why |
|---|---|---|
| **The menu itself** — entry *appears / no longer appears*, "right-click → select X", caption / localization of the entry | **Synthetic Explorer menu (this doc)** — the *only* valid observer | The CLI/back-door is **blind to the menu**: it runs even when the entry is correctly hidden, so it gives a false PASS (the L652 trap). If the desktop is locked → `BLK-ENV`; do **not** substitute the CLI. |
| **Module behavior** — engine finds the lockers, images get resized, files get renamed (the menu is just the trigger) | **CLI / back-door** (`FileLocksmithCLI.exe`, `last-run.log`, Named Event, DSC) | Instant, deterministic, foreground-free, works on a locked desktop. Synthetic adds ~10s + foreground/retry fragility without changing the assertion. |
**Golden-path rule (do once per module):** run **one** full synthetic right-click → invoke-the-item → confirm-launch. That proves the menu→launch wiring is actually registered *and* validates that the fast back-door is behaviorally equivalent to the real menu (e.g. File Locksmith L641 `step-04/05` did exactly this). After that one golden run, trust the back-door for the remaining behavior items.
Net: for a context-menu module, **most items are behavior → CLI-first**; the **menu-presence/absence/launch/localization items → synthetic-first**; plus one golden-path synthetic launch.
## Is it stable?
**Yes — with the robust variant below.** Verified repeatedly on Win11 (2026-06-08) launching File Locksmith via a genuine right-click + menu click. Two rules make it reliable; ignore them and it gets flaky:
1. **Invoke the menu item by UIA InvokePattern, not a coordinate left-click.** The menu item exposes `InvokePattern` (`isInvokable=True`). `winapp ui invoke <selector> -w <menuHwnd>` is robust and needs no foreground/coordinates for the *click*. A synthetic left-click at the item's pixel center also works but is the fragile part (DPI, menu repositioning near screen edges, scrolled menus).
2. **The right-click that OPENS the menu still needs synthetic input on a foregrounded window — and occasionally a retry.** The first right-click right after Explorer opens sometimes misses (foreground not settled). `Open-PtExplorerContextMenu` retries up to 3×; that removed the flakiness in testing.
**Hard prerequisite — unlocked interactive desktop.** Synthetic right-click injects into the session input stream, so it requires foreground. If the workstation is locked / RDP minimized (`GetForegroundWindow()=0`), this flow is `BLK-ENV` — there is no foreground-free way to open a context menu. `Open-PtExplorerContextMenu` throws a clear BLK-ENV error in that case. (A 4-hour idle auto-lock is the common culprit — see `references/environment-setup.md`.)
**Other constraints:**
- **Settings for these modules live in a module-OWNED file, not the PT-store `settings.json`** — see `SKILL.md` pitfall #18. The context-menu handler reads e.g. `power-rename-settings.json` / `file-locksmith-settings.json` / `image-resizer-settings.json` / `New\settings.json` at launch; editing the PT-store `<Module>\settings.json` (what `Get-PtModuleSettings` reads) often has **no effect** on the live handler. Drive icon/extended-menu/feature toggles via the module-owned file + relaunch (restart runner+Explorer for the menu handlers), then restore.
- This is the **Win11 packaged** context menu (`Microsoft.UI.Content.PopupWindowSiteBridge` / "PopupHost"). The packaged module commands appear **only** here — not in classic `Shell.Application.Verbs()` and not via `CoCreate` of the command CLSID (`REGDB_E_CLASSNOTREGISTERED`). On Win10, or under "Show more options", you'd get the classic menu instead (different structure).
- The menu exists in the UIA tree **only while open** — you must open it with real input first; you can't enumerate it cold.
- A menu-launched module UI runs **non-elevated** (Explorer's integrity), even if your agent shell is elevated. Mind elevation-visibility (e.g. a non-elevated File Locksmith can't see higher-IL processes — match locker integrity with `scripts/pt-nonelevated.ps1`).
## Recipe (robust)
```powershell
. "$skill\scripts\pt-explorer-contextmenu.ps1"
# 0) Guard: must be an unlocked desktop
if (-not (Test-PtDesktopInteractive)) { <# mark BLK-ENV, cite references/environment-setup.md #> }
# 1) Open Explorer on the target folder and grab its CabinetWClass HWND
Start-Process explorer.exe $dir; Start-Sleep 4
$hwnd = (winapp ui list-windows --json | ConvertFrom-Json |
Where-Object { $_.className -eq 'CabinetWClass' -and $_.title -match [regex]::Escape((Split-Path $dir -Leaf)) } |
Select-Object -First 1).hwnd
# 2) Open the real context menu (synthetic right-click, auto-retry)
$menu = Open-PtExplorerContextMenu -ExplorerHwnd $hwnd -FileName 'target.txt'
# 3a) ASSERT PRESENCE / ABSENCE (e.g. "entry no longer appears" when the module is disabled)
$items = Get-PtContextMenuItems -MenuHwnd $menu # all visible MenuItem names
$present = $items -contains 'Unlock with File Locksmith'
# 3b) LAUNCH the module via the real menu (UIA invoke by NAME — robust)
$ok = Invoke-PtContextMenuItem -MenuHwnd $menu -ItemName 'Unlock with File Locksmith'
# 4) Verify the module launched (its process/window appears) — e.g.:
Start-Sleep 4
$ui = Get-Process PowerToys.FileLocksmithUI -EA SilentlyContinue # or PowerToys.ImageResizer, PowerToys.PowerRename
```
To **assert absence** after disabling a module: re-open the menu and check `Get-PtContextMenuItems` no longer contains the caption (the packaged `GetState` re-reads the enabled flag live, so no Explorer restart is needed between toggles).
## Multi-file selection (Image Resizer, PowerRename)
These operate on a **selection** of files. Select first (Shell COM is reliable and foreground-free), then right-click one of the selected items:
- Use `scripts/pt-explorer-com.ps1``Open-PtExplorerAtPath` + `Select-PtExplorerFiles` to establish the multi-select.
- Then `Open-PtExplorerContextMenu` on one selected file and `Invoke-PtContextMenuItem` — the module receives the whole selection (the shell handler enumerates all selected `IShellItem`s).
## Module captions (match by NAME)
Match the **visible caption**, not the AutomationId (Explorer assigns per-session numeric IDs like `32012` whose value/order varies). Discover the exact caption at runtime with `Get-PtContextMenuItems`. Verified captions:
| Module | Launched process | Menu caption (verified ✓ / expected) |
|---|---|---|
| File Locksmith | `PowerToys.FileLocksmithUI.exe` | ✓ `Unlock with File Locksmith` (NB: **not** the checklist's "What's using this file?") |
| PowerRename | `PowerToys.PowerRename.exe` | ✓ `Rename with PowerRename` |
| Image Resizer | `PowerToys.ImageResizer.exe` | `Resize images` (verify via `Get-PtContextMenuItems` — caption shifted across versions) |
| New+ | (creates from template) | `New+` (submenu) |
> Tip: if a module's caption is unknown, enable the module, open the menu on an applicable file, and run `Get-PtContextMenuItems` to read the exact string — then hard-match it for present/absent assertions.
## Common failure modes → fixes
| Symptom | Cause | Fix |
|---|---|---|
| `BLK-ENV: ... GetForegroundWindow()=0` | desktop locked / RDP minimized | unlock & keep mstsc un-minimized (`references/environment-setup.md`); mark `BLK-ENV`, not a test failure |
| "popup not found after N attempts" | foreground not settled (esp. first right-click after Explorer opens) | the helper already retries 3×; raise `-MaxTries`, or pre-foreground the window once before calling |
| menu item `invoke` returns but nothing launches | matched the wrong node / item disabled | match `type -eq 'MenuItem'` by exact Name; confirm the module is enabled |
| caption not found though module enabled | wrong/old caption string, or it's under "Show more options" (classic menu) | enumerate with `Get-PtContextMenuItems`; for classic menu invoke `expandtoclassic` first |
| launched UI shows nothing | menu-launched UI is non-elevated and can't see higher-IL targets | match target integrity (`scripts/pt-nonelevated.ps1`) |
## Referenced by
- `references/modules/file-locksmith.md` (L641/L652 — real right-click launch + menu present/absent)
- *(future)* `references/modules/image-resizer.md`, `references/modules/power-rename.md`, `references/modules/new-plus.md` — reference this doc for their context-menu items.

View File

@@ -1,116 +0,0 @@
# Per-module verification profiles (`references/modules/`)
This folder holds **one short profile per PowerToys module**. Each profile is self-contained guidance specific to that module — paths, entry-paths, capability/control recipes, common BLOCKED traps, fixture lists, source citations.
## When to read
When this skill runs for a specific module, check whether `references/modules/<module>.md` exists here. If yes: **read it BEFORE walking the SKILL.md drive-stack** — it tells you which entry-paths actually work for this module's quirks and which BLOCKED traps to avoid.
If no profile exists, fall back to SKILL.md + the helper scripts.
## Shared cross-module flows
Some flows are common to several modules and live in their own top-level docs (not per-module):
- **`../references/explorer-context-menu-flow.md`** — driving the real Win11 Explorer right-click context menu end-to-end (open + assert present/absent + launch). Referenced by File Locksmith and any future **Image Resizer / PowerRename / New+** profiles.
## Why per-module (not just one big SKILL.md)
- Each module has its own quirks (Peek's `_isFromCli` guard, CmdPal's TextChanged-broken state, PT Run's mini-popup HWND, Workspaces' snapshot-elevation rules). Bundling all of them into the global SKILL.md bloats context and forces every verification to load 25+ KB of mostly-irrelevant text.
- A profile lets a focused verification run with only the relevant 5-10 KB.
- New gotchas discovered during a module verification round get added to that module's profile, not the global one — keeps the global doc stable.
## Profile catalog
| Module | Profile | Status |
|---|---|---|
| Peek | `peek.md` | ✅ written 2026-06-08 |
| File Locksmith | `file-locksmith.md` | ✅ written 2026-06-08 |
| Image Resizer | `image-resizer.md` | ✅ written 2026-06-09 |
| PowerRename | `power-rename.md` | ✅ written 2026-06-10 (first to cite `../context-menu-cookbook.md` for shared mechanics) |
| New+ | `new-plus.md` | ✅ written 2026-06-18 (registration-gate for menu presence; Settings-UI toggle drives template auto-copy) |
| (other modules to be added as we encounter sign-off needs) | — | — |
## For Explorer-context-menu modules: read the canonical flow doc first
If you're writing a profile for a module that registers an entry in Explorer's Win11 right-click menu (PowerRename, File Locksmith, Image Resizer, New+, Preview Pane, RegistryPreview), **read `../references/explorer-context-menu-flow.md` first**. It has the canonical synthetic-right-click + UIA-invoke recipe with:
- Which-approach-first decision rule (CLI back-door vs synthetic menu, with the false-positive trap warning)
- Stability rules (UIA InvokePattern, retry on first right-click)
- Recipe (robust 5-step flow)
- Multi-file selection notes
- Module captions table (per-module menu-item display names)
- Common failure modes
- The unlocked-desktop requirement (BLK-ENV gating)
The shared helper is `scripts/pt-explorer-contextmenu.ps1` (`Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems`).
Your module profile then only documents the **module-specific** quirks: settings.json schema keys, expected verb caption regex, capability/control recipes, source citations, ceiling.
`power-rename.md` is the model — ~9 KB despite covering 18 items because the generic mechanics live in the canonical flow doc.
## Profile template
When writing a new profile, use this skeleton:
```markdown
# <Module> — module verification profile
**PT module**: `<ModuleKey>` (one-line description)
**Source**: `<PT-repo>\src\modules\<dir>\` (PT repo)
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\<dir>\settings.json`
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\<dir>\Logs\v<ver>\log_<date>.log`
**Exes**: `<full path>`
**Default hotkey**: `<keys>` (modifiers + code, plus path to ActivationShortcut in settings)
**Named Event**: `Local\<name>` (friendly name in pt-shared-events.ps1 catalog)
**DSC resource**: `Microsoft.PowerToys/<Name>Settings`
## Entry-paths (try in order)
### 1. <fastest path>
<powershell code + when to use + source citation>
### 2. <alternate path>
<...>
### 3. <last-resort path>
<...>
## Recipes — a control/observation map, NOT a per-test-case answer key
| # | Capability | Drive (control / settings key) | Observe (where the result shows) |
|---|---|---|---|
| 1 | <a module capability, e.g. "context-menu entry present when enabled"> | <which AutomationId / control / settings key drives it> | <where the result is visible: preview column, settings.json, disk, log, menu> |
| 2 | <next capability> | <...> | <...> |
> **Mapping process** (agent at runtime): read the actual checklist item → identify the capability → find its row → drive the named control and **design your own inputs + assertions for that item**. If no row matches, it's a NEW capability — drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
> **Why a map, not an answer key**: the table must carry only **durable module knowledge** — which control drives a capability and where to observe the result. Concrete Search/Replace inputs and expected-output assertions are *per-test-case answers*; baking them in turns the profile into a cheat sheet that (a) lets the agent copy answers without understanding and (b) goes stale the moment a checklist item changes its wording or values. Keep inputs + assertions OUT. Only a real UI redesign (a renamed/moved/removed control) should force an edit to this table.
## Common BLOCKED traps
<list of mistakes prior agents made + how to avoid them>
## Fixture files needed
<list of pre-canned files the verification expects>
## Source citations
<paths in PT repo that explain module behavior or guards>
## Ceiling
<observed PASS rate / total>
## Don'ts
<list of common mistakes>
```
## Hygiene
- **Keep each profile under ~10 KB.** If it grows beyond that, the module has too many quirks — escalate to maintainer review of the upstream checklist.
- **The recipe table is a control/observation MAP, not an answer key.** Columns are *Capability → Drive (control/key) → Observe*. **Do NOT bake in concrete Search/Replace inputs or expected-output assertions** — those are per-test-case answers that go stale when a checklist item changes and let the agent copy without understanding. The agent designs inputs + assertions at runtime from the actual checklist item.
- **Tables are capability-keyed, NOT line-keyed.** Upstream checklist line numbers (`L<n>`) **must not appear** in the profile — they drift between releases (items added/removed/reordered) and turn the table into a silent mismatch trap. PT-source-code file:line citations (e.g. `dllmain.cpp:73`) ARE allowed; they're version-pinned and serve a different purpose.
- **Cite source-code line numbers** where module behavior surprises (e.g. CLI guards, debounce timings, fallback chains). Reviewers can verify your claims by reading those lines.
- **Update the profile after every verification round**; promote any new technique into the right helper script if it generalizes beyond this module.

View File

@@ -1,103 +0,0 @@
# File Locksmith — module verification profile
**PT module**: `File Locksmith` (shows which processes are using selected files/dirs and lets you kill them)
**Source**: `<PT-repo>\src\modules\FileLocksmith\` (PT repo)
**Settings file (module)**: `%LOCALAPPDATA%\Microsoft\PowerToys\File Locksmith\settings.json` (`{"properties":{"bool_show_extended_menu":{...}}}`) and `file-locksmith-settings.json` (`{"showInExtendedContextMenu":bool}`)
**Enable flag**: `%LOCALAPPDATA%\Microsoft\PowerToys\settings.json``enabled."File Locksmith"` (general settings; runner-owned)
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\File Locksmith\FileLocksmithUI\Logs\…`
**Exes**: UI = `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.FileLocksmithUI.exe`; CLI = `%LOCALAPPDATA%\PowerToys\FileLocksmithCLI.exe`
**Context menu**: Win11 packaged `IExplorerCommand` CLSID `{AAF1E27D-4976-49C2-8895-AAFA743C0A7E}` (sparse pkg `Microsoft.PowerToys.FileLocksmithContextMenu`); legacy `FileLocksmithExt.dll`. Caption resource = "What's using this file?".
**Named Event / DSC**: no Named Event. DSC resource `microsoft.powertoys.FileLocksmith.settings` exists (controls module settings, not the master enable flag).
**No global hotkey** — entry is the Explorer context menu only.
## The two back-doors that make this module fully drivable (no Explorer needed)
### 1. `FileLocksmithCLI.exe` — deterministic engine ground-truth (PREFER for assertions)
Accepts paths as args; `--json`, `--kill`, `--wait`, `--timeout`. Uses the **same** `find_processes_recursive` engine as the UI.
```powershell
$cli = "$env:LOCALAPPDATA\PowerToys\FileLocksmithCLI.exe"
& $cli "<file|dir|drive>" --json | ConvertFrom-Json # {processes:[{pid,name,user,files[]}]}
& $cli "<path>" --kill # terminate lockers (== End task)
```
Detection = open **File handles** + **loaded modules** under the path (exact file, exact dir, dir-prefix recursive). `FileLocksmith.cpp:18-113`.
### 2. `last-run.log` IPC + launch UI — exercises the REAL UI code path
The context-menu handler writes selected paths to `…\File Locksmith\last-run.log` then launches the UI; the UI reads them in `MainViewModel()` ctor. Reproduce it:
```powershell
# UTF-16LE, each path + WCHAR \n, trailing empty-line terminator, NO BOM
function Write-LastRun([string[]]$Paths){
$f="$env:LOCALAPPDATA\Microsoft\PowerToys\File Locksmith\last-run.log"; $ms=[IO.MemoryStream]::new()
foreach($p in $Paths){$b=[Text.Encoding]::Unicode.GetBytes($p);$ms.Write($b,0,$b.Length);$n=[Text.Encoding]::Unicode.GetBytes("`n");$ms.Write($n,0,$n.Length)}
$n=[Text.Encoding]::Unicode.GetBytes("`n");$ms.Write($n,0,$n.Length);[IO.File]::WriteAllBytes($f,$ms.ToArray())
}
Write-LastRun @("C:\path\to\file"); Start-Process "$env:LOCALAPPDATA\PowerToys\WinUI3Apps\PowerToys.FileLocksmithUI.exe"
```
Source: `ExplorerCommand.cpp:182-227`, `dllmain.cpp:94-159`, `IPC.cpp`, `NativeMethods.cpp:62-97`.
### UI selectors (winapp ui)
- Window title: `Administrator: File Locksmith` (elevated) vs `File Locksmith` (non-elevated).
- Header button = the path label (`btn-<pathname>-…`); top-right `btn-…` = **Reload** (tooltip "Reload").
- Per-row End task button = `btn-…` (parent of `lbl-endtask-…`); invoke it (`InvokePattern`).
- `RestartAsAdminBtn` = shield icon, **visible only when non-elevated** (`MainPage.xaml:72-82`).
- `ProcessesListView` is virtualized; use `winapp ui scroll ProcessesListView --direction down/up`.
## Recipes — a control/observation map, NOT a per-test-case answer key
> Maps each capability to **how to drive it** and **where the result shows**. No canned process counts / paths / assertions — design those at runtime from the actual checklist item.
| # | Capability | Drive (entry-path / control) | Observe (where the result shows) |
|---|---|---|---|
| 1 | A locked file lists all its locking processes | CLI + UI on one locked file (with multiple lockers) | each locker shows as a ListItem |
| 2 | "End task" kills the locker and de-lists it | `winapp ui invoke` the End-task button | locker PID dies + row removed |
| 3 | Reload rediscovers a locker started after the UI opened | start a new locker → invoke Reload | the new locker appears |
| 4 | Closing a locker externally auto-removes it | external `Stop-Process` on a locker | auto-delisted via `WatchProcess`; empty state shown |
| 5 | Directory path finds lockers recursively | CLI/UI with a directory path | lockers inside the tree are listed |
| 6 | Drive root lists many lockers without crashing | CLI/UI with a drive root | large list renders; no crash |
| 7 | Non-elevated FL does NOT see the elevated runner | run CLI/UI non-elevated via scheduled task `RunLevel Limited` | `PowerToys.exe` absent (medium-IL FL can't see elevated procs) |
| 8 | "Restart as administrator" surfaces elevated-only lockers | non-elev UI shows the button; elevated run shows them | elevated run lists `PowerToys.exe` (UAC consent click NOT automatable) |
| 9 | Scrolling a large list doesn't crash | UI on a drive root + `winapp ui scroll` | process alive + responsive after scroll |
| 10 | Disabling FL removes the Explorer context-menu entry | Settings toggle Off (winapp ui) | `enabled."File Locksmith"`→false; `GetState→ECS_HIDDEN` (source) |
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control and design your own inputs + assertions. If no row matches, drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
## Common BLOCKED traps (avoid)
- **Don't BLOCK the lock-detection / End-task / scroll-doesn't-crash items as "needs a real installer / right-click"** — both the CLI and the `last-run.log`+UI back-door fully drive them with any locked file. The context menu literally just writes `last-run.log` + launches the same exe.
- **Launching the UI from an elevated shell makes it elevated** (title "Administrator: …") and hides the `RestartAsAdminBtn`. To test the non-elevated case (Recipes 7-8), launch via a **scheduled task with `-RunLevel Limited` / LogonType Interactive** — it lands on the user's desktop at medium IL.
- **`set-value` does fire the Reload** here (TogglePattern/Invoke work); no TextChanged gotchas.
## Elevation semantics (non-elevated FL invisibility — Recipes 7-8 core)
A medium-IL File Locksmith can't `DuplicateHandle`/read modules of higher-integrity processes, so the **elevated PowerToys.exe runner is invisible** to a non-elevated FL and **visible** to an elevated FL (which also calls `SetDebugPrivilege`, `App.xaml.cs:53-61`). Per-user installs put PowerToys under `%LOCALAPPDATA%\PowerToys`, not "Program Files" — use the PT install dir as the stand-in and note the caveat.
## Context-menu disable gate (Recipe 10)
`GetEnabled()` reads general `enabled."File Locksmith"` (`Settings.cpp:53-77`). When false: Win11 `GetState→ECS_HIDDEN` (`dllmain.cpp:81-84`); legacy `QueryContextMenu→E_FAIL` (`ExplorerCommand.cpp:116-119`). Disabling does NOT unregister the sparse package (stays `Status Ok`) — the entry is hidden dynamically. The packaged `IExplorerCommand` is **not** enumerable via Shell `Verbs()` and **not** `CoCreate`-able from a non-Explorer host (`REGDB_E_CLASSNOTREGISTERED`), so the pixel-level render is the only un-automatable bit (`BLK-VISUAL-RENDER` if you need it).
## Fixture files needed
None pre-canned. Create a temp file and lock it with a helper process (pwsh holding `File.Open(path, OpenOrCreate, Read, ReadWrite)` so multiple lockers coexist).
## Source citations
- `FileLocksmithLibInterop\FileLocksmith.cpp:18-113``find_processes_recursive` (handles + modules, recursive).
- `FileLocksmithLibInterop\NativeMethods.cpp:62-140``ReadPathsFromFile`, `StartAsElevated` (runas/--elevated).
- `FileLocksmithLib\IPC.cpp`, `Constants.h` — last-run.log format/path.
- `FileLocksmithUI\…\MainViewModel.cs:80-183` — load/EndTask/WatchProcess/RestartElevated.
- `FileLocksmithUI\…\MainPage.xaml` — control layout + RestartAsAdminBtn visibility.
- `FileLocksmithExt\ExplorerCommand.cpp`, `FileLocksmithContextMenu\dllmain.cpp`, `FileLocksmithLib\Settings.cpp` — enable gate.
## Ceiling
10/10 PASS observed (2026-06-08). The lock-detection / End-task / refresh / drive-scroll items cleanly driven; the Restart-as-admin item PASS-with-caveat (UAC consent click not automatable; outcome verified). **The disable-removes-menu item PASS — behaviorally verified** by a real Explorer right-click: with FL enabled the Win11 menu shows `MenuItem "Unlock with File Locksmith"`; after disabling in Settings the same right-click menu no longer shows it (no Explorer restart needed — `GetState` re-reads `enabled."File Locksmith"` live). NB the **shipped caption is "Unlock with File Locksmith"**, not the checklist's "What's using this file?". The right-click test needs an **unlocked interactive desktop** (a 4-hour idle auto-lock makes `GetForegroundWindow()=0``BLK-ENV`).
## Real right-click verification (Recipe 10) — works on an unlocked desktop
**Use the shared flow: `references/explorer-context-menu-flow.md` + `scripts/pt-explorer-contextmenu.ps1`.** FL's caption is **"Unlock with File Locksmith"**. Quick version:
```powershell
. "$skill\scripts\pt-explorer-contextmenu.ps1"
$menu = Open-PtExplorerContextMenu -ExplorerHwnd $hwnd -FileName 'target.txt' # synthetic right-click (+retry)
# present/absent assertion:
(Get-PtContextMenuItems -MenuHwnd $menu) -contains 'Unlock with File Locksmith' # true enabled / false disabled
# real launch (UIA invoke by name):
Invoke-PtContextMenuItem -MenuHwnd $menu -ItemName 'Unlock with File Locksmith' # -> launches PowerToys.FileLocksmithUI.exe (non-elevated)
# Toggle FL off in Settings, re-open menu, assert the caption is gone. No Explorer restart needed (GetState re-reads live).
```
## Don'ts
- Don't expect `Shell.Application.Verbs()` to show the FL entry — it's a Win11 packaged command, invisible to classic verbs.
- Don't kill processes by name; use `Stop-Process -Id <pid>`.
- Don't forget to restore `enabled."File Locksmith"=true` and close test-spawned UI/Settings after the disable-removes-menu test.

View File

@@ -1,87 +0,0 @@
# Image Resizer — module verification profile
**PT module**: `Image Resizer` (resize images via Explorer right-click; WinUI 3 GUI + headless CLI)
**Source**: `<PT-repo>\src\modules\imageresizer\` (PT repo)
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\Image Resizer\settings.json` (PowerToys-wrapper shape: `{ "properties": { "imageresizer_*": { "value": … } }, "name": "Image Resizer", "version": "1" }`). A legacy `sizes.json` mirrors `imageresizer_sizes`; `image-resizer-settings.json` is `{}` (unused).
**Enable flag**: `%LOCALAPPDATA%\Microsoft\PowerToys\settings.json``enabled."Image Resizer"` (runner-owned; restart runner after toggling).
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\Image Resizer\Logs\…`
**Exes**: GUI = `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.ImageResizer.exe`; **CLI = `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.ImageResizerCLI.exe`**.
**Context menu**: Win11 packaged `IExplorerCommand` (sparse pkg `ImageResizerContextMenuPackage.msix`, dllmain.cpp) + legacy classic `ImageResizerExt.dll` (`dll/ContextMenuHandler.cpp`). **Shipped caption = "Resize with Image Resizer"** (`IDS_IMAGERESIZER_CONTEXT_MENU_ENTRY`; checklist's "Resize images" is STALE).
**No global hotkey / no Named Event / no DSC for the engine** — entry is the Explorer menu (or direct exe launch).
## The back-door that makes this module ~fully drivable (no Explorer needed)
### `PowerToys.ImageResizerCLI.exe` — the deterministic engine (PREFER for all resize-behavior items)
Shares the exact `ResizeBatch.FromCliOptions``ResizeBatch.ProcessAsync``ResizeOperation.ExecuteAsync` engine as the GUI (`ui/Cli/ImageResizerCliExecutor.cs:76-108`, `App.xaml.cs:102`). Reads live `Image Resizer\settings.json` then applies CLI overrides (`CliSettingsApplier.cs`).
```
--width/-w --height/-h --unit/-u {Centimeter|Inch|Percent|Pixel} --fit/-f {Fill|Fit|Stretch}
--size <presetIndex> --shrink-only --replace/-r --ignore-orientation --remove-metadata
--quality/-q --keep-date-modified --filename/-n "<%1..%6>" --destination/-d <dir>
--show-config --help <files…> (also accepts \\.\pipe\<name> and stdin file list)
```
`--show-config` dumps the effective settings (great pre/post check). `-d <dir>` keeps outputs isolated. Assert output dimensions with `[System.Drawing.Image]::FromFile(p)`.
**Caveat**: `--ignore-orientation`/`--shrink-only`/`--replace`/`--keep-date-modified`/`--remove-metadata` are *flags* — they can only set the value **true**; to test the **false** case, temporarily edit `settings.json` (back up + restore).
### Direct GUI launch — for the two UI-only items (gif warning, size-list populated)
`Start-Process PowerToys.ImageResizer.exe "<file>"` opens the window pre-loaded (argv/stdin via `ResizeBatch.FromCommandLine`). Behaviorally identical to the context-menu launch (`dllmain.cpp:219-245` just writes a pipe + launches the same exe). Then drive with `winapp ui`:
- Size selector: `SizeComboBox``winapp ui invoke SizeComboBox -w <hwnd>` to expand, then `inspect` shows `itm-<name>-XXXX` ListItems.
- Gif warning: `Message Text "Gif files with animations may not be correctly resized."` InfoBar, bound to `ViewModel.HasGifFiles` (set when any file ends `.gif`).
## Engine facts (verified from source — cite these for the resize items)
- `ResizeFit`: **Fill=0, Fit=1, Stretch=2** (`ResizeFit.cs`). Fit=`min(scaleX,scaleY)`; Fill=`max`+centered-crop; Stretch=independent (`ResizeOperation.cs:449-498`).
- `ResizeUnit`: **Centimeter=0, Inch=1, Percent=2, Pixel=3** (`ResizeUnit.cs`). Inch=`v*dpi`; cm=`v*dpi/2.54`; Percent=`v/100*orig`; Pixel=`v` (`ResizeSize.cs:109-123`). **Outputs depend on image DPI** — read actual DPI and compute expectations from it (a 120-DPI fixture gives 10cm→472px, 4in→480px).
- Filename `%1..%6` → original-name, size-name, selected-W, selected-H, **output**-W, **output**-H (`Settings.cs:229-239`, `ResizeOperation.cs:593-601`).
- ShrinkOnly: if target scale>1, returns `noTransformNeeded` (file copied unchanged) (`ResizeOperation.cs:462-475`).
- KeepDateModified: `SetLastWriteTimeUtc(out, GetLastWriteTimeUtc(src))` (`ResizeOperation.cs:146-149`).
- Replace: `File.Replace(out, src, backup)` then recycle backup — no copy left (`ResizeOperation.cs:151-156`).
- IgnoreOrientation swap: gated by `IgnoreOrientation && !HasAuto && Unit != Percent` (`ResizeOperation.cs:419-444`).
## Recipes — a control/observation map, NOT a per-test-case answer key
> Maps each capability to **which control/CLI flag drives it** and **where the result shows**. CLI flag *names* and fit-mode/unit/field enumerations are stable IR knowledge and stay; concrete flag *values*, fixtures, and expected outputs are per-test-case — design those at runtime.
| # | Capability | Drive (control / CLI flag) | Observe (where the result shows) |
|---|---|---|---|
| 1 | Module disabled → context-menu entry absent | toggle `enabled` off + restart runner; synthetic menu (only valid observer) | "Resize with Image Resizer" absent. Gate: `dllmain.cpp:87-91` (ECS_HIDDEN), `ContextMenuHandler.cpp:70-71,383-385`. Locked desktop → BLK-ENV |
| 2 | Module enabled → entry present (modern + classic), click launches the GUI | synthetic menu + invoke | `Get-PtContextMenuItems` shows "Resize with Image Resizer"; classic "Show more options" too; invoke → `PowerToys.ImageResizer.exe` launches |
| 3 | Remove a built-in size / add a custom size | edit `imageresizer_sizes` (INTEGER Ids!) + launch GUI | `SizeComboBox` reflects the edit (removed gone, custom present) |
| 4 | Resize one / multiple files end-to-end | CLI `--size <id> [files…]` | outputs at the size's Fit dimensions |
| 5 | GIF animation warning on `.gif` input | GUI on a `.gif` | warning InfoBar present (`winapp ui inspect`) |
| 6 | Fit modes (Fill / Fit / Stretch) | CLI `--width --height --fit <mode>` | output shape matches the mode (crop / letterbox / exact) |
| 7 | Unit conversion (cm / inch / percent / pixel) | CLI `--unit <u>` | output px = unit converted at the image's DPI |
| 8 | Custom filename format (`%1`..`%6` fields) | CLI `--filename <fmt>` | output filename follows the format fields |
| 9 | "Keep date modified" | CLI `--keep-date-modified` | output mtime == source mtime (control: without the flag, differs) |
| 10 | "Shrink only" | CLI `--shrink-only` | an already-small image is untouched (control: a large one still shrinks) |
| 11 | "Replace original" | CLI `--replace` | original replaced in place; no `(name) (1)` copy |
| 12 | "Ignore orientation" | settings (false) vs flag (true) | on a portrait target over a landscape image: false→no W/H swap, true→swap |
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control/flag and design your own inputs + assertions. If no row matches, drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
## Common BLOCKED traps (avoid)
- **Don't mark the resize-behavior items BLOCKED for "needs a real right-click".** The CLI fully drives them with the identical engine; the menu is just the trigger (prove the menu→launch wiring once with the golden path in Recipe 2).
- **PowerShell `ConvertTo-Json` writes computed numbers as doubles (`"Id": 3.0`)** → `System.Text.Json` rejects `imageresizer_sizes` and the app silently falls back to the 4 built-in default presets (Small/Medium/Large/Phone). Cast Ids to `[int]` or regex-strip `\.0`. This bit the remove-size-add-custom item (Recipe 3) the first time.
- **cm/inch outputs depend on the fixture's DPI, not 96.** System.Drawing saves at the session display DPI (here 120). Compute expectations from the actual DPI.
- **Caption is "Resize with Image Resizer", not the checklist's "Resize images"** (both menus). Hard-match the real caption.
- **Idle auto-lock = BLK-ENV for the disabled-absent + enabled-present items (Recipes 1-2)** (synthetic right-click needs foreground). Disable lock/sleep before the run (`references/environment-setup.md`).
## Fixture files needed
None pre-canned. Generate with `System.Drawing`: a landscape (e.g. 1200×800) and portrait (800×1200) JPEG, a small (100×100) PNG, a square (400×400) PNG, a `.gif` (single frame is fine — the warning is extension-based), and 3 identical images for the multi-file batch.
## Source citations
- `ui/Cli/ImageResizerCliExecutor.cs`, `ui/Models/CliOptions.cs`, `ui/Cli/CliSettingsApplier.cs`, `ui/Cli/Commands/ImageResizerRootCommand.cs` — CLI surface + engine reuse.
- `ui/Models/ResizeOperation.cs:419-501,572-617,146-156` — dimension math, filename, keep-date, replace.
- `ui/Models/ResizeSize.cs:78-124`, `ResizeFit.cs`, `ResizeUnit.cs` — unit/fit math + enum order.
- `ui/Properties/Settings.cs:65,105-111,229-239,431-552` — paths, defaults, JSON property names, FileNameFormat.
- `ImageResizerContextMenu/dllmain.cpp:49,79-133,219-245,284` — modern menu title, enable+image gate, launch, caption.
- `dll/ContextMenuHandler.cpp:21,46-48,70-71,383-385` — classic menu caption + enable gate.
- `ui/ViewModels/InputViewModel.cs:76,143`, `ImageResizerXAML/Views/InputPage.xaml:293-299`, `Strings/en-us/Resources.resw:148-149` — gif warning.
## Ceiling
**18/18 PASS** observed (2026-06-09). All 14 resize-behavior items + the enabled-entry-present-in-both-menus + the remove-size + the gif-warning items cleanly driven via CLI/GUI. The disabled-entry-absent case (in both modern + classic menus, with sibling entries remaining and the entry returning on re-enable) verified live once the desktop was unlocked. NB: an idle auto-lock will turn the menu-presence Recipes 1-2 into BLK-ENV — disable lock/sleep up front (`references/environment-setup.md`).
## Don'ts
- Don't expect `Shell.Application.Verbs()` to list the entry — it's a Win11 packaged command (classic verbs are blind; `CoCreate``REGDB_E_CLASSNOTREGISTERED`).
- Don't hardcode 96 DPI for cm/inch math.
- Don't write preset Ids as JSON doubles.
- Don't kill processes by name; use `Stop-Process -Id <pid>`.
- Don't forget to restore `enabled."Image Resizer"=true` + restart runner, and revert any `settings.json`/`sizes.json` edits.

View File

@@ -1,77 +0,0 @@
# New+ — module verification profile
**PT module**: `NewPlus` (Explorer right-click → "New+" submenu that creates files/folders from a user templates folder)
**Source**: `<PT-repo>\src\modules\NewPlus\` (shell ext) + `<PT-repo>\src\settings-ui\Settings.UI\ViewModels\NewPlusViewModel.cs` (Settings UI)
**Module-owned settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\NewPlus\settings.json`**folder is `NewPlus`, NOT `New`** (matches SKILL.md pitfall #18 table). Keys: `HideFileExtension`, `HideStartingDigits`, `TemplateLocation`, `ReplaceVariables`, `BuiltInNewHidePreference`.
**Templates folder (default)**: `%LOCALAPPDATA%\Microsoft\PowerToys\NewPlus\Templates` (per `TemplateLocation`)
**Default-templates source**: `%LOCALAPPDATA%\PowerToys\WinUI3Apps\Assets\NewPlus\Templates` (also `%ProgramFiles%\PowerToys\...` on machine installs)
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\NewPlus\NewPlus.ShellExtension\Logs\v<ver>\log_<date>.log`
**Packaged command**: sparse MSIX `Microsoft.PowerToys.NewPlusContextMenu`; command CLSID `{FF90D477-E32A-4BE8-8CC5-A502A97F5401}`
**Named Event**: none. **DSC**: n/a.
> Read **`../references/explorer-context-menu-flow.md` first** — New+ is a Win11 packaged-IExplorerCommand context-menu module; the menu can only be eyeballed via a real synthetic right-click on an **unlocked interactive desktop**. On a locked/RDP-minimized desktop (`Test-PtDesktopInteractive=False`) all "menu appears / template appears / hidden-caption" assertions are BLK-ENV / BLK-VISUAL-RENDER, not product FAILs.
## Entry-paths (try in order)
### 1. Enable/disable + registration gate (menu presence/absence) — headless-safe
Flip `enabled.NewPlus` in master `settings.json` + `Restart-PtRunner`, **or** toggle the Settings switch (below). Observe the gate, no foreground needed:
- CLSID registered ⇒ `Test-Path "HKCU:\Software\Classes\CLSID\{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"` is `True` (enabled) / `False` (disabled).
- Log lines `New+ context menu registered` / `... unregistered` + `Runtime registration completed for CLSID ...`.
- Sparse package stays `Status Ok` even when disabled (hidden dynamically — SKILL.md pitfall #17).
### 2. Settings UI toggles via UIA invoke — headless-safe, **required for template auto-copy**
`Start-Process …\PowerToys.exe --open-settings=NewPlus`, then `winapp ui invoke <btn> -w <settingsHwnd>`:
- Enable toggle: `btn-new-248c` (under `NewPlusEnableToggle`) — **the enable transition runs `CopyTemplateExamples`** (Settings-UI side, `NewPlusViewModel.IsEnabled` setter). A master-`settings.json` flip + runner restart does **NOT** copy templates.
- `btn-hidethefileexte-24a0` (Hide file extension), `btn-hideleadingdigi-24a8` (Hide leading digits). AutomationIds carry a per-session suffix — re-`inspect` to get the live id.
### 3. Synthetic right-click on the folder **BACKGROUND** (the menu-render observer) — needs unlocked desktop
New+ lives in the folder-background ("New") menu, **not** a file's context menu — so `pt-explorer-contextmenu.ps1`'s `Open-PtExplorerContextMenu` (which right-clicks a *file item*) is the wrong entry. Right-click an **empty area of the file list** instead, then expand the `New+` submenu (a separate popup window one level deeper):
```powershell
# force-foreground the CabinetWClass window, GetWindowRect, RightClick at ~45% width / 68% height (empty list area)
# -> main bg menu popup (PopupWindowSiteBridge). Then:
$np = (winapp ui search 'New+' -w $mainMenuHwnd --json).matches | ? type -eq MenuItem | select -First 1
winapp ui invoke $np.selector -w $mainMenuHwnd # expands the New+ submenu
# the submenu is the PopupWindowSiteBridge popup that contains 'Open templates' but NOT 'Sort by'
# enumerate its MenuItems (templates are 1:1 with the Templates-folder entries) / invoke one by name
```
Template items render with **caption transforms applied** (HideFileExtension strips `.txt`; HideStartingDigits strips `01. `). Selecting a template creates it in the current folder + enters rename mode. BLK-ENV only if `Test-PtDesktopInteractive` is False. **No Explorer restart needed** for setting A/B — the handler re-reads `NewPlus\settings.json` on each menu build.
## Recipes — a control/observation map, NOT an answer key
| # | Capability | Drive (control / settings key) | Observe (where the result shows) |
|---|---|---|---|
| 1 | Menu entry present when enabled | enable (master flag + restart, or `btn-new-248c`) | CLSID registered in `HKCU\…\CLSID`, log `context menu registered`; *visible submenu* = synthetic menu only (BLK-VISUAL-RENDER if locked) |
| 2 | Menu entry absent when disabled | disable | CLSID absent, log `context menu unregistered`; package still `Status Ok` |
| 3 | Templates folder created empty | shell ext `create_folder_if_not_exist(root)` on menu build (delete folder → right-click) | folder recreated **empty** — needs synthetic menu (BLK-ENV if locked) |
| 4 | Default templates copied when empty | `CopyTemplateExamples` on Settings-UI **enable** transition (`btn-new-248c` off→on) while folder empty | Templates folder repopulated from install Assets (filesystem — headless-safe) |
| 5 | A template (file/folder) shows + creates on select | put item in Templates folder; select it in the New+ submenu | submenu item (1:1 with dir entries) + `SHFileOperation FO_COPY` to target — synthetic menu only |
| 6 | Hide file extension | `HideFileExtension` / `btn-hidethefileexte-24a0` | strips ext from **menu caption only** (`get_menu_title`, `show_extension=false`); created file keeps ext — caption is BLK-VISUAL-RENDER if locked |
| 7 | Hide starting digits/spaces/dots | `HideStartingDigits` / `btn-hideleadingdigi-24a8` | strips leading digits+separator from **both** menu caption and **created filename** (`remove_starting_digits_from_filename` via `get_menu_title` + `copy_template`); needs a digit-prefixed template + render |
> Verify a setting actually drives behavior by editing the **module-owned** `NewPlus\settings.json` (not the PT-store mirror) and relaunching; the Settings toggles round-trip into this same file.
## Common BLOCKED traps
- **Master-flip + runner restart does not copy default templates** — that's a Settings-UI action (`NewPlusViewModel.IsEnabled`). Use the UIA toggle for any template-auto-copy item.
- **Menu render is invisible without a real right-click** — packaged command is not `CoCreate`-able (`REGDB_E_CLASSNOTREGISTERED`) and not in classic `Shell.Application.Verbs()`. Locked desktop ⇒ BLK-ENV; do not substitute a CLI/back-door (there isn't one, and it'd be a false PASS).
- **No template-count observable** — `saved_number_of_templates` is an in-memory static (`new_utilities.cpp`), not registry/log.
## Fixture files needed
- A plain file (e.g. `test.txt`) and a folder-with-files to drop into Templates (template-appears items).
- A digit-prefixed template (e.g. `01. Test.txt`) to exercise Hide-starting-digits.
## Source citations
- `src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp``get_menu_title` (hide-extension), `remove_starting_digits_from_filename`, `copy_object_to`.
- `src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h``copy_template`, `create_folder_if_not_exist`, `get_newplus_setting_hide_*`, `register_msix_package`.
- `src/modules/NewPlus/NewShellExtensionContextMenu/shell_context_sub_menu.cpp``create_folder_if_not_exist(root)` + template enumeration.
- `src/settings-ui/Settings.UI/ViewModels/NewPlusViewModel.cs``CopyTemplateExamples` (creates dir; copies examples only when files==0 && dirs==0), called from `IsEnabled` setter / `OpenNewTemplateFolder` / `DashboardViewModel`.
## Ceiling
- Unlocked interactive desktop: **9/9 PASS** (verified 2026-06-18 via background-menu synthetic right-click + submenu expansion).
- Locked/non-interactive desktop: ~3/9 (registration-gate present/absent + template auto-copy); menu-render/select items fall to BLK-ENV/BLK-VISUAL-RENDER — re-run after unlocking.
## Don'ts
- Don't edit `…\PowerToys\New\settings.json` — wrong path; the file is under `NewPlus\`.
- Don't use `Open-PtExplorerContextMenu` (file-item right-click) for New+ — it's the folder **background** ("New") menu; right-click empty list space instead.
- Don't forget to **expand the `New+` submenu** (invoke it) before enumerating templates — they live one popup deeper than the main menu.
- Don't mark menu-render items as product FAIL on a locked desktop — it's BLK-ENV.
- Don't restart Explorer to apply a setting change — the handler re-reads `NewPlus\settings.json` per menu build.

View File

@@ -1,117 +0,0 @@
# Peek — module verification profile
**PT module**: `Peek` (file previewer activated on Ctrl+Space with Explorer file selected)
**Source**: `<PT-repo>\src\modules\Peek\` (PT repo)
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\Peek\settings.json`
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\Peek\Logs\v<ver>\log_<date>.log`
**Exes**: `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.Peek.UI.exe`
**Default hotkey**: `Ctrl+Space` (modifiers=`ctrl`, code=32; see `settings.json``ActivationShortcut`)
**Named Event**: `Local\ShowPeekEvent` (friendly name: `Peek.Show` in `pt-shared-events.ps1` catalog)
**DSC resource**: `Microsoft.PowerToys/PeekSettings`
## Three entry-paths (try in order)
### 1. CLI back-door — fastest, no Explorer needed
```powershell
Start-Process "$env:LOCALAPPDATA\PowerToys\WinUI3Apps\PowerToys.Peek.UI.exe" -ArgumentList "<file>"
```
**Source**: `Peek.UI\PeekXAML\App.xaml.cs:106-134` — when last arg is not int (=runner PID) and is an existing file, it sets `_launchedFromCli=true`, builds `SelectedItemByPath`, calls `OnShowPeek()`. Bypasses hotkey + Explorer foreground.
**Use for**: single-file previewer rendering tests (Recipes 1-2) and the CLI-accepts-path assertion (Recipe 8).
**Cannot use for**: navigation tests (Recipes 4-7, 10-11) — source has `if (_isFromCli) return;` guard that disables arrow navigation, and CLI mode spawns a fresh process every call (no pin-state-across-reopen).
### 2. Shell.Application COM + Ctrl+Space — Explorer-driven, supports navigation
This is the canonical "do what a real user would do" path that drives all the navigation/pin tests.
```powershell
# Dot-source helpers first
. "$skill\scripts\pt-explorer-com.ps1"
. "$skill\scripts\pt-sendinput-chord.ps1"
# Set up multi-file selection in Explorer + trigger Peek in one call:
$peekHwnd = Invoke-PtPeekWithExplorerSelection `
-FolderPath 'D:\fixtures' `
-FileNames 'test-markdown.md','test-html.html','test-source.cs'
# Now Peek is open over a 3-file IShellItemArray. Test:
winapp ui invoke 'PinButton' -w $peekHwnd # pin
# (move window via SetWindowPos)
Send-PtChord -Key 0x27 # Right arrow → switch file
# verify the pinned position stuck
```
**Use for**: pin behavior, multi-file navigation, file switching (Recipes 4-7, 10-11).
**Requires**: interactive desktop session (`Test-PtInteractiveDesktop` must show both `ForegroundOk=True` and `ShellComOk=True`).
### 3. Named Event signal — quick smoke
```powershell
Invoke-PtSharedEvent -Name 'Peek.Show'
```
Wakes the resident Peek process (different from CLI back-door — respects current Explorer foreground selection). Used by some framework tests for the "Peek is enabled and listening" assertion.
## Recipes — a control/observation map, NOT a per-test-case answer key
> Maps each Peek *capability* to **how to drive it** and **where the result shows**. It does NOT prescribe concrete fixtures/coords/inputs or expected values — design those at runtime from the actual checklist item. Only a real UI/behavior change should force an edit here.
| # | Capability | Drive (control / command) | Observe (where the result shows) |
|---|---|---|---|
| 1 | File-type previewer renders (image / text+code / markdown / PDF / HTML / archive / unsupported) | `Peek.UI.exe <fixture>` (entry-path 1) → `winapp ui inspect -w <hwnd> --depth 7` | the type's previewer node present (`ImagePreview Image`; `PreviewBrowser Pane` for dev/text/md/HTML; archive tree for zip; File-Type/Size/Date view for unsupported). Prefer `winapp ui search` for an in-fixture marker over OCR |
| 2 | "Open with default app" via button | `winapp ui invoke LaunchAppButton` | a new editor process/window for `<file>` appears (PID diff) |
| 3 | "Open with default app" via Enter | `Assert-PtForegroundOrAbort``Send-PtChord -Key <Enter>` | same as #2 |
| 4 | Pin keeps window position when switching files | Shell COM + Ctrl+Space (entry-path 2) → `winapp ui invoke PinButton` → move window → navigate to next file | window stays at the pinned coordinates |
| 5 | Pin position persists across close + reopen | pinned → Esc to close (graceful — **don't `Stop-Process`**, it bypasses the pin-save handler) → reopen via Shell COM + Ctrl+Space | new window opens at the same pinned coordinates |
| 6 | Unpin releases the lock; switching file reverts to default | `winapp ui invoke PinButton` again (unpin) → navigate | window moves to the default position |
| 7 | Unpinned reopen uses default position | unpinned → Esc-close → reopen | new window at default, not the stale pinned coords |
| 8 | `Peek.UI.exe <file>` CLI opens Peek | entry-path 1 | covered by #1 across file types |
| 9 | Concurrent Peek sessions don't crash/interfere | launch `Peek.UI.exe` several times on different files, leaving windows open | each spawns its own process/window; no error in `Peek\Logs` |
| 10 | Arrow keys cycle between selected files | Shell COM multi-file selection → Ctrl+Space → `Send-PtChord` Right/Left | window title updates to each file in sequence, wraps at the ends |
| 11 | Multi-file selection scopes navigation | select a subset of a folder → navigate | only the selected files cycle, not the rest |
| 12 | Activation-hotkey reassignment takes effect | edit `Peek\settings.json` `properties.ActivationShortcut``Restart-PtRunner` (**not hot-reloaded** — see Gotchas) → press the new chord, then the old chord | new chord opens Peek; old chord does nothing |
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control and design your own inputs + assertions for *that* item. If no row matches, it's a NEW capability — drive ad-hoc and add a row (capability + control + observation point; no canned inputs).
## BLOCKED triage (single source of truth)
If the agent only tried the CLI back-door and marked the pin / navigation tests BLOCKED → **misdiagnosis**, try entry-path #2 (Shell.Application COM + Ctrl+Space).
If the agent tried Shell COM + Ctrl+Space and got `GetForegroundWindow()=0` + `SendInput → ACCESS_DENIED (5)`**environment**, not framework. The session has no attached input desktop (RDP minimized, screen locked, screensaver, etc.). See `SKILL.md` pitfall #13 and `references/environment-setup.md` for the per-scenario table + powercfg setup commands. Mark BLK-ENV with mitigation citation.
Both traps were observed in 2026-06-08 sign-off runs; preventing both is now the agent's pre-flight job (`pt-session-diagnose.ps1`).
## Fixture files needed
Put these in a workspace `fixtures/` folder before starting:
- `small-image.png` (any 200x150 PNG)
- `Program.cs` (any C# file)
- `readme.md` (markdown with H1 + bold + bullet list)
- `test-pdf.pdf` (PDF with embedded text "PDF_FIXTURE_OK" + "PDF_MARKER_42")
- `page.html` (HTML with `<h1>` containing "HTMLPEEKMARKER")
- `archive.zip` (zip containing 1 small text file)
- `unsupported.xyz` (any small binary)
- 3 differently-sized images for the pin-position tests (e.g. 320x240, 800x600, 1920x1080)
## Source citations
- `<PT-repo>\src\modules\Peek\Peek.UI\PeekXAML\App.xaml.cs:106-134` — CLI arg parsing, `_isFromCli` flag, OnShowPeek call.
- `<PT-repo>\src\modules\Peek\Peek.UI\PeekXAML\Models\NavigationManager.cs``// TODO: implement navigation` + `if (_isFromCli) return;` guards.
- `<PT-repo>\src\common\interop\shared_constants.h``ShowPeekEvent` name.
## Ceiling
**18/18 = 100%** achievable from a normal interactive admin console session (verified 2026-06-08). The change-shortcut item is PASS-able via the settings.json + runner-restart path — see Recipe 12.
## Peek-specific gotchas
- **Activation-shortcut is NOT hot-reloaded.** Editing `Peek\settings.json` `ActivationShortcut` and waiting for the file-watcher debounce does nothing — the centralized keyboard hook only re-registers the chord after `Restart-PtRunner`. Restart after the change AND again after restoring.
- **PinButton spawns a `PopupHost` teaching-tip.** Invoking `PinButton` pops a small confirmation flyout (≈192x63) titled `PopupHost` that surfaces *first* in `winapp ui list-windows`. A naive "first HWND" regex grabs the popup, not Peek. Match by title suffix `- Peek` (regex like `HWND (\d+): "([^"]*- Peek)"`) and/or cache the original Peek HWND before invoking PinButton.
- **Win11 Notepad tabs/session-restore** muddy the "open-with-default-app" tests (Recipes 2-3): the spawned Notepad restores prior tabs, so the foreground Notepad's title may not show your file. Enumerate all Notepad windows and match `"<file> - Notepad"` explicitly.
## Don'ts
- **Don't `Stop-Process PowerToys.Peek.UI -Force`** to close Peek between iterations — bypasses the save handler, breaks the pin-state-persistence tests (Recipes 5, 7). Use Esc / `winapp ui invoke CloseButton`.
- **Don't assume CLI back-door supports navigation** — it doesn't (`_isFromCli` guard). For nav tests use Shell COM + Ctrl+Space.
- **Don't OCR the previewer surface** when UIA already exposes the correct nodes (`ImagePreview`, `PreviewBrowser`, `LaunchAppButton`, `PinButton`). UIA is more reliable than OCR.

View File

@@ -1,114 +0,0 @@
# PowerRename — module verification profile
**PT module**: `PowerRename` (bulk-rename UI launched via Explorer context menu on selected files/folders)
**Source**: `<PT-repo>\src\modules\PowerRename\` (PT repo)
**Settings file**: `%LOCALAPPDATA%\Microsoft\PowerToys\PowerRename\settings.json`
**Logs**: `%LOCALAPPDATA%\Microsoft\PowerToys\PowerRename\Logs\v<ver>\log_<date>.log`
**Exe**: `%LOCALAPPDATA%\PowerToys\WinUI3Apps\PowerToys.PowerRename.exe`
**Activation**: Explorer right-click → "Rename with PowerRename" (Win11 Tier-1 menu; **no classic HKCR verb on Win11**); optional global hotkey if user-configured
**DSC resource**: `Microsoft.PowerToys/PowerRenameSettings`
## Shared mechanics
For the synthetic-right-click + context-menu-invoke flow that ALL Explorer-context-menu modules use, see **`references/explorer-context-menu-flow.md`** + **`scripts/pt-explorer-contextmenu.ps1`** (`Test-PtDesktopInteractive`, `Open-PtExplorerContextMenu`, `Invoke-PtContextMenuItem`, `Get-PtContextMenuItems`). That doc covers stability rules, multi-file selection, BLK-ENV handling, and module-caption table. Don't duplicate; cite by section.
For the Win11 IExplorerCommand vs classic HKCR distinction, see `scripts/pt-shell-verbs.ps1` header — PR is **modern-menu-only on Win11**, so classic-verb enumeration via Shell.Application **will not find it**.
## Entry-paths (try in order)
### 1. Direct CLI launch with file args — PREFERRED for UI-driven tests (verified 2026-06-10)
```powershell
$tmp = New-Item -ItemType Directory -Path "$env:TEMP\pr-fixture-$(Get-Random)"
1..3 | ForEach-Object { 'x' | Set-Content "$($tmp.FullName)\file$_.txt" }
Start-Process "$env:LOCALAPPDATA\PowerToys\WinUI3Apps\PowerToys.PowerRename.exe" `
-ArgumentList "$($tmp.FullName)\file1.txt","$($tmp.FullName)\file2.txt","$($tmp.FullName)\file3.txt"
Start-Sleep -Milliseconds 1500
$pr = (winapp ui list-windows -a PowerToys.PowerRename 2>$null | Out-String) -split "`r?`n" |
ForEach-Object { if ($_ -match 'HWND (\d+):') { [int64]$matches[1] } } | Select-Object -First 1
winapp ui inspect -w $pr --depth 5 -i 2>$null | Out-String | Select-String 'CheckBox "file\d\.txt"'
# Expect 3 hits (file1/2/3.txt, [on] by default)
```
Bypasses the context menu entirely; same code path inside the exe (it parses argv as the file list). **Use for every UI-driven option/regex/preview test** (Recipes 4-12 below).
### 2. Synthetic right-click + Invoke-PtContextMenuItem — for "menu entry present/absent" assertions (Recipes 1-3)
Use the canonical flow from `references/explorer-context-menu-flow.md` Recipe. The menu-presence assertion is the ONE thing the CLI back-door cannot prove (it works even if the menu entry is correctly hidden — the false-positive trap described in that doc).
```powershell
. "$skill\scripts\pt-explorer-contextmenu.ps1"
$hwnd = Open-PtExplorerContextMenu -FolderPath 'D:\fixtures' -FileNames 'a.txt'
$items = Get-PtContextMenuItems -MenuHwnd $hwnd
$has = $items | Where-Object Name -match 'Rename with PowerRename'
# assert $has -> entry present
```
### 3. Shell COM classic verb (does NOT work on Win11 stock install)
```powershell
Invoke-PtShellVerb -Path 'D:\fixtures\a.txt' -NamePattern 'PowerRename' # -> False
```
Returns False on Win11 because PT registers PR only via IExplorerCommand, not as a classic HKCR shell verb. **Use only for negative checks** (and prefer the synthetic-menu enumeration above, which observes the actual Tier-1 menu).
## Recipes — a control/observation map, NOT a per-test-case answer key
> **What this table is (and isn't):** it maps each PowerRename *capability* to **which control drives it** (AutomationId / settings key) and **where the result shows up**. It deliberately does **NOT** prescribe specific Search/Replace inputs or expected-output assertions — those are the agent's job to design from the actual checklist item at runtime. Keeping it input/assertion-free means the table survives checklist-wording changes; only a real UI redesign (renamed/moved control) should force an edit here (as happened to rows 5 & 12 in build 0.100.0).
| # | Capability | Drive (control / settings key) | Observe (where the result shows) |
|---|---|---|---|
| 1 | Context-menu entry present when enabled, gone when disabled | master `enabled.PowerRename` flip + `Restart-PtRunner`; synthetic menu (entry-path 2) | `Get-PtContextMenuItems` includes / excludes "Rename with PowerRename" |
| 2 | "Show icon on context menu" | `ShowIcon` in `power-rename-settings.json` + relaunch | menu entry shows icon vs text-only (screenshot); or HKCR `Icon` |
| 3 | "Appear only in extended menu" | `ExtendedContextMenuOnly` + relaunch | Tier-1 menu hides PR; classic "Show more options" still lists it |
| 4 | Any search/replace option toggle (regex, match-all, case-sensitive, autocomplete, last-use) | `winapp ui invoke checkBox_regex` / `checkBox_matchAll` / `checkBox_case` (etc.); re-read `power-rename-settings.json` | the settings key flips **and** the preview behavior changes accordingly |
| 5 | Case mode (single-select) | toggle **buttons** `toggleButton_lowerCase` / `upperCase` / `titleCase` / `capitalize` (not a dropdown) | preview column shows case-transformed names |
| 6 | Scope: include/exclude Files / Folders / Subfolders | `toggleButton_includeFiles` / `includeFolders` / `includeSubfolders` | excluded row types appear disabled in the preview |
| 7 | Apply-to scope: name-only / extension-only | the "Apply to" selector | replacement affects only the name vs only the extension (preview) |
| 8 | Enumerate items | `toggleButton_enumItems`; Replace accepts `${start=,increment=,padding=}` tokens | preview shows the substituted counter |
| 9 | Datetime tokens | Replace accepts `$DD` `$MMMM` `$YYYY` `$hh` `$mm` `$ss` `$fff` | preview value matches `(Get-Item <file>).CreationTime` formatted the same way |
| 10 | Boost library (Perl regex beyond .NET, e.g. lookbehind) | `UseBoostLib`**read at process start; relaunch PR after toggling** | the Perl-only pattern matches in the preview without error |
| 11 | Per-row include/exclude in the preview | invoke a row checkbox to uncheck | the unchecked file is unchanged on disk after Rename |
| 12 | Filter preview / select-all (NOT a column-header click — headers `TxtBlock_Original`/`TxtBlock_Renamed` are non-interactive labels) | `btn-filter-XXXX``button_showAll` / `button_showRenamed`; `checkBox_selectAll` | visible row set shrinks/grows; all rows toggle on/off |
> **Mapping process**: read the actual checklist item → identify the capability → find its row → drive the named control and design your own inputs + assertions for *that* item. If no row matches, it's a NEW capability — drive it ad-hoc and add a row (capability + control + observation point, no canned inputs).
## Fixture files needed
In a workspace `fixtures/` folder:
- `a.txt`, `b.txt`, `c.txt` — multi-select
- `IMG_001.png`, `IMG_002.png`, `IMG_003.png` — regex capture
- subfolder `subdir/` with 2 inner files — folder/subfolder exclusion
- `Foo_A_A_A.txt` — match-all
- `MIXED.txt` — case-sensitive
Always copy fixtures to a disposable temp folder before running actual rename operations.
## Gotchas
- **TWO settings files — PR reads `power-rename-settings.json`, NOT `settings.json`** (verified 2026-06-10). `%LOCALAPPDATA%\Microsoft\PowerToys\PowerRename\` holds both: (1) `settings.json` = PT-store, keys `bool_mru_enabled`/`bool_persist_input`/`bool_show_icon_on_menu`/`bool_show_extended_menu`/`bool_use_boost_lib`/`int_max_mru_size` (what `Get-PtModuleSettings` + the Settings UI bind to); (2) `power-rename-settings.json` = the module's own store, keys `ShowIcon`/`ExtendedContextMenuOnly`/`PersistState`/`MRUEnabled`/`MaxMRUSize`/`UseBoostLib`**this is the file the PR UI exe and the context-menu COM handlers actually read at launch** (`lib/Settings.cpp` `CSettings::Load→ParseJson`). The runner (`dll/dllmain.cpp:301-307`) syncs PT-store→module-store only on a Settings-UI *change event*; the PT-store file can sit stale for days. **To drive ShowIcon / ExtendedContextMenuOnly / MRUEnabled / PersistState / UseBoostLib deterministically, edit `power-rename-settings.json` directly + relaunch PR (or restart runner+Explorer for the menu handlers), then restore.** Map (settings.json key → user-facing toggle): ShowIcon→"Show icon on context menu", ExtendedContextMenuOnly→"Appear only in extended menu", MRUEnabled→autocomplete, PersistState→"Show values from last use", UseBoostLib→"Use Boost library". MRU values live in `search-mru.json`/`replace-mru.json`; last-used (persist) in `power-rename-last-run-data.json`.
- **"Show icon on context menu" has no Settings-UI toggle in current builds** — drive it via `power-rename-settings.json` `ShowIcon`. Behavior is observable on the synthetic menu (icon vs text-only); source `PowerRenameContextMenu/dllmain.cpp:73` (`GetIcon→null`).
- **The "Appear only in extended menu" classic `#32768` popup is not winapp-enumerable** — assert the Tier-1 *hide* (observed; `dllmain.cpp:108` `ECS_HIDDEN`) and cite `PowerRenameExt.cpp:84` (`E_FAIL` unless `CMF_EXTENDEDVERBS`) for the "still in extended menu" half.
- **PR registers on the directory *background* menu too** — the synthetic right-click often lands on background (View/Sort by/Group by/...) yet still shows/hides `Rename with PowerRename`, which is a valid, stable surface for menu-entry / icon-visibility / extended-menu-only present-absent comparisons.
- **`set-value` on search/replace DOES fire the preview** (TextChanged works, unlike CmdPal) — Apply button enabling/disabling is a reliable match/no-match signal. The search/replace Edit AutomationIds are random per launch (`txt-textbox-XXXX`); discover them each launch by name (`Edit "Search for"` / `Edit "Replace with"`).
- **Preview-row uncheck + column-header invokes need the Preview populated first** — set Search/Replace and wait ~500 ms for the regex engine; otherwise the invokes hit an empty list.
- **Boost library is read at PR process start** — close + relaunch PR after toggling.
- **Icon-on-menu and extended-only checks prefer registry over screenshot** — read HKCR `Extended` / `Icon` REG_SZ; more reliable + locale-independent.
- **Disk mutation is real** — run renames against `$env:TEMP\pr-test-<random>`, not real fixtures.
- **COM cache staleness** when re-checking verbs after enable/disable — call `Reset-PtShellComCache` from `scripts/pt-shell-verbs.ps1`.
## Source citations
- `<PT-repo>\src\modules\PowerRename\dllmain.cpp` — IExplorerCommand registration (no classic HKCR shadow on Win11).
- `<PT-repo>\src\modules\PowerRename\PowerRenameUILib\` — XAML for main PR window (toggle/checkbox AutomationIds).
- `<PT-repo>\src\modules\PowerRename\PowerRenameLib\Settings.cpp` — settings.json schema canonical property names.
## Ceiling
Expected **18/18 = 100%** from an interactive admin console session. Direct-CLI (#1) covers UI-driven items; synthetic-menu (#2) covers menu-presence assertions.
## Don'ts
- **Don't** try `Invoke-PtShellVerb 'PowerRename'` — returns False on Win11 (no classic registration). Use synthetic menu via `Invoke-PtContextMenuItem` or direct-CLI.
- **Don't** run rename operations against reusable fixtures — copy to a disposable temp folder.
- **Don't** trust screenshot-only for icon-on-menu or extended-only checks — registry inspection is faster + locale-independent.
- **Don't** skip the synthetic-menu test for the menu-presence assertion — CLI back-door PASSes even when the menu entry is correctly hidden (false-positive trap described in `references/explorer-context-menu-flow.md`).

View File

@@ -1,122 +0,0 @@
# Pre-flight checks, bootstrap, and state hygiene
This doc covers the **agent-runtime** environment probing and lifecycle hooks. Read alongside `SKILL.md` (the playbook) and `references/environment-setup.md` (one-time user env prep).
## Pre-flight checks (do these first; abort if any fails)
1. **Admin check**`Test-PtAdmin` must return the elevation level matching `[ADMIN: YES]` items in the module's checklist. If the module contains `[ADMIN: YES]` items and `Test-PtAdmin` returns `False`, **STOP** and tell the user "this module requires an elevated session". Do NOT silently mark those items BLOCKED-LACK-ADMIN — that hides a fixable env issue.
2. **PT runner present**`Test-PtRunnerAdmin` should show the runner exists. If it doesn't exist, start PowerToys (`Start-Process "$env:LOCALAPPDATA\PowerToys\PowerToys.exe"`).
3. **Module installed**`Get-PtModuleSettings -ModuleDir <ModuleDir>` (or `Get-CmdPalSettings` for CmdPal) returns non-null.
4. **Interactive-desktop availability + session attachment** — the single most common cause of false-BLOCKED reports is a session mismatch where the agent runs in an elevated **non-console session** (e.g. RDP that's been disconnected/minimized, fast user switching, run-as-different-user, or scheduled-task-with-highest-privilege). In that scenario `Test-PtAdmin=True` but `GetForegroundWindow()=0` and `SendInput` returns `ERROR_ACCESS_DENIED (5)` — input injection cannot reach the active desktop.
```powershell
# Sessions
$agentSession = [Diagnostics.Process]::GetCurrentProcess().SessionId
$consoleSession = (Get-Process explorer -EA SilentlyContinue | Select-Object -First 1).SessionId
"Agent session=$agentSession Console explorer session=$consoleSession"
# Foreground + Shell COM probe (use scripts/pt-session-diagnose.ps1 for the full version)
Add-Type 'using System; using System.Runtime.InteropServices; public class FG4 { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); }'
$hasFg = $false
for ($i = 0; $i -lt 5; $i++) { if ([FG4]::GetForegroundWindow() -ne [IntPtr]::Zero) { $hasFg=$true; break }; Start-Sleep -Milliseconds 200 }
$shellOk = $false
try { $shellOk = (@((New-Object -ComObject Shell.Application).Windows()).Count -ge 0) } catch {}
"Interactive desktop: ForegroundOk=$hasFg ShellComOk=$shellOk"
if (-not $hasFg -and $agentSession -ne $consoleSession) {
Write-Host "===========================================================" -ForegroundColor Red
Write-Host "NON-INTERACTIVE SESSION DETECTED" -ForegroundColor Red
Write-Host "Agent is in Session $agentSession but the active console is Session $consoleSession." -ForegroundColor Red
Write-Host "SendInput, global hotkeys, and arrow-key navigation will NOT work here." -ForegroundColor Red
Write-Host "Items requiring input injection will be marked BLK-ENV up-front." -ForegroundColor Red
Write-Host "Mitigation: see references/environment-setup.md, or relaunch in console session:" -ForegroundColor Yellow
Write-Host " psexec -accepteula -h -i $consoleSession -s pwsh.exe" -ForegroundColor Yellow
Write-Host "===========================================================" -ForegroundColor Red
# Continue verification — schema/UIA/CLI-based tests still produce real evidence
}
```
**Key distinction** (all rows assume `Admin=True`):
- **ForegroundOk + ShellComOk** → Everything works — interactive elevated session.
- **ShellComOk only (ForegroundOk false)** → Non-interactive (e.g. Session ≠ console, RDP minimized, screen locked, screensaver). Only schema / UIA-invoke / CLI / Named-Event tests work. Mark input-injection items as `BLK-ENV` and **cite `references/environment-setup.md` in the report** so the user can fix env and re-run.
- **Neither (ShellComOk false)** → Session 0 / service context — even Shell COM fails. Very few tests possible.
5. **Discipline: try AT LEAST 2 distinct entry-paths before marking BLOCKED.** For Peek/FZ/Workspaces/Image Resizer/PowerRename/File Locksmith specifically, the obvious entry-path is the global hotkey but Shell.Application COM driving Explorer also works — see per-module profiles under `references/modules/`. Marking BLOCKED after trying only the CLI launch (a common trap) hides easily-PASS-able items in an interactive session.
## Bootstrap (paste at start of your verification script)
```powershell
$skill = '<this skill folder>' # the folder containing SKILL.md
Get-ChildItem "$skill\scripts" -Filter '*.ps1' | ForEach-Object { . $_.FullName }
$workspace = "$env:TEMP\verify-<Module>-$(Get-Date -Format yyyyMMdd-HHmmss)"
New-Item -ItemType Directory -Path $workspace, "$workspace\artifacts" | Out-Null
$report = "$workspace\verify-<Module>.md"
"# <Module> verification — $(Get-Date -Format 'yyyy-MM-dd HH:mm')" | Set-Content $report
"" | Add-Content $report
"## Pre-flight" | Add-Content $report
"- IsAdmin: $(Test-PtAdmin)" | Add-Content $report
"- PT runner: PID=$((Test-PtRunnerAdmin).Pid) Elevated=$((Test-PtRunnerAdmin).Elevated)" | Add-Content $report
# Then proceed with pre-flight checks #4-#6 above and write their results into the report.
```
## State hygiene (CRITICAL — always restore)
Wrap any settings/registry mutation in try/finally:
```powershell
# Per-item: settings.json edits
$bk = Backup-PtModuleSettings -ModuleDir <ModuleDir>
try {
# ... mutate + assert ...
} finally {
Restore-PtModuleSettings -ModuleDir <ModuleDir> -BackupPath $bk
}
# After GPO/admin tests
Remove-Item HKLM:\Software\Policies\PowerToys -Recurse -Force -EA SilentlyContinue
Remove-Item HKCU:\Software\Policies\PowerToys -Recurse -Force -EA SilentlyContinue
Remove-Item 'C:\Windows\PolicyDefinitions\PowerToys.admx' -Force -EA SilentlyContinue
Remove-Item 'C:\Windows\PolicyDefinitions\en-US\PowerToys.adml' -Force -EA SilentlyContinue
# Spawned processes (notepad, regedit, etc.) — kill by PID, not by name
foreach ($pid in $spawnedPids) { Stop-Process -Id $pid -Force -EA SilentlyContinue }
```
## Final wrap-up (run AFTER all per-item tables are written)
1. **Run state-hygiene cleanup** above for everything that wasn't restored per-item.
2. **Write the top-of-report summary** per `references/reporting-format.md` §B.
3. **Write the §G Retrospective** — reflect on the run itself: every friction (classified by source + severity + minutes/attempts cost + suggested fix), or `Everything was smooth — no friction encountered.` See `references/reporting-format.md` §G. Don't skip it; it's how the skill improves.
4. **Verify every screenshot referenced in the report actually exists on disk** (before the move, while paths still resolve under `$workspace`):
```powershell
$missing = Get-Content $report | Select-String 'artifacts/L\d+/step-\d+-[^\.\s]+\.(png|txt|log|json|ps1)' -AllMatches |
ForEach-Object { $_.Matches.Value } | Sort-Object -Unique |
Where-Object { -not (Test-Path (Join-Path $workspace $_)) }
if ($missing) { Write-Warning "Missing artifacts: $($missing -join ', ')" }
```
5. **Move the workspace to the sign-off archive** (LAST step, after the report + artifact check pass):
```powershell
$signoff = "$env:OneDrive\PowerToys\Module-Signoff"
New-Item -ItemType Directory -Path $signoff -Force | Out-Null
$final = Join-Path $signoff (Split-Path $workspace -Leaf)
Move-Item -Path $workspace -Destination $final -Force
$report = Join-Path $final (Split-Path $report -Leaf)
```
The report uses **relative** `artifacts/…` paths, so the whole tree moves intact.
6. **Print the FINAL (moved) report path** as the very last line of your response — the `…\Module-Signoff\verify-<Module>-<timestamp>\verify-<Module>.md` path, NOT the temp path.
## Hard rules
- **Never silently send keys via SendInput** to a target window without first calling `Assert-PtForegroundOrAbort -AppId <id>`. Keys silently leak to your terminal if the target isn't foreground.
- **Never mark BLOCKED without trying at least 2 distinct entry-paths from the drive-stack** (SKILL.md §2). If you can't drive the item, name the specific obstacle (not "I can't").
- **Never assume any external repo is cloned locally.** The helpers under `scripts/` are self-contained. Use `Test-Path` guards before referencing any external path.
- **Never invent test steps for a `[CLARITY: VAGUE-*]` item** — mark it **FAIL (cause: checklist-ambiguous)** and quote the original wording so the user can fix the checklist. The checklist is test code; an undefinable test is a broken test.
- **Always restore state** before exiting (even on error). State hygiene wraps every mutation in try/finally.
- **Separate the two FAIL causes**: *product* FAILs are bugs to file; *checklist* FAILs (stale feature or ambiguous spec) are items to rewrite/prune. If a large share of a module's items are checklist-FAILs, the checklist needs an overhaul before re-verifying — don't punt drivable items into a FAIL.
- **Never continue past 3 consecutive errors against the same item** — mark it BLOCKED with the concrete symptom/obstacle and move on. Per-item budget is ~5 minutes; if stuck longer, it's BLOCKED (name the wall).

View File

@@ -1,45 +0,0 @@
# Environment Variables — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
## Legend
Each item is annotated with two metadata tags:
**Admin requirement**:
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
**Clarity**:
- (no marker) - clear, has explicit assert
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
---
## Environment Variables (20 items)
- [ ] **[ADMIN: YES]** (L791) Launch as administrator ON - Launch Environment Variables and confirm that SYSTEM variables ARE editable and Add variable button is enabled
- [ ] **[ADMIN: YES]** (L792) Launch as administrator OFF - Launch Environment Variables and confirm that SYSTEM variables ARE NOT editable and Add variable button is disabled
- [ ] **[ADMIN: NO]** (L795) Add new User variable. Open OS Environment variables window and confirm that added variable is there. Also, confirm that it's added to "Applied variables" list.
- [ ] **[ADMIN: NO]** (L796) Edit one User variable. Open OS Environment variables window and confirm that variable is changed. Also, confirm that change is applied to "Applied variables" list.
- [ ] **[ADMIN: NO]** (L797) Remove one User variable. Open OS Environment variables window and confirm that variable is removed. Also, confirm that variable is removed from "Applied variables" list.
- [ ] **[ADMIN: NO]** (L801) Add new profile with no variables and name it "Test_profile_1" (referenced below by name)
- [ ] **[ADMIN: NO]** (L802) Edit "Test_profile_1": Add one new variable to profile e.g. name: "profile_1_variable_1" value: "profile_1_value_1"
- [ ] **[ADMIN: NO]** (L803) Add new profile "Test_profile_2": From "Add profile dialog" add two new variables (profile_2_variable_1:profile_2_value_1 and profile_2_variable_2:profile_2_value_2). Set profile to enabled and click Save. Open OS Environment variables window and confirm that all variables from the profile are applied correctly. Also, confirm that "Applied variables" list contains all variables from the profile.
- [ ] **[ADMIN: NO]** (L804) Apply "Test_profile_1" while "Test_profile_2" is still aplpied. Open OS Environment variables window and confirm that all variables from Test_profile_2 are unapplied and that all variables from Test_profile_1 are applied. Also, confirm that state of "Applied variables" list is updated correctly.
- [ ] **[ADMIN: NO]** (L805) Unapply applied profile. Open OS Environment variables window and confirm that all variables from the profile are unapplied correctly. Also, confirm that "Applied variables" list does not contain variables from the profile.
- [ ] **[ADMIN: NO]** (L808) To "Test_profile_1" add one existing variable from USER variables, e.g. TMP. After adding, change it's value to e.g "test_TMP" (or manually add variable named TMP with value test_TMP).
- [ ] **[ADMIN: NO]** (L809) Apply "Test_profile_1". Open OS Environment variables window and confirm that TMP variable in USER variables has value "test_TMP". Confirm that there is backup variable "TMP_PowerToys_Test_profile_1" with original value of TMP var. Also, confirm that "Applied variables" list is updated correctly - there is TMP profile variable, and backup User variable..
- [ ] **[ADMIN: NO]** (L810) Unapply "Test_profile_1". Open OS Environment variables window and confirm that TMP variable in USER variable has original value and that there is no backup variable. Also, confirm that "Applied variables" list is updated correctly.
- [ ] **[ADMIN: NO]** (L813) In "Applied variables" list confirm that PATH variable is shown properly: value of USER Path concatenated to the end of SYSTEM Path.
- [ ] **[ADMIN: NO]** (L814) To "Test_profile_1" add variable named PATH with value "path1;path2;path3" and click Save. Confirm that PATH variable in profile is shown as list (list of 3 values and not as path1;path2;path3).
- [ ] **[ADMIN: NO]** (L815) Edit PATH variable from "Test_profile_1". Try different options from ... menu (Delete, Move up, Move down, etc...). Click Save.
- [ ] **[ADMIN: NO]** (L816) Apply "Test_profile_1". Open OS Environment variables window and confirm that profile is applied correctly - Path value and backup variable. Also, in "Applied variables" list check that Path variable has correct value: value of profile PATH concatenated to the end of SYSTEM Path.
- [ ] **[ADMIN: NO]** (L819) Close the app and reopen it. Confirm that the state of the app is the same as before closing.
- [ ] **[ADMIN: NO]** (L821) "Test_profile_1" should still be applied (if not apply it). Delete "Test_profile_1". Confirm that profile is unapplied (both in OS Environment variables window and "Applied variables" list).
- [ ] **[ADMIN: NO]** (L822) Delete "Test_profile_2". Check profiles.json file and confirm that both profiles are gone.

View File

@@ -1,35 +0,0 @@
# File Locksmith — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
## Legend
Each item is annotated with two metadata tags:
**Admin requirement**:
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
**Clarity**:
- (no marker) - clear, has explicit assert
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
---
## File Locksmith (10 items)
- [ ] **[ADMIN: COND]** (L641) Right-click the executable file, select "Unlock with File Locksmith" and verify it shows up. (2 entries will show, since the installer starts two processes)
- [ ] **[ADMIN: COND]** (L642) End the tasks in File Locksmith UI and verify that closes the installer.
- [ ] **[ADMIN: COND]** (L643) Start the installer executable again and press the Refresh button in File Locksmith UI. It should find new processes using the files.
- [ ] **[ADMIN: COND]** (L644) Close the installer window and verify the processes are delisted from the File Locksmith UI. Close the window
- [ ] **[ADMIN: COND]** (L646) Right click the directory where the executable is located, select "Unlock with File Locksmith" and verify it shows up.
- [ ] **[ADMIN: COND]** (L647) Right click the drive where the executable is located, select "Unlock with File Locksmith" and verify it shows up. You can close the PowerToys installer now.
- [ ] **[ADMIN: COND]** (L649) Right click "Program Files", select "Unlock with File Locksmith" and verify "PowerToys.exe" doesn't show up.
- [ ] **[ADMIN: YES]** (L650) Press the File Locksmith "Restart as an administrator" button and verify "PowerToys.exe" shows up.
- [ ] **[ADMIN: YES]** (L651) Right-click the drive where Windows is installed, select "Unlock with File Locksmith" and scroll down and up, verify File Locksmith doesn't crash with all those entries being shown. Repeat after clicking the File Locksmith "Restart as an administrator" button.
- [ ] **[ADMIN: COND]** (L652) Disable File Locksmith in Settings and verify the context menu entry no longer appears.

View File

@@ -1,43 +0,0 @@
# Image Resizer — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
## Legend
Each item is annotated with two metadata tags:
**Admin requirement**:
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
**Clarity**:
- (no marker) - clear, has explicit assert
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
---
## Image Resizer (18 items)
- [ ] **[ADMIN: NO]** (L309) Disable the Image Resizer and check that `Resize with Image Resizer` is absent in the context menu
- [ ] **[ADMIN: NO]** (L310) Enable the Image Resizer and check that `Resize with Image Resizer` is present in the context menu (both Win11 modern and old menus)
- [ ] **[ADMIN: NO]** (L311) Remove one image size and add a custom image size. Open the Image Resize window from the context menu and verify changes are populated
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L312) Resize one image
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L313) Resize multiple images
- [ ] **[ADMIN: NO]** (L314) Open image resizer to resize a .gif and verify "Gif files with animations may not be correctly resized." warning appears
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L316) Resize images with Fill option
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L317) Resize images with Fit option
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L318) Resize images with Stretch option
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L320) Resize using dimension Centimeters
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L321) Resize using dimension Inches
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L322) Resize using dimension Percents
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L323) Resize using dimension Pixels
- [ ] **[ADMIN: NO]** (L325) Change Filename format to %1 - %2 - %3 - %4 - %5 - %6 and verify applied
- [ ] **[ADMIN: NO]** (L326) Check Use original date modified and verify modified date not changed for resized
- [ ] **[ADMIN: NO]** (L327) Check Make pictures smaller but not larger and verify smaller pictures not resized
- [ ] **[ADMIN: NO]** (L328) Check Resize the original pictures (don't create copies) and verify original is resized
- [ ] **[ADMIN: NO]** (L329) Uncheck Ignore the orientation and verify swapped W/H actually resizes if W!=H

View File

@@ -1,14 +0,0 @@
# Release checklist — per-module index
One file per module; a verification run loads only its module's file.
> **Scope:** only modules that have been verified end-to-end (with a sign-off report) are checked in here so far. The remaining modules' checklists will be added as each is verified.
| Module | Items | File |
|---|---:|---|
| Environment Variables | 20 | `environment-variables.md` |
| File Locksmith | 10 | `file-locksmith.md` |
| Image Resizer | 18 | `image-resizer.md` |
| New+ | 9 | `new-plus.md` |
| Peek | 18 | `peek.md` |
| PowerRename | 18 | `power-rename.md` |

View File

@@ -1,34 +0,0 @@
# New+ — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
## Legend
Each item is annotated with two metadata tags:
**Admin requirement**:
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
**Clarity**:
- (no marker) - clear, has explicit assert
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
---
## New+ (9 items)
- [ ] **[ADMIN: NO]** (L969) Verify NewPlus menu is in Explorer context menu. (Windows 11 tier 1 context menu only. May need Explorer restart.)
- [ ] **[ADMIN: NO]** (L971) Verify NewPlus menu is not in Explorer context menu.
- [ ] **[ADMIN: NO]** (L973) Verify the folder is created and empty.
- [ ] **[ADMIN: NO]** (L974) Copy a file to the templates folder, verify it's added to the New+ context menu and that if you select it the file is created.
- [ ] **[ADMIN: NO]** (L975) Copy a folder with files inside to the templates folder, verify it's added to the New+ context menu and that if you select it the folder and files inside are created.
- [ ] **[ADMIN: NO]** (L976) Delete all files and folders from inside the templates folder. Verify that no templates are available in the context menu.
- [ ] **[ADMIN: NO]** (L977) Disable and re-Enable New+ while the templates folder is still empty. Verify the default templates were copied over and are available in the context menu.
- [ ] **[ADMIN: NO]** (L979) Test the "Hide template filename extension" option in Settings.
- [ ] **[ADMIN: NO]** (L980) Test the "Hide template filename starting digits, spaces and dots" option in Settings.

View File

@@ -1,43 +0,0 @@
# Peek — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
## Legend
Each item is annotated with two metadata tags:
**Admin requirement**:
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
**Clarity**:
- (no marker) - clear, has explicit assert
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
---
## Peek (18 items)
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L697) Image
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L698) Text or dev file
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L699) Markdown file
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L700) PDF
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L701) HTML
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L702) Archive files (.zip, .tar, .rar)
- [ ] **[ADMIN: NO]** (L703) Any other not mentioned file (.exe for example) to verify the unsupported file view is shown
- [ ] **[ADMIN: NO]** (L706) Pin the window, switch between images of different size, verify the window stays at the same place and the same size.
- [ ] **[ADMIN: NO]** (L707) Pin the window, close and reopen Peek, verify the new window is opened at the same place and the same size as before.
- [ ] **[ADMIN: NO]** (L708) Unpin the window, switch to a different file, verify the window is moved to the default place.
- [ ] **[ADMIN: NO]** (L709) Unpin the window, close and reopen Peek, verify the new window is opened on the default place.
- [ ] **[ADMIN: NO]** (L712) By clicking a button.
- [ ] **[ADMIN: NO]** (L713) By pressing enter.
- [ ] **[ADMIN: NO]** (L716) Can use peek command to peek files
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L717) Peek can work without problem when a peek session is on
- [ ] **[ADMIN: NO]** (L719) Switch between files in the folder using `LeftArrow` and `RightArrow`, verify you can switch between all files in the folder.
- [ ] **[ADMIN: NO]** (L720) Open multiple files, verify you can switch only between selected files.
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-AMBIGUOUS]** (L721) Change the shortcut, verify the new one works.

View File

@@ -1,43 +0,0 @@
# PowerRename — PowerToys release checklist
> Source: split from `release-checklist-annotated.md` (generated 2026-06-06). One module per file.
## Legend
Each item is annotated with two metadata tags:
**Admin requirement**:
- `[ADMIN: NO]` - runnable from a standard (non-elevated) shell
- `[ADMIN: YES]` - requires elevated session (writes to HKLM, %WinDir%\System32, MSI install, GPO templates, etc.)
- `[ADMIN: COND]` - conditional - the basic case is non-admin but specific sub-cases require admin (e.g. "test with elevated target app", "Restart as admin" variants)
**Clarity**:
- (no marker) - clear, has explicit assert
- `[CLARITY: VAGUE-NO-STEPS]` - original wording is just a module/feature name without procedural steps
- `[CLARITY: VAGUE-NO-ASSERT]` - original wording describes an action but does not state the expected outcome
- `[CLARITY: VAGUE-AMBIGUOUS]` - original wording uses vague verbs like "works" without a measurable outcome
- `[REWRITTEN]` - original wording was vague; this checklist has rewritten the description to be concrete. Original wording preserved in italics below the item.
---
## PowerRename (18 items)
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-AMBIGUOUS]** (L393) Check if disable and enable of the module works. (On Win11) Check if both old context menu and Win11 tier1 context menu items are present when module is enabled.
- [ ] **[ADMIN: NO]** (L394) Check that with the `Show icon on context menu` icon is shown and vice versa.
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-AMBIGUOUS]** (L395) Check if `Appear only in extended context menu` works.
- [ ] **[ADMIN: NO]** (L396) Enable/disable autocomplete.
- [ ] **[ADMIN: NO]** (L397) Enable/disable `Show values from last use`.
- [ ] **[ADMIN: NO]** (L399) Make Uppercase/Lowercase/Titlecase (could be selected only one at the time)
- [ ] **[ADMIN: NO]** (L400) Exclude Folders/Files/Subfolder Items (could be selected several)
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L401) Item Name/Extension Only (one at the time)
- [ ] **[ADMIN: NO]** (L402) Enumerate Items. Test advanced enumeration using different values for every field ${start=10,increment=2,padding=4}.
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L403) Case Sensitive
- [ ] **[ADMIN: NO]** (L404) Match All Occurrences. If checked, all matches of text in the `Search` field will be replaced with the Replace text. Otherwise, only the first instance of the `Search` for text in the file name will be replaced (left to right).
- [ ] **[ADMIN: NO]** (L406) Search with an expression (e.g. `(.*).png`)
- [ ] **[ADMIN: NO]** (L407) Replace with an expression (e.g. `foo_$1.png`)
- [ ] **[ADMIN: NO] [CLARITY: VAGUE-NO-STEPS]** (L408) Replace using file creation date and time (e.g. `$hh-$mm-$ss-$fff` `$DD_$MMMM_$YYYY`)
- [ ] **[ADMIN: NO]** (L409) Turn on `Use Boost library` and test with Perl Regular Expression Syntax (e.g. `(?<=t)est`)
- [ ] **[ADMIN: NO]** (L411) In the `preview` window uncheck some items to exclude them from renaming.
- [ ] **[ADMIN: NO]** (L412) Use the **Filter** (funnel) button above the file list → choose "Only show files that will be renamed" / "Show all files" to filter the preview.
- [ ] **[ADMIN: NO]** (L413) Use the **Select/deselect all** checkbox above the file list to toggle all rows checked/unchecked.

View File

@@ -1,159 +0,0 @@
# Reporting format
This doc defines the **required** report shape for every per-module verification run. Modeled on `PR-validation\Round1\PR-47211-validation\report.md` style — table-driven, reproducible, no prose narratives.
## §A — Per-item table (one per checklist item)
```markdown
## Item L<line_num> — <verbatim description from the module's checklist> — **<PASS|FAIL|BLOCKED>** <emoji>
**Admin**: <NO|COND|YES> | **Clarity**: <CLEAR|VAGUE-*|REWRITTEN> | **Category**: <PASS: verification method (free text) · FAIL: cause = product | checklist-stale | checklist-ambiguous · BLOCKED: a BLK-* reason>
### Verification steps performed
| # | Step | winapp / probe commands | Evidence / result |
|---|---|---|---|
| 1 | <what step 1 does> | `<exact command>`<br>`<another command if multiple>` | <what you observed; reference artifact filename> |
| 2 | <what step 2 does> | `<command>` | <evidence>; screenshot: `artifacts/L<line>/step-02-<name>.png` |
| 3 | ... | ... | ... |
### Artifacts produced
- `artifacts/L<line>/step-01-<name>.png` — <one-line description>
- `artifacts/L<line>/step-02-<name>.txt` — full inspect dump
- ...
### Verdict reasoning
- ✅ <assertion 1 that PASSed, with reference to the line of code / settings key / log line that proves it>
- ✅ <assertion 2>
- ❌ <if BLOCKED, the specific obstacle: "BLK-HARDWARE because MWB needs 2 physical PCs; this session has 1 ([System.Windows.Forms.Screen]::AllScreens.Count = 1)">
### Caveats (optional)
- <Any deviation from the user-documented flow, e.g. "Tested via settings.json write rather than UI checkbox because SelectionItemPattern.Select clobbers other selections in ListView.">
```
## §B — Top-of-report summary (write LAST, after all per-item tables)
```markdown
# <Module> verification report — <YYYY-MM-DD HH:MM>
## Summary
- **PASS**: <n> · **FAIL (product)**: <n> · **FAIL (checklist)**: <n> · **BLOCKED**: <n> · **Total**: <n> · **PASS%**: <n>
- **Top blocker categories**: <category>: <count>, <category>: <count>, ...
- **Items needing follow-up**: L<line> (<reason>), L<line> (<reason>), ...
- **State mutations performed + restored**: <count> settings.json edits restored, <count> registry keys removed, <count> fixture files deleted
## Pre-flight
- IsAdmin: <true|false>
- PT runner: PID=<n> Elevated=<true|false>
- <Module> settings file: <path> (exists=<true|false>)
- Interactive desktop: ForegroundOk=<true|false> ShellComOk=<true|false>
## Items
<all per-item tables here, in line_num order>
## Cleanup performed
- <list of every restore action taken>
## Retrospective (self-reflection on the run — write LAST)
<Per §G. If the whole run was frictionless, write exactly: **Everything was smooth — no friction encountered.**>
```
## §C — Required rules for step tables
1. **Every `winapp ui ...` command goes in the "winapp / probe commands" cell, verbatim, in backticks**, including `-w <hwnd>` / `-a <appId>` arguments and full selector strings. Reviewers will paste these into their own shell to reproduce.
2. **Every screenshot path goes in the "Evidence" cell** of the step that produced it, formatted as `screenshot: artifacts/L<line>/step-NN-<name>.png`. Never embed screenshots as `![...](...)` in the table body (breaks GitHub markdown rendering inside cells); just give the path.
3. **If a step has multiple commands**, separate them in the same cell with `<br>` so they render as one cell with multiple lines.
4. **PowerShell scriptlets > 3 lines**: write them to a separate `.ps1` in the artifacts folder and reference as ``script: `artifacts/L<line>/step-NN.ps1` `` in the cell. Keep the table cell to 1-3 lines.
5. **`—` (em dash) is allowed for non-CLI steps** like "Read sign-off entry + diff", "Create validation folder", "Cleanup notepad". Don't fabricate a command for steps that were purely cognitive or file-system level.
6. **Numbered steps must be contiguous** (1, 2, 3, ...). Don't skip numbers.
7. **At least one screenshot per PASS item if the item is a user-visible behavioral test**. Schema-only assertions (settings.json key check) don't need screenshots; behavioral tests (popup shown, dialog appeared, theme switched) do.
## §D — Reporting style
- Be specific. "Verified via UIA inspect returned `itm-calculator-XXXX`" beats "verified UIA".
- Include exact UIA selectors, log line text, settings.json keys, and screenshot filenames so the user can audit.
- For BLOCKED items, the 1-sentence reason should name **what specifically blocks**, e.g.:
- "BLK-HARDWARE: requires 2nd monitor; session has 1 (verified via `[System.Windows.Forms.Screen]::AllScreens.Count`)."
- "BLK-DRAG-REQUIRED: synthetic mouse drag insufficient for FZ snap-and-drag; needs real cursor motion."
- "BLK-ENV: SendInput returned ACCESS_DENIED (5) because Session $agentSession ≠ console Session $consoleSession. See `references/environment-setup.md`."
- "BLK-EXTERNAL-APP: requires real OpenAI API key; no key provisioned in test env."
## §E — Reporting anti-patterns (extra strict)
- Do NOT collapse multiple probe commands into a single English sentence like "verified via UIA". List every `winapp ui ...` command verbatim in a step row.
- Do NOT skip the step table for "trivial" items. Even a 1-step item (e.g. "Get-CmdPalSettings shows EnableDock=true") gets a 1-row table.
- Do NOT write screenshot references as `![alt](path)` inside table cells (GitHub renders markdown images poorly in cells). Write them as plain text path: `screenshot: artifacts/L<line>/step-NN-<name>.png`.
- Do NOT use "the test passed" as a screenshot caption — describe what's visible (e.g. "Settings page with FZ template grid showing 7 templates").
- Do NOT reference screenshots that you didn't actually capture. The final wrap-up `Test-Path` loop (see `references/pre-flight.md` §Final wrap-up step 3) will catch missing files; failing that check means the report is invalid.
- Do NOT cite source code line numbers (e.g. `CharacterMappings.cs:273`) without having actually read that line. If you cite source, the path must be real and the line number must contain what you claim.
## §F — Example item (reference: PR-47211 validation report style)
```markdown
## Item L455 — Activate Quick Accent (left Alt + arrow key) on a character, verify accents popup — **PASS** ✅
**Admin**: NO | **Clarity**: CLEAR | **Category**: drove full UIA flow + asserted accents popup
### Verification steps performed
| # | Step | winapp / probe commands | Evidence / result |
|---|---|---|---|
| 1 | Locate Settings window | `winapp ui list-windows --json` | `hwnd=263304`, `PowerToys.Settings` PID 31740 |
| 2 | Navigate to Quick Accent + expand language flyout | `winapp ui invoke QuickAccentNavItem -w 263304`<br>`winapp ui invoke btn-choosecharacter-1c4d -w 263304` | Page loaded; flyout expanded |
| 3 | Enumerate language list + screenshot | `winapp ui inspect btn-choosecharacter-1c4d -w 263304 --depth 5`<br>`winapp ui screenshot -w 263304 -o "artifacts/L455/step-03-language-list.png"` | 38 spoken + 6 special languages, alphabetic. screenshot: `artifacts/L455/step-03-language-list.png` |
| 4 | Single-language (French) popup test | `winapp ui invoke itm-french-1cac -w 263304`<br>`winapp ui inspect characters -w <popupHwnd> --depth 3`<br>`winapp ui screenshot -w <popupHwnd> -o "artifacts/L455/step-04-popup-FR-E.png"` | Popup chars for **E** = `é è ê ë €` (5), matches `FR.VK_E` in `CharacterMappings.cs:273`. screenshot: `artifacts/L455/step-04-popup-FR-E.png` |
| 5 | Restore baseline | — | settings.json reverted to `selected_lang="ALL"` |
### Artifacts produced
- `artifacts/L455/step-03-language-list.png` — Settings page with expanded language flyout
- `artifacts/L455/step-03-language-list.txt` — full UIA inspect dump of the list
- `artifacts/L455/step-04-popup-FR-E.png` — Popup with French only: `é è ê ë €`
### Verdict reasoning
- ✅ Popup characters match `CharacterMappings.cs` entries exactly (5/5 for FR.VK_E)
- ✅ Popup appeared within 500ms of hold-A; no crash
- ✅ Language list ordering is alphabetic by localized name
```
## §G — Retrospective (self-reflection)
After the run, reflect on the **process** (not the product) so the skill itself gets better over time. **If nothing slowed you down, write exactly one line: `Everything was smooth — no friction encountered.`** Otherwise, list each friction as a row and assign a source + severity.
```markdown
## Retrospective
| # | Friction (what slowed you / what was wrong) | Source | Severity | Cost | Suggested fix |
|---|---|---|---|---|---|
| 1 | <concrete description — what you expected vs what happened> | <one source tag below> | <HIGH/MED/LOW> | <~min wasted · N attempts> | <the doc line / helper function / tool behavior to change> |
```
**Source** — classify each friction into exactly one bucket so the right owner can fix it:
| Source tag | Meaning |
|---|---|
| `SKILL-UNCLEAR` | This skill's `SKILL.md` / `references/pre-flight.md` / module profile guidance was missing, ambiguous, or wrong. |
| `WINAPP-TOOL-BUG` | The `winapp` CLI itself misbehaved (crash, wrong output, flag not honored) — a product defect in the tool. |
| `WINAPP-DOC-UNCLEAR` | `references/winapp-ui-testing.md` was unclear/incorrect about how to use the tool (the tool worked; the docs misled you). |
| `HELPER-FLAW` | A shipped `scripts/*.ps1` had a logic bug, bad default, or wrong assumption. Name the function. |
| `PT-PRODUCT` | A PowerToys behavior/quirk made driving hard (distinct from a product **FAIL** — this is friction, not a checklist failure). |
| `CHECKLIST` | The checklist item itself was wrong/stale/ambiguous (e.g. describes a renamed or removed control). Note: this usually *also* produces a `FAIL (cause: checklist-*)` verdict on the item; log it here too so the checklist owner sees it as a process-improvement signal. |
| `ENVIRONMENT` | RDP/session/desktop/elevation friction not already covered by `references/environment-setup.md`. |
**Severity** — judge by *impact on future agents*, not just yourself:
- **HIGH** — most agents will hit it; blocks progress or wastes >10 min, or you needed a non-obvious workaround.
- **MED** — many agents may hit it; cost a few minutes or 2-3 retries; workaround exists once known.
- **LOW** — edge case or cosmetic; <1 min; noted for completeness.
**Cost** — be concrete: approximate minutes wasted **and** number of attempts (e.g. `~8 min · 3 attempts`). This is the raw signal for prioritizing skill fixes.
**Suggested fix** — point at the specific artifact to change: a doc line/section, a helper function name, or a `winapp` behavior to file. Vague reflections ("docs could be clearer") are not actionable — cite the line.
Example:
```markdown
## Retrospective
| # | Friction | Source | Severity | Cost | Suggested fix |
|---|---|---|---|---|---|
| 1 | `winapp ui inspect --depth 7 -w $hwnd` threw "Cannot bind argument" until I moved `-w` after `--depth`. | `WINAPP-TOOL-BUG` | MED | ~6 min · 3 attempts | Already noted in pitfall #14, but the tool should parse flag order — file against winapp. |
| 2 | SKILL.md §2.A says "wait 4s debounce" but PowerRename needed a full `Restart-PtRunner`; the module-owned-file note (pitfall #18) wasn't cross-linked from §2.A. | `SKILL-UNCLEAR` | HIGH | ~12 min · 4 attempts | Add an explicit "shell-ext modules → see pitfall #18" pointer inside §2.A. |
```

View File

@@ -1,531 +0,0 @@
# WinUI UI-testing mechanics (winapp ui)
> **Provenance:** Adapted from the `winui-ui-testing` skill in [microsoft/win-dev-skills](https://github.com/microsoft/win-dev-skills) (MIT, © Microsoft Corporation and Contributors), with PowerToys-specific edits. This is a **reference doc** for the `powertoys-module-verification` skill — it is intentionally not a standalone skill (no frontmatter), so it is not separately discovered.
Automated UI testing for WinUI 3 apps — generate a batch test script, run all tests in one pass, read results. Covers element assertions, interactions, value checking (TextBox, ComboBox, ToggleSwitch), file pickers, flyouts, dialogs, persistence, and accessibility audits.
### Approach
The goal of this skill is to validate UI and app functionality automatically, without manual interaction, by exercising the app's UI elements, verifying their state, and asserting that the app behaves as expected under test conditions.
There are two main approaches:
1. Interactive exploration — manually run the app, use `winapp ui <command>` to explore the UI tree, find AutomationIds, verify element properties, and test functionality interactively. This is useful for discovery, but slow and expensive if repeated for every test iteration.
2. Scripted batch testing — generate a `ui-tests.ps1` script that exercises all UI elements and asserts expected behavior in one pass. This allows you to run the tests automatically, capture results, and iterate quickly without manually interacting with the app each time.
Unless the user asked for interactive exploration, or you are unfamiliar with the code/app or need to explore the UI tree to discover AutomationIds for hidden or dynamically generated elements (flyouts, dialogs, lazy-loaded content), **prefer scripted batch testing** — it is faster, repeatable, and produces a record of pass/fail results that can be reviewed and acted on.
### `winapp ui` Verbs
`status`, `inspect`, `search`, `get-property`, `get-value`, `screenshot`, `invoke`, `click`, `set-value`, `focus`, `scroll`, `scroll-into-view`, `wait-for`, `list-windows`, `get-focused`. Run `winapp ui --cli-schema` for the complete command structure as JSON, or `winapp ui <verb> --help` for any single verb.
### Step 1: Use the Running App
If the app is already running, use its PID. **Do NOT relaunch** — use the PID already captured from the build step. If the app is not running, build and launch it using the guidance in the winui-dev-workflow skill.
### Step 2: Write the Test Script
**If you wrote the code:** Skip inspect — you already know all the AutomationIds and control structure from the XAML and code-behind. Write tests directly from that knowledge. Inspect misses popups, flyouts, dialogs, and lazy-loaded content anyway.
**If you're verifying code you didn't write:** Run inspect first to discover the UI:
```powershell
winapp ui inspect -a <PID> --interactive
```
Then read the XAML files to find AutomationIds that aren't currently visible (flyout items, dialog buttons, secondary pages).
Create a `ui-tests.ps1` file that tests all the app's requirements in one pass:
```powershell
# ui-tests.ps1
param([Parameter(Mandatory)][int]$AppPid)
# NOTE: Do NOT name the parameter $Pid — it's read-only in PowerShell
$ErrorActionPreference = 'Continue'
$pass = 0; $fail = 0; $results = @()
# Get main window HWND (avoids PopupHost interference with JSON parsing)
$windows = winapp ui list-windows -a $AppPid --json 2>$null | ConvertFrom-Json
$hwnd = ($windows | Where-Object { $_.title -ne "PopupHost" } | Select-Object -First 1).hwnd
function Test-UI {
param([string]$Name, [scriptblock]$Script)
# IMPORTANT: Inside $Script, use 'throw' to signal failure — NOT 'exit 1'
# (exit terminates the entire script, not just the test)
try {
$output = & $Script 2>&1
if ($LASTEXITCODE -eq 0) {
$script:pass++; $script:results += @{ name = $Name; status = "PASS" }
} else {
$script:fail++; $script:results += @{ name = $Name; status = "FAIL"; detail = "$output" }
}
} catch {
$script:fail++; $script:results += @{ name = $Name; status = "FAIL"; detail = "$_" }
}
}
# ─── Element Existence ───
Test-UI "NavHome exists" { winapp ui wait-for "NavHome" -a $AppPid -t 3000 }
Test-UI "NavSettings exists" { winapp ui wait-for "NavSettings" -a $AppPid -t 3000 }
# ─── Navigation ───
Test-UI "Navigate to Settings" { winapp ui invoke "NavSettings" -a $AppPid }
Test-UI "Settings page loaded" { winapp ui wait-for "TxtUserName" -a $AppPid -t 3000 }
# ─── Interactions ───
Test-UI "Set username" { winapp ui set-value "TxtUserName" "TestUser" -a $AppPid }
Test-UI "Click Save" { winapp ui invoke "BtnSave" -a $AppPid } # commits the TextBox binding
Test-UI "Username value set" {
winapp ui wait-for "TxtUserName" -a $AppPid --value "TestUser" -t 2000
}
# ─── Value assertions for different control types ───
Test-UI "Theme is System default" {
winapp ui wait-for "CmbTheme" -a $AppPid --value "System default" -t 2000
}
Test-UI "Logging is off" {
winapp ui wait-for "TglLogging" -a $AppPid --value "Off" -t 2000
}
# ─── Accessibility Audit ───
# Only audit controls in the app's main window (exclude OS picker/popup controls)
$allElements = (winapp ui inspect -a $AppPid --interactive --json 2>$null | ConvertFrom-Json).elements
$appElements = @($allElements | Where-Object {
$_.type -match 'Button|TextBox|ComboBox|CheckBox|ToggleSwitch|TabItem|Edit' -and
$_.name -notmatch 'Minimize|Maximize|Close|System' -and # window chrome
$_.className -notmatch 'PickerHost|#32770|CabinetWClass' # OS dialogs
})
$missingId = @($appElements | Where-Object { -not $_.automationId })
if ($missingId.Count -eq 0) {
$pass++; $results += @{ name = "All app controls have AutomationId"; status = "PASS" }
} else {
$fail++
$names = ($missingId | ForEach-Object { "$($_.type) '$($_.name)'" }) -join ", "
$results += @{ name = "AutomationId coverage"; status = "FAIL"; detail = "Missing: $names" }
}
# ─── State Screenshots (capture each meaningful state for visual review) ───
New-Item -ItemType Directory -Force -Path "screenshots" | Out-Null
winapp ui screenshot -a $AppPid -o "screenshots/01-initial.png" 2>$null
# ...take more screenshots after key interactions above (mode switches, dialogs opened, etc.)
# ─── Final Screenshot ───
winapp ui screenshot -a $AppPid -o "test-screenshot.png" 2>$null
# ─── Results ───
Write-Host "`nPassed: $pass | Failed: $fail"
$results | Where-Object { $_.status -eq "FAIL" } | ForEach-Object {
Write-Host " FAIL: $($_.name)$($_.detail)" -ForegroundColor Red
}
$results | ConvertTo-Json | Out-File "test-results.json"
if ($fail -gt 0) { exit 1 } else { exit 0 }
```
### What to Test
Write tests for **every requirement** from the user's prompt:
| Requirement type | Test approach |
|---|---|
| "Has a button that does X" | `search` to verify exists, `invoke` to click, `wait-for --value` to check result |
| "Text field shows value" | `wait-for "TxtName" --value "expected"` — works for TextBox, TextBlock, labels |
| "Status bar contains text" | `wait-for "StatusBar" --value "words" --contains` — substring match for dynamic content |
| "Dropdown is set to X" | `wait-for "CmbTheme" --value "Dark"` — reads the selected item automatically |
| "Toggle is on/off" | `wait-for "TglFeature" --value "On"` — reads the toggle state |
| "Navigation between pages" | `invoke` nav item, `wait-for` a page-specific element to appear |
| "Open file dialog" | `invoke` trigger, `list-windows` to find picker HWND, interact with `-w` |
| "Save file dialog" | Same as open — find picker with `list-windows`, `set-value` filename, `invoke` Save |
| "Right-click context menu" | `click --right` on element, `invoke` the flyout MenuItem |
| "Confirmation dialog" | `invoke` trigger, `search` for dialog buttons, `invoke` Primary/Secondary/Close |
| "Data persists" | Set values, `invoke` a button (to commit bindings), verify data file on disk (`Get-Content` + `ConvertFrom-Json`) |
| "All controls accessible" | `inspect --interactive --json` + check all have AutomationId |
### Step 3: Run and Read Results
```powershell
.\ui-tests.ps1 -AppPid <PID>
```
Read `test-results.json` for structured pass/fail. Only fix code if tests fail.
### Step 3.5: Look at the Screenshots
UIA assertions don't see clipping, overlap, wrong theming, or controls bleeding past their container — UIA returns `PASS` while the app is visually broken. **Capture screenshots with `winapp ui screenshot` and view each PNG.**
Capture the initial state and any state after a major interaction (the State Screenshots block in the script template above handles this).
**Visual checklist — fail the run if any item is `no`:**
- [ ] No unintended scrollbars
- [ ] No text ending in `…` that shouldn't be
- [ ] Hero elements fully visible (not sliced)
- [ ] Right-edge controls fully visible
- [ ] No overlapping rows
- [ ] Content uses the available width — no asymmetric dead zones (e.g. content pinned to one edge leaving empty space on the other)
- [ ] Spacing intentional — not cramped, not unintentionally vast
- [ ] Theming matches the user's ask (Light/Dark/HighContrast if relevant)
- [ ] Focus/hover/error states render if tested
If the checklist fails, it's a bug — fix before declaring done. Window too small → grow per `winui-design` Step 4.
### Step 4: Fix and Rerun (if the user asked for it)
If tests fail:
1. Read the failure details from `test-results.json`
2. Batch-fix all issues in one pass
3. Rebuild with `.\BuildAndRun.ps1` (blocking mode — shows crash info if the fix broke something)
4. Rerun `.\ui-tests.ps1 -AppPid <PID>` (parse PID from the `launched (PID: XXXXX)` output)
**Maximum 2 fix-and-rerun cycles.** If the same tests keep failing after 2 cycles, report them as known issues and move on — do not keep iterating.
### Assertion Reference
Use `wait-for --value` as the primary assertion — it uses a smart fallback chain that reads the right value for any control type:
| Control type | `--value` reads from | Example |
|---|---|---|
| TextBlock / Label | Name property | `wait-for "LblTitle" --value "Home"` |
| TextBox / NumberBox | ValuePattern | `wait-for "TxtName" --value "John"` |
| RichEditBox | TextPattern | `wait-for "Editor" --value "Hello"` |
| ComboBox | Selected item (SelectionPattern) | `wait-for "CmbTheme" --value "Dark"` |
| ToggleSwitch | Toggle state (On/Off) | `wait-for "TglDark" --value "On"` |
| CheckBox | Toggle state (On/Off) | `wait-for "ChkAgree" --value "On"` |
**Full assertion commands:**
| Assertion | Command |
|---|---|
| Element exists | `winapp ui wait-for "Id" -a PID -t 3000` |
| Element has exact value | `winapp ui wait-for "Id" -a PID --value "expected" -t 3000` |
| Value contains text | `winapp ui wait-for "Id" -a PID --value "words" --contains -t 3000` |
| Element gone | `winapp ui wait-for "Id" -a PID --gone -t 3000` |
| Specific property | `winapp ui wait-for "Id" -a PID -p IsEnabled --value "True" -t 3000` |
| Button clickable | `winapp ui invoke "Id" -a PID` (exit code 0) |
| Set then verify | `winapp ui set-value "Id" "text" -a PID` then `wait-for --value` |
| Screenshot | `winapp ui screenshot -a PID -o path.png` |
| Dialog appeared | `winapp ui list-windows -a PID --json` (check window count) |
| Right-click menu | `winapp ui click "Id" -a PID --right` then `wait-for` menu item |
| Read raw property | `winapp ui get-property "Id" -a PID -p IsEnabled --json` |
| Read current value (no wait) | `(winapp ui get-value "Id" -a PID --json \| ConvertFrom-Json).text` — always pass `--json` when capturing into a variable (plain stdout can include advisory text like "Auto-selected HWND … from N windows"); otherwise prefer `wait-for --value` |
| Scroll item into view | `winapp ui scroll-into-view "Id" -a PID` — call before `wait-for` on virtualized ListView/repeater items below the fold |
| Set keyboard focus | `winapp ui focus "Id" -a PID` — cleaner than clicking another control to trigger a TextBox `LostFocus` commit |
### Testing File Pickers
File/folder pickers (FileOpenPicker, FileSavePicker, FolderPicker) run in a separate `PickerHost` process but are fully interactable. The picker appears as an owned dialog window.
```powershell
# 1. Trigger the picker
winapp ui invoke "BtnOpenFile" -a $AppPid
# 2. Find the picker window (it's a dialog owned by the app window)
Start-Sleep 1
$allWindows = winapp ui list-windows -a $AppPid --json 2>$null | ConvertFrom-Json
$picker = $allWindows | Where-Object { $_.title -match "Open|Save" }
$pickerHwnd = $picker.hwnd
# 3. Interact with the picker using -w <HWND>
# Type a filename:
winapp ui set-value "FileNameControlHost" "test.txt" -w $pickerHwnd
# Click Open/Save:
winapp ui invoke "Open" -w $pickerHwnd # or "Save", "Cancel"
# Or cancel:
winapp ui invoke "Cancel" -w $pickerHwnd
# 4. Verify the app processed the file
winapp ui wait-for "StatusBar" -a $AppPid -p Name --value "opened" -t 3000
```
**Tip:** Use `winapp ui inspect -w <pickerHwnd> --interactive` to discover the picker's controls — they include the folder tree, file list, filename textbox, and Open/Cancel buttons.
### Testing Context Menus and Flyouts
MenuFlyouts and ContextFlyouts are fully testable. They appear in the UI automation tree when open.
```powershell
# 1. Right-click to open a ContextFlyout
winapp ui click "LstItems" -a $AppPid --right
Start-Sleep 0.5
# 2. The flyout MenuItems appear in the tree immediately
# Find them with inspect or search:
winapp ui inspect -a $AppPid --interactive # shows MnuCopy, MnuDelete, etc.
# 3. Click a flyout item
winapp ui invoke "MnuCopy" -a $AppPid
# 4. Verify the action
winapp ui wait-for "StatusText" -a $AppPid -p Name --value "Copied" -t 2000
```
**For MenuBar flyouts** (File, Edit, View menus):
```powershell
# Click the menu header to open
winapp ui invoke "FileMenu" -a $AppPid
Start-Sleep 0.5
# Click the sub-item
winapp ui invoke "MenuSaveAs" -a $AppPid
```
### Testing ContentDialogs
ContentDialogs are in-app controls (same window) — they appear directly in the UI tree when shown.
```powershell
# 1. Trigger the dialog
winapp ui invoke "BtnDelete" -a $AppPid
Start-Sleep 0.5
# 2. The dialog buttons appear in the tree
# For a standard confirmation dialog:
winapp ui search "Primary" -a $AppPid --json # finds the primary button
winapp ui invoke "Primary" -a $AppPid # click "Yes"/"Delete"/"Save"
# Or:
winapp ui invoke "Secondary" -a $AppPid # click "No"/"Don't Save"
winapp ui invoke "Close" -a $AppPid # click "Cancel"
# 3. Wait for dialog to dismiss
winapp ui wait-for "Primary" -a $AppPid --gone -t 3000
```
**Tip:** ContentDialog buttons often don't have custom AutomationIds — use `inspect` to find the actual selector (slug or text match).
### Key Gotchas
- **`set-value` does NOT commit default TextBox bindings** — WinUI 3 `x:Bind TwoWay` on TextBox.Text updates the ViewModel on `LostFocus` by default. UIA `set-value` changes the text but doesn't trigger focus events. **Fix:** apps should use `UpdateSourceTrigger=PropertyChanged` on TextBox bindings (see design skill). If the app doesn't, `invoke` a button or `click` another element after `set-value` to trigger `LostFocus`.
- **Verify persistence via the data file, not UI relaunch** — killing and relaunching a packaged app from a test script is fragile (MSIX registration timing, PID issues). Instead, check the data file on disk: `Get-Content $dataFile | ConvertFrom-Json` and verify expected values.
- **Use `$AppPid` not `$Pid`** — `$Pid` is a read-only automatic variable in PowerShell
- **Use `--value` without `-p`** — it auto-detects the right UIA pattern (TextPattern → ValuePattern → TogglePattern → SelectionPattern → Name). Only use `-p PropertyName --value` when you need a specific property like `IsEnabled`
- **File pickers need `-w <HWND>`** — they run in a separate PickerHost process, so `-a PID` won't find them. Use `list-windows` to discover the picker HWND first
- **Flyouts need a short `Start-Sleep`** after triggering — the menu items appear in the tree asynchronously
### CRITICAL — `invoke` vs `click`: choose the right verb
**`winapp ui invoke <sel>`** dispatches through UIA's **`InvokePattern` via COM IPC**:
- ✅ Bypasses Windows UIPI (User Interface Privilege Isolation)
- ✅ Works even when your test runs elevated and the target is non-elevated AppX
- ✅ Does NOT steal foreground / does NOT trigger focus-loss handlers
- ✅ Works on Buttons, ListItems, ToggleSwitches, CheckBoxes — anything that exposes `InvokePattern` or `TogglePattern`
- ❌ Does NOT work on elements without an UIA action pattern (plain Grid, Text, Pane) — error message says "does not support any invoke pattern"
**`winapp ui click <sel>`** uses Win32 **`SendInput`** under the hood:
-**BLOCKED by UIPI** when source is elevated and target is non-elevated (or any AppX) — error: `SendInput failed — the target window may be elevated`
- ❌ Triggers foreground change → can dismiss popups, dialogs, AppX windows that hide on deactivation
- ✅ Only use when you genuinely need a synthetic mouse click (e.g. testing mouse hover/right-click flyouts where InvokePattern is unavailable)
- ✅ Subject to your process having interactive desktop access
**Rule of thumb**: try `invoke` first; only fall back to `click` if the target lacks InvokePattern AND you have a non-elevated test runner.
### CRITICAL — DataTemplate AutomationId vs ListItem InvokePattern
When XAML binds `AutomationProperties.AutomationId="{x:Bind <DataProperty>}"` inside a `ListView.ItemTemplate`'s `<DataTemplate>`, the AutomationId lives on the **inner Grid (Group)** the template produces — NOT on the outer ListItem the ListView wraps around it. The outer ListItem is what carries `InvokePattern`.
Concrete example (CmdPal PR #48033 binds Command.Id this way):
```powershell
# This FAILS with "does not support any invoke pattern":
winapp ui invoke 'com.microsoft.cmdpal.calculator' -w $hwnd
# Element grp-commicrosoftcmd-XXXX (Group) does not support any invoke pattern.
# No invokable ancestor was found.
# This WORKS — find by Name (matches all 3 siblings), pick the ListItem child:
$r = winapp ui search 'Calculator' -w $hwnd --json | ConvertFrom-Json
$li = $r.matches | Where-Object type -eq 'ListItem' | Select-Object -First 1
winapp ui invoke $li.selector -w $hwnd # selector like 'itm-calculator-7e3f'
```
If you encounter "does not support any invoke pattern" while trying to use a data-bound AutomationId, this is almost always the cause. The fix is to search by Name and invoke the sibling ListItem.
### CRITICAL — Keystroke input that bypasses UIPI (PostMessage)
`winapp ui` has no `send-keys` verb. For keystroke input into elevated/AppX targets where SendInput fails, use **inline Win32 `PostMessage WM_KEYDOWN/WM_KEYUP`** which goes through the target's message queue without UIPI checks:
```powershell
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class K {
[DllImport("user32.dll", CharSet=CharSet.Auto)]
public static extern bool PostMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp);
public const uint WM_KEYDOWN = 0x0100;
public const uint WM_KEYUP = 0x0101;
}
"@
function Send-KeyToHwnd {
param([IntPtr]$Hwnd, [byte]$Vk)
[void][K]::PostMessage($Hwnd, [K]::WM_KEYDOWN, [IntPtr]$Vk, [IntPtr]0)
Start-Sleep -Milliseconds 30
[void][K]::PostMessage($Hwnd, [K]::WM_KEYUP, [IntPtr]$Vk, [IntPtr]0)
}
# Common VK codes:
# 0x08 Backspace 0x09 Tab 0x0D Enter 0x1B Escape
# 0x25 Left 0x26 Up 0x27 Right 0x28 Down
Send-KeyToHwnd -Hwnd $h -Vk 0x28 # Down arrow
Send-KeyToHwnd -Hwnd $h -Vk 0x0D # Enter
```
**Caveats**:
- WinUI3 apps' raw-input hooks may NOT process some keys via WM_KEYDOWN — `Esc` in particular often goes ignored (use BackButton invoke instead). Arrow keys + Enter typically work for ListView navigation.
- PostMessage returns immediately; allow 50-200 ms before reading state.
- Repeat `Send-KeyToHwnd` calls work for multi-step navigation (Down × 5 to scroll, then Enter).
### CRITICAL — Global hotkeys / PowerToys activation chords (SendInput, verified working)
`PostMessage` above targets a specific window's queue. To fire a **global hotkey** (e.g. a PowerToys activation chord like `Win+Shift+C`) you must inject into the **system input stream** with `SendInput` so the low-level keyboard hook (`WH_KEYBOARD_LL`) sees it. This **works for Win+ chords** — the common belief that "Win+ chords can't be injected" is false; it's almost always a **marshaling bug** (`SendInput` returns `0`, `GetLastError()==87`) from building the `INPUT[]` array in PowerShell. Build the array in C#:
```powershell
Add-Type @"
using System; using System.Runtime.InteropServices; using System.Collections.Generic;
public static class Inj {
[StructLayout(LayoutKind.Sequential)]
struct INPUT { public uint type; public KEYBDINPUT ki; public int p1; public int p2; } // p1/p2 pad the union -> cb=40 on x64
[StructLayout(LayoutKind.Sequential)]
struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[DllImport("user32.dll", SetLastError=true)] static extern uint SendInput(uint n, INPUT[] p, int cb);
const uint KEYUP = 0x0002;
static INPUT K(ushort vk, bool up){ INPUT i=new INPUT(); i.type=1; i.ki.wVk=vk; i.ki.dwFlags=up?KEYUP:0; return i; }
public static uint Chord(ushort[] mods, ushort key){ // mods down -> key tap -> mods up (reverse)
var l=new List<INPUT>();
foreach(var m in mods) l.Add(K(m,false));
l.Add(K(key,false)); l.Add(K(key,true));
for(int i=mods.Length-1;i>=0;i--) l.Add(K(mods[i],true));
var a=l.ToArray(); return SendInput((uint)a.Length,a,Marshal.SizeOf(typeof(INPUT)));
}
}
"@
# LWIN=0x5B CTRL=0x11 SHIFT=0x10 ALT=0x12 ; main key VK from the module's settings.json "code"
$sent = [Inj]::Chord([uint16[]]@(0x5B,0x10), [uint16]0x43) # Win+Shift+C (Color Picker)
if ($sent -eq 0) { throw "SendInput failed err=$([Runtime.InteropServices.Marshal]::GetLastWin32Error())" }
```
**Caveats**:
- The injector must run at the **same or higher integrity level** as the hook owner (PowerToys runner). Default per-user installs run the runner at Medium IL, so a normal shell works; if the runner is elevated, run the injector elevated too (otherwise UIPI silently drops the injection).
- Must run in the interactive desktop session.
- OS-reserved chords (Win+L, Win+Tab) are consumed by Windows before any hook and cannot be injected this way.
- Verify the result via the runner trace log line `… hotkey is invoked from Centralized keyboard hook` (`%LOCALAPPDATA%\Microsoft\PowerToys\RunnerLogs\runner-log_<date>.log`) and/or the module's observable side-effect (overlay window, spawned editor process).
### CRITICAL — Verify foreground BEFORE every SendInput targeting a specific window
`SendInput` injects into the **session-wide** input stream — it goes to whatever IS foreground at the moment. If your target window has lost foreground (very common with AppX windows), the keys silently land in another window (often your own terminal) with no error returned.
Always check the foreground state immediately before calling `SendInput`. For winapp ui's output, the literal substring `foreground` appears in the line for the foreground window:
```powershell
function Test-AppForeground {
param([Parameter(Mandatory)][string]$AppId)
$r = winapp ui list-windows -a $AppId 2>$null | Out-String
return ($r -match 'foreground')
}
# Force foreground (works ONCE per session reliably; subsequent attempts may be blocked by
# Windows foreground-lock):
function Force-AppForeground {
param([Parameter(Mandatory)][IntPtr]$Hwnd, [int]$ProcessId)
Add-Type -TypeDefinition @'
using System; using System.Runtime.InteropServices;
public static class Fg {
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int pid);
}
'@ -EA SilentlyContinue
[Fg]::AllowSetForegroundWindow($ProcessId) | Out-Null
[Fg]::ShowWindow($Hwnd, 9) | Out-Null # SW_RESTORE
$fg = [Fg]::GetForegroundWindow(); $fgPid = 0
$fgThread = [Fg]::GetWindowThreadProcessId($fg, [ref]$fgPid)
$curThread = [Fg]::GetCurrentThreadId()
if ($fgThread -ne 0 -and $fgThread -ne $curThread) { [Fg]::AttachThreadInput($curThread, $fgThread, $true) | Out-Null }
[Fg]::BringWindowToTop($Hwnd) | Out-Null
[Fg]::SetForegroundWindow($Hwnd) | Out-Null
if ($fgThread -ne 0 -and $fgThread -ne $curThread) { [Fg]::AttachThreadInput($curThread, $fgThread, $false) | Out-Null }
Start-Sleep -Milliseconds 400
}
# Guard pattern: abort instead of silently sending keys to wrong window
if (-not (Test-AppForeground -AppId 'Microsoft.CmdPal.UI')) {
Force-AppForeground -Hwnd $h -ProcessId $pid
if (-not (Test-AppForeground -AppId 'Microsoft.CmdPal.UI')) {
throw 'Cannot force CmdPal foreground; aborting SendInput batch'
}
}
# ... now safe to SendInput ...
```
**Tip**: when foreground cannot be reliably maintained, prefer `winapp ui set-value` (UIA-IPC, no foreground required) or `winapp ui invoke` (UIA InvokePattern, no foreground required) instead of SendInput.
### CRITICAL — `set-value` bypasses TextChanged for some apps (CmdPal alias detection)
`winapp ui set-value` writes the value through UIA's ValuePattern, which fires a programmatic value-change event. **It does NOT raise the `TextBox.TextChanged` event** the way real keystrokes do. For apps whose logic listens to `TextChanged` rather than to property changes — most notably CmdPal's alias detection (typing `=`, `<`, `>`, `:`, `$`, `??`, `)` in MainSearchBox triggers navigation to a provider sub-page) — `set-value` will set the text but the alias will NOT activate.
Workarounds:
- For plain queries: `winapp ui set-value` works fine (CmdPal still re-runs all providers on value change).
- For alias-triggered navigation: use **real keystrokes** via Force-AppForeground + SendInput, typing one character at a time with ~60-100ms delay so the alias detector sees the TextChanged sequence.
- Alternative: invoke the provider tile directly by its stable AutomationId (e.g. `winapp ui invoke 'com.microsoft.cmdpal.calculator' -w $hwnd`) when you only need the destination page, not the alias path.
### CRITICAL — Stunted UIA tree recovery
After ~30+ rapid `set-value` calls or after AppX has been interactive too long, an AppX window's UIA tree can degrade to a "stunted" state where `winapp ui inspect -w $h --depth 6` returns only ~5 elements (TitleBar / Close / Min / Max / RootPane) — even though the app looks fine visually.
Probe + recover pattern:
```powershell
# Probe: any healthy ListView-based AppX has >50 UIA nodes at depth 6
$probe = winapp ui inspect -w $h --depth 6 --json | ConvertFrom-Json
$nodes = 0
$stack = [System.Collections.Stack]::new()
if ($probe.windows[0].elements) { foreach ($e in $probe.windows[0].elements) { $stack.Push($e) } }
while ($stack.Count -gt 0) {
$n = $stack.Pop(); $nodes++
if ($n.PSObject.Properties['children']) { foreach ($c in $n.children) { $stack.Push($c) } }
}
if ($nodes -lt 6) {
Write-Warning "UIA tree stunted ($nodes nodes); restarting AppX"
Get-Process Microsoft.CmdPal.UI -EA SilentlyContinue | ForEach-Object {
Stop-Process -Id $_.Id -Force
Wait-Process -Id $_.Id -Timeout 5 -EA SilentlyContinue
}
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
Start-Sleep 5
# Re-resolve HWND with list-windows
}
```
### Settings.json mutation safety contract
When the only realistic way to reach a needed test state is editing the app's persistent settings (e.g. multi-select that the UI's `SelectionItemPattern.Select` clobbers), wrap mutations with **byte-identical backup + restore-on-exit**:
```powershell
$settings = "$env:LOCALAPPDATA\Packages\Microsoft.CommandPalette_8wekyb3d8bbwe\LocalState\settings.json"
$backup = "$env:TEMP\settings-backup-$(Get-Random).json"
$origBytes = [System.IO.File]::ReadAllBytes($settings)
[System.IO.File]::WriteAllBytes($backup, $origBytes)
try {
# 1. Stop the AppX so we can write the file (apps usually hold it open)
Get-Process Microsoft.CmdPal.UI -EA SilentlyContinue | Stop-Process -Force
Start-Sleep 1
# 2. Mutate
$j = $origBytes | ForEach-Object { [char]$_ } | Join-String | ConvertFrom-Json
$j.SomeKey = 'TestValue'
[System.IO.File]::WriteAllBytes($settings, [System.Text.Encoding]::UTF8.GetBytes(($j | ConvertTo-Json -Depth 10)))
# 3. Restart AppX so it re-reads the mutated settings
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
Start-Sleep 5
# 4. ... run your test ...
} finally {
# ALWAYS restore — verify byte-identical via length + SHA256
Get-Process Microsoft.CmdPal.UI -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue
Start-Sleep 1
[System.IO.File]::WriteAllBytes($settings, $origBytes)
$check = [System.IO.File]::ReadAllBytes($settings)
if ($check.Length -ne $origBytes.Length) { Write-Error "Restore length mismatch!" }
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
}
```
**Important**: this should be used ONLY when the UI route is unreachable. Any setting flippable through the AppX Settings UI should be flipped that way instead (it's the documented user flow and tests real binding code).

View File

@@ -1,76 +0,0 @@
# scripts/pt-admin-probe.ps1
# Verify the current session is elevated AND that PT runner inherits the admin token.
if (-not ('PtTok' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class PtTok {
[DllImport("advapi32.dll", SetLastError=true)]
public static extern bool OpenProcessToken(IntPtr h, uint da, out IntPtr t);
[DllImport("advapi32.dll", SetLastError=true)]
public static extern bool GetTokenInformation(IntPtr t, uint c, IntPtr ti, uint l, out uint rl);
[DllImport("kernel32.dll")] public static extern IntPtr GetCurrentProcess();
[DllImport("kernel32.dll")] public static extern IntPtr OpenProcess(uint da, bool inh, int pid);
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
}
'@
}
function Test-PtAdmin {
<#
.SYNOPSIS
Check whether the current session is elevated by reading the process token's TokenElevation
information class (20). Returns $true if elevated.
#>
[CmdletBinding()] param()
$t = [IntPtr]::Zero
[PtTok]::OpenProcessToken([PtTok]::GetCurrentProcess(), 8, [ref]$t) | Out-Null
$ti = [Runtime.InteropServices.Marshal]::AllocHGlobal(4)
$rl = 0
try {
[PtTok]::GetTokenInformation($t, 20, $ti, 4, [ref]$rl) | Out-Null
return ([Runtime.InteropServices.Marshal]::ReadInt32($ti) -eq 1)
} finally {
[Runtime.InteropServices.Marshal]::FreeHGlobal($ti)
[PtTok]::CloseHandle($t) | Out-Null
}
}
function Test-ProcessElevated {
<#
.SYNOPSIS
Check whether a specific PID is elevated (TokenElevation = 1).
#>
[CmdletBinding()] param([Parameter(Mandatory)][int]$ProcessId)
$proc = [PtTok]::OpenProcess(0x1000, $false, $ProcessId) # PROCESS_QUERY_LIMITED_INFORMATION
if ($proc -eq [IntPtr]::Zero) { return $null }
try {
$t = [IntPtr]::Zero
if (-not [PtTok]::OpenProcessToken($proc, 8, [ref]$t)) { return $null }
try {
$ti = [Runtime.InteropServices.Marshal]::AllocHGlobal(4)
$rl = 0
try {
[PtTok]::GetTokenInformation($t, 20, $ti, 4, [ref]$rl) | Out-Null
return ([Runtime.InteropServices.Marshal]::ReadInt32($ti) -eq 1)
} finally { [Runtime.InteropServices.Marshal]::FreeHGlobal($ti) }
} finally { [PtTok]::CloseHandle($t) | Out-Null }
} finally { [PtTok]::CloseHandle($proc) | Out-Null }
}
function Test-PtRunnerAdmin {
<#
.SYNOPSIS
Check whether the PT runner (PowerToys.exe) is currently running elevated.
.OUTPUTS
PSCustomObject with .Found (bool), .Pid (int), .Elevated (bool|$null)
#>
$pt = Get-Process PowerToys -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $pt) { return [pscustomobject]@{ Found=$false; Pid=$null; Elevated=$null } }
[pscustomobject]@{
Found = $true
Pid = $pt.Id
Elevated = (Test-ProcessElevated -ProcessId $pt.Id)
}
}

View File

@@ -1,61 +0,0 @@
# scripts/pt-clipboard-diff.ps1
# Multi-format clipboard inspection. Used to assert that AdvancedPaste plain-paste actually strips
# rich formats while preserving UnicodeText (and similar before/after assertions).
Add-Type -AssemblyName System.Windows.Forms
function Get-PtClipboardFormats {
<#
.SYNOPSIS
Return the list of format names currently on the clipboard (e.g. UnicodeText, HTML Format,
Rich Text Format, FileDrop, DeviceIndependentBitmap, etc.).
#>
$obj = [System.Windows.Forms.Clipboard]::GetDataObject()
if (-not $obj) { return @() }
return $obj.GetFormats()
}
function Get-PtClipboardText {
[System.Windows.Forms.Clipboard]::GetText()
}
function Compare-PtClipboardFormatDiff {
<#
.SYNOPSIS
Diff helper. Given a 'before' formats list (from Get-PtClipboardFormats), return:
- Added: formats present in current clipboard but not in before
- Removed: formats present in before but not in current
- Common: formats present in both
.EXAMPLE
$before = Get-PtClipboardFormats # e.g. UnicodeText + HTML Format + RTF
# ... user/script triggers AP plain-paste ...
$diff = Compare-PtClipboardFormatDiff -Before $before
# $diff.Removed should contain 'HTML Format' and 'Rich Text Format'
# $diff.Common should still contain 'UnicodeText'
#>
param([Parameter(Mandatory)][string[]]$Before)
$current = Get-PtClipboardFormats
[pscustomobject]@{
Before = $Before
Current = $current
Added = @($current | Where-Object { $_ -notin $Before })
Removed = @($Before | Where-Object { $_ -notin $current })
Common = @($current | Where-Object { $_ -in $Before })
}
}
function Set-PtClipboardRich {
<#
.SYNOPSIS
Put HTML + UnicodeText on the clipboard so plain-paste detection has something to strip.
Useful as test fixture before invoking AdvancedPaste.PasteAsPlainText.
#>
param(
[string]$Text = 'Hello world',
[string]$Html = '<html><body><b>Hello</b> <i>world</i></body></html>'
)
$obj = New-Object System.Windows.Forms.DataObject
$obj.SetText($Text, [System.Windows.Forms.TextDataFormat]::UnicodeText)
$obj.SetText($Html, [System.Windows.Forms.TextDataFormat]::Html)
[System.Windows.Forms.Clipboard]::SetDataObject($obj, $true)
}

View File

@@ -1,99 +0,0 @@
# scripts/pt-cmdpal-recycle.ps1
# Recover CmdPal AppX from "stuck" states (TextChanged-broken, sub-page hang, foreground-lock).
# The helper Microsoft.CmdPal.Ext.PowerToys is kept alive so the CmdPal.Show event listener wiring
# is not lost on recycle.
function Reset-CmdPalAppX {
<#
.SYNOPSIS
Kill the Microsoft.CmdPal.UI process and relaunch the AppX. Returns the new HWND or 0 on failure.
.NOTES
Symptoms requiring this:
- set-value MainSearchBox echoes the text but ZERO ListItems appear within 1.5s
- winapp ui invoke <button> hangs subsequent inspect calls
- Force-PtForeground returns false repeatedly
#>
$cp = Get-Process Microsoft.CmdPal.UI -ErrorAction SilentlyContinue
if ($cp) {
Stop-Process -Id $cp.Id -Force
$deadline = (Get-Date).AddSeconds(5)
while ((Get-Process -Id $cp.Id -ErrorAction SilentlyContinue) -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 200
}
}
Start-Process 'shell:AppsFolder\Microsoft.CommandPalette_8wekyb3d8bbwe!App'
$deadline = (Get-Date).AddSeconds(10)
do {
Start-Sleep -Milliseconds 300
$r = winapp ui list-windows -a Microsoft.CmdPal.UI 2>$null | Out-String
if ($r -match 'HWND (\d+):') { return [IntPtr][int64]$matches[1] }
} while ((Get-Date) -lt $deadline)
return [IntPtr]::Zero
}
function Reset-CmdPalToHome {
<#
.SYNOPSIS
Navigate CmdPal back to the home page from any sub-page by invoking BackButton via UIA.
CmdPal's Esc handler is unreachable via SendInput from elevated sessions (UIPI), and Esc-via-
PostMessage is filtered by the WinUI 3 raw-input hook. BackButton invoke via UIA InvokePattern
works regardless.
#>
$homePlaceholder = 'Search for apps, files and commands'
for ($i = 0; $i -lt 6; $i++) {
$cur = winapp ui get-value 'MainSearchBox' -a Microsoft.CmdPal.UI 2>$null
if ($cur -and ($cur -match [regex]::Escape($homePlaceholder))) { break }
winapp ui invoke 'BackButton' -a Microsoft.CmdPal.UI 2>$null | Out-Null
Start-Sleep -Milliseconds 200
}
# Re-signal Show in case BackButton dismissed the window
if (Get-Command Invoke-PtSharedEvent -ErrorAction SilentlyContinue) {
try { Invoke-PtSharedEvent -Name 'CmdPal.Show' | Out-Null } catch {}
}
Start-Sleep -Milliseconds 500
}
function Test-CmdPalDegraded {
<#
.SYNOPSIS
Probe the AppX with a known-good query ('notepad') and verify >=1 ListItem appears within
1500ms. Returns $true if degraded (TextChanged-broken).
#>
Reset-CmdPalToHome
winapp ui set-value 'MainSearchBox' 'notepad' -a Microsoft.CmdPal.UI 2>$null | Out-Null
$deadline = (Get-Date).AddMilliseconds(1500)
do {
$insLines = (winapp ui inspect -a Microsoft.CmdPal.UI --depth 7 -i 2>$null) -split "`n"
$items = $insLines | Where-Object { $_ -match 'itm-' -and $_ -match 'ListItem' }
if ($items.Count -gt 0) {
winapp ui set-value 'MainSearchBox' '' -a Microsoft.CmdPal.UI 2>$null | Out-Null
return $false
}
Start-Sleep -Milliseconds 150
} while ((Get-Date) -lt $deadline)
return $true
}
function Invoke-CmdPalQuery {
<#
.SYNOPSIS
Type a query into MainSearchBox after returning to home. Auto-recovers if AppX is degraded.
Returns the result items as an array of strings (text lines starting with itm-).
.EXAMPLE
$items = Invoke-CmdPalQuery -Query 'notepad'
if ($items | Where-Object { $_ -match 'Notepad' }) { 'PASS' } else { 'FAIL' }
#>
param([Parameter(Mandatory)][string]$Query, [int]$WaitMs = 800)
Reset-CmdPalToHome
winapp ui set-value 'MainSearchBox' $Query -a Microsoft.CmdPal.UI 2>$null | Out-Null
Start-Sleep -Milliseconds $WaitMs
$out = winapp ui inspect -a Microsoft.CmdPal.UI --depth 7 -i 2>$null | Out-String
$items = ($out -split "`r?`n" | Where-Object { $_ -match 'itm-' -and $_ -match 'ListItem' })
if ($items.Count -eq 0) {
if (Test-CmdPalDegraded) {
Reset-CmdPalAppX | Out-Null
return (Invoke-CmdPalQuery -Query $Query -WaitMs $WaitMs)
}
}
return $items
}

View File

@@ -1,136 +0,0 @@
# scripts/pt-explorer-com.ps1
# Drive Explorer windows via Shell.Application COM to set up file selections, then trigger
# PT modules that read IShellItemArray from the foreground Explorer window (Peek, Image Resizer,
# PowerRename, File Locksmith, Workspaces).
#
# This bypasses needing a real mouse / interactive selection — Shell COM does the selection
# programmatically, then the PT hotkey (e.g. Ctrl+Space for Peek) fires the centralized hook
# which reads Explorer's selection at the moment of activation.
#
# Requires an interactive desktop session. If GetForegroundWindow() returns 0 or no Explorer
# windows are open, the functions return $null/$false instead of throwing — callers should
# treat that as a BLK-ENV signal (an environment block, not a product FAIL).
function Get-PtExplorerWindows {
<#
.SYNOPSIS
Return all open Explorer windows as Shell COM objects (with .LocationName, .Document.Folder, etc.).
Returns @() if no Explorer windows are open.
#>
try {
$shell = New-Object -ComObject Shell.Application
return @($shell.Windows() | Where-Object { $_.Name -eq 'File Explorer' -or $_.FullName -match 'explorer\.exe$' })
} catch { return @() }
}
function Open-PtExplorerAtPath {
<#
.SYNOPSIS
Open a fresh Explorer window at the given path. Returns the Shell COM window object.
Useful when no Explorer is open yet.
#>
[CmdletBinding()] param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path $Path)) { throw "Path not found: $Path" }
Start-Process explorer.exe -ArgumentList $Path
Start-Sleep -Milliseconds 1500
$wins = Get-PtExplorerWindows
# Note: the -replace must be wrapped in its own parens, otherwise the ',' in -replace '\\','/'
# is parsed as a second argument to [regex]::Escape() (overload error: "argument count: 2").
$needle = [regex]::Escape(((Resolve-Path $Path).Path -replace '\\','/'))
return ($wins | Where-Object { $_.LocationURL -match $needle } | Select-Object -First 1)
}
function Select-PtExplorerFiles {
<#
.SYNOPSIS
Select 1+ files in an open Explorer window via Shell COM. The window comes to foreground.
.DESCRIPTION
Uses Shell.Application's SelectItem(item, flags) API. Flags:
0x01 = SVSI_SELECT
0x04 = SVSI_DESELECTOTHERS (apply to the first item only when selecting multiple)
0x08 = SVSI_ENSUREVISIBLE
0x20 = SVSI_FOCUSED
Returns $true on success, $false if any file wasn't found in the folder.
.EXAMPLE
$win = Get-PtExplorerWindows | Select-Object -First 1
Select-PtExplorerFiles -ExplorerWindow $win -FileNames 'test-markdown.md','test-html.html','test-source.cs'
Send-PtChord -Mods 0x11 -Key 0x20 # Ctrl+Space → Peek opens on 3 selected files
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]$ExplorerWindow,
[Parameter(Mandatory)][string[]]$FileNames
)
if (-not $ExplorerWindow.Document) { return $false }
$folder = $ExplorerWindow.Document.Folder
$first = $true
foreach ($name in $FileNames) {
$item = $folder.ParseName($name)
if (-not $item) { Write-Warning "File not found in folder: $name"; return $false }
# First item: SELECT + DESELECTOTHERS + ENSUREVISIBLE + FOCUSED = 0x2D
# Subsequent items: SELECT + ENSUREVISIBLE = 0x09
$flags = if ($first) { 0x2D } else { 0x09 }
$ExplorerWindow.Document.SelectItem($item, $flags)
$first = $false
}
Start-Sleep -Milliseconds 300
return $true
}
function Invoke-PtPeekWithExplorerSelection {
<#
.SYNOPSIS
Set up an Explorer multi-file selection and trigger Peek via Ctrl+Space.
Returns the new Peek window HWND, or $null on failure.
.EXAMPLE
$h = Invoke-PtPeekWithExplorerSelection -FolderPath D:\fixtures -FileNames 'a.png','b.md','c.cs'
winapp ui invoke PinButton -w $h
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$FolderPath,
[Parameter(Mandatory)][string[]]$FileNames
)
$win = Get-PtExplorerWindows | Where-Object { $_.LocationURL -match [regex]::Escape(($FolderPath -replace '\\','/')) } | Select-Object -First 1
if (-not $win) { $win = Open-PtExplorerAtPath -Path $FolderPath }
if (-not $win) { return $null }
if (-not (Select-PtExplorerFiles -ExplorerWindow $win -FileNames $FileNames)) { return $null }
# Capture pre-state Peek HWND list to detect the new window
$beforeHwnds = @(Get-Process PowerToys.Peek.UI -EA SilentlyContinue | ForEach-Object MainWindowHandle)
# Fire Ctrl+Space (Peek default). Requires pt-sendinput-chord.ps1 to be dot-sourced first.
if (-not (Get-Command Send-PtChord -EA SilentlyContinue)) {
throw "Send-PtChord not loaded. Dot-source scripts/pt-sendinput-chord.ps1 first."
}
Send-PtChord -Mods 0x11 -Key 0x20 | Out-Null # Ctrl+Space
Start-Sleep -Milliseconds 1200
# Find the new Peek window HWND
$afterHwnds = @(Get-Process PowerToys.Peek.UI -EA SilentlyContinue | ForEach-Object MainWindowHandle)
$new = $afterHwnds | Where-Object { $_ -ne 0 -and $_ -notin $beforeHwnds } | Select-Object -First 1
if (-not $new) { $new = $afterHwnds | Where-Object { $_ -ne 0 } | Select-Object -First 1 }
return $new
}
function Test-PtInteractiveDesktop {
<#
.SYNOPSIS
Probe whether the current session is interactive (foreground + Shell COM both working).
Returns a PSCustomObject with .ForegroundOk and .ShellComOk.
.EXAMPLE
$env = Test-PtInteractiveDesktop
if (-not $env.ForegroundOk -or -not $env.ShellComOk) {
Write-Warning "Non-interactive session — Explorer-driven techniques will fail."
}
#>
Add-Type 'using System; using System.Runtime.InteropServices; public class FG3 { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); }' -EA SilentlyContinue
$hasFg = $false
for ($i = 0; $i -lt 5; $i++) {
if ([FG3]::GetForegroundWindow() -ne [IntPtr]::Zero) { $hasFg = $true; break }
Start-Sleep -Milliseconds 200
}
$shellOk = $false
try { @((New-Object -ComObject Shell.Application).Windows()).Count | Out-Null; $shellOk = $true } catch {}
[pscustomobject]@{ ForegroundOk = $hasFg; ShellComOk = $shellOk }
}

View File

@@ -1,96 +0,0 @@
# pt-explorer-contextmenu.ps1 — drive any Explorer (Win11) context-menu PowerToys module
# end-to-end the way a real user does: open Explorer, select file(s), synthetic right-click
# to OPEN the menu, then UIA-invoke the module's menu item by NAME (robust — no coordinate
# click). Used by File Locksmith, Image Resizer, PowerRename, New+, etc.
#
# See explorer-context-menu-flow.md for the full write-up, stability notes, and per-module captions.
#
# Requires an UNLOCKED interactive desktop (synthetic right-click needs foreground). Check first:
# if ([PtFg]::GetForegroundWindow() -eq [IntPtr]::Zero) -> desktop locked -> BLK-ENV.
Add-Type -TypeDefinition @'
using System; using System.Runtime.InteropServices;
public static class PtCtx {
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, IntPtr e);
public const uint RIGHTDOWN=0x0008, RIGHTUP=0x0010, LEFTDOWN=0x0002, LEFTUP=0x0004;
public static void ForceForeground(IntPtr h) {
IntPtr fg = GetForegroundWindow(); uint fp;
uint ft = GetWindowThreadProcessId(fg, out fp); uint ct = GetCurrentThreadId();
ShowWindow(h, 9);
if (ft != 0 && ft != ct) AttachThreadInput(ct, ft, true);
BringWindowToTop(h); SetForegroundWindow(h);
if (ft != 0 && ft != ct) AttachThreadInput(ct, ft, false);
}
public static void RightClick(int x, int y) {
SetCursorPos(x, y); System.Threading.Thread.Sleep(250);
mouse_event(RIGHTDOWN,0,0,0,IntPtr.Zero); System.Threading.Thread.Sleep(70); mouse_event(RIGHTUP,0,0,0,IntPtr.Zero);
}
}
'@ -ErrorAction SilentlyContinue
function Test-PtDesktopInteractive {
# Polls up to $TimeoutSec for a foreground window. A momentary 0 is common for a few seconds
# right after Restart-PtRunner / Explorer restart — without the poll that blip is misclassified
# as a locked desktop (false BLK-ENV). A genuinely locked/non-interactive desktop stays 0 for
# the whole window and still returns $false.
param([int]$TimeoutSec = 5)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
if ([PtCtx]::GetForegroundWindow() -ne [IntPtr]::Zero) { return $true }
Start-Sleep -Milliseconds 250
} while ((Get-Date) -lt $deadline)
return $false
}
# Opens the Win11 context menu for a file in an already-open Explorer window and returns the
# menu popup HWND. $ExplorerHwnd = the CabinetWClass window; $FileName = item to right-click.
function Open-PtExplorerContextMenu {
param([Parameter(Mandatory)][int]$ExplorerHwnd, [Parameter(Mandatory)][string]$FileName, [int]$MaxTries = 3)
if (-not (Test-PtDesktopInteractive)) { throw 'BLK-ENV: desktop is locked / no foreground (GetForegroundWindow()=0). Unlock and retry.' }
for ($try = 1; $try -le $MaxTries; $try++) {
[PtCtx]::ForceForeground([IntPtr]$ExplorerHwnd); Start-Sleep -Milliseconds 500
$item = (winapp ui search $FileName -w $ExplorerHwnd --json 2>$null | ConvertFrom-Json).matches |
Where-Object { $_.type -eq 'ListItem' } | Select-Object -First 1
if (-not $item) { throw "File item '$FileName' not found in Explorer window $ExplorerHwnd" }
# Right-click near the row's LEFT edge (on the filename), not the geometric center:
# in Details view the ListItem rect spans ~full row width (measured 71% of window), so
# x+width/2 lands far right over other columns / empty canvas → background menu or missed
# click. x + min(80, width/2) is on the filename in Details AND on the tile in Icons view.
[PtCtx]::RightClick([int]($item.x + [Math]::Min(80, $item.width/2)), [int]($item.y + $item.height/2))
Start-Sleep -Seconds 2
# The Win11 menu is its own top-level popup window:
$menu = winapp ui list-windows --json 2>$null | ConvertFrom-Json |
Where-Object { $_.className -match 'PopupWindowSiteBridge' } | Sort-Object height -Descending | Select-Object -First 1
if ($menu) { return $menu.hwnd }
Start-Sleep -Milliseconds 500 # retry: foreground/menu wasn't ready (common on the first attempt right after Explorer opens)
}
throw "Context-menu popup window not found after $MaxTries right-click attempts"
}
# Invokes a context-menu item by its visible NAME (robust — UIA InvokePattern, no coord click).
# Returns $true if invoked. Match the module caption, e.g.:
# File Locksmith -> 'Unlock with File Locksmith' PowerRename -> 'Rename with PowerRename'
# Image Resizer -> 'Resize images' (verify by enumerating) New+ -> 'New+'
function Invoke-PtContextMenuItem {
param([Parameter(Mandatory)][int]$MenuHwnd, [Parameter(Mandatory)][string]$ItemName)
$m = (winapp ui search $ItemName -w $MenuHwnd --json 2>$null | ConvertFrom-Json).matches |
Where-Object { $_.type -eq 'MenuItem' } | Select-Object -First 1
if (-not $m) { return $false } # caller can treat $false as "entry absent" (e.g. module disabled)
winapp ui invoke $m.selector -w $MenuHwnd 2>$null | Out-Null
return $true
}
# Lists all context-menu item names (for discovering a module's caption or asserting absence).
function Get-PtContextMenuItems {
param([Parameter(Mandatory)][int]$MenuHwnd)
winapp ui inspect -w $MenuHwnd --depth 8 2>$null | Out-String |
Select-String 'MenuItem "([^"]+)"' -AllMatches | ForEach-Object { $_.Matches } | ForEach-Object { $_.Groups[1].Value }
}

View File

@@ -1,100 +0,0 @@
# scripts/pt-foreground-guard.ps1
# Verify and force a window to foreground BEFORE sending SendInput.
# Without this guard, SendInput keys silently leak to the caller's terminal when
# the target window has lost foreground (common with CmdPal AppX where Windows
# foreground-lock blocks SetForegroundWindow after the first attempt).
#
# Use winapp ui set-value for UIA-friendly inputs (no foreground required).
# Use this guard ONLY when you need real keystrokes (e.g. CmdPal alias detection).
if (-not ('PtFg' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class PtFg {
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int pid);
}
'@
}
function Test-PtForeground {
<#
.SYNOPSIS
Check whether the target AppX is currently foreground by parsing winapp ui list-windows output
for the literal substring 'foreground'.
#>
param([Parameter(Mandatory)][string]$AppId)
$r = winapp ui list-windows -a $AppId 2>$null | Out-String
return ($r -match 'foreground')
}
function Get-PtHwnd {
<#
.SYNOPSIS
Return the first HWND for the given AppX/exe. Returns [IntPtr]::Zero if none.
#>
param([Parameter(Mandatory)][string]$AppId)
$r = winapp ui list-windows -a $AppId 2>$null | Out-String
if ($r -match 'HWND (\d+):') { return [IntPtr][int64]$matches[1] }
return [IntPtr]::Zero
}
function Force-PtForeground {
<#
.SYNOPSIS
Force the target AppX window to foreground using the AttachThreadInput + AllowSetForegroundWindow
trick. Returns $true if window is foreground after this attempt; $false otherwise.
.NOTES
Windows foreground-lock will block subsequent SetForegroundWindow calls in the same session if
a real interactive event hasn't fired recently. If this returns $false repeatedly, the only
reliable recovery is to recycle the AppX (kill + relaunch via shell:AppsFolder URI).
#>
param([Parameter(Mandatory)][string]$AppId)
$h = Get-PtHwnd -AppId $AppId
if ($h -eq [IntPtr]::Zero) { return $false }
# Permission grant
$proc = Get-Process | Where-Object { $_.MainWindowHandle -eq $h } | Select-Object -First 1
if ($proc) { [PtFg]::AllowSetForegroundWindow($proc.Id) | Out-Null }
[PtFg]::ShowWindow($h, 9) | Out-Null # SW_RESTORE
Start-Sleep -Milliseconds 150
# AttachThreadInput trick
$fg = [PtFg]::GetForegroundWindow()
$fgPid = 0
$fgThread = [PtFg]::GetWindowThreadProcessId($fg, [ref]$fgPid)
$curThread = [PtFg]::GetCurrentThreadId()
if ($fgThread -ne 0 -and $fgThread -ne $curThread) {
[PtFg]::AttachThreadInput($curThread, $fgThread, $true) | Out-Null
}
[PtFg]::BringWindowToTop($h) | Out-Null
[PtFg]::SetForegroundWindow($h) | Out-Null
[PtFg]::ShowWindow($h, 5) | Out-Null # SW_SHOW
if ($fgThread -ne 0 -and $fgThread -ne $curThread) {
[PtFg]::AttachThreadInput($curThread, $fgThread, $false) | Out-Null
}
Start-Sleep -Milliseconds 400
return (Test-PtForeground -AppId $AppId)
}
function Assert-PtForegroundOrAbort {
<#
.SYNOPSIS
Guard helper. Throws if the target AppX is NOT foreground. Use this immediately before any
SendInput call to ensure keys don't leak to the wrong window.
#>
param([Parameter(Mandatory)][string]$AppId)
if (-not (Test-PtForeground -AppId $AppId)) {
if (-not (Force-PtForeground -AppId $AppId)) {
throw "ABORT: $AppId is not foreground and cannot be forced foreground. SendInput would leak to wrong window."
}
}
}

View File

@@ -1,76 +0,0 @@
# pt-nonelevated.ps1 — launch a process at MEDIUM integrity (non-elevated) from an
# already-elevated agent shell. Needed for tests that assert elevation-dependent
# visibility (e.g. File Locksmith L649/L650: a non-elevated module must NOT see
# higher-integrity processes; an elevated one must).
#
# The drive-stack in SKILL.md only covers gaining MORE privilege. De-elevation is the
# opposite problem: from a High-IL shell you cannot simply CreateProcess a Medium-IL
# child. The robust, dependency-free way is a one-shot Scheduled Task registered with
# RunLevel=Limited + LogonType=Interactive, which lands on the logged-on user's desktop
# at their filtered (medium) token. (The classic explorer-shell-injection trick is more
# fragile across sessions.)
#
# Functions:
# Start-PtNonElevated -Exe <path> [-Arguments <str>] -> launches a GUI/console exe non-elevated, returns the spawned PID(s)
# Invoke-PtNonElevatedCapture -Exe <path> -Arguments <str> -OutFile <path> -> runs a console exe non-elevated, redirects stdout/err to a file, waits, returns the file path
#
# Verify elevation of the result with Test-ProcessElevated (scripts/pt-admin-probe.ps1).
function Start-PtNonElevated {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Exe,
[string]$Arguments = '',
[int]$WaitSeconds = 5,
[string]$MatchProcessName # optional: base name to enumerate spawned PIDs (e.g. 'PowerToys.FileLocksmithUI')
)
if (-not (Test-Path $Exe)) { throw "Exe not found: $Exe" }
$taskName = "PtNonElev_$([guid]::NewGuid().ToString('N').Substring(0,8))"
$before = @()
if ($MatchProcessName) { $before = @(Get-Process -Name $MatchProcessName -EA SilentlyContinue | Select-Object -Expand Id) }
try {
$action = New-ScheduledTaskAction -Execute $Exe -Argument $Arguments
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -RunLevel Limited -LogonType Interactive
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds $WaitSeconds
if ($MatchProcessName) {
$after = @(Get-Process -Name $MatchProcessName -EA SilentlyContinue | Select-Object -Expand Id)
return ($after | Where-Object { $_ -notin $before })
}
return $null
}
finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue
}
}
function Invoke-PtNonElevatedCapture {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Exe,
[string]$Arguments = '',
[Parameter(Mandatory)][string]$OutFile,
[int]$TimeoutSeconds = 30
)
if (-not (Test-Path $Exe)) { throw "Exe not found: $Exe" }
Remove-Item $OutFile -EA SilentlyContinue
$wrap = [IO.Path]::ChangeExtension($OutFile, '.cmd')
"`"$Exe`" $Arguments > `"$OutFile`" 2>&1" | Set-Content -Encoding ascii $wrap
$taskName = "PtNonElev_$([guid]::NewGuid().ToString('N').Substring(0,8))"
try {
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c `"$wrap`""
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -RunLevel Limited -LogonType Interactive
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do { Start-Sleep 1; $info = Get-ScheduledTaskInfo -TaskName $taskName }
while ($info.LastTaskResult -eq 267009 -and (Get-Date) -lt $deadline) # 267009 = task still running
Start-Sleep 1
return $OutFile
}
finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -EA SilentlyContinue
Remove-Item $wrap -EA SilentlyContinue
}
}

View File

@@ -1,94 +0,0 @@
# scripts/pt-sendinput-chord.ps1
# Inject a global hotkey chord (e.g. Win+Shift+/) into the system input stream.
# Critical: INPUT struct MUST be cb=40 on x64 (with padding for the MOUSEINPUT union member).
# The common bug "Win+ hotkeys can't be injected" is a marshaling error producing 32-byte struct
# and SendInput returns 0 with GetLastError()==87 (ERROR_INVALID_PARAMETER).
#
# This SHOULD be a last resort. Prefer Named Events (Invoke-PtSharedEvent) when the module exposes one.
# Use this only for: (a) explicit hotkey-trigger verification tests, (b) modules without Named Events,
# (c) UI keystrokes inside an already-foreground window (use Send-KeyToHwnd via PostMessage instead
# for elevated -> non-elevated AppX, see references/winapp-ui-testing.md).
if (-not ('PtChord' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
using System.Collections.Generic;
public static class PtChord {
[StructLayout(LayoutKind.Sequential)]
struct INPUT { public uint type; public KEYBDINPUT ki; public int pad1; public int pad2; } // pad to 40 bytes
[StructLayout(LayoutKind.Sequential)]
struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
[DllImport("user32.dll", SetLastError=true)]
static extern uint SendInput(uint n, INPUT[] p, int cb);
const uint KEYUP = 0x0002;
static INPUT K(ushort vk, bool up) { INPUT i=new INPUT(); i.type=1; i.ki.wVk=vk; i.ki.dwFlags=up?KEYUP:0; return i; }
public static uint Chord(ushort[] mods, ushort key) {
var l=new List<INPUT>();
foreach(var m in mods) l.Add(K(m,false));
l.Add(K(key,false)); l.Add(K(key,true));
for(int i=mods.Length-1;i>=0;i--) l.Add(K(mods[i],true));
var a=l.ToArray();
return SendInput((uint)a.Length, a, Marshal.SizeOf(typeof(INPUT)));
}
public static uint Tap(ushort key) { return Chord(new ushort[0], key); }
}
'@
}
# Common VK codes for chord mods:
# LWIN=0x5B RWIN=0x5C CTRL=0x11 SHIFT=0x10 ALT=0x12
# Main key VKs:
# 0x08 Backspace 0x09 Tab 0x0D Enter 0x1B Escape 0x20 Space
# 0x25 Left 0x26 Up 0x27 Right 0x28 Down
# 0x30..0x39 0..9 0x41..0x5A A..Z
function Send-PtChord {
<#
.SYNOPSIS
Inject a hotkey chord. Returns number of inputs Windows accepted (0 = failed; check GetLastError).
.EXAMPLE
Send-PtChord -Mods 0x5B,0x10 -Key 0x43 # Win+Shift+C (Color Picker)
Send-PtChord -Mods 0x5B,0x11 -Key 0x52 # Win+Ctrl+R (PowerOcr)
Send-PtChord -Mods 0x5B,0xA4 -Key 0x20 # Win+Alt+Space (CmdPal default)
Send-PtChord -Key 0x0D # plain Enter (no mods)
#>
[CmdletBinding()]
param(
[uint16[]]$Mods = @(),
[Parameter(Mandatory)][uint16]$Key
)
$sent = [PtChord]::Chord($Mods, $Key)
if ($sent -eq 0) {
$err = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
throw "SendInput failed (returned 0, GetLastError=$err). Likely caller is at lower integrity than PT runner, or chord is OS-reserved (Win+L, Win+Tab)."
}
return $sent
}
function Wait-PtHotkeyAccepted {
<#
.SYNOPSIS
After Send-PtChord, verify the PT runner saw it by tailing its log for the centralized-hook line.
Returns the matching log line (if any) within $TimeoutSec.
.EXAMPLE
Send-PtChord -Mods 0x5B,0x10 -Key 0x43
$line = Wait-PtHotkeyAccepted -ModuleHint 'Color' -TimeoutSec 3
if (-not $line) { throw "Runner did not log hotkey invocation" }
#>
[CmdletBinding()]
param([string]$ModuleHint = '', [int]$TimeoutSec = 3)
$log = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\PowerToys\RunnerLogs" -Filter 'runner-log_*.log' -EA SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $log) { return $null }
$start = (Get-Date).AddSeconds(-2)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$line = Get-Content $log.FullName -Tail 50 -EA SilentlyContinue |
Where-Object { $_ -match 'hotkey is invoked from Centralized keyboard hook' -and ($ModuleHint -eq '' -or $_ -match $ModuleHint) } |
Select-Object -Last 1
if ($line) { return $line }
Start-Sleep -Milliseconds 200
} while ((Get-Date) -lt $deadline)
return $null
}

Some files were not shown because too many files have changed in this diff Show More