# Worklog Session End Hook # # Claude Code SessionEnd hook for the PCMA Daily Work Tracker. It appends one # compact JSON line per coding session, per local date, to: # # \Work Log\claude-sessions\-.jsonl # # A cloud service reads those files through Microsoft Graph each night. The # line format is a contract with that service, so do not reorder, rename, or # drop keys without changing the service at the same time: # # {"v":1,"session_id":"..","date":"yyyy-MM-dd","start":"..","end":"..", # "active_seconds":0,"machine":"..","repo":"..","branch":"..", # "commits":[{"hash":"abc1234","subject":".."}],"summary":"..","reason":".."} # # Rules That Matter # - The hook never breaks the session: everything is inside try/catch, it # always exits 0, and it writes nothing to stdout. # - The OneDrive root comes from WORKLOG_ONEDRIVE_ROOT, or from the OneDrive # registry key whose ConfiguredTenantId matches the PCMA tenant. There is # deliberately NO fallback to %OneDrive% or %OneDriveCommercial%: on some # machines those point at another organization's tenant, and a write to # the wrong tenant is worse than no write. # - Errors go to %LOCALAPPDATA%\PCMA-WorkLog\hook-errors.log (local only). # - Runs under Windows PowerShell 5.1 and PowerShell 7. Keep the source # ASCII-only and avoid syntax that 5.1 does not have. # - Speed matters: Claude Code gives SessionEnd hooks a short time budget. # The hook path uses .NET calls instead of cmdlets on purpose. The first # use of a cmdlet such as Test-Path or New-Object costs tens of # milliseconds in Windows PowerShell, and that adds up to more than the # real work. # # Usage # powershell.exe -NoProfile -ExecutionPolicy Bypass -File worklog-session-end.ps1 # Hook mode. Reads the SessionEnd JSON from stdin. # powershell.exe -NoProfile -ExecutionPolicy Bypass -File worklog-session-end.ps1 -RepoFilter "PCMA*;pcma-*" # Hook mode, logging only matching work. See Repo Filter below. # powershell.exe -NoProfile -ExecutionPolicy Bypass -File worklog-session-end.ps1 -ShowTargetFolder # Prints the resolved target folder (or nothing when it cannot be # resolved) and exits. Used by Install-WorkLogHook.ps1. # # Repo Filter # For a machine used for more than one organization. The filter is one or # more wildcard patterns separated by semicolons, from -RepoFilter or, when # that is empty, from the WORKLOG_REPO_FILTER environment variable. A # session is logged when any pattern matches (case-insensitive, -like # rules) the repo name or any folder name in the session's working # directory path. With no filter every session is logged. With a filter # and no match the hook writes nothing and logs nothing. param( [string]$RepoFilter = '', [switch]$ShowTargetFolder ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- $script:DefaultTenantId = '758ab235-b480-4c59-8e56-30144f4893ce' $script:MaxIdleGapSeconds = 900 $script:DaysBack = 3 $script:MaxCommits = 20 $script:SummaryLimit = 200 $script:GitTimeoutMs = 5000 $script:AppendAttempts = 5 $script:ErrorLogMaxBytes = 1048576 $script:Utf8NoBom = [System.Text.UTF8Encoding]::new($false) $script:Invariant = [System.Globalization.CultureInfo]::InvariantCulture $script:SessionId = '' $script:Serializer = $null $regexOptions = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant # Scanning Notes # Lines are searched for the literal "timestamp" with ordinal string calls, # and the regexes below only ever run on a piece of a line. Running a regex # across whole lines costs seconds on a large transcript under PowerShell 7, # while the ordinal search costs milliseconds in both editions. # A "timestamp" key and its ISO 8601 value, anchored at the start of the text # it is given. Claude Code writes 2026-11-10T15:02:11.123Z. The same key also # appears nested inside some entries (file snapshots, attachments, tool # results), so every hit is checked for being a top-level key before it counts. $script:TimestampKey = '"timestamp"' $script:TimestampWindow = 96 $stampPattern = '^"timestamp"\s*:\s*"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?(?:Z|[+-][0-9]{2}:[0-9]{2}))"' # Top-Level Test # A key is top-level when exactly one bracket, the root object, is open where # it stands. The patterns walk JSON tokens (text, whole strings, brackets) # and use a .NET balancing group named "open" to pair brackets, so brackets # inside strings never count. Whichever side of the key is shorter is tested: # RxStampAndTail: key and value, then balanced text, then the root closes. # RxStampOnly + RxTopLevelHead: key and value; the text before the key is # the root "{" plus balanced text. $jsonTokens = '(?>(?>[^"{}\[\]]+)|"(?>[^"\\]*(?:\\.[^"\\]*)*)"|(?[{\[])|(?<-open>[}\]]))*(?(open)(?!))' $script:RxStampAndTail = [System.Text.RegularExpressions.Regex]::new(($stampPattern + $jsonTokens + '\}\s*$'), $regexOptions) $script:RxStampOnly = [System.Text.RegularExpressions.Regex]::new($stampPattern, $regexOptions) $script:RxTopLevelHead = [System.Text.RegularExpressions.Regex]::new(('^\s*\{' + $jsonTokens + '$'), $regexOptions) $script:RxWhitespace = [System.Text.RegularExpressions.Regex]::new('\s+', $regexOptions) # --------------------------------------------------------------------------- # Error Log # --------------------------------------------------------------------------- function Get-LogStamp { # Log stamps are written in US Central Time and labeled as such. try { $zone = [System.TimeZoneInfo]::FindSystemTimeZoneById('Central Standard Time') $central = [System.TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $zone) return $central.ToString('yyyy-MM-dd HH:mm:ss', $script:Invariant) + ' America/Chicago' } catch { return [DateTimeOffset]::Now.ToString('yyyy-MM-dd HH:mm:ss zzz', $script:Invariant) } } function Write-HookError { param([string]$Message) # Local only, never OneDrive. A failure to log must never surface. try { $directory = $env:WORKLOG_HOOK_LOG_DIR if ([string]::IsNullOrWhiteSpace($directory)) { $base = $env:LOCALAPPDATA if ([string]::IsNullOrWhiteSpace($base)) { $base = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) } if ([string]::IsNullOrWhiteSpace($base)) { return } $directory = [System.IO.Path]::Combine($base, 'PCMA-WorkLog') } [void][System.IO.Directory]::CreateDirectory($directory) $file = [System.IO.Path]::Combine($directory, 'hook-errors.log') if ([System.IO.File]::Exists($file)) { if ([System.IO.FileInfo]::new($file).Length -gt $script:ErrorLogMaxBytes) { $old = $file + '.old' if ([System.IO.File]::Exists($old)) { [System.IO.File]::Delete($old) } [System.IO.File]::Move($file, $old) } } $oneLine = $script:RxWhitespace.Replace([string]$Message, ' ').Trim() $entry = '{0} session={1} {2}{3}' -f (Get-LogStamp), $script:SessionId, $oneLine, [Environment]::NewLine [System.IO.File]::AppendAllText($file, $entry, $script:Utf8NoBom) } catch { } } # --------------------------------------------------------------------------- # Json Helpers # --------------------------------------------------------------------------- function ConvertFrom-JsonText { param([string]$Text) # Returns dictionaries and arrays in both editions. Windows PowerShell 5.1 # ConvertFrom-Json rejects documents over 2 MB (a prompt with a pasted # image can exceed that), so 5.1 uses the serializer directly. if ($PSVersionTable.PSVersion.Major -ge 6) { return (ConvertFrom-Json -InputObject $Text -AsHashtable) } if ($null -eq $script:Serializer) { # Loading by full name takes about 10 ms; Add-Type takes about 170 ms. try { [void][System.Reflection.Assembly]::Load('System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35') } catch { Add-Type -AssemblyName System.Web.Extensions } $script:Serializer = [System.Web.Script.Serialization.JavaScriptSerializer]::new() $script:Serializer.MaxJsonLength = [int]::MaxValue $script:Serializer.RecursionLimit = 512 } return $script:Serializer.DeserializeObject($Text) } function Get-JsonMember { param($Node, [string]$Name) if ($null -eq $Node) { return $null } if ($Node -is [System.Collections.IDictionary]) { # ContainsKey, not Contains: the generic dictionary that Windows # PowerShell 5.1 gets from the serializer only exposes ContainsKey. if ($Node.ContainsKey($Name)) { return $Node[$Name] } return $null } if ($Node -is [string] -or $Node -is [System.ValueType]) { return $null } $property = $Node.PSObject.Properties[$Name] if ($null -ne $property) { return $property.Value } return $null } function ConvertTo-JsonLiteral { param([string]$Value) # Quoted JSON string. Everything outside printable ASCII is written as a # \uXXXX escape, so the output file is plain ASCII (valid UTF-8, no BOM # questions) and no character can be mistaken for a line break by a reader. $builder = [System.Text.StringBuilder]::new() [void]$builder.Append('"') if ($null -ne $Value) { $length = $Value.Length for ($i = 0; $i -lt $length; $i++) { $char = $Value[$i] $code = [int]$char if ($code -eq 34) { [void]$builder.Append('\"') } elseif ($code -eq 92) { [void]$builder.Append('\\') } elseif ($code -ge 32 -and $code -lt 127) { [void]$builder.Append($char) } elseif ($code -eq 10) { [void]$builder.Append('\n') } elseif ($code -eq 13) { [void]$builder.Append('\r') } elseif ($code -eq 9) { [void]$builder.Append('\t') } elseif ($code -ge 0xD800 -and $code -le 0xDBFF -and ($i + 1) -lt $length -and [int]$Value[$i + 1] -ge 0xDC00 -and [int]$Value[$i + 1] -le 0xDFFF) { [void]$builder.Append('\u').Append($code.ToString('x4', $script:Invariant)) [void]$builder.Append('\u').Append(([int]$Value[$i + 1]).ToString('x4', $script:Invariant)) $i++ } elseif ($code -ge 0xD800 -and $code -le 0xDFFF) { [void]$builder.Append('\ufffd') } else { [void]$builder.Append('\u').Append($code.ToString('x4', $script:Invariant)) } } } [void]$builder.Append('"') return $builder.ToString() } # --------------------------------------------------------------------------- # Target Folder # --------------------------------------------------------------------------- function Resolve-WorkLogRoot { # Order: (1) WORKLOG_ONEDRIVE_ROOT, (2) the OneDrive Business account whose # ConfiguredTenantId equals the PCMA tenant. Nothing else. Returns $null # when neither resolves to an existing folder. $override = $env:WORKLOG_ONEDRIVE_ROOT if (-not [string]::IsNullOrWhiteSpace($override)) { $override = $override.Trim() if ([System.IO.Directory]::Exists($override)) { return $override } return $null } $tenant = $env:WORKLOG_TENANT_ID if ([string]::IsNullOrWhiteSpace($tenant)) { $tenant = $script:DefaultTenantId } $tenant = $tenant.Trim() # HKCU:\Software\Microsoft\OneDrive\Accounts\Business*, read through the # .NET registry classes (much faster to start than the registry provider). $accounts = $null try { $accounts = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\Microsoft\OneDrive\Accounts') } catch { return $null } if ($null -eq $accounts) { return $null } try { foreach ($name in $accounts.GetSubKeyNames()) { if (-not $name.StartsWith('Business', [System.StringComparison]::OrdinalIgnoreCase)) { continue } $configured = '' $folder = '' $key = $accounts.OpenSubKey($name) if ($null -eq $key) { continue } try { $configured = [string]$key.GetValue('ConfiguredTenantId') $folder = [string]$key.GetValue('UserFolder') } finally { $key.Dispose() } if ([string]::IsNullOrWhiteSpace($configured)) { continue } if ($configured.Trim() -ine $tenant) { continue } if ([string]::IsNullOrWhiteSpace($folder)) { continue } if ([System.IO.Directory]::Exists($folder)) { return $folder } } } finally { $accounts.Dispose() } return $null } function Resolve-TargetFolder { $root = Resolve-WorkLogRoot if ($null -eq $root) { return $null } return [System.IO.Path]::Combine($root, 'Work Log', 'claude-sessions') } # --------------------------------------------------------------------------- # Hook Input # --------------------------------------------------------------------------- function Read-StandardInput { # Read raw bytes and decode as UTF-8. The console input encoding is an OEM # code page under Windows PowerShell, which would damage non-ASCII paths. if (-not [Console]::IsInputRedirected) { return '' } $buffer = [System.IO.MemoryStream]::new() $stdin = [Console]::OpenStandardInput() try { $stdin.CopyTo($buffer) } finally { $stdin.Dispose() } $text = $script:Utf8NoBom.GetString($buffer.ToArray()) if ($text.Length -eq 0) { try { $text = [Console]::In.ReadToEnd() } catch { $text = '' } } return $text.TrimStart([char]0xFEFF) } # --------------------------------------------------------------------------- # Transcript # --------------------------------------------------------------------------- function Get-PromptSummary { param([string]$Line) # Returns the cleaned prompt text when the line is a real user prompt, # otherwise $null. Within one entry, text parts that start with "<" # (IDE context, system reminders) are skipped in favor of the first part # that does not. try { $entry = ConvertFrom-JsonText $Line } catch { return $null } if ([string](Get-JsonMember $entry 'type') -cne 'user') { return $null } if ((Get-JsonMember $entry 'isMeta') -eq $true) { return $null } if ((Get-JsonMember $entry 'isSidechain') -eq $true) { return $null } $content = Get-JsonMember (Get-JsonMember $entry 'message') 'content' $texts = [System.Collections.Generic.List[string]]::new() if ($content -is [string]) { $texts.Add($content) } elseif ($content -is [System.Collections.IEnumerable] -and $content -isnot [System.Collections.IDictionary]) { foreach ($part in $content) { if ([string](Get-JsonMember $part 'type') -ceq 'text') { $partText = Get-JsonMember $part 'text' if ($partText -is [string]) { $texts.Add($partText) } } } } foreach ($text in $texts) { $clean = $script:RxWhitespace.Replace($text, ' ').Trim() if ($clean.Length -eq 0) { continue } if ($clean.StartsWith('<', [System.StringComparison]::Ordinal)) { continue } if ($clean.Length -gt $script:SummaryLimit) { $cut = $script:SummaryLimit # Do not split a surrogate pair. if ([char]::IsHighSurrogate($clean[$cut - 1])) { $cut-- } $clean = $clean.Substring(0, $cut) } return $clean } return $null } function Read-Transcript { param([string]$Path, [string]$LowStamp) # One streaming pass. Each line's top-level timestamp is found without # parsing the line, and only lines that could be a user prompt are parsed # as JSON, and only until the summary is found. $LowStamp is the earliest # UTC stamp worth keeping, in the same fixed-width format Claude Code # writes, so older history is skipped with a plain string comparison. # # The loop body is kept to a handful of .NET calls with no function calls: # each call is cheap in Windows PowerShell and several microseconds in # PowerShell 7, and a transcript can have tens of thousands of lines. $stamps = [System.Collections.Generic.List[string]]::new() $summary = $null $ordinal = [System.StringComparison]::Ordinal $key = $script:TimestampKey $maxWindow = $script:TimestampWindow $rxStampAndTail = $script:RxStampAndTail $rxStampOnly = $script:RxStampOnly $rxHead = $script:RxTopLevelHead $share = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete $stream = [System.IO.FileStream]::new($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $share, 1048576, [System.IO.FileOptions]::SequentialScan) $reader = [System.IO.StreamReader]::new($stream, $script:Utf8NoBom, $true, 1048576) try { while ($null -ne ($line = $reader.ReadLine())) { # Walk the "timestamp" keys from the end of the line backward and # keep the first one that is a top-level key. # Regexes get a Substring, never Match(input, start, length): that # overload is an order of magnitude slower on long lines in # PowerShell 7. $index = $line.LastIndexOf($key, $ordinal) while ($index -ge 0) { $after = $line.Length - $index if ($after -le ($index + $maxWindow)) { # The key is in the back half: one match covers the value # and everything after it. $match = $rxStampAndTail.Match($line.Substring($index)) $topLevel = $match.Success } else { if ($after -gt $maxWindow) { $after = $maxWindow } $match = $rxStampOnly.Match($line.Substring($index, $after)) $topLevel = $match.Success if ($topLevel) { $topLevel = $rxHead.IsMatch($line.Substring(0, $index)) } } if ($topLevel) { $stamp = $match.Groups[1].Value if ($stamp.Length -ne 24 -or [string]::CompareOrdinal($stamp, $LowStamp) -ge 0) { $stamps.Add($stamp) } break } if ($index -eq 0) { break } $index = $line.LastIndexOf($key, $index - 1, $ordinal) } # Summary candidates: user entries (Claude Code writes compact # JSON, so the literal below is enough). Tool results are user # entries too and can be large, but they never hold the first real # prompt, so they are not parsed. if ($null -eq $summary) { if ($line.IndexOf('"type":"user"', $ordinal) -ge 0 -and $line.IndexOf('"tool_use_id"', $ordinal) -lt 0) { $summary = Get-PromptSummary $line } } } } finally { $reader.Dispose() } if ($null -eq $summary) { $summary = '' } return @{ Stamps = $stamps; Summary = $summary } } # --------------------------------------------------------------------------- # Local Dates # --------------------------------------------------------------------------- function Get-LocalDayStartUtcTicks { param([DateTime]$Day) $zone = [System.TimeZoneInfo]::Local $local = [DateTime]::SpecifyKind($Day.Date, [System.DateTimeKind]::Unspecified) # Midnight does not exist on some daylight saving transitions. if ($zone.IsInvalidTime($local)) { $local = $local.AddHours(1) } return [System.TimeZoneInfo]::ConvertTimeToUtc($local, $zone).Ticks } function Format-LocalStamp { param([long]$UtcTicks) $utc = [System.DateTimeOffset]::new($UtcTicks, [TimeSpan]::Zero) return $utc.ToLocalTime().ToString("yyyy-MM-dd'T'HH:mm:sszzz", $script:Invariant) } function Get-DayGroups { param($Stamps, [DateTime]$Today) # Groups the transcript timestamps by local date, for today back to # $script:DaysBack days ago. Each group has the first and last timestamp # of that date and the active time: the sum of gaps between consecutive # timestamps, skipping any gap longer than the idle limit. # # A plain long[] is used because array indexing is far cheaper than list # calls in PowerShell 7. $ticks = [long[]]::new([Math]::Max(1, $Stamps.Count)) $total = 0 $universal = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal $canonical = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'" $invariant = $script:Invariant foreach ($stamp in $Stamps) { try { if ($stamp.Length -eq 24) { $ticks[$total] = [DateTime]::ParseExact($stamp, $canonical, $invariant, $universal).Ticks } else { $ticks[$total] = [DateTimeOffset]::Parse($stamp, $invariant, [System.Globalization.DateTimeStyles]::AssumeUniversal).UtcTicks } $total++ } catch { } } [Array]::Sort($ticks, 0, $total) $groups = [System.Collections.ArrayList]::new() $maxGap = [long]$script:MaxIdleGapSeconds * 10000000 $index = 0 for ($back = $script:DaysBack; $back -ge 0; $back--) { $day = $Today.Date.AddDays(-$back) $from = Get-LocalDayStartUtcTicks $day $to = Get-LocalDayStartUtcTicks $day.AddDays(1) while ($index -lt $total -and $ticks[$index] -lt $from) { $index++ } if ($index -ge $total) { break } if ($ticks[$index] -ge $to) { continue } $first = $ticks[$index] $last = $first $active = [long]0 $index++ while ($index -lt $total -and $ticks[$index] -lt $to) { $gap = $ticks[$index] - $last if ($gap -le $maxGap) { $active += $gap } $last = $ticks[$index] $index++ } $seconds = [long][Math]::Round(([double]$active / 10000000.0), [System.MidpointRounding]::AwayFromZero) [void]$groups.Add(@{ Date = $day.ToString('yyyy-MM-dd', $script:Invariant) First = $first Last = $last ActiveSeconds = $seconds }) } return ,$groups } # --------------------------------------------------------------------------- # Git # --------------------------------------------------------------------------- function Find-Git { # Looks for git on PATH only. A bare process start would also look in the # working directory, which is the project folder, so the full path is # resolved here and relative PATH entries are skipped. $path = $env:PATH if ([string]::IsNullOrWhiteSpace($path)) { return $null } $names = @('git') if ([System.IO.Path]::DirectorySeparatorChar -eq '\') { $names = @('git.exe', 'git.cmd', 'git.bat') } foreach ($entry in $path.Split([System.IO.Path]::PathSeparator)) { $directory = $entry.Trim().Trim('"') if ($directory.Length -eq 0) { continue } try { if (-not [System.IO.Path]::IsPathRooted($directory)) { continue } foreach ($name in $names) { $candidate = [System.IO.Path]::Combine($directory, $name) if ([System.IO.File]::Exists($candidate)) { return $candidate } } } catch { } } return $null } function Test-GitFolderAbove { param([string]$Cwd) # Cheap check that saves starting git at all outside a repository: is # there a .git folder (or a .git file, for worktrees and submodules) in # this folder or any parent? When unsure, say yes and let git decide. if (-not [string]::IsNullOrEmpty($env:GIT_DIR)) { return $true } try { $directory = [System.IO.DirectoryInfo]::new($Cwd) while ($null -ne $directory) { $marker = [System.IO.Path]::Combine($directory.FullName, '.git') if ([System.IO.Directory]::Exists($marker) -or [System.IO.File]::Exists($marker)) { return $true } $directory = $directory.Parent } return $false } catch { return $true } } # Repo Filter Helpers function Get-RepoFilterPatterns { # The -RepoFilter parameter wins over WORKLOG_REPO_FILTER. Returns the # non-empty patterns; an empty result means "log everything". $value = $RepoFilter if ([string]::IsNullOrWhiteSpace($value)) { $value = $env:WORKLOG_REPO_FILTER } $patterns = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($value)) { foreach ($part in $value.Split(';')) { $pattern = $part.Trim() if ($pattern.Length -gt 0) { $patterns.Add($pattern) } } } return ,$patterns } function Test-FilterMatch { param($Patterns, [string]$Name) if ([string]::IsNullOrEmpty($Name)) { return $false } foreach ($pattern in $Patterns) { if ($Name -like $pattern) { return $true } } return $false } function Test-CwdMatchesFilter { param($Patterns, [string]$Cwd) # Any folder name in the working directory path may match. if ([string]::IsNullOrWhiteSpace($Cwd)) { return $false } foreach ($name in $Cwd.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries)) { if (Test-FilterMatch $Patterns $name) { return $true } } return $false } function Get-RepoName { param([string]$Cwd) # Name of the git top-level folder, or '' outside a repository. Only used # by the repo filter when no folder name in the path matched. if ([string]::IsNullOrWhiteSpace($Cwd)) { return '' } if (-not [System.IO.Directory]::Exists($Cwd)) { return '' } if (-not (Test-GitFolderAbove $Cwd)) { return '' } $git = Find-Git if ($null -eq $git) { return '' } $top = Wait-Git (Start-Git $git $Cwd 'rev-parse --show-toplevel') if ($null -eq $top -or [string]::IsNullOrWhiteSpace($top.Output)) { return '' } return [System.IO.Path]::GetFileName($top.Output.Split([char]10)[0].Trim().TrimEnd('\', '/')) } # Git Calls function Start-Git { param([string]$GitPath, [string]$WorkingDirectory, [string]$Arguments) # Starts git without waiting, so git calls can overlap (starting a # process is the slow part on Windows). Output is read as UTF-8 so commit # subjects survive, and nothing reaches the console. Returns $null when # git cannot be started. try { $info = [System.Diagnostics.ProcessStartInfo]::new() $info.FileName = $GitPath $info.Arguments = $Arguments $info.WorkingDirectory = $WorkingDirectory $info.UseShellExecute = $false $info.CreateNoWindow = $true $info.RedirectStandardInput = $true $info.RedirectStandardOutput = $true $info.RedirectStandardError = $true $info.StandardOutputEncoding = $script:Utf8NoBom $info.StandardErrorEncoding = $script:Utf8NoBom $info.EnvironmentVariables['GIT_TERMINAL_PROMPT'] = '0' $info.EnvironmentVariables['GIT_OPTIONAL_LOCKS'] = '0' $process = [System.Diagnostics.Process]::Start($info) $process.StandardInput.Close() return @{ Process = $process Out = $process.StandardOutput.ReadToEndAsync() Err = $process.StandardError.ReadToEndAsync() } } catch { return $null } } function Wait-Git { param($Handle) # Returns @{ ExitCode; Output } for a finished git process, or $null when # it could not be started or did not finish in time. if ($null -eq $Handle) { return $null } $process = $Handle.Process try { if (-not $process.WaitForExit($script:GitTimeoutMs)) { try { $process.Kill() } catch { } return $null } $process.WaitForExit() $output = [string]$Handle.Out.Result [void]$Handle.Err.Result return @{ ExitCode = $process.ExitCode; Output = $output } } catch { return $null } finally { $process.Dispose() } } function Get-GitInfo { param([string]$Cwd, [long]$SinceUtcTicks) # Repo name, branch, and the current user's commits reachable from HEAD or # any local branch. Without git, or outside a repository, the repo name is # the folder name and there are no commits. $leaf = '' if (-not [string]::IsNullOrWhiteSpace($Cwd)) { $trimmed = $Cwd.TrimEnd('\', '/') $leaf = [System.IO.Path]::GetFileName($trimmed) if ([string]::IsNullOrEmpty($leaf)) { $leaf = $trimmed } } $commits = [System.Collections.ArrayList]::new() $result = @{ Repo = $leaf; Branch = ''; Commits = $commits } if ([string]::IsNullOrWhiteSpace($Cwd)) { return $result } if (-not [System.IO.Directory]::Exists($Cwd)) { return $result } if (-not (Test-GitFolderAbove $Cwd)) { return $result } $git = Find-Git if ($null -eq $git) { return $result } # --since filters on committer date, which is never earlier than the author # date in normal use. One extra day of margin, then the exact author date # test happens per date in Select-Commits. $since = [System.DateTimeOffset]::new($SinceUtcTicks, [TimeSpan]::Zero).AddDays(-1) $sinceText = $since.ToString('yyyy-MM-dd HH:mm:ss', $script:Invariant) + ' +0000' # Two git calls, started together: # rev-parse prints the top-level folder, then the branch name # log prints every recent commit; the author filter is applied below $revParse = Start-Git $git $Cwd 'rev-parse --show-toplevel --abbrev-ref HEAD' $history = Start-Git $git $Cwd ('log HEAD --branches --since="{0}" --format=%H%x1f%at%x1f%ae%x1f%s' -f $sinceText) $revParse = Wait-Git $revParse $history = Wait-Git $history # Outside a repository rev-parse prints nothing. In a repository with no # commits yet it prints the folder and then fails on HEAD: the repo name # is still good and the branch stays empty. if ($null -eq $revParse -or [string]::IsNullOrWhiteSpace($revParse.Output)) { return $result } $rows = $revParse.Output.Split([char]10) $topLeaf = [System.IO.Path]::GetFileName($rows[0].Trim().TrimEnd('\', '/')) if ([string]::IsNullOrEmpty($topLeaf)) { return $result } $result.Repo = $topLeaf if ($revParse.ExitCode -eq 0 -and $rows.Count -ge 2) { $result.Branch = $rows[1].Trim() } if ($null -eq $history -or $history.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($history.Output)) { return $result } # Only now is the current user's email needed, so a session with no recent # commits never pays for the third git call. $config = Wait-Git (Start-Git $git $Cwd 'config user.email') if ($null -eq $config -or $config.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($config.Output)) { return $result } $email = $config.Output.Trim() $separator = [char]0x1F $order = 0 foreach ($row in $history.Output.Split([char]10)) { $fields = $row.TrimEnd([char]13).Split($separator, 4) if ($fields.Count -lt 4) { continue } if ($fields[0] -notmatch '^[0-9a-f]{40,64}$') { continue } if ($fields[2].Trim() -ine $email) { continue } $authored = [long]0 if (-not [long]::TryParse($fields[1], [ref]$authored)) { continue } [void]$commits.Add(@{ Hash = $fields[0].Substring(0, 7); Subject = $fields[3]; Authored = $authored; Order = $order }) $order++ } return $result } function Select-Commits { param($Commits, [long]$FirstUtcTicks, [long]$LastUtcTicks) # Commits whose author date falls inside [start, end] at whole-second # resolution (the resolution of both git and the start/end fields), # newest first, capped. Always returns an array. $from = [System.DateTimeOffset]::new($FirstUtcTicks, [TimeSpan]::Zero).ToUnixTimeSeconds() $to = [System.DateTimeOffset]::new($LastUtcTicks, [TimeSpan]::Zero).ToUnixTimeSeconds() $inside = [System.Collections.ArrayList]::new() foreach ($commit in $Commits) { if ($commit.Authored -ge $from -and $commit.Authored -le $to) { [void]$inside.Add($commit) } } if ($inside.Count -eq 0) { return ,@() } # Sort by author time, newest first; git's own order breaks ties. $keys = [long[]]::new($inside.Count) $items = $inside.ToArray() for ($i = 0; $i -lt $items.Count; $i++) { $keys[$i] = (-1 * [long]$items[$i].Authored * 1000000) + [long]$items[$i].Order } [Array]::Sort($keys, $items) if ($items.Count -gt $script:MaxCommits) { $capped = [object[]]::new($script:MaxCommits) [Array]::Copy($items, $capped, $script:MaxCommits) $items = $capped } return ,$items } # --------------------------------------------------------------------------- # Output # --------------------------------------------------------------------------- function Format-WorkLogLine { param([hashtable]$Record) # Built by hand so the key order is fixed and "commits" is always an array, # whatever PowerShell does with zero or one element collections. $builder = [System.Text.StringBuilder]::new() [void]$builder.Append('{"v":1') [void]$builder.Append(',"session_id":').Append((ConvertTo-JsonLiteral $Record.SessionId)) [void]$builder.Append(',"date":').Append((ConvertTo-JsonLiteral $Record.Date)) [void]$builder.Append(',"start":').Append((ConvertTo-JsonLiteral $Record.Start)) [void]$builder.Append(',"end":').Append((ConvertTo-JsonLiteral $Record.End)) [void]$builder.Append(',"active_seconds":').Append(([long]$Record.ActiveSeconds).ToString($script:Invariant)) [void]$builder.Append(',"machine":').Append((ConvertTo-JsonLiteral $Record.Machine)) [void]$builder.Append(',"repo":').Append((ConvertTo-JsonLiteral $Record.Repo)) [void]$builder.Append(',"branch":').Append((ConvertTo-JsonLiteral $Record.Branch)) [void]$builder.Append(',"commits":[') $first = $true foreach ($commit in @($Record.Commits)) { if ($null -eq $commit) { continue } if (-not $first) { [void]$builder.Append(',') } $first = $false [void]$builder.Append('{"hash":').Append((ConvertTo-JsonLiteral $commit.Hash)) [void]$builder.Append(',"subject":').Append((ConvertTo-JsonLiteral $commit.Subject)).Append('}') } [void]$builder.Append(']') [void]$builder.Append(',"summary":').Append((ConvertTo-JsonLiteral $Record.Summary)) [void]$builder.Append(',"reason":').Append((ConvertTo-JsonLiteral $Record.Reason)) [void]$builder.Append('}') return $builder.ToString() } function Add-WorkLogLine { param([string]$File, [string]$Line, [string]$SessionId, [string]$End) # Appends one LF-terminated line as UTF-8 without BOM, unless the file # already has a line with this session_id and this end. Retries briefly # when the sync client or another session has the file open. $prefix = '' if ([System.IO.File]::Exists($File)) { $existing = '' $share = [System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete $stream = [System.IO.FileStream]::new($File, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, $share) $reader = [System.IO.StreamReader]::new($stream, $script:Utf8NoBom, $true) try { $existing = $reader.ReadToEnd() } finally { $reader.Dispose() } $idPattern = '"session_id"\s*:\s*' + [regex]::Escape((ConvertTo-JsonLiteral $SessionId)) $endPattern = '"end"\s*:\s*' + [regex]::Escape((ConvertTo-JsonLiteral $End)) foreach ($row in $existing.Split([char]10)) { if ($row.Length -eq 0) { continue } if (($row -match $idPattern) -and ($row -match $endPattern)) { return } } if ($existing.Length -gt 0 -and -not $existing.EndsWith("`n", [System.StringComparison]::Ordinal)) { $prefix = "`n" } } $bytes = $script:Utf8NoBom.GetBytes($prefix + $Line + "`n") for ($attempt = 1; $attempt -le $script:AppendAttempts; $attempt++) { try { $out = [System.IO.FileStream]::new($File, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) try { $out.Write($bytes, 0, $bytes.Length) } finally { $out.Dispose() } return } catch [System.IO.IOException] { if ($attempt -ge $script:AppendAttempts) { throw } [System.Threading.Thread]::Sleep(150) } } } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- function Invoke-WorkLogHook { # Resolve the target first. On a machine without the PCMA OneDrive the # hook does nothing at all, quietly. $folder = Resolve-TargetFolder if ($null -eq $folder) { return } $inputText = Read-StandardInput if ([string]::IsNullOrWhiteSpace($inputText)) { throw 'No hook input was received on stdin.' } $hookInput = $null try { $hookInput = ConvertFrom-JsonText $inputText } catch { throw ('Hook input is not valid JSON: ' + $_.Exception.Message) } $transcript = [string](Get-JsonMember $hookInput 'transcript_path') if ([string]::IsNullOrWhiteSpace($transcript)) { throw 'Hook input has no transcript_path.' } $sessionId = [string](Get-JsonMember $hookInput 'session_id') if ([string]::IsNullOrWhiteSpace($sessionId)) { $sessionId = [System.IO.Path]::GetFileNameWithoutExtension($transcript) } $script:SessionId = $sessionId $cwd = [string](Get-JsonMember $hookInput 'cwd') $reason = [string](Get-JsonMember $hookInput 'reason') # Repo filter, before any other work: a session that does not match is # none of this hook's business, so it writes nothing and logs nothing. $patterns = Get-RepoFilterPatterns if ($patterns.Count -gt 0) { if (-not (Test-CwdMatchesFilter $patterns $cwd)) { if (-not (Test-FilterMatch $patterns (Get-RepoName $cwd))) { return } } } if (-not [System.IO.File]::Exists($transcript)) { throw ('Transcript file not found: ' + $transcript) } $today = [DateTime]::Now.Date $lowTicks = Get-LocalDayStartUtcTicks $today.AddDays(-$script:DaysBack) $lowStamp = [System.DateTime]::new($lowTicks, [System.DateTimeKind]::Utc).ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", $script:Invariant) $read = Read-Transcript $transcript $lowStamp $groups = Get-DayGroups $read.Stamps $today if ($groups.Count -eq 0) { return } $git = Get-GitInfo $cwd ([long]$groups[0].First) $machine = $env:COMPUTERNAME if ([string]::IsNullOrWhiteSpace($machine)) { $machine = [Environment]::MachineName } [void][System.IO.Directory]::CreateDirectory($folder) foreach ($group in $groups) { # One date failing (a locked file, for example) must not lose the others. try { $end = Format-LocalStamp $group.Last $record = @{ SessionId = $sessionId Date = $group.Date Start = (Format-LocalStamp $group.First) End = $end ActiveSeconds = $group.ActiveSeconds Machine = $machine Repo = $git.Repo Branch = $git.Branch Commits = (Select-Commits $git.Commits $group.First $group.Last) Summary = $read.Summary Reason = $reason } $file = [System.IO.Path]::Combine($folder, ('{0}-{1}.jsonl' -f $machine, $group.Date)) Add-WorkLogLine $file (Format-WorkLogLine $record) $sessionId $end } catch { Write-HookError ('Could not write the line for ' + $group.Date + ': ' + $_.Exception.Message) } } } if ($ShowTargetFolder) { try { $target = Resolve-TargetFolder if ($null -ne $target) { $target } } catch { } return } try { $null = Invoke-WorkLogHook } catch { $where = '' if ($null -ne $_.InvocationInfo) { $where = ' (line ' + $_.InvocationInfo.ScriptLineNumber + ')' } Write-HookError ($_.Exception.Message + $where) } exit 0