diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..50268c2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.md] +indent_size = 2 +trim_trailing_whitespace = false + +[*.json] +indent_size = 2 + +[*.yml] +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d615fff --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.ps1 text eol=lf +*.psm1 text eol=lf +*.psd1 text eol=lf \ No newline at end of file diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..cc8e91c --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,47 @@ +name: validate + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + powershell: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - name: Parse PowerShell scripts + shell: pwsh + run: | + $failed = $false + Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $failed = $true + Write-Error "$($_.FullName) has parser errors: $($errors.Message -join '; ')" + } + } + if ($failed) { exit 1 } + - name: Check Markdown final newlines + shell: pwsh + run: | + $failed = $false + Get-ChildItem -Recurse -File -Filter *.md | ForEach-Object { + $bytes = [System.IO.File]::ReadAllBytes($_.FullName) + if ($bytes.Length -gt 0 -and $bytes[-1] -ne 10) { + $failed = $true + Write-Error "$($_.FullName) must end with a newline." + } + } + if ($failed) { exit 1 } + - name: Analyze synthetic baseline + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path .\sysadmin-windows-startup-performance\reports,.\sysadmin-windows-startup-performance\state | Out-Null + powershell -NoProfile -ExecutionPolicy Bypass -File .\sysadmin-windows-startup-performance\scripts\analyze-startup-baseline.ps1 -BaselinePath .\sysadmin-windows-startup-performance\examples\synthetic-baseline.json -OutputPath .\sysadmin-windows-startup-performance\reports\synthetic-analysis.md -AnalysisOutputPath .\sysadmin-windows-startup-performance\state\synthetic-analysis.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9513dbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Generated local performance data +**/state/* +**/reports/* +**/logs/* +!**/state/.gitkeep +!**/reports/.gitkeep +!**/logs/.gitkeep + +# PowerShell and editor noise +*.tmp +*.log +*.bak +*.ps1xml +.vscode/ +.idea/ + +# OS noise +Thumbs.db +Desktop.ini \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..834a7b0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Agent Instructions + +- When creating or editing Markdown files, always end each `.md` file with a final newline. +- Keep skills small, composable, and safe by default. +- Do not commit generated reports, state files, logs, or machine-specific diagnostic captures. +- Prefer synthetic examples over real local machine data in public artifacts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bc19851 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Contributing + +Contributions should keep skills small, composable, and safe by default. + +## Guidelines + +- Keep `SKILL.md` concise and move detailed material into directly linked `references/` files. +- Prefer deterministic scripts for repeated or fragile workflows. +- Keep generated state, reports, logs, and machine-specific artifacts out of git. +- Avoid destructive behavior in diagnostic skills. When remediation is needed, make it a separate reviewed workflow. +- Include synthetic examples rather than real machine captures. +- Put skill-specific usage, safety, data-handling, and validation details inside the owning skill folder. + +## Validation + +Before opening a pull request: + +- Run your runtime's skill validator for every changed skill. +- Parse or lint bundled scripts with the language's normal tooling. +- Run the synthetic examples or validation commands documented by each changed skill. +- Confirm generated outputs remain ignored by git. + +The repository workflow also checks PowerShell parsing, Markdown final newlines, and the current synthetic analysis example. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..175169d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 KarmCraft + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6169d60 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Skills Library + +A public collection of agent skills that I have found useful enough to package, maintain, and share. + +The goal is to keep this library practical: composable, reusable skills that solve real workflow problems for humans and agents using `SKILL.md` compatible runtimes. Each skill should be small enough to understand, safe by default, and easy to combine with other skills. + +Each top-level folder is a self-contained, directly installable skill. + +## Skills + +| Skill | Purpose | +| --- | --- | +| [`sysadmin-windows-startup-performance`](sysadmin-windows-startup-performance/) | Diagnose Windows startup performance with read-only baselines and data-driven analysis. | + +## Install + +Copy the skill folder you want into the skill directory used by your agent runtime. + +For Codex on Windows: + +```powershell +$SkillName = "" +Copy-Item -Recurse ".\$SkillName" "$env:USERPROFILE\.codex\skills\$SkillName" +``` + +For other `SKILL.md` compatible tools, use their documented skill location. + +## Repository Layout + +```text +/ + SKILL.md + agents/openai.yaml + scripts/ + tools/ + references/ + examples/ + templates/ +``` + +Use `tools/` for helper CLIs, adapters, or small utilities that support a skill but are not the primary workflow scripts. Keep primary repeatable workflows in `scripts/`, long-form guidance in `references/`, and avoid vendoring large binaries, secrets, or machine-specific executables. + +Not every skill uses every optional folder. Generated reports, state files, and logs are intentionally ignored by git. + +## Safety + +Skills should be safe by default and explicit about their boundaries. Diagnostic skills should collect evidence before recommending changes, and any remediation workflow should be separately reviewed, reversible, and opt-in. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..87fb09e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Reporting + +Please report security issues privately through GitHub security advisories if available, or by opening a minimal issue that does not include secrets or exploit details. + +## Data Handling + +Do not attach real diagnostic captures, generated reports, or local inventory unless you have reviewed and sanitized them first. + +Before sharing artifacts, remove hostnames, usernames, domains, command-line arguments, environment paths, service or task arguments, secrets, tokens, and local network identifiers. + +Collectors should default to least-sensitive output and document any switches that include more detail in the owning skill's safety reference. diff --git a/sysadmin-windows-startup-performance/SKILL.md b/sysadmin-windows-startup-performance/SKILL.md new file mode 100644 index 0000000..a52546e --- /dev/null +++ b/sysadmin-windows-startup-performance/SKILL.md @@ -0,0 +1,74 @@ +--- +name: sysadmin-windows-startup-performance +description: Collect and analyze read-only Windows startup performance baselines. Use when Codex is asked to diagnose slow Windows boot or login, collect Diagnostics-Performance event log data, inspect startup apps, services, scheduled tasks, resource pressure, recent boot warnings, or prepare conservative data-driven recommendations without changing system configuration. +--- + +# Sysadmin Windows Startup Performance + +## Overview + +Use this skill for read-only Windows startup diagnostics. It collects sanitized startup baselines, analyzes likely bottlenecks, and produces JSON plus Markdown outputs that other skills can consume. + +## Files + +- `scripts/collect-startup-baseline.ps1`: elevated read-only collector for Diagnostics-Performance events, startup inventory, service/task signals, resources, and recent boot warnings. +- `scripts/analyze-startup-baseline.ps1`: analyzer that ranks findings and writes a Markdown report. +- `references/data-model.md`: baseline and analysis schema notes. +- `references/safety.md`: operating rules and remediation boundaries. +- `examples/synthetic-baseline.json`: synthetic fixture for analyzer validation. +- `tools/`: reserved for optional helper CLIs, adapters, or small utilities that support this skill. +- `templates/local-performance/`: optional project-local diagnostic hub scaffold. + +## Workflow + +1. Read `references/safety.md` before collecting data or recommending changes. +2. Create or use a local hub folder with `scripts/`, `state/`, `reports/`, and `logs/`. A template is available at `templates/local-performance/` when this repository is installed as a project. +3. Run the collector from an elevated PowerShell session. Do not fall back to non-admin collection because protected boot timing logs can be missed. +4. Run the analyzer on the latest or specified baseline. +5. Prefer at least three comparable post-reboot baselines before recommending startup changes. +6. If the user asks for remediation, produce a dry-run plan with rollback notes first. Do not mutate startup apps, registry entries, scheduled tasks, services, drivers, security tools, or Windows settings without explicit approval. + +## Commands + +Collect a baseline: + +```powershell +.\scripts\collect-startup-baseline.ps1 +``` + +Analyze the latest baseline from the same hub: + +```powershell +.\scripts\analyze-startup-baseline.ps1 +``` + +Analyze a specific baseline: + +```powershell +.\scripts\analyze-startup-baseline.ps1 -BaselinePath .\state\startup-baseline-YYYYMMDD-HHMMSS.json +``` + +Include full command-line previews only when explicitly needed: + +```powershell +.\scripts\collect-startup-baseline.ps1 -IncludeCommandLines +``` + +## Validation + +When modifying this skill, run the analyzer against the synthetic baseline from the repository root: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\sysadmin-windows-startup-performance\scripts\analyze-startup-baseline.ps1 -BaselinePath .\sysadmin-windows-startup-performance\examples\synthetic-baseline.json -OutputPath .\sysadmin-windows-startup-performance\reports\synthetic-analysis.md -AnalysisOutputPath .\sysadmin-windows-startup-performance\state\synthetic-analysis.json +``` + +The command writes ignored `reports/` and `state/` outputs under the skill folder. + +## Output + +The collector writes `state/startup-baseline-*.json` unless `-NoWrite` or `-OutputPath` is used. The analyzer writes: + +- `reports/startup-baseline-analysis-*.md` +- `state/startup-analysis-*.json` + +See `references/data-model.md` before building downstream tooling against these files. diff --git a/sysadmin-windows-startup-performance/agents/openai.yaml b/sysadmin-windows-startup-performance/agents/openai.yaml new file mode 100644 index 0000000..fdef6a7 --- /dev/null +++ b/sysadmin-windows-startup-performance/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Sysadmin Windows Startup Performance" + short_description: "Diagnose Windows startup performance." + default_prompt: "For a first-time assessment, guide the user through five reboot cycles. After each boot, wait 3 to 5 minutes, then run $sysadmin-windows-startup-performance to collect one read-only startup baseline. After the fifth collection, run the single-baseline analyzer on the latest collection; when comparison is needed, analyze earlier baseline files one at a time and summarize repeated patterns." diff --git a/sysadmin-windows-startup-performance/examples/synthetic-baseline.json b/sysadmin-windows-startup-performance/examples/synthetic-baseline.json new file mode 100644 index 0000000..75bbf11 --- /dev/null +++ b/sysadmin-windows-startup-performance/examples/synthetic-baseline.json @@ -0,0 +1,182 @@ +{ + "schema_version": 1, + "kind": "windows_startup_baseline", + "collected_at_local": "2026-01-15T08:05:00+00:00", + "collected_at_utc": "2026-01-15T08:05:00Z", + "machine": { + "computer_name": "EXAMPLE-PC", + "manufacturer": "Example Vendor", + "model": "Example Workstation", + "domain_role": 0 + }, + "operating_system": { + "caption": "Microsoft Windows 11 Pro", + "version": "10.0.26100", + "build_number": "26100" + }, + "boot": { + "last_boot_local": "2026-01-15T08:00:00+00:00", + "uptime_seconds": 300 + }, + "diagnostics_performance_events": [ + { + "provider": "Microsoft-Windows-Diagnostics-Performance", + "id": 100, + "level": "Warning", + "time_created": "2026-01-15T08:01:00+00:00", + "record_id": 1001, + "data": { + "BootTime": "78000", + "MainPathBootTime": "32000", + "BootPostBootTime": "46000" + } + }, + { + "provider": "Microsoft-Windows-Diagnostics-Performance", + "id": 101, + "level": "Warning", + "time_created": "2026-01-15T08:01:10+00:00", + "record_id": 1002, + "data": { + "Name": "ExampleSync.exe", + "TotalTime": "12000" + } + } + ], + "diagnostics_performance_collection": { + "log_name": "Microsoft-Windows-Diagnostics-Performance/Operational", + "requested_recent_event_count": 300, + "requested_boot_event_target": 25, + "requested_scan_count": 5000, + "effective_scan_count": 5000, + "scanned_event_count": 128, + "matched_diagnostics_event_count": 2, + "matched_boot_event_count": 1, + "returned_event_count": 2, + "returned_boot_event_count": 1, + "boot_target_satisfied": false, + "scan_limit_reached": false, + "target_window_event_count": 2, + "oldest_target_boot_time": "2026-01-15T08:01:00+00:00", + "newest_returned_time": "2026-01-15T08:01:10+00:00", + "oldest_returned_time": "2026-01-15T08:01:00+00:00", + "mode": "boot-targeted" + }, + "startup": { + "registry_entries": [ + { + "scope": "CurrentUser", + "kind": "Run", + "key_path": "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", + "name": "Example Launcher", + "command": { + "executable": "C:\\Program Files\\Example\\ExampleLauncher.exe", + "executable_name": "ExampleLauncher.exe", + "arguments_present": false, + "command_hash_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + } + ], + "startup_folder_items": [], + "scheduled_startup_logon_tasks": [ + { + "task_path": "\\Example\\", + "task_name": "ExampleLogonTask", + "state": "Ready", + "author": "Example Vendor", + "trigger_types": [ + "MSFT_TaskLogonTrigger" + ], + "actions": [ + { + "execute": "ExampleTask.exe", + "execute_name": "ExampleTask.exe", + "arguments_present": true, + "arguments_hash_sha256": "1111111111111111111111111111111111111111111111111111111111111111" + } + ] + } + ], + "auto_start_services": [ + { + "name": "ExampleService", + "display_name": "Example Service", + "state": "Running", + "status": "OK", + "delayed_auto_start": false, + "service_account": "LocalSystem", + "path": { + "executable": "C:\\Program Files\\Example\\ExampleService.exe", + "executable_name": "ExampleService.exe", + "arguments_present": false, + "command_hash_sha256": "2222222222222222222222222222222222222222222222222222222222222222" + } + } + ] + }, + "resources": { + "disks": [ + { + "device_id": "C:", + "size_bytes": 512000000000, + "free_bytes": 153600000000, + "free_percent": 30.0 + } + ], + "memory": { + "total_visible_bytes": 34359738368, + "free_physical_bytes": 8589934592, + "free_percent": 25.0 + }, + "page_files": [ + { + "name": "C:\\pagefile.sys", + "allocated_base_mb": 4096, + "current_usage_mb": 512, + "peak_usage_mb": 1024 + } + ], + "top_processes": { + "by_cumulative_cpu": [ + { + "id": 100, + "name": "ExampleSync", + "cpu_seconds": 42.5, + "working_set_bytes": 400000000, + "private_memory_bytes": 250000000 + } + ], + "by_working_set": [ + { + "id": 200, + "name": "ExampleEditor", + "cpu_seconds": 10.1, + "working_set_bytes": 900000000, + "private_memory_bytes": 700000000 + } + ] + }, + "perf_processes": { + "by_cpu_now": [ + { + "id": 100, + "name": "ExampleSync", + "percent_processor_time": 12, + "io_data_bytes_per_sec": 1000000, + "working_set_bytes": 400000000 + } + ], + "by_io_now": [ + { + "id": 100, + "name": "ExampleSync", + "percent_processor_time": 12, + "io_data_bytes_per_sec": 1000000, + "working_set_bytes": 400000000 + } + ] + } + }, + "recent_boot_events": [], + "collection_errors": [] +} diff --git a/sysadmin-windows-startup-performance/references/data-model.md b/sysadmin-windows-startup-performance/references/data-model.md new file mode 100644 index 0000000..2463401 --- /dev/null +++ b/sysadmin-windows-startup-performance/references/data-model.md @@ -0,0 +1,33 @@ +# Data Model + +The collector writes `schema_version = 1` JSON with `kind = "windows_startup_baseline"`. + +## Baseline Fields + +- `machine`: computer name, manufacturer, model, and domain role. +- `operating_system`: Windows caption, version, and build. +- `boot`: last boot timestamp and uptime. +- `diagnostics_performance_events`: recent events from `Microsoft-Windows-Diagnostics-Performance/Operational`, especially IDs 100-199. +- `diagnostics_performance_collection`: metadata about scan depth, returned events, and boot-event target satisfaction. +- `startup.registry_entries`: startup registry Run entries. +- `startup.startup_folder_items`: user and common startup-folder shortcuts. +- `startup.scheduled_startup_logon_tasks`: scheduled tasks triggered at startup or logon. +- `startup.auto_start_services`: automatic and delayed-automatic services. +- `resources.disks`: fixed disk free-space summary. +- `resources.memory`: physical memory summary. +- `resources.page_files`: pagefile usage summary. +- `resources.top_processes`: process snapshot ranked by cumulative CPU seconds and working set. +- `resources.perf_processes`: current formatted performance counters ranked by CPU and I/O. +- `recent_boot_events`: recent System/Application warning and error events around boot. +- `collection_errors`: non-fatal partial collection errors. + +## Analysis Fields + +The analyzer writes `schema_version = 1` JSON with `kind = "windows_startup_analysis"`. + +- `summary`: finding counts and inventory counts. +- `findings`: ranked findings with `severity`, `category`, `title`, `evidence`, `recommendation`, and `confidence`. +- `report_path`: Markdown report path. +- `baseline_path`: source baseline path. + +Downstream skills should consume the analysis JSON for structured automation and the Markdown report for user-facing summaries. diff --git a/sysadmin-windows-startup-performance/references/safety.md b/sysadmin-windows-startup-performance/references/safety.md new file mode 100644 index 0000000..ee4752a --- /dev/null +++ b/sysadmin-windows-startup-performance/references/safety.md @@ -0,0 +1,45 @@ +# Safety + +Use this skill for measurement first. + +## Collector Rules + +- Run `collect-startup-baseline.ps1` from an elevated PowerShell session. +- Do not run a non-admin fallback for final boot timing, because the Diagnostics-Performance operational log can require elevation. +- Keep command-line previews disabled unless the user explicitly asks for them. +- Treat generated JSON and reports as local diagnostic data. + +## Data Handling + +Do not attach real startup baselines that contain hostnames, usernames, command-line arguments, scheduled-task arguments, service metadata, or other local inventory unless you have reviewed and sanitized them first. + +The collector summarizes command lines by default and omits full command-line previews unless explicitly run with `-IncludeCommandLines`. + +## Remediation Rules + +Do not change system configuration as part of this skill's default workflow. + +Do not disable, remove, or modify: + +- startup registry entries +- startup folder shortcuts +- scheduled tasks +- services +- drivers +- antivirus, EDR, backup, disk, sync-critical, update, or Microsoft components +- firmware, power, security, or Windows policy settings + +If the user asks for changes, first provide a dry-run plan with: + +- exact target +- evidence from baseline data +- expected effect +- risk +- rollback command or manual rollback path +- whether elevation is required + +Require explicit approval before applying changes. + +## Recommendation Threshold + +Prefer three or more comparable post-reboot baselines before recommending persistent startup changes. A single baseline is enough to identify what to measure next, not enough to aggressively optimize. diff --git a/sysadmin-windows-startup-performance/reports/.gitkeep b/sysadmin-windows-startup-performance/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sysadmin-windows-startup-performance/scripts/analyze-startup-baseline.ps1 b/sysadmin-windows-startup-performance/scripts/analyze-startup-baseline.ps1 new file mode 100644 index 0000000..ccc98d3 --- /dev/null +++ b/sysadmin-windows-startup-performance/scripts/analyze-startup-baseline.ps1 @@ -0,0 +1,480 @@ +[CmdletBinding()] +param( + [string]$BaselinePath = '', + [string]$OutputPath = '', + [string]$AnalysisOutputPath = '', + [switch]$NoJsonOutput +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:Findings = [System.Collections.Generic.List[object]]::new() + +function Get-PropertyValue { + param( + [AllowNull()]$Object, + [Parameter(Mandatory = $true)][string]$Name + ) + + if ($null -eq $Object) { + return $null + } + + if ($Object -is [System.Collections.IDictionary] -and $Object.Contains($Name)) { + return $Object[$Name] + } + + $property = $Object.PSObject.Properties[$Name] + if ($property) { + return $property.Value + } + + return $null +} + +function ConvertTo-NumberOrNull { + param([AllowNull()]$Value) + + if ($null -eq $Value) { + return $null + } + + $text = ([string]$Value).Trim() + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + + $longValue = [int64]0 + if ([int64]::TryParse($text, [ref]$longValue)) { + return $longValue + } + + $doubleValue = [double]0 + if ([double]::TryParse($text, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$doubleValue)) { + return $doubleValue + } + + return $null +} + +function Get-EventDataValue { + param( + [AllowNull()]$Event, + [Parameter(Mandatory = $true)][string[]]$Names + ) + + $data = Get-PropertyValue -Object $Event -Name 'data' + if ($null -eq $data) { + return $null + } + + foreach ($name in $Names) { + $value = Get-PropertyValue -Object $data -Name $name + if (-not [string]::IsNullOrWhiteSpace([string]$value)) { + return $value + } + } + + return $null +} + +function Get-MaxDurationFromEvent { + param([AllowNull()]$Event) + + $data = Get-PropertyValue -Object $Event -Name 'data' + if ($null -eq $data) { + return $null + } + + $maxValue = $null + foreach ($property in $data.PSObject.Properties) { + if ($property.Name -notmatch '(?i)(time|duration|delay)$') { + continue + } + + $number = ConvertTo-NumberOrNull -Value $property.Value + if ($null -eq $number) { + continue + } + + if ($null -eq $maxValue -or $number -gt $maxValue) { + $maxValue = $number + } + } + + return $maxValue +} + +function Add-Finding { + param( + [Parameter(Mandatory = $true)][ValidateSet('high', 'medium', 'low', 'info')][string]$Severity, + [Parameter(Mandatory = $true)][string]$Category, + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Evidence, + [Parameter(Mandatory = $true)][string]$Recommendation, + [ValidateSet('high', 'medium', 'low')][string]$Confidence = 'medium' + ) + + $script:Findings.Add([pscustomobject]@{ + severity = $Severity + category = $Category + title = $Title + evidence = $Evidence + recommendation = $Recommendation + confidence = $Confidence + }) +} + +function Format-TableText { + param([AllowNull()]$Value) + + if ($null -eq $Value) { + return '' + } + + return ([string]$Value).Replace('|', '/').Replace("`r", ' ').Replace("`n", ' ') +} + +function Format-Milliseconds { + param([AllowNull()]$Value) + + $number = ConvertTo-NumberOrNull -Value $Value + if ($null -eq $number) { + return '' + } + + if ($number -ge 1000) { + return ('{0:n1}s' -f ($number / 1000.0)) + } + + return ('{0:n0}ms' -f $number) +} + +function Format-Bytes { + param([AllowNull()]$Value) + + $number = ConvertTo-NumberOrNull -Value $Value + if ($null -eq $number) { + return '' + } + + $units = @('B', 'KB', 'MB', 'GB', 'TB') + $size = [double]$number + $index = 0 + while ($size -ge 1024 -and $index -lt ($units.Count - 1)) { + $size = $size / 1024 + $index += 1 + } + + return ('{0:n1} {1}' -f $size, $units[$index]) +} + +function Resolve-LatestBaselinePath { + param([Parameter(Mandatory = $true)][string]$HubRoot) + + $stateDir = Join-Path $HubRoot 'state' + if (-not (Test-Path -LiteralPath $stateDir)) { + throw "No local-performance state directory found at $stateDir. Run collect-startup-baseline.ps1 first." + } + + $latest = Get-ChildItem -LiteralPath $stateDir -Filter 'startup-baseline-*.json' -File -ErrorAction Stop | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if ($null -eq $latest) { + throw "No startup baseline JSON files found under $stateDir. Run collect-startup-baseline.ps1 first." + } + + return $latest.FullName +} + +function Get-DegradationIdentity { + param([Parameter(Mandatory = $true)]$Event) + + $identity = Get-EventDataValue -Event $Event -Names @('FriendlyName', 'FileName', 'Name', 'ServiceName', 'ImagePath', 'Path', 'ProcessName') + if ([string]::IsNullOrWhiteSpace([string]$identity)) { + $identity = 'Unspecified component' + } + + return [string]$identity +} + +$scriptRoot = $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($scriptRoot)) { + $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +} +$hubRoot = Split-Path -Parent $scriptRoot +if ([string]::IsNullOrWhiteSpace($BaselinePath)) { + $BaselinePath = Resolve-LatestBaselinePath -HubRoot $hubRoot +} +$BaselinePath = [IO.Path]::GetFullPath($BaselinePath) + +$raw = Get-Content -Raw -LiteralPath $BaselinePath +$baseline = $raw | ConvertFrom-Json +$generatedAt = Get-Date +$stamp = $generatedAt.ToString('yyyyMMdd-HHmmss') + +if ([string]::IsNullOrWhiteSpace($OutputPath)) { + $OutputPath = Join-Path $hubRoot "reports\startup-baseline-analysis-$stamp.md" +} +if ([string]::IsNullOrWhiteSpace($AnalysisOutputPath) -and -not $NoJsonOutput) { + $AnalysisOutputPath = Join-Path $hubRoot "state\startup-analysis-$stamp.json" +} + +$diagnostics = @(Get-PropertyValue -Object $baseline -Name 'diagnostics_performance_events') +$bootEvents = @($diagnostics | Where-Object { [int](Get-PropertyValue -Object $_ -Name 'id') -eq 100 } | Sort-Object time_created -Descending) +$degradationEvents = @($diagnostics | Where-Object { [int](Get-PropertyValue -Object $_ -Name 'id') -ne 100 }) + +if ($bootEvents.Count -eq 0) { + Add-Finding -Severity 'medium' -Category 'measurement' -Title 'No Diagnostics-Performance boot event found' -Evidence 'The collector did not return event ID 100 from Microsoft-Windows-Diagnostics-Performance/Operational.' -Recommendation 'Confirm the Diagnostics-Performance operational log is enabled and rerun the collector after a reboot.' -Confidence 'medium' +} elseif ($bootEvents.Count -lt 3) { + Add-Finding -Severity 'info' -Category 'measurement' -Title 'Baseline history is still thin' -Evidence ("Only {0} boot timing event(s) were included in this baseline." -f $bootEvents.Count) -Recommendation 'Collect at least three post-reboot baselines before applying startup changes.' -Confidence 'high' +} + +$latestBoot = if ($bootEvents.Count -gt 0) { $bootEvents[0] } else { $null } +if ($null -ne $latestBoot) { + $bootTime = ConvertTo-NumberOrNull -Value (Get-EventDataValue -Event $latestBoot -Names @('BootTime')) + $mainPath = ConvertTo-NumberOrNull -Value (Get-EventDataValue -Event $latestBoot -Names @('MainPathBootTime')) + $postBoot = ConvertTo-NumberOrNull -Value (Get-EventDataValue -Event $latestBoot -Names @('BootPostBootTime')) + + if ($null -ne $bootTime) { + if ($bootTime -ge 120000) { + Add-Finding -Severity 'high' -Category 'boot' -Title 'Latest boot time is high' -Evidence ("Latest Diagnostics-Performance BootTime is {0}." -f (Format-Milliseconds $bootTime)) -Recommendation 'Prioritize boot degradation events and startup/logon load before making changes.' -Confidence 'high' + } elseif ($bootTime -ge 90000) { + Add-Finding -Severity 'medium' -Category 'boot' -Title 'Latest boot time is elevated' -Evidence ("Latest Diagnostics-Performance BootTime is {0}." -f (Format-Milliseconds $bootTime)) -Recommendation 'Review repeated degradation events and collect two more post-reboot baselines.' -Confidence 'high' + } + } + + if ($null -ne $mainPath -and $mainPath -ge 45000) { + Add-Finding -Severity 'medium' -Category 'boot' -Title 'Main boot path is slow' -Evidence ("MainPathBootTime is {0}." -f (Format-Milliseconds $mainPath)) -Recommendation 'Look first at driver/service degradation events rather than only login startup apps.' -Confidence 'medium' + } + + if ($null -ne $postBoot -and $postBoot -ge 60000) { + Add-Finding -Severity 'medium' -Category 'login' -Title 'Post-boot phase is slow' -Evidence ("BootPostBootTime is {0}." -f (Format-Milliseconds $postBoot)) -Recommendation 'Inspect startup apps, logon scheduled tasks, sync tools, launchers, and post-login resource pressure.' -Confidence 'medium' + } +} + +$degradationGroups = @($degradationEvents | Group-Object -Property { "{0}|{1}" -f (Get-PropertyValue -Object $_ -Name 'id'), (Get-DegradationIdentity -Event $_) }) +foreach ($group in $degradationGroups) { + $events = @($group.Group) + $id = Get-PropertyValue -Object $events[0] -Name 'id' + $identity = Get-DegradationIdentity -Event $events[0] + $durations = @($events | ForEach-Object { Get-MaxDurationFromEvent -Event $_ } | Where-Object { $null -ne $_ }) + $maxDuration = if ($durations.Count -gt 0) { ($durations | Measure-Object -Maximum).Maximum } else { $null } + $latestTime = ($events | Sort-Object time_created -Descending | Select-Object -First 1).time_created + + if ($events.Count -ge 3 -or ($null -ne $maxDuration -and $maxDuration -ge 10000)) { + $severity = if ($events.Count -ge 3 -and $null -ne $maxDuration -and $maxDuration -ge 15000) { 'high' } else { 'medium' } + $durationText = if ($null -ne $maxDuration) { Format-Milliseconds $maxDuration } else { 'unknown duration' } + Add-Finding -Severity $severity -Category 'degradation' -Title ("Repeated boot degradation: {0}" -f $identity) -Evidence ("Event ID {0} appeared {1} time(s); max observed duration {2}; most recent {3}." -f $id, $events.Count, $durationText, $latestTime) -Recommendation 'Investigate this component across more baselines before disabling or changing it.' -Confidence 'medium' + } +} + +$startup = Get-PropertyValue -Object $baseline -Name 'startup' +$registryEntries = @(Get-PropertyValue -Object $startup -Name 'registry_entries') +$startupFolderItems = @(Get-PropertyValue -Object $startup -Name 'startup_folder_items') +$scheduledTasks = @(Get-PropertyValue -Object $startup -Name 'scheduled_startup_logon_tasks') +$autoServices = @(Get-PropertyValue -Object $startup -Name 'auto_start_services') +$delayedServices = @($autoServices | Where-Object { [bool](Get-PropertyValue -Object $_ -Name 'delayed_auto_start') }) +$nonDelayedServices = @($autoServices | Where-Object { -not [bool](Get-PropertyValue -Object $_ -Name 'delayed_auto_start') }) + +if (($registryEntries.Count + $startupFolderItems.Count) -ge 20) { + Add-Finding -Severity 'medium' -Category 'startup' -Title 'Many startup app entries' -Evidence ("Registry startup entries: {0}; startup-folder items: {1}." -f $registryEntries.Count, $startupFolderItems.Count) -Recommendation 'Review third-party startup apps first and disable only nonessential items after a baseline comparison.' -Confidence 'medium' +} +if ($scheduledTasks.Count -ge 20) { + Add-Finding -Severity 'medium' -Category 'startup' -Title 'Many startup/logon scheduled tasks' -Evidence ("Startup/logon scheduled tasks: {0}." -f $scheduledTasks.Count) -Recommendation 'Review logon-triggered third-party tasks and consider delayed triggers only after an approved dry-run plan.' -Confidence 'medium' +} +if ($nonDelayedServices.Count -ge 100) { + Add-Finding -Severity 'low' -Category 'services' -Title 'Large auto-start service surface' -Evidence ("Auto-start services: {0}; delayed-auto services: {1}." -f $autoServices.Count, $delayedServices.Count) -Recommendation 'Do not bulk-disable services. Use boot degradation events to identify specific third-party candidates.' -Confidence 'low' +} + +$resources = Get-PropertyValue -Object $baseline -Name 'resources' +$disks = @(Get-PropertyValue -Object $resources -Name 'disks') +foreach ($disk in $disks) { + $freePercent = ConvertTo-NumberOrNull -Value (Get-PropertyValue -Object $disk -Name 'free_percent') + if ($null -eq $freePercent) { + continue + } + $deviceId = Get-PropertyValue -Object $disk -Name 'device_id' + if ($freePercent -lt 15) { + Add-Finding -Severity 'high' -Category 'disk' -Title ("Low free space on {0}" -f $deviceId) -Evidence ("Free space is {0:n1}% ({1} free)." -f $freePercent, (Format-Bytes (Get-PropertyValue -Object $disk -Name 'free_bytes'))) -Recommendation 'Free disk space before tuning startup; low space can amplify update, indexing, and paging delays.' -Confidence 'high' + } elseif ($freePercent -lt 25) { + Add-Finding -Severity 'medium' -Category 'disk' -Title ("Free space is getting tight on {0}" -f $deviceId) -Evidence ("Free space is {0:n1}% ({1} free)." -f $freePercent, (Format-Bytes (Get-PropertyValue -Object $disk -Name 'free_bytes'))) -Recommendation 'Plan cleanup if this is the system or active workspace drive.' -Confidence 'medium' + } +} + +$memory = Get-PropertyValue -Object $resources -Name 'memory' +$memoryFreePercent = ConvertTo-NumberOrNull -Value (Get-PropertyValue -Object $memory -Name 'free_percent') +if ($null -ne $memoryFreePercent) { + if ($memoryFreePercent -lt 15) { + Add-Finding -Severity 'high' -Category 'memory' -Title 'Low free physical memory at collection time' -Evidence ("Free physical memory was {0:n1}% ({1} free)." -f $memoryFreePercent, (Format-Bytes (Get-PropertyValue -Object $memory -Name 'free_physical_bytes'))) -Recommendation 'Compare post-login baselines and inspect high working-set processes before disabling startup items.' -Confidence 'medium' + } elseif ($memoryFreePercent -lt 25) { + Add-Finding -Severity 'medium' -Category 'memory' -Title 'Physical memory headroom is limited' -Evidence ("Free physical memory was {0:n1}% ({1} free)." -f $memoryFreePercent, (Format-Bytes (Get-PropertyValue -Object $memory -Name 'free_physical_bytes'))) -Recommendation 'Check whether the same processes dominate memory after reboot.' -Confidence 'medium' + } +} + +$pageFiles = @(Get-PropertyValue -Object $resources -Name 'page_files') +foreach ($pageFile in $pageFiles) { + $currentUsageMb = ConvertTo-NumberOrNull -Value (Get-PropertyValue -Object $pageFile -Name 'current_usage_mb') + $peakUsageMb = ConvertTo-NumberOrNull -Value (Get-PropertyValue -Object $pageFile -Name 'peak_usage_mb') + if ($currentUsageMb -ge 2048 -or $peakUsageMb -ge 4096) { + Add-Finding -Severity 'medium' -Category 'memory' -Title 'Pagefile usage is notable' -Evidence ("Current pagefile usage: {0} MB; peak: {1} MB." -f $currentUsageMb, $peakUsageMb) -Recommendation 'Look for post-login memory pressure before tuning services.' -Confidence 'medium' + } +} + +$perfProcesses = Get-PropertyValue -Object $resources -Name 'perf_processes' +$topCpuNow = @(Get-PropertyValue -Object $perfProcesses -Name 'by_cpu_now') +$topIoNow = @(Get-PropertyValue -Object $perfProcesses -Name 'by_io_now') +$cpuHot = @($topCpuNow | Where-Object { (ConvertTo-NumberOrNull -Value (Get-PropertyValue -Object $_ -Name 'percent_processor_time')) -ge 25 } | Select-Object -First 5) +if ($cpuHot.Count -gt 0) { + $names = ($cpuHot | ForEach-Object { "{0} ({1}%)" -f (Get-PropertyValue -Object $_ -Name 'name'), (Get-PropertyValue -Object $_ -Name 'percent_processor_time') }) -join ', ' + Add-Finding -Severity 'medium' -Category 'resource-pressure' -Title 'CPU pressure at collection time' -Evidence ("Top current CPU process samples: {0}." -f $names) -Recommendation 'Collect a post-login snapshot 3-5 minutes after reboot to see whether this repeats.' -Confidence 'low' +} +$ioHot = @($topIoNow | Where-Object { (ConvertTo-NumberOrNull -Value (Get-PropertyValue -Object $_ -Name 'io_data_bytes_per_sec')) -ge 5242880 } | Select-Object -First 5) +if ($ioHot.Count -gt 0) { + $names = ($ioHot | ForEach-Object { "{0} ({1}/s)" -f (Get-PropertyValue -Object $_ -Name 'name'), (Format-Bytes (Get-PropertyValue -Object $_ -Name 'io_data_bytes_per_sec')) }) -join ', ' + Add-Finding -Severity 'medium' -Category 'resource-pressure' -Title 'Disk I/O pressure at collection time' -Evidence ("Top current I/O process samples: {0}." -f $names) -Recommendation 'Correlate with post-login boot timing and sync/indexing processes before changing startup.' -Confidence 'low' +} + +$topProcesses = Get-PropertyValue -Object $resources -Name 'top_processes' +$topMemory = @(Get-PropertyValue -Object $topProcesses -Name 'by_working_set') +$memoryHot = @($topMemory | Where-Object { (ConvertTo-NumberOrNull -Value (Get-PropertyValue -Object $_ -Name 'working_set_bytes')) -ge 1610612736 } | Select-Object -First 5) +if ($memoryHot.Count -gt 0) { + $names = ($memoryHot | ForEach-Object { "{0} ({1})" -f (Get-PropertyValue -Object $_ -Name 'name'), (Format-Bytes (Get-PropertyValue -Object $_ -Name 'working_set_bytes')) }) -join ', ' + Add-Finding -Severity 'low' -Category 'resource-pressure' -Title 'Large working-set processes observed' -Evidence ("Top memory processes: {0}." -f $names) -Recommendation 'Treat this as a snapshot signal; compare after a fresh reboot before changing anything.' -Confidence 'low' +} + +$recentEvents = @(Get-PropertyValue -Object $baseline -Name 'recent_boot_events') +$recentGroups = @($recentEvents | Group-Object -Property { "{0}|{1}|{2}" -f (Get-PropertyValue -Object $_ -Name 'log'), (Get-PropertyValue -Object $_ -Name 'provider'), (Get-PropertyValue -Object $_ -Name 'id') }) +foreach ($group in $recentGroups) { + if ($group.Count -lt 3) { + continue + } + $first = $group.Group[0] + Add-Finding -Severity 'low' -Category 'event-log' -Title 'Repeated warning/error near boot' -Evidence ("{0} provider {1}, event ID {2}, appeared {3} time(s) near the current boot." -f (Get-PropertyValue -Object $first -Name 'log'), (Get-PropertyValue -Object $first -Name 'provider'), (Get-PropertyValue -Object $first -Name 'id'), $group.Count) -Recommendation 'Inspect the full event details locally if this provider also correlates with boot degradation.' -Confidence 'low' +} + +$collectionErrors = @(Get-PropertyValue -Object $baseline -Name 'collection_errors') +if ($collectionErrors.Count -gt 0) { + $diagnosticsAccessError = @($collectionErrors | Where-Object { (Get-PropertyValue -Object $_ -Name 'section') -eq 'diagnostics_performance_log' } | Select-Object -First 1) + if ($diagnosticsAccessError.Count -gt 0) { + Add-Finding -Severity 'info' -Category 'measurement' -Title 'Diagnostics-Performance log was not accessible' -Evidence 'The protected Diagnostics-Performance operational log could not be listed from this shell, so boot timing event IDs 100-199 were unavailable.' -Recommendation 'For exact boot-path timing, rerun the collector from an elevated PowerShell session after reboot. Continue using the rest of the baseline for startup inventory and resource-pressure signals.' -Confidence 'high' + } + $otherErrors = @($collectionErrors | Where-Object { (Get-PropertyValue -Object $_ -Name 'section') -ne 'diagnostics_performance_log' }) + if ($otherErrors.Count -gt 0) { + Add-Finding -Severity 'info' -Category 'measurement' -Title 'Collector had partial read errors' -Evidence ("Collector recorded {0} non-Diagnostics partial error(s)." -f $otherErrors.Count) -Recommendation 'Review collection_errors in the JSON if an expected signal is missing.' -Confidence 'high' + } +} + +$severityWeight = @{ high = 0; medium = 1; low = 2; info = 3 } +$orderedFindings = @($script:Findings | Sort-Object @{ Expression = { $severityWeight[[string]$_.severity] } }, @{ Expression = { $_.category } }, @{ Expression = { $_.title } }) + +$summary = [ordered]@{ + finding_count = $orderedFindings.Count + high = @($orderedFindings | Where-Object { $_.severity -eq 'high' }).Count + medium = @($orderedFindings | Where-Object { $_.severity -eq 'medium' }).Count + low = @($orderedFindings | Where-Object { $_.severity -eq 'low' }).Count + info = @($orderedFindings | Where-Object { $_.severity -eq 'info' }).Count + boot_event_count = $bootEvents.Count + startup_registry_entry_count = $registryEntries.Count + startup_folder_item_count = $startupFolderItems.Count + startup_logon_task_count = $scheduledTasks.Count + auto_start_service_count = $autoServices.Count +} + +$lines = [System.Collections.Generic.List[string]]::new() +$lines.Add('# Windows Startup Baseline Analysis') +$lines.Add('') +$lines.Add(('- Generated: {0}' -f $generatedAt.ToString('yyyy-MM-dd HH:mm:ss K'))) +$lines.Add(('- Baseline: `{0}`' -f $BaselinePath)) +$lines.Add('- Mode: read-only analysis; no startup or system settings were changed.') +$lines.Add('') + +$machine = Get-PropertyValue -Object $baseline -Name 'machine' +$os = Get-PropertyValue -Object $baseline -Name 'operating_system' +$boot = Get-PropertyValue -Object $baseline -Name 'boot' +$lines.Add('## Machine') +$lines.Add('') +$lines.Add(('- Computer: {0}' -f (Format-TableText (Get-PropertyValue -Object $machine -Name 'computer_name')))) +$lines.Add(('- Model: {0} {1}' -f (Format-TableText (Get-PropertyValue -Object $machine -Name 'manufacturer')), (Format-TableText (Get-PropertyValue -Object $machine -Name 'model')))) +$lines.Add(('- OS: {0} {1} build {2}' -f (Format-TableText (Get-PropertyValue -Object $os -Name 'caption')), (Format-TableText (Get-PropertyValue -Object $os -Name 'version')), (Format-TableText (Get-PropertyValue -Object $os -Name 'build_number')))) +$lines.Add(('- Last boot: {0}' -f (Format-TableText (Get-PropertyValue -Object $boot -Name 'last_boot_local')))) +$lines.Add('') + +$lines.Add('## Boot Timing') +$lines.Add('') +if ($bootEvents.Count -eq 0) { + $lines.Add('No Diagnostics-Performance boot timing events were collected.') +} else { + $lines.Add('| Time | BootTime | MainPath | PostBoot |') + $lines.Add('| --- | ---: | ---: | ---: |') + foreach ($event in ($bootEvents | Select-Object -First 5)) { + $lines.Add(('| {0} | {1} | {2} | {3} |' -f + (Format-TableText (Get-PropertyValue -Object $event -Name 'time_created')), + (Format-Milliseconds (Get-EventDataValue -Event $event -Names @('BootTime'))), + (Format-Milliseconds (Get-EventDataValue -Event $event -Names @('MainPathBootTime'))), + (Format-Milliseconds (Get-EventDataValue -Event $event -Names @('BootPostBootTime'))))) + } +} +$lines.Add('') + +$lines.Add('## Startup Inventory') +$lines.Add('') +$lines.Add('| Signal | Count |') +$lines.Add('| --- | ---: |') +$lines.Add(('| Registry startup entries | {0} |' -f $registryEntries.Count)) +$lines.Add(('| Startup-folder items | {0} |' -f $startupFolderItems.Count)) +$lines.Add(('| Startup/logon scheduled tasks | {0} |' -f $scheduledTasks.Count)) +$lines.Add(('| Auto-start services | {0} |' -f $autoServices.Count)) +$lines.Add(('| Delayed auto-start services | {0} |' -f $delayedServices.Count)) +$lines.Add('') + +$lines.Add('## Ranked Findings') +$lines.Add('') +if ($orderedFindings.Count -eq 0) { + $lines.Add('No material bottleneck candidates were identified from this baseline. Collect more post-reboot baselines before concluding startup is clean.') +} else { + $index = 1 + foreach ($finding in $orderedFindings) { + $lines.Add(("{0}. [{1}] {2}" -f $index, ([string]$finding.severity).ToUpperInvariant(), $finding.title)) + $lines.Add((" - Category: {0}; confidence: {1}" -f $finding.category, $finding.confidence)) + $lines.Add((" - Evidence: {0}" -f $finding.evidence)) + $lines.Add((" - Next step: {0}" -f $finding.recommendation)) + $index += 1 + } +} +$lines.Add('') + +$lines.Add('## Suggested Next Measurement') +$lines.Add('') +$lines.Add('- Reboot normally, wait 3-5 minutes after login, then run the collector again.') +$lines.Add('- Repeat until there are at least three comparable baselines.') +$lines.Add('- Do not apply startup changes from a single snapshot; use a dry-run plan first.') +$lines.Add('') + +$reportDirectory = Split-Path -Parent $OutputPath +if (-not [string]::IsNullOrWhiteSpace($reportDirectory)) { + New-Item -ItemType Directory -Force -Path $reportDirectory | Out-Null +} +Set-Content -LiteralPath $OutputPath -Value $lines -Encoding UTF8 + +$analysis = [ordered]@{ + schema_version = 1 + kind = 'windows_startup_analysis' + generated_at_local = $generatedAt.ToString('o') + baseline_path = $BaselinePath + report_path = [IO.Path]::GetFullPath($OutputPath) + summary = $summary + findings = $orderedFindings +} +$analysisJson = $analysis | ConvertTo-Json -Depth 10 +if (-not $NoJsonOutput) { + $analysisDirectory = Split-Path -Parent $AnalysisOutputPath + if (-not [string]::IsNullOrWhiteSpace($analysisDirectory)) { + New-Item -ItemType Directory -Force -Path $analysisDirectory | Out-Null + } + Set-Content -LiteralPath $AnalysisOutputPath -Value $analysisJson -Encoding UTF8 +} + +$analysisJson \ No newline at end of file diff --git a/sysadmin-windows-startup-performance/scripts/collect-startup-baseline.ps1 b/sysadmin-windows-startup-performance/scripts/collect-startup-baseline.ps1 new file mode 100644 index 0000000..bbde9cd --- /dev/null +++ b/sysadmin-windows-startup-performance/scripts/collect-startup-baseline.ps1 @@ -0,0 +1,872 @@ +#Requires -RunAsAdministrator +[CmdletBinding()] +param( + [string]$OutputPath = '', + [ValidateRange(1, 10000)] + [int]$DiagnosticsEventCount = 300, + [ValidateRange(0, 500)] + [int]$DiagnosticsBootEventTarget = 25, + [ValidateRange(100, 100000)] + [int]$DiagnosticsEventScanCount = 5000, + [int]$RecentEventCount = 120, + [switch]$IncludeCommandLines, + [switch]$NoWrite +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:CollectionErrors = [System.Collections.Generic.List[object]]::new() + +function Add-CollectionError { + param( + [Parameter(Mandatory = $true)][string]$Section, + [Parameter(Mandatory = $true)]$ErrorRecord + ) + + $script:CollectionErrors.Add([ordered]@{ + section = ConvertTo-SafeString -Value $Section -MaxLength 300 + message = ConvertTo-SafeString -Value $ErrorRecord.Exception.Message -MaxLength 500 + }) +} + +function ConvertTo-SafeString { + param( + [AllowNull()]$Value, + [int]$MaxLength = 500 + ) + + if ($null -eq $Value) { + return $null + } + + $text = [string]$Value + $profile = [Environment]::GetFolderPath('UserProfile') + if (-not [string]::IsNullOrWhiteSpace($profile)) { + $text = $text.Replace($profile, '%USERPROFILE%') + } + + $userName = [Environment]::UserName + if (-not [string]::IsNullOrWhiteSpace($userName)) { + $text = $text.Replace("\Users\$userName\", '\Users\%USERNAME%\') + } + + $text = $text -replace '(?i)(token|apikey|api_key|password|secret|bearer)=([^\s;&]+)', '$1=' + + if ($text.Length -gt $MaxLength) { + $prefixLength = [Math]::Max(0, $MaxLength - 3) + return $text.Substring(0, $prefixLength) + '...' + } + + return $text +} + +function Get-Sha256Hex { + param([AllowNull()][string]$Text) + + if ($null -eq $Text) { + return $null + } + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $hashBytes = $sha.ComputeHash($bytes) + return [BitConverter]::ToString($hashBytes).Replace('-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Get-CommandSummary { + param( + [AllowNull()][string]$CommandLine, + [switch]$IncludeFullCommandLine + ) + + if ([string]::IsNullOrWhiteSpace($CommandLine)) { + return [ordered]@{ + executable = $null + executable_name = $null + arguments_present = $false + command_hash_sha256 = $null + } + } + + $safe = ConvertTo-SafeString -Value $CommandLine -MaxLength 2000 + $executable = $null + $remaining = '' + if ($safe -match '^\s*"([^"]+)"\s*(.*)$') { + $executable = $Matches[1] + $remaining = $Matches[2] + } elseif ($safe -match '^\s*([^\s]+)\s*(.*)$') { + $executable = $Matches[1] + $remaining = $Matches[2] + } + + $name = $null + if (-not [string]::IsNullOrWhiteSpace($executable)) { + try { + $name = Split-Path -Leaf $executable + } catch { + $name = $executable + } + } + + $summary = [ordered]@{ + executable = ConvertTo-SafeString -Value $executable -MaxLength 500 + executable_name = $name + arguments_present = -not [string]::IsNullOrWhiteSpace($remaining) + command_hash_sha256 = Get-Sha256Hex -Text $safe + } + + if ($IncludeFullCommandLine) { + $summary['command_line_preview'] = ConvertTo-SafeString -Value $safe -MaxLength 1000 + } + + return $summary +} + +function Get-EventDataValue { + param( + [Parameter(Mandatory = $true)]$DataNode, + [Parameter(Mandatory = $true)][string]$FallbackName, + [int]$Index + ) + + $name = $null + try { + $name = [string]$DataNode.Name + } catch { + $name = $null + } + + if ([string]::IsNullOrWhiteSpace($name)) { + $name = "${FallbackName}_${Index}" + } + + $value = $null + try { + $value = [string]$DataNode.'#text' + } catch { + $value = [string]$DataNode + } + + return @($name, (ConvertTo-SafeString -Value $value -MaxLength 500)) +} + +function Convert-WinEventRecord { + param([Parameter(Mandatory = $true)]$Event) + + $data = [ordered]@{} + try { + $xml = [xml]$Event.ToXml() + $index = 0 + foreach ($node in $xml.Event.EventData.Data) { + $pair = Get-EventDataValue -DataNode $node -FallbackName 'data' -Index $index + $data[$pair[0]] = $pair[1] + $index += 1 + } + } catch { + $data['parse_error'] = ConvertTo-SafeString -Value $_.Exception.Message -MaxLength 300 + } + + return [ordered]@{ + time_created = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { $null } + id = [int]$Event.Id + provider = $Event.ProviderName + level = $Event.LevelDisplayName + record_id = $Event.RecordId + data = $data + } +} + +function Convert-WinEventSummary { + param( + [Parameter(Mandatory = $true)]$Event, + [Parameter(Mandatory = $true)][string]$LogName + ) + + return [ordered]@{ + log = $LogName + time_created = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { $null } + id = [int]$Event.Id + provider = $Event.ProviderName + level = $Event.LevelDisplayName + record_id = $Event.RecordId + } +} + +function Get-DiagnosticsPerformanceEvents { + param( + [int]$MaxEvents, + [int]$BootEventTarget, + [int]$ScanCount + ) + + $logName = 'Microsoft-Windows-Diagnostics-Performance/Operational' + + try { + $null = Get-WinEvent -ListLog $logName -ErrorAction Stop + } catch { + Add-CollectionError -Section 'diagnostics_performance_log' -ErrorRecord $_ + return [ordered]@{ + events = @() + metadata = [ordered]@{ + log_name = $logName + requested_recent_event_count = $MaxEvents + requested_boot_event_target = $BootEventTarget + requested_scan_count = $ScanCount + effective_scan_count = $null + scanned_event_count = 0 + matched_diagnostics_event_count = 0 + matched_boot_event_count = 0 + returned_event_count = 0 + returned_boot_event_count = 0 + boot_target_satisfied = $false + scan_limit_reached = $false + mode = 'unavailable' + } + } + } + + try { + $effectiveScanCount = [Math]::Max($ScanCount, [Math]::Max($MaxEvents * 10, 500)) + $rawEvents = @(Get-WinEvent -FilterHashtable @{ + LogName = $logName + } -MaxEvents $effectiveScanCount -ErrorAction Stop) + + $diagnosticEvents = @($rawEvents | + Where-Object { $_.Id -ge 100 -and $_.Id -le 199 }) + $bootEvents = @($diagnosticEvents | + Where-Object { $_.Id -eq 100 }) + + $recentWindow = @($diagnosticEvents | Select-Object -First $MaxEvents) + $selectedEvents = $recentWindow + $targetWindowEventCount = $null + $oldestTargetBootTime = $null + $mode = 'recent-count' + + if ($BootEventTarget -gt 0 -and $bootEvents.Count -gt 0) { + $targetBootEvents = @($bootEvents | Select-Object -First $BootEventTarget) + $oldestTargetBoot = $targetBootEvents[-1] + $oldestTargetBootTime = if ($oldestTargetBoot.TimeCreated) { $oldestTargetBoot.TimeCreated.ToString('o') } else { $null } + $targetWindow = @($diagnosticEvents | Where-Object { + if ($oldestTargetBoot.TimeCreated -and $_.TimeCreated) { + $_.TimeCreated -ge $oldestTargetBoot.TimeCreated + } else { + $_.RecordId -ge $oldestTargetBoot.RecordId + } + }) + $targetWindowEventCount = $targetWindow.Count + + if ($targetWindow.Count -gt $selectedEvents.Count) { + $selectedEvents = $targetWindow + $mode = 'boot-targeted' + } else { + $mode = 'recent-count-with-boot-target' + } + } + + $returnedBootCount = @($selectedEvents | Where-Object { $_.Id -eq 100 }).Count + $newestReturned = $selectedEvents | Select-Object -First 1 + $oldestReturned = $selectedEvents | Select-Object -Last 1 + + return [ordered]@{ + events = @($selectedEvents | ForEach-Object { Convert-WinEventRecord -Event $_ }) + metadata = [ordered]@{ + log_name = $logName + requested_recent_event_count = $MaxEvents + requested_boot_event_target = $BootEventTarget + requested_scan_count = $ScanCount + effective_scan_count = $effectiveScanCount + scanned_event_count = $rawEvents.Count + matched_diagnostics_event_count = $diagnosticEvents.Count + matched_boot_event_count = $bootEvents.Count + returned_event_count = $selectedEvents.Count + returned_boot_event_count = $returnedBootCount + boot_target_satisfied = if ($BootEventTarget -gt 0) { $returnedBootCount -ge $BootEventTarget } else { $null } + scan_limit_reached = $rawEvents.Count -ge $effectiveScanCount + target_window_event_count = $targetWindowEventCount + oldest_target_boot_time = $oldestTargetBootTime + newest_returned_time = if ($newestReturned -and $newestReturned.TimeCreated) { $newestReturned.TimeCreated.ToString('o') } else { $null } + oldest_returned_time = if ($oldestReturned -and $oldestReturned.TimeCreated) { $oldestReturned.TimeCreated.ToString('o') } else { $null } + mode = $mode + } + } + } catch { + if ($_.Exception.Message -like '*No events were found*') { + return [ordered]@{ + events = @() + metadata = [ordered]@{ + log_name = $logName + requested_recent_event_count = $MaxEvents + requested_boot_event_target = $BootEventTarget + requested_scan_count = $ScanCount + effective_scan_count = $null + scanned_event_count = 0 + matched_diagnostics_event_count = 0 + matched_boot_event_count = 0 + returned_event_count = 0 + returned_boot_event_count = 0 + boot_target_satisfied = $false + scan_limit_reached = $false + mode = 'empty' + } + } + } + Add-CollectionError -Section 'diagnostics_performance_events' -ErrorRecord $_ + return [ordered]@{ + events = @() + metadata = [ordered]@{ + log_name = $logName + requested_recent_event_count = $MaxEvents + requested_boot_event_target = $BootEventTarget + requested_scan_count = $ScanCount + effective_scan_count = $null + scanned_event_count = 0 + matched_diagnostics_event_count = 0 + matched_boot_event_count = 0 + returned_event_count = 0 + returned_boot_event_count = 0 + boot_target_satisfied = $false + scan_limit_reached = $false + mode = 'error' + } + } + } +} + +function Get-RegistryStartupEntries { + param([switch]$IncludeFullCommandLine) + + $keys = @( + @{ scope = 'CurrentUser'; kind = 'Run'; path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' }, + @{ scope = 'CurrentUser'; kind = 'RunOnce'; path = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' }, + @{ scope = 'LocalMachine'; kind = 'Run'; path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' }, + @{ scope = 'LocalMachine'; kind = 'RunOnce'; path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce' }, + @{ scope = 'LocalMachine32'; kind = 'Run'; path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run' }, + @{ scope = 'LocalMachine32'; kind = 'RunOnce'; path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce' } + ) + + $entries = @() + foreach ($key in $keys) { + try { + if (-not (Test-Path -LiteralPath $key.path)) { + continue + } + + $item = Get-ItemProperty -LiteralPath $key.path -ErrorAction Stop + foreach ($property in $item.PSObject.Properties) { + if ($property.Name -in @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')) { + continue + } + + $entries += [ordered]@{ + scope = $key.scope + kind = $key.kind + key_path = $key.path + name = $property.Name + command = Get-CommandSummary -CommandLine ([string]$property.Value) -IncludeFullCommandLine:$IncludeFullCommandLine + } + } + } catch { + Add-CollectionError -Section "startup_registry:$($key.path)" -ErrorRecord $_ + } + } + + return $entries +} + +function Get-ShortcutTarget { + param([Parameter(Mandatory = $true)][string]$Path) + + try { + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($Path) + $target = [string]$shortcut.TargetPath + $arguments = [string]$shortcut.Arguments + if ([string]::IsNullOrWhiteSpace($arguments)) { + return $target + } + return ('"{0}" {1}' -f $target, $arguments) + } catch { + Add-CollectionError -Section "startup_shortcut:$Path" -ErrorRecord $_ + return $null + } +} + +function Get-StartupFolderItems { + param([switch]$IncludeFullCommandLine) + + $folders = @( + @{ scope = 'CurrentUser'; path = [Environment]::GetFolderPath('Startup') }, + @{ scope = 'AllUsers'; path = [Environment]::GetFolderPath('CommonStartup') } + ) + + $items = @() + foreach ($folder in $folders) { + try { + if ([string]::IsNullOrWhiteSpace($folder.path) -or -not (Test-Path -LiteralPath $folder.path)) { + continue + } + + foreach ($file in Get-ChildItem -LiteralPath $folder.path -File -Force -ErrorAction Stop) { + $commandLine = $file.FullName + if ($file.Extension -ieq '.lnk') { + $target = Get-ShortcutTarget -Path $file.FullName + if (-not [string]::IsNullOrWhiteSpace($target)) { + $commandLine = $target + } + } + + $items += [ordered]@{ + scope = $folder.scope + name = $file.Name + extension = $file.Extension + path = ConvertTo-SafeString -Value $file.FullName -MaxLength 500 + command = Get-CommandSummary -CommandLine $commandLine -IncludeFullCommandLine:$IncludeFullCommandLine + } + } + } catch { + Add-CollectionError -Section "startup_folder:$($folder.path)" -ErrorRecord $_ + } + } + + return $items +} + +function Get-ScheduledTaskTriggerTypes { + param([AllowNull()]$Task) + + if ($null -eq $Task) { + return @() + } + + $types = @() + foreach ($trigger in @($Task.Triggers)) { + if ($null -eq $trigger) { + continue + } + + $className = $null + $cimClassProperty = $trigger.PSObject.Properties['CimClass'] + if ($cimClassProperty -and $null -ne $cimClassProperty.Value) { + try { + $className = [string]$cimClassProperty.Value.CimClassName + } catch { + $className = $null + } + } + + if ([string]::IsNullOrWhiteSpace($className)) { + foreach ($typeName in $trigger.PSObject.TypeNames) { + if ($typeName -match 'MSFT_Task[A-Za-z]+Trigger') { + $className = $Matches[0] + break + } + } + } + + if (-not [string]::IsNullOrWhiteSpace($className)) { + $types += $className + } + } + + return @($types | Select-Object -Unique) +} +function Get-ScheduledStartupTasks { + param([switch]$IncludeFullCommandLine) + + try { + $tasks = Get-ScheduledTask -ErrorAction Stop + } catch { + Add-CollectionError -Section 'scheduled_tasks' -ErrorRecord $_ + return @() + } + + $results = @() + foreach ($task in $tasks) { + try { + $triggerTypes = Get-ScheduledTaskTriggerTypes -Task $task + $isStartupOrLogon = $false + foreach ($type in $triggerTypes) { + if ($type -in @('MSFT_TaskBootTrigger', 'MSFT_TaskLogonTrigger')) { + $isStartupOrLogon = $true + } + } + if (-not $isStartupOrLogon) { + continue + } + + $actions = @() + foreach ($action in $task.Actions) { + $execute = $null + $arguments = $null + if ($action.PSObject.Properties['Execute']) { + $execute = [string]$action.Execute + } + if ($action.PSObject.Properties['Arguments']) { + $arguments = [string]$action.Arguments + } + + $actionRecord = [ordered]@{ + execute = ConvertTo-SafeString -Value $execute -MaxLength 500 + execute_name = if ([string]::IsNullOrWhiteSpace($execute)) { $null } else { Split-Path -Leaf $execute } + arguments_present = -not [string]::IsNullOrWhiteSpace($arguments) + arguments_hash_sha256 = if ([string]::IsNullOrWhiteSpace($arguments)) { $null } else { Get-Sha256Hex -Text (ConvertTo-SafeString -Value $arguments -MaxLength 2000) } + } + if ($IncludeFullCommandLine -and -not [string]::IsNullOrWhiteSpace($arguments)) { + $actionRecord['arguments_preview'] = ConvertTo-SafeString -Value $arguments -MaxLength 1000 + } + $actions += $actionRecord + } + + $results += [ordered]@{ + task_path = $task.TaskPath + task_name = $task.TaskName + state = [string]$task.State + author = ConvertTo-SafeString -Value $task.Author -MaxLength 300 + trigger_types = $triggerTypes + actions = $actions + } + } catch { + Add-CollectionError -Section "scheduled_task:$($task.TaskPath)$($task.TaskName)" -ErrorRecord $_ + } + } + + return @($results | Sort-Object task_path, task_name) +} + +function Get-AutoStartServices { + param([switch]$IncludeFullCommandLine) + + try { + $services = Get-CimInstance -ClassName Win32_Service -ErrorAction Stop | Where-Object { $_.StartMode -eq 'Auto' } + } catch { + Add-CollectionError -Section 'auto_start_services' -ErrorRecord $_ + return @() + } + + $results = @() + foreach ($service in $services) { + $delayed = $false + try { + $keyPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$($service.Name)" + $property = Get-ItemProperty -LiteralPath $keyPath -Name DelayedAutoStart -ErrorAction SilentlyContinue + if ($null -ne $property -and $property.PSObject.Properties['DelayedAutoStart']) { + $delayed = [int]$property.DelayedAutoStart -eq 1 + } + } catch { + $delayed = $false + } + + $results += [ordered]@{ + name = $service.Name + display_name = $service.DisplayName + state = $service.State + status = $service.Status + delayed_auto_start = $delayed + service_account = ConvertTo-SafeString -Value $service.StartName -MaxLength 300 + path = Get-CommandSummary -CommandLine ([string]$service.PathName) -IncludeFullCommandLine:$IncludeFullCommandLine + } + } + + return @($results | Sort-Object display_name, name) +} + +function Get-DiskSummary { + try { + $disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction Stop + return @($disks | ForEach-Object { + $size = [double]$_.Size + $free = [double]$_.FreeSpace + [ordered]@{ + device_id = $_.DeviceID + volume_name = ConvertTo-SafeString -Value $_.VolumeName -MaxLength 200 + size_bytes = [int64]$_.Size + free_bytes = [int64]$_.FreeSpace + free_percent = if ($size -gt 0) { [Math]::Round(($free / $size) * 100, 2) } else { $null } + } + }) + } catch { + Add-CollectionError -Section 'disk_summary' -ErrorRecord $_ + return @() + } +} + +function Get-MemorySummary { + param([Parameter(Mandatory = $true)]$OperatingSystem) + + try { + $totalKb = [double]$OperatingSystem.TotalVisibleMemorySize + $freeKb = [double]$OperatingSystem.FreePhysicalMemory + return [ordered]@{ + total_visible_bytes = [int64]($totalKb * 1024) + free_physical_bytes = [int64]($freeKb * 1024) + free_percent = if ($totalKb -gt 0) { [Math]::Round(($freeKb / $totalKb) * 100, 2) } else { $null } + } + } catch { + Add-CollectionError -Section 'memory_summary' -ErrorRecord $_ + return [ordered]@{} + } +} + +function Get-PageFileSummary { + try { + $pageFiles = Get-CimInstance -ClassName Win32_PageFileUsage -ErrorAction Stop + return @($pageFiles | ForEach-Object { + [ordered]@{ + name = ConvertTo-SafeString -Value $_.Name -MaxLength 500 + allocated_base_mb = [int]$_.AllocatedBaseSize + current_usage_mb = [int]$_.CurrentUsage + peak_usage_mb = [int]$_.PeakUsage + } + }) + } catch { + Add-CollectionError -Section 'page_file_summary' -ErrorRecord $_ + return @() + } +} + +function Get-ProcessCpuSeconds { + param([Parameter(Mandatory = $true)]$Process) + + try { + $totalProcessorTime = $Process.TotalProcessorTime + if ($null -eq $totalProcessorTime) { + return $null + } + + if ($totalProcessorTime -is [TimeSpan]) { + return [Math]::Round([double]$totalProcessorTime.TotalSeconds, 2) + } + + $totalSecondsProperty = $totalProcessorTime.PSObject.Properties['TotalSeconds'] + if ($null -ne $totalSecondsProperty) { + return [Math]::Round([double]$totalSecondsProperty.Value, 2) + } + } catch { + return $null + } + + return $null +} + +function Get-ProcessSnapshotRecord { + param([Parameter(Mandatory = $true)]$Process) + + $id = $null + $name = $null + $workingSetBytes = $null + $privateMemoryBytes = $null + + try { + $id = [int]$Process.Id + } catch { + $id = $null + } + + try { + $name = [string]$Process.ProcessName + } catch { + $name = $null + } + + try { + $workingSetBytes = [int64]$Process.WorkingSet64 + } catch { + $workingSetBytes = $null + } + + try { + $privateMemoryBytes = [int64]$Process.PrivateMemorySize64 + } catch { + $privateMemoryBytes = $null + } + + if ($null -eq $id -and [string]::IsNullOrWhiteSpace($name)) { + return $null + } + + return [ordered]@{ + id = $id + name = $name + cpu_seconds = Get-ProcessCpuSeconds -Process $Process + working_set_bytes = $workingSetBytes + private_memory_bytes = $privateMemoryBytes + } +} + +function Get-TopProcessSummary { + try { + $processes = Get-Process -ErrorAction Stop + $records = @($processes | ForEach-Object { + Get-ProcessSnapshotRecord -Process $_ + } | Where-Object { $null -ne $_ }) + + $topCpu = @($records | + Where-Object { $null -ne $_['cpu_seconds'] } | + Sort-Object { $_['cpu_seconds'] } -Descending | + Select-Object -First 12) + $topMemory = @($records | + Where-Object { $null -ne $_['working_set_bytes'] } | + Sort-Object { $_['working_set_bytes'] } -Descending | + Select-Object -First 12) + + return [ordered]@{ + by_cumulative_cpu = $topCpu + by_working_set = $topMemory + } + } catch { + Add-CollectionError -Section 'top_process_summary' -ErrorRecord $_ + return [ordered]@{} + } +} +function Get-PerfProcessSummary { + try { + $perf = Get-CimInstance -ClassName Win32_PerfFormattedData_PerfProc_Process -ErrorAction Stop | + Where-Object { $_.Name -notin @('_Total', 'Idle') } + + $topCpu = @($perf | Sort-Object PercentProcessorTime -Descending | Select-Object -First 12 | ForEach-Object { + [ordered]@{ + id = [int]$_.IDProcess + name = $_.Name + percent_processor_time = [int64]$_.PercentProcessorTime + io_data_bytes_per_sec = [int64]$_.IODataBytesPersec + working_set_bytes = [int64]$_.WorkingSet + } + }) + $topIo = @($perf | Sort-Object IODataBytesPersec -Descending | Select-Object -First 12 | ForEach-Object { + [ordered]@{ + id = [int]$_.IDProcess + name = $_.Name + percent_processor_time = [int64]$_.PercentProcessorTime + io_data_bytes_per_sec = [int64]$_.IODataBytesPersec + working_set_bytes = [int64]$_.WorkingSet + } + }) + + return [ordered]@{ + by_cpu_now = $topCpu + by_io_now = $topIo + } + } catch { + Add-CollectionError -Section 'perf_process_summary' -ErrorRecord $_ + return [ordered]@{} + } +} + +function Get-RecentBootWarnings { + param( + [Parameter(Mandatory = $true)][DateTime]$StartTime, + [int]$MaxEvents + ) + + $logs = @('System', 'Application') + $results = @() + foreach ($log in $logs) { + try { + $events = Get-WinEvent -FilterHashtable @{ + LogName = $log + StartTime = $StartTime + Level = @(2, 3) + } -MaxEvents $MaxEvents -ErrorAction Stop + $results += @($events | ForEach-Object { Convert-WinEventSummary -Event $_ -LogName $log }) + } catch { + if ($_.Exception.Message -like '*No events were found*') { + continue + } + Add-CollectionError -Section "recent_boot_events:$log" -ErrorRecord $_ + } + } + + return @($results | Sort-Object time_created -Descending | Select-Object -First $MaxEvents) +} + +$scriptRoot = $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($scriptRoot)) { + $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +} +$hubRoot = Split-Path -Parent $scriptRoot +if ([string]::IsNullOrWhiteSpace($OutputPath) -and -not $NoWrite) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $OutputPath = Join-Path $hubRoot "state\startup-baseline-$stamp.json" +} + +$now = Get-Date +$os = $null +$computer = $null +try { + $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop +} catch { + Add-CollectionError -Section 'operating_system' -ErrorRecord $_ +} +try { + $computer = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop +} catch { + Add-CollectionError -Section 'computer_system' -ErrorRecord $_ +} + +$lastBoot = $null +if ($null -ne $os) { + $lastBoot = $os.LastBootUpTime +} +if ($null -eq $lastBoot) { + $lastBoot = $now +} + +$diagnosticsPerformance = Get-DiagnosticsPerformanceEvents ` + -MaxEvents $DiagnosticsEventCount ` + -BootEventTarget $DiagnosticsBootEventTarget ` + -ScanCount $DiagnosticsEventScanCount + +$baseline = [ordered]@{ + schema_version = 1 + kind = 'windows_startup_baseline' + collected_at_local = $now.ToString('o') + collected_at_utc = $now.ToUniversalTime().ToString('o') + machine = [ordered]@{ + computer_name = if ($null -ne $computer) { $computer.Name } else { $env:COMPUTERNAME } + manufacturer = if ($null -ne $computer) { ConvertTo-SafeString -Value $computer.Manufacturer -MaxLength 200 } else { $null } + model = if ($null -ne $computer) { ConvertTo-SafeString -Value $computer.Model -MaxLength 200 } else { $null } + domain_role = if ($null -ne $computer) { $computer.DomainRole } else { $null } + } + operating_system = [ordered]@{ + caption = if ($null -ne $os) { $os.Caption } else { $null } + version = if ($null -ne $os) { $os.Version } else { $null } + build_number = if ($null -ne $os) { $os.BuildNumber } else { $null } + } + boot = [ordered]@{ + last_boot_local = $lastBoot.ToString('o') + uptime_seconds = [int64]([Math]::Max(0, ($now - $lastBoot).TotalSeconds)) + } + diagnostics_performance_events = $diagnosticsPerformance.events + diagnostics_performance_collection = $diagnosticsPerformance.metadata + startup = [ordered]@{ + registry_entries = Get-RegistryStartupEntries -IncludeFullCommandLine:$IncludeCommandLines + startup_folder_items = Get-StartupFolderItems -IncludeFullCommandLine:$IncludeCommandLines + scheduled_startup_logon_tasks = Get-ScheduledStartupTasks -IncludeFullCommandLine:$IncludeCommandLines + auto_start_services = Get-AutoStartServices -IncludeFullCommandLine:$IncludeCommandLines + } + resources = [ordered]@{ + disks = Get-DiskSummary + memory = Get-MemorySummary -OperatingSystem $os + page_files = Get-PageFileSummary + top_processes = Get-TopProcessSummary + perf_processes = Get-PerfProcessSummary + } + recent_boot_events = Get-RecentBootWarnings -StartTime $lastBoot.AddMinutes(-5) -MaxEvents $RecentEventCount + collection_errors = $script:CollectionErrors +} + +$json = $baseline | ConvertTo-Json -Depth 14 +if (-not $NoWrite) { + $directory = Split-Path -Parent $OutputPath + if (-not [string]::IsNullOrWhiteSpace($directory)) { + New-Item -ItemType Directory -Force -Path $directory | Out-Null + } + Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8 +} + +$json diff --git a/sysadmin-windows-startup-performance/state/.gitkeep b/sysadmin-windows-startup-performance/state/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sysadmin-windows-startup-performance/templates/local-performance/README.md b/sysadmin-windows-startup-performance/templates/local-performance/README.md new file mode 100644 index 0000000..41b4dbb --- /dev/null +++ b/sysadmin-windows-startup-performance/templates/local-performance/README.md @@ -0,0 +1,18 @@ +# Local Performance Hub + +Template folder for using the `sysadmin-windows-startup-performance` skill as a project-local diagnostic hub. + +Copy the skill scripts into `scripts/`, then run from an elevated PowerShell session: + +```powershell +.\scripts\collect-startup-baseline.ps1 +.\scripts\analyze-startup-baseline.ps1 +``` + +Generated baselines go in `state/`; Markdown reports go in `reports/`; logs are reserved for future scheduled runs. + +To install this skill into Codex from the repository root: + +```powershell +Copy-Item -Recurse .\sysadmin-windows-startup-performance "$env:USERPROFILE\.codex\skills\sysadmin-windows-startup-performance" +``` diff --git a/sysadmin-windows-startup-performance/templates/local-performance/logs/.gitkeep b/sysadmin-windows-startup-performance/templates/local-performance/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sysadmin-windows-startup-performance/templates/local-performance/reports/.gitkeep b/sysadmin-windows-startup-performance/templates/local-performance/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sysadmin-windows-startup-performance/templates/local-performance/state/.gitkeep b/sysadmin-windows-startup-performance/templates/local-performance/state/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sysadmin-windows-startup-performance/tools/.gitkeep b/sysadmin-windows-startup-performance/tools/.gitkeep new file mode 100644 index 0000000..e69de29