prizmkit 1.1.63 → 1.1.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.63",
3
- "bundledAt": "2026-06-08T18:06:47.062Z",
4
- "bundledFrom": "72acb14"
2
+ "frameworkVersion": "1.1.66",
3
+ "bundledAt": "2026-06-08T19:45:48.630Z",
4
+ "bundledFrom": "940cbd4"
5
5
  }
@@ -21,6 +21,17 @@
21
21
  # MAX_RETRIES=3 # Max retry attempts per task before marking failed
22
22
  # SESSION_TIMEOUT=0 # Session timeout in seconds (0 = no limit)
23
23
  # VERBOSE=1 # Verbose logging (1=on, 0=off)
24
+ # HEARTBEAT_INTERVAL=30 # Poll interval for session progress/stale checks
25
+ # STALE_KILL_THRESHOLD=900 # Auto-kill session after N seconds without log progress (0 = disabled)
26
+ # STALE_KILL_GRACE_SECONDS=10 # Grace period after stale-kill before force-stopping the job
27
+ # CODEX_SUBAGENT_TIMEOUT_SECONDS=840 # Codex subagent max runtime; defaults to stale threshold - 60
28
+ # LOG_CLEANUP_ENABLED=1 # Run periodic session log cleanup
29
+ # LOG_RETENTION_DAYS=14 # Delete session logs older than N days
30
+ # LOG_MAX_TOTAL_MB=1024 # Keep total logs under N MB via oldest-first cleanup
31
+ # STOP_ON_FAILURE=0 # Stop after the first failed task (1=stop, 0=continue)
32
+ # ENABLE_DEPLOY=0 # Start a deploy session after all tasks complete successfully
33
+ # DEV_BRANCH= # Optional custom dev branch name for each task
34
+ # AUTO_PUSH=0 # Push original branch after successful merge (1=enabled)
24
35
 
25
36
  # ─── Feature Pipeline Only ────────────────────────────────────────────
26
37
  # ENABLE_CRITIC=false # Enable adversarial critic review (true/false)
@@ -345,6 +345,17 @@ pending, in_progress, completed, failed, skipped
345
345
  | `MAX_RETRIES` | integer | (not specified) | Retry attempts per task |
346
346
  | `SESSION_TIMEOUT` | integer | 0 | 0 = no limit |
347
347
  | `VERBOSE` | integer | (not specified) | 1=on, 0=off |
348
+ | `HEARTBEAT_INTERVAL` | integer | 30 | Poll interval for session progress/stale checks |
349
+ | `STALE_KILL_THRESHOLD` | integer | 900 | Auto-kill after N seconds without log progress; 0 disables |
350
+ | `STALE_KILL_GRACE_SECONDS` | integer | 10 | Grace period after stale-kill before force-stopping |
351
+ | `CODEX_SUBAGENT_TIMEOUT_SECONDS` | integer | 840 | Codex subagent max runtime |
352
+ | `LOG_CLEANUP_ENABLED` | boolean | 1 | Periodic session log cleanup |
353
+ | `LOG_RETENTION_DAYS` | integer | 14 | Delete session logs older than N days |
354
+ | `LOG_MAX_TOTAL_MB` | integer | 1024 | Keep total logs under N MB |
355
+ | `STOP_ON_FAILURE` | boolean | 0 | Stop after the first failed task |
356
+ | `ENABLE_DEPLOY` | boolean | 0 | Start deploy session after all tasks complete |
357
+ | `DEV_BRANCH` | string | auto-generated | Optional custom dev branch name |
358
+ | `AUTO_PUSH` | boolean | 0 | Push original branch after successful merge |
348
359
  | `ENABLE_CRITIC` | boolean | false | Adversarial review enable |
349
360
  | `PIPELINE_MODE` | string | auto-detect | lite/standard/full override |
350
361
 
@@ -0,0 +1,222 @@
1
+ Set-StrictMode -Version 2.0
2
+ $ErrorActionPreference = 'Stop'
3
+
4
+ function Get-PrizmCurrentBranch {
5
+ param([string]$ProjectRoot)
6
+ $branch = & git -C $ProjectRoot rev-parse --abbrev-ref HEAD 2>$null
7
+ if ($LASTEXITCODE -eq 0 -and $branch) { return [string]($branch | Select-Object -First 1) }
8
+ return ''
9
+ }
10
+
11
+ function Test-PrizmTrackedDirty {
12
+ param([string]$ProjectRoot)
13
+ $trackedDirty = & git -C $ProjectRoot diff --name-only 2>$null
14
+ $stagedDirty = & git -C $ProjectRoot diff --cached --name-only 2>$null
15
+ return -not [string]::IsNullOrWhiteSpace((@($trackedDirty) + @($stagedDirty) -join "`n"))
16
+ }
17
+
18
+ function New-PrizmDevBranch {
19
+ param(
20
+ [string]$ProjectRoot,
21
+ [string]$DevBranch,
22
+ [string]$SourceBranch
23
+ )
24
+
25
+ if (-not $DevBranch -or -not $SourceBranch) { return $false }
26
+
27
+ & git -C $ProjectRoot rev-parse --verify $DevBranch *> $null
28
+ if ($LASTEXITCODE -eq 0) {
29
+ Write-PrizmInfo "Branch already exists: $DevBranch - checking out"
30
+ & git -C $ProjectRoot checkout $DevBranch *> $null
31
+ if ($LASTEXITCODE -ne 0) {
32
+ Write-PrizmError "Failed to checkout existing branch: $DevBranch"
33
+ return $false
34
+ }
35
+ return $true
36
+ }
37
+
38
+ & git -C $ProjectRoot checkout -b $DevBranch $SourceBranch *> $null
39
+ if ($LASTEXITCODE -ne 0) {
40
+ Write-PrizmWarn "Failed to create branch: $DevBranch from $SourceBranch"
41
+ return $false
42
+ }
43
+
44
+ Write-PrizmInfo "Created and checked out branch: $DevBranch (from $SourceBranch)"
45
+ return $true
46
+ }
47
+
48
+ function Switch-PrizmOriginalBranch {
49
+ param(
50
+ [string]$ProjectRoot,
51
+ [string]$OriginalBranch
52
+ )
53
+
54
+ if (-not $OriginalBranch) { return $false }
55
+ $currentBranch = Get-PrizmCurrentBranch $ProjectRoot
56
+ if ($currentBranch -eq $OriginalBranch) { return $true }
57
+
58
+ & git -C $ProjectRoot checkout $OriginalBranch *> $null
59
+ if ($LASTEXITCODE -ne 0) {
60
+ Write-PrizmError "Failed to checkout original branch: $OriginalBranch"
61
+ return $false
62
+ }
63
+
64
+ Write-PrizmInfo "Returned to branch: $OriginalBranch"
65
+ return $true
66
+ }
67
+
68
+ function Save-PrizmBranchWip {
69
+ param(
70
+ [string]$ProjectRoot,
71
+ [string]$DevBranch
72
+ )
73
+
74
+ if (-not $DevBranch) { return $true }
75
+
76
+ $currentBranch = Get-PrizmCurrentBranch $ProjectRoot
77
+ if ($currentBranch -ne $DevBranch) { return $true }
78
+
79
+ $changes = & git -C $ProjectRoot status --porcelain 2>$null
80
+ if ([string]::IsNullOrWhiteSpace(($changes -join "`n"))) { return $true }
81
+
82
+ Write-PrizmWarn "Saving uncommitted work-in-progress on branch: $DevBranch"
83
+ & git -C $ProjectRoot add -A *> $null
84
+ if ($LASTEXITCODE -ne 0) {
85
+ Write-PrizmWarn "git add -A failed - uncommitted work may remain on $DevBranch"
86
+ return $true
87
+ }
88
+
89
+ & git -C $ProjectRoot commit --no-verify `
90
+ -m "wip($DevBranch): interrupted - uncommitted work saved" `
91
+ -m "Pipeline was interrupted or failed. This commit preserves work-in-progress." `
92
+ -m "To resume: git checkout $DevBranch" *> $null
93
+
94
+ if ($LASTEXITCODE -eq 0) {
95
+ Write-PrizmInfo "Saved uncommitted work on branch $DevBranch"
96
+ } else {
97
+ Write-PrizmWarn "git commit failed - uncommitted work may remain on $DevBranch"
98
+ }
99
+
100
+ return $true
101
+ }
102
+
103
+ function Merge-PrizmDevBranch {
104
+ param(
105
+ [string]$ProjectRoot,
106
+ [string]$DevBranch,
107
+ [string]$OriginalBranch,
108
+ [bool]$AutoPush = $false
109
+ )
110
+
111
+ if (-not $DevBranch -or -not $OriginalBranch) { return $false }
112
+
113
+ $hadStash = $false
114
+ if (Test-PrizmTrackedDirty $ProjectRoot) {
115
+ & git -C $ProjectRoot stash push -m 'pipeline-merge-stash' *> $null
116
+ if ($LASTEXITCODE -eq 0) {
117
+ $hadStash = $true
118
+ } else {
119
+ Write-PrizmWarn "git stash failed - tracked changes may block branch merge"
120
+ }
121
+ }
122
+
123
+ Write-PrizmInfo "Merging $DevBranch into $OriginalBranch..."
124
+ & git -C $ProjectRoot rebase $OriginalBranch $DevBranch
125
+ if ($LASTEXITCODE -ne 0) {
126
+ Write-PrizmError "Rebase of $DevBranch onto $OriginalBranch failed - resolve manually"
127
+ & git -C $ProjectRoot rebase --abort *> $null
128
+ if ($hadStash) {
129
+ & git -C $ProjectRoot stash pop *> $null
130
+ if ($LASTEXITCODE -ne 0) { Write-PrizmWarn "git stash pop failed after rebase abort" }
131
+ }
132
+ return $false
133
+ }
134
+
135
+ & git -C $ProjectRoot checkout $OriginalBranch *> $null
136
+ if ($LASTEXITCODE -ne 0) {
137
+ Write-PrizmError "Failed to checkout $OriginalBranch for merge"
138
+ if ($hadStash) {
139
+ & git -C $ProjectRoot stash pop *> $null
140
+ if ($LASTEXITCODE -ne 0) { Write-PrizmWarn "git stash pop failed after checkout failure" }
141
+ }
142
+ return $false
143
+ }
144
+
145
+ & git -C $ProjectRoot merge --ff-only $DevBranch
146
+ if ($LASTEXITCODE -ne 0) {
147
+ Write-PrizmError "Merge failed after rebase - resolve manually"
148
+ if ($hadStash) {
149
+ & git -C $ProjectRoot stash pop *> $null
150
+ if ($LASTEXITCODE -ne 0) { Write-PrizmWarn "git stash pop failed after merge failure" }
151
+ }
152
+ return $false
153
+ }
154
+
155
+ Write-PrizmSuccess "Merged $DevBranch into $OriginalBranch"
156
+
157
+ if ($AutoPush) {
158
+ Write-PrizmInfo "Pushing $OriginalBranch to remote..."
159
+ & git -C $ProjectRoot push *> $null
160
+ if ($LASTEXITCODE -eq 0) {
161
+ Write-PrizmSuccess "Pushed $OriginalBranch to remote"
162
+ } else {
163
+ Write-PrizmWarn "Push failed - run git push manually"
164
+ }
165
+ }
166
+
167
+ & git -C $ProjectRoot branch -d $DevBranch *> $null
168
+ if ($LASTEXITCODE -eq 0) { Write-PrizmInfo "Deleted merged branch: $DevBranch" }
169
+
170
+ if ($hadStash) {
171
+ & git -C $ProjectRoot stash pop *> $null
172
+ if ($LASTEXITCODE -ne 0) { Write-PrizmWarn "git stash pop failed after merge" }
173
+ }
174
+
175
+ return $true
176
+ }
177
+
178
+ function Restore-PrizmOriginalBranch {
179
+ param(
180
+ [string]$ProjectRoot,
181
+ [string]$OriginalBranch,
182
+ [string]$DevBranch = ''
183
+ )
184
+
185
+ if (-not $OriginalBranch) { return $true }
186
+
187
+ & git -C $ProjectRoot rebase --show-current-patch *> $null
188
+ if ($LASTEXITCODE -eq 0) {
189
+ Write-PrizmWarn "Aborting in-progress rebase..."
190
+ & git -C $ProjectRoot rebase --abort *> $null
191
+ }
192
+
193
+ $currentBranch = Get-PrizmCurrentBranch $ProjectRoot
194
+ if ($currentBranch -eq $OriginalBranch) { return $true }
195
+
196
+ $wipBranch = if ($DevBranch) { $DevBranch } else { $currentBranch }
197
+ if ($wipBranch -and $wipBranch -ne $OriginalBranch) {
198
+ Save-PrizmBranchWip $ProjectRoot $wipBranch | Out-Null
199
+ }
200
+
201
+ Write-PrizmInfo "Ensuring return to original branch: $OriginalBranch (currently on: $currentBranch)"
202
+
203
+ $hadStash = $false
204
+ if (Test-PrizmTrackedDirty $ProjectRoot) {
205
+ & git -C $ProjectRoot stash push -m 'pipeline-ensure-return-stash' *> $null
206
+ if ($LASTEXITCODE -eq 0) { $hadStash = $true }
207
+ }
208
+
209
+ & git -C $ProjectRoot checkout $OriginalBranch *> $null
210
+ if ($LASTEXITCODE -eq 0) {
211
+ Write-PrizmInfo "Returned to branch: $OriginalBranch"
212
+ } else {
213
+ Write-PrizmError "Failed to checkout $OriginalBranch - manual recovery needed"
214
+ }
215
+
216
+ if ($hadStash) {
217
+ & git -C $ProjectRoot stash pop *> $null
218
+ if ($LASTEXITCODE -ne 0) { Write-PrizmWarn "git stash pop failed during branch return" }
219
+ }
220
+
221
+ return $true
222
+ }
@@ -3,6 +3,47 @@ $ErrorActionPreference = 'Stop'
3
3
 
4
4
  $script:PrizmAiProcess = $null
5
5
 
6
+ if (-not ('PrizmLineLogBridge' -as [type])) {
7
+ Add-Type -TypeDefinition @'
8
+ using System;
9
+ using System.Diagnostics;
10
+ using System.IO;
11
+ using System.Text;
12
+
13
+ public sealed class PrizmLineLogBridge : IDisposable
14
+ {
15
+ private readonly StreamWriter writer;
16
+ private readonly object sync = new object();
17
+ public readonly DataReceivedEventHandler Handler;
18
+
19
+ public PrizmLineLogBridge(string logPath)
20
+ {
21
+ writer = new StreamWriter(logPath, false, new UTF8Encoding(false));
22
+ Handler = new DataReceivedEventHandler(OnDataReceived);
23
+ }
24
+
25
+ private void OnDataReceived(object sender, DataReceivedEventArgs eventArgs)
26
+ {
27
+ if (eventArgs.Data == null) return;
28
+ lock (sync)
29
+ {
30
+ writer.WriteLine(eventArgs.Data);
31
+ writer.Flush();
32
+ }
33
+ }
34
+
35
+ public void Dispose()
36
+ {
37
+ lock (sync)
38
+ {
39
+ writer.Flush();
40
+ writer.Dispose();
41
+ }
42
+ }
43
+ }
44
+ '@
45
+ }
46
+
6
47
  function Write-PrizmInfo { param([string]$Message) Write-Host "[INFO] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" -ForegroundColor Blue }
7
48
  function Write-PrizmWarn { param([string]$Message) Write-Host "[WARN] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" -ForegroundColor Yellow }
8
49
  function Write-PrizmError { param([string]$Message) Write-Host "[ERROR] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" -ForegroundColor Red }
@@ -298,6 +339,90 @@ function Get-PrizmSessionPlatform {
298
339
  return Get-PrizmPlatformFromCli $CliCommand
299
340
  }
300
341
 
342
+ function Get-PrizmCodexSubagentTimeoutSeconds {
343
+ $configuredTimeout = 0
344
+ if ([int]::TryParse($env:CODEX_SUBAGENT_TIMEOUT_SECONDS, [ref]$configuredTimeout) -and $configuredTimeout -gt 0) {
345
+ return $configuredTimeout
346
+ }
347
+
348
+ $outerThreshold = 0
349
+ $outerThresholdText = if ($env:STALE_KILL_THRESHOLD) { $env:STALE_KILL_THRESHOLD } else { $env:SESSION_TIMEOUT }
350
+ if ([int]::TryParse($outerThresholdText, [ref]$outerThreshold) -and $outerThreshold -gt 120) {
351
+ return ($outerThreshold - 60)
352
+ }
353
+
354
+ return 840
355
+ }
356
+
357
+ function Test-PrizmCodexJsonSupport {
358
+ param([string]$CliExecutable)
359
+ try {
360
+ $helpOutput = & $CliExecutable exec --help 2>&1
361
+ return (($helpOutput -join "`n") -match '--json')
362
+ } catch {
363
+ return $false
364
+ }
365
+ }
366
+
367
+ function Test-PrizmStreamJsonSupport {
368
+ param([string]$CliCommand)
369
+ $sessionPlatform = Get-PrizmSessionPlatform $CliCommand
370
+ $parsedCli = Split-PrizmCommandLine $CliCommand
371
+
372
+ if ($sessionPlatform -eq 'codebuddy') { return $true }
373
+ if ($sessionPlatform -eq 'codex') { return (Test-PrizmCodexJsonSupport $parsedCli.Command) }
374
+
375
+ try {
376
+ $helpOutput = & $parsedCli.Command --help 2>&1
377
+ return (($helpOutput -join "`n") -match 'stream-json')
378
+ } catch {
379
+ return $false
380
+ }
381
+ }
382
+
383
+ function Start-PrizmProgressParser {
384
+ param(
385
+ [string[]]$PythonCommand,
386
+ [string]$ScriptsDir,
387
+ [string]$SessionLog,
388
+ [string]$ProgressFile,
389
+ [string]$CliCommand
390
+ )
391
+
392
+ if (-not (Test-PrizmStreamJsonSupport $CliCommand)) { return $null }
393
+
394
+ $parserScript = Join-Path $ScriptsDir 'parse-stream-progress.py'
395
+ if (-not (Test-Path $parserScript)) { return $null }
396
+
397
+ $cmd = $PythonCommand[0]
398
+ $prefix = @()
399
+ if ($PythonCommand.Count -gt 1) { $prefix = $PythonCommand[1..($PythonCommand.Count - 1)] }
400
+
401
+ $progressDir = Split-Path $ProgressFile -Parent
402
+ if ($progressDir) { New-Item -ItemType Directory -Force -Path $progressDir | Out-Null }
403
+
404
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
405
+ $psi.UseShellExecute = $false
406
+ $psi.CreateNoWindow = $true
407
+ $psi.FileName = $cmd
408
+ $psi.Arguments = Join-PrizmProcessArguments ($prefix + @($parserScript, '--session-log', $SessionLog, '--progress-file', $ProgressFile))
409
+ return [System.Diagnostics.Process]::Start($psi)
410
+ }
411
+
412
+ function Stop-PrizmProgressParser {
413
+ param($Process)
414
+ if ($Process -eq $null) { return }
415
+ try {
416
+ if (-not $Process.HasExited) {
417
+ try {
418
+ $Process.Kill($true)
419
+ } catch {
420
+ Stop-PrizmProcessTreeById -ProcessId ([int]$Process.Id)
421
+ }
422
+ }
423
+ } catch {}
424
+ }
425
+
301
426
  function Stop-PrizmProcessTreeById {
302
427
  param([int]$ProcessId)
303
428
  if ($ProcessId -le 0) { return }
@@ -328,6 +453,28 @@ function Stop-PrizmProcessTreeById {
328
453
  }
329
454
  }
330
455
 
456
+ function Stop-PrizmSessionProcess {
457
+ param([string]$PidPath)
458
+ if (-not (Test-Path $PidPath)) { return }
459
+ $rawPid = Get-Content $PidPath -ErrorAction SilentlyContinue | Select-Object -First 1
460
+ $aiPid = 0
461
+ if ([int]::TryParse($rawPid, [ref]$aiPid)) {
462
+ Stop-PrizmProcessTreeById -ProcessId $aiPid
463
+ }
464
+ }
465
+
466
+ function Write-PrizmStaleKillMarker {
467
+ param([string]$MarkerPath, [int]$StaleSeconds, [int]$Threshold)
468
+ $markerDir = Split-Path $MarkerPath -Parent
469
+ if ($markerDir) { New-Item -ItemType Directory -Force -Path $markerDir | Out-Null }
470
+ [ordered]@{
471
+ killed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
472
+ reason = 'stale_session'
473
+ stale_seconds = $StaleSeconds
474
+ threshold = $Threshold
475
+ } | ConvertTo-Json -Compress | Set-Content -Path $MarkerPath -Encoding UTF8
476
+ }
477
+
331
478
  function Invoke-PrizmAiSession {
332
479
  param(
333
480
  [string]$CliCommand,
@@ -352,16 +499,27 @@ function Invoke-PrizmAiSession {
352
499
  $parsedCli = Split-PrizmCommandLine $CliCommand
353
500
  $cliExecutable = $parsedCli.Command
354
501
  $sessionPlatform = Get-PrizmSessionPlatform $CliCommand
502
+ $useStreamJson = Test-PrizmStreamJsonSupport $CliCommand
355
503
 
356
504
  if ($sessionPlatform -eq 'claude') {
357
505
  $cliArgs += @('-p', '--dangerously-skip-permissions')
506
+ if ($env:VERBOSE -in @('1','true','yes','on') -or $useStreamJson) { $cliArgs += '--verbose' }
507
+ if ($useStreamJson) { $cliArgs += @('--output-format', 'stream-json') }
358
508
  if ($Model) { $cliArgs += @('--model', $Model) }
359
509
  } elseif ($sessionPlatform -eq 'codex') {
360
- $cliArgs += @('--ask-for-approval', 'never', '--sandbox', 'danger-full-access', 'exec', '--cd', $ProjectRoot, '--skip-git-repo-check')
510
+ $cliArgs += @('--ask-for-approval', 'never', '--sandbox', 'danger-full-access')
511
+ $codexSubagentTimeout = Get-PrizmCodexSubagentTimeoutSeconds
512
+ if ($codexSubagentTimeout -gt 0) {
513
+ $cliArgs += @('--config', "agents.job_max_runtime_seconds=$codexSubagentTimeout")
514
+ }
515
+ $cliArgs += @('exec', '--cd', $ProjectRoot, '--skip-git-repo-check')
516
+ if ($useStreamJson) { $cliArgs += '--json' }
361
517
  if ($Model) { $cliArgs += @('--model', $Model) }
362
518
  $cliArgs += '-'
363
519
  } else {
364
520
  $cliArgs += @('--print', '-y')
521
+ if ($env:VERBOSE -in @('1','true','yes','on')) { $cliArgs += '--verbose' }
522
+ if ($useStreamJson) { $cliArgs += @('--output-format', 'stream-json') }
365
523
  if ($Model) { $cliArgs += @('--model', $Model) }
366
524
  }
367
525
  $generatedArgs = (($cliArgs | ForEach-Object { ConvertTo-PrizmProcessArgument $_ }) -join ' ')
@@ -390,20 +548,27 @@ function Invoke-PrizmAiSession {
390
548
 
391
549
  $process = [System.Diagnostics.Process]::Start($psi)
392
550
  $script:PrizmAiProcess = $process
551
+ $logBridge = [PrizmLineLogBridge]::new($LogPath)
552
+ $process.add_OutputDataReceived($logBridge.Handler)
553
+ $process.add_ErrorDataReceived($logBridge.Handler)
393
554
  if ($PidPath) {
394
555
  $pidDir = Split-Path $PidPath -Parent
395
556
  if ($pidDir) { New-Item -ItemType Directory -Force -Path $pidDir | Out-Null }
396
557
  Set-Content -Path $PidPath -Value ([string]$process.Id) -Encoding UTF8
397
558
  }
398
- $process.StandardInput.Write($prompt)
399
- $process.StandardInput.Close()
400
- $stdoutTask = $process.StandardOutput.ReadToEndAsync()
401
- $stderrTask = $process.StandardError.ReadToEndAsync()
402
- $process.WaitForExit()
403
- $stdout = $stdoutTask.Result
404
- $stderr = $stderrTask.Result
405
- Set-Content -Path $LogPath -Value ($stdout + $stderr) -Encoding UTF8
406
- return $process.ExitCode
559
+ try {
560
+ $process.BeginOutputReadLine()
561
+ $process.BeginErrorReadLine()
562
+ $process.StandardInput.Write($prompt)
563
+ $process.StandardInput.Close()
564
+ $process.WaitForExit()
565
+ $process.WaitForExit()
566
+ return $process.ExitCode
567
+ } finally {
568
+ $process.remove_OutputDataReceived($logBridge.Handler)
569
+ $process.remove_ErrorDataReceived($logBridge.Handler)
570
+ $logBridge.Dispose()
571
+ }
407
572
  }
408
573
 
409
574
  function New-PrizmSessionId {
@@ -1,4 +1,5 @@
1
1
  . "$PSScriptRoot\common.ps1"
2
+ . "$PSScriptRoot\branch.ps1"
2
3
  $global:PRIZM_EXIT_CODE = 0
3
4
 
4
5
  function Invoke-PrizmPipeline {
@@ -83,6 +84,54 @@ function Invoke-PrizmPipeline {
83
84
  }
84
85
  $timeoutSeconds = $parsedEnvTimeout
85
86
  }
87
+ $heartbeatInterval = 30
88
+ if ($env:HEARTBEAT_INTERVAL) {
89
+ $parsedHeartbeatInterval = 0
90
+ if (-not [int]::TryParse($env:HEARTBEAT_INTERVAL, [ref]$parsedHeartbeatInterval) -or $parsedHeartbeatInterval -lt 1) {
91
+ throw "HEARTBEAT_INTERVAL must be a positive integer: $($env:HEARTBEAT_INTERVAL)"
92
+ }
93
+ $heartbeatInterval = $parsedHeartbeatInterval
94
+ }
95
+ $staleKillThreshold = 900
96
+ if ($env:STALE_KILL_THRESHOLD) {
97
+ $parsedStaleKillThreshold = 0
98
+ if (-not [int]::TryParse($env:STALE_KILL_THRESHOLD, [ref]$parsedStaleKillThreshold) -or $parsedStaleKillThreshold -lt 0) {
99
+ throw "STALE_KILL_THRESHOLD must be a non-negative integer: $($env:STALE_KILL_THRESHOLD)"
100
+ }
101
+ $staleKillThreshold = $parsedStaleKillThreshold
102
+ }
103
+ $staleKillGraceSeconds = 10
104
+ if ($env:STALE_KILL_GRACE_SECONDS) {
105
+ $parsedStaleKillGraceSeconds = 0
106
+ if (-not [int]::TryParse($env:STALE_KILL_GRACE_SECONDS, [ref]$parsedStaleKillGraceSeconds) -or $parsedStaleKillGraceSeconds -lt 0) {
107
+ throw "STALE_KILL_GRACE_SECONDS must be a non-negative integer: $($env:STALE_KILL_GRACE_SECONDS)"
108
+ }
109
+ $staleKillGraceSeconds = $parsedStaleKillGraceSeconds
110
+ }
111
+ $autoPush = $env:AUTO_PUSH -in @('1','true','yes','on')
112
+ $enableDeploy = $env:ENABLE_DEPLOY -in @('1','true','yes','on')
113
+ $stopOnFailure = $env:STOP_ON_FAILURE -in @('1','true','yes','on')
114
+ $devBranchOverride = if ($env:DEV_BRANCH) { $env:DEV_BRANCH.Trim() } else { '' }
115
+ $logCleanupEnabled = $true
116
+ if ($env:LOG_CLEANUP_ENABLED) {
117
+ $logCleanupEnabled = $env:LOG_CLEANUP_ENABLED -in @('1','true','yes','on')
118
+ }
119
+ $logRetentionDays = 14
120
+ if ($env:LOG_RETENTION_DAYS) {
121
+ $parsedLogRetentionDays = 0
122
+ if (-not [int]::TryParse($env:LOG_RETENTION_DAYS, [ref]$parsedLogRetentionDays) -or $parsedLogRetentionDays -lt 0) {
123
+ throw "LOG_RETENTION_DAYS must be a non-negative integer: $($env:LOG_RETENTION_DAYS)"
124
+ }
125
+ $logRetentionDays = $parsedLogRetentionDays
126
+ }
127
+ $logMaxTotalMb = 1024
128
+ if ($env:LOG_MAX_TOTAL_MB) {
129
+ $parsedLogMaxTotalMb = 0
130
+ if (-not [int]::TryParse($env:LOG_MAX_TOTAL_MB, [ref]$parsedLogMaxTotalMb) -or $parsedLogMaxTotalMb -lt 0) {
131
+ throw "LOG_MAX_TOTAL_MB must be a non-negative integer: $($env:LOG_MAX_TOTAL_MB)"
132
+ }
133
+ $logMaxTotalMb = $parsedLogMaxTotalMb
134
+ }
86
135
  $featuresFilter = $null
87
136
 
88
137
  for ($i = 0; $i -lt $remaining.Count; $i++) {
@@ -130,7 +179,7 @@ function Invoke-PrizmPipeline {
130
179
  $criticLabel = if ($critic) { $critic } else { 'plan-default' }
131
180
  $retryLabel = if ($maxRetries -ne $null) { [string]$maxRetries } else { 'default' }
132
181
  Write-PrizmInfo "Verbose mode enabled."
133
- Write-PrizmInfo "Effective options: mode=$modeLabel critic=$criticLabel maxRetries=$retryLabel timeoutSeconds=$timeoutSeconds dryRun=$dryRun"
182
+ Write-PrizmInfo "Effective options: mode=$modeLabel critic=$criticLabel maxRetries=$retryLabel timeoutSeconds=$timeoutSeconds staleKillThreshold=$staleKillThreshold dryRun=$dryRun"
134
183
  }
135
184
 
136
185
  if (-not (Test-Path $listPath)) { throw "List file not found: $listPath" }
@@ -227,6 +276,28 @@ function Invoke-PrizmPipeline {
227
276
  return -not [string]::IsNullOrWhiteSpace([string]$dirty)
228
277
  }
229
278
 
279
+ function Stop-PrizmSessionProcess {
280
+ param([string]$PidPath)
281
+ if (-not (Test-Path $PidPath)) { return }
282
+ $rawPid = Get-Content $PidPath -ErrorAction SilentlyContinue | Select-Object -First 1
283
+ $aiPid = 0
284
+ if ([int]::TryParse($rawPid, [ref]$aiPid)) {
285
+ Stop-PrizmProcessTreeById -ProcessId $aiPid
286
+ }
287
+ }
288
+
289
+ function Write-PrizmStaleKillMarker {
290
+ param([string]$MarkerPath, [int]$StaleSeconds, [int]$Threshold)
291
+ $markerDir = Split-Path $MarkerPath -Parent
292
+ if ($markerDir) { New-Item -ItemType Directory -Force -Path $markerDir | Out-Null }
293
+ [ordered]@{
294
+ killed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
295
+ reason = 'stale_session'
296
+ stale_seconds = $StaleSeconds
297
+ threshold = $Threshold
298
+ } | ConvertTo-Json -Compress | Set-Content -Path $MarkerPath -Encoding UTF8
299
+ }
300
+
230
301
  function Invoke-PrizmGitAutoCommit {
231
302
  param([string]$ProjectRoot, [string]$Message)
232
303
  & git -C $ProjectRoot add -A *> $null
@@ -251,6 +322,140 @@ function Invoke-PrizmPipeline {
251
322
  & git -C $ProjectRoot commit --no-verify --amend --no-edit --only -- $listRel *> $null
252
323
  }
253
324
 
325
+ function Invoke-PrizmGitCommitPath {
326
+ param([string]$ProjectRoot, [string]$Path, [string]$Message)
327
+ if (-not $Path) { return $false }
328
+ $relPath = ConvertTo-PrizmGitRelativePath $ProjectRoot $Path
329
+ if (-not $relPath) { return $false }
330
+ & git -C $ProjectRoot diff --quiet -- $relPath 2>$null
331
+ if ($LASTEXITCODE -eq 0) { return $false }
332
+ & git -C $ProjectRoot add -- $relPath *> $null
333
+ if ($LASTEXITCODE -ne 0) { return $false }
334
+ & git -C $ProjectRoot commit --no-verify -m $Message *> $null
335
+ return $LASTEXITCODE -eq 0
336
+ }
337
+
338
+ function New-PrizmDefaultDevBranchName {
339
+ param([string]$Kind, [string]$CurrentItemId)
340
+ $timestamp = Get-Date -Format 'yyyyMMddHHmm'
341
+ switch ($Kind) {
342
+ 'feature' { return "dev/$CurrentItemId-$timestamp" }
343
+ 'bugfix' { return "bugfix/$CurrentItemId-$timestamp" }
344
+ 'refactor' { return "refactor/$CurrentItemId-$timestamp" }
345
+ default { return "dev/$CurrentItemId-$timestamp" }
346
+ }
347
+ }
348
+
349
+ function Invoke-PrizmLogCleanup {
350
+ if (-not $logCleanupEnabled) { return }
351
+ if (-not (Test-Path $stateDir)) { return }
352
+
353
+ try {
354
+ $cleanup = Invoke-PrizmPythonJson $python @(
355
+ (Join-Path $paths.ScriptsDir 'cleanup-logs.py'),
356
+ '--state-dir', $stateDir,
357
+ '--retention-days', [string]$logRetentionDays,
358
+ '--max-total-mb', [string]$logMaxTotalMb
359
+ )
360
+ if ($cleanup -and $cleanup.deleted_files -gt 0) {
361
+ $reclaimedKb = [int]([int64]$cleanup.reclaimed_bytes / 1024)
362
+ Write-PrizmInfo "Log cleanup: deleted $($cleanup.deleted_files) files, reclaimed ${reclaimedKb}KB"
363
+ }
364
+ } catch {
365
+ Write-PrizmWarn "Log cleanup failed (continuing)"
366
+ }
367
+ }
368
+
369
+ function Get-PrizmDeployIncompleteItems {
370
+ param([string]$Kind, [string]$ListPath)
371
+
372
+ if (-not (Test-Path $ListPath)) { return @("List file not found: $ListPath") }
373
+ try {
374
+ $data = Get-Content -Raw $ListPath | ConvertFrom-Json
375
+ } catch {
376
+ return @("List file is not valid JSON: $ListPath")
377
+ }
378
+
379
+ $collectionName = @{ feature = 'features'; bugfix = 'bugs'; refactor = 'refactors' }[$Kind]
380
+ $allowedStatuses = @{
381
+ feature = @('completed', 'skipped')
382
+ bugfix = @('completed', 'skipped', 'needs_info')
383
+ refactor = @('completed', 'skipped')
384
+ }[$Kind]
385
+
386
+ $items = @()
387
+ $property = $data.PSObject.Properties[$collectionName]
388
+ if ($property -and $property.Value) { $items = @($property.Value) }
389
+
390
+ $bad = @()
391
+ foreach ($item in $items) {
392
+ $status = if ($item.PSObject.Properties['status']) { [string]$item.status } else { 'unknown' }
393
+ if ($status -notin $allowedStatuses) {
394
+ $itemIdText = if ($item.PSObject.Properties['id']) { [string]$item.id } else { 'unknown' }
395
+ $titleText = if ($item.PSObject.Properties['title']) { [string]$item.title } else { '' }
396
+ $bad += " ${itemIdText}: $status - $titleText"
397
+ }
398
+ }
399
+ return @($bad)
400
+ }
401
+
402
+ function Invoke-PrizmDeploySession {
403
+ param([string]$Kind, [string]$ListPath)
404
+
405
+ if (-not $enableDeploy) { return 0 }
406
+
407
+ $incomplete = @(Get-PrizmDeployIncompleteItems $Kind $ListPath)
408
+ if ($incomplete.Count -gt 0) {
409
+ Write-PrizmWarn "DEPLOY BLOCKED: $($incomplete.Count) task(s) not completed successfully."
410
+ foreach ($line in $incomplete) { Write-PrizmWarn $line }
411
+ Write-PrizmWarn "Fix failed tasks and re-run, or manually run /prizmkit-deploy."
412
+ return 1
413
+ }
414
+
415
+ Write-PrizmInfo "All tasks completed - starting deploy session..."
416
+ Write-PrizmInfo "ENABLE_DEPLOY=1"
417
+
418
+ $deploySessionId = "deploy-$(Get-Date -Format 'yyyyMMddHHmmss')"
419
+ $deploySessionDir = Join-Path $stateDir "deploy\$deploySessionId"
420
+ $deployLogsDir = Join-Path $deploySessionDir 'logs'
421
+ New-Item -ItemType Directory -Force -Path $deployLogsDir | Out-Null
422
+
423
+ $deployPrompt = Join-Path $deploySessionDir 'bootstrap-prompt.md'
424
+ $deployLog = Join-Path $deployLogsDir 'session.log'
425
+ $deployPidPath = Join-Path $deployLogsDir 'ai.pid'
426
+ $deployBranch = Get-PrizmCurrentBranch $paths.ProjectRoot
427
+ if (-not $deployBranch) { $deployBranch = 'unknown' }
428
+ $deployCommit = & git -C $paths.ProjectRoot rev-parse --short HEAD 2>$null
429
+ if ($LASTEXITCODE -ne 0 -or -not $deployCommit) { $deployCommit = 'unknown' }
430
+ $deployCommit = [string]($deployCommit | Select-Object -First 1)
431
+
432
+ @(
433
+ '## Deploy',
434
+ '',
435
+ "All $Kind tasks in the pipeline completed successfully.",
436
+ '',
437
+ "- Branch: $deployBranch",
438
+ "- Commit: $deployCommit",
439
+ '',
440
+ 'Run /prizmkit-deploy to deploy the project. Read .prizmkit/deploy/deploy.config.json',
441
+ 'for deployment configuration. If no deploy config exists, guide the user through',
442
+ 'setting one up before deploying.'
443
+ ) | Set-Content -Path $deployPrompt -Encoding UTF8
444
+
445
+ Write-PrizmInfo "Deploy prompt: $deployPrompt"
446
+ Write-PrizmInfo "Deploy log: $deployLog"
447
+
448
+ $cli = Resolve-PrizmAiCli $paths.ProjectRoot $paths.PrizmkitDir
449
+ $env:PRIZMKIT_PLATFORM = Get-PrizmPlatformFromProject $paths.ProjectRoot $paths.PrizmkitDir $cli
450
+ $exitCode = Invoke-PrizmAiSession -CliCommand $cli -PromptPath $deployPrompt -LogPath $deployLog -ProjectRoot $paths.ProjectRoot -Model $env:MODEL -PidPath $deployPidPath
451
+ if ($exitCode -eq 0) {
452
+ Write-PrizmSuccess "Deploy session completed"
453
+ } else {
454
+ Write-PrizmWarn "Deploy session failed with exit code $exitCode"
455
+ }
456
+ return $exitCode
457
+ }
458
+
254
459
  function Invoke-PrizmPipelineItem {
255
460
  param([string]$CurrentItemId)
256
461
  $script:PRIZM_ITEM_EXIT_CODE = 0
@@ -260,7 +465,8 @@ function Invoke-PrizmPipeline {
260
465
  $retryCount = '0'
261
466
  $resumePhase = 'null'
262
467
  $isGitRepository = if (-not $dryRun) { Test-PrizmGitRepository $paths.ProjectRoot } else { $false }
263
- $baseCommit = if ($isGitRepository) { Get-PrizmGitHead $paths.ProjectRoot } else { '' }
468
+ $originalBranch = if ($isGitRepository) { Get-PrizmCurrentBranch $paths.ProjectRoot } else { '' }
469
+ $devBranchName = ''
264
470
  $hadDirtyBaseline = if ($isGitRepository) { Test-PrizmGitWorkDirty $paths.ProjectRoot $stateDir $listPath } else { $false }
265
471
  if ($hadDirtyBaseline) {
266
472
  Write-PrizmWarn "Dirty working tree detected before pipeline bookkeeping; session success requires a new commit."
@@ -281,8 +487,21 @@ function Invoke-PrizmPipeline {
281
487
  $start = Invoke-PrizmPythonJson $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'start', $idOption, $CurrentItemId, '--session-id', $sessionId) + $maxRetryArgs)
282
488
  if ($start.retry_count -ne $null) { $retryCount = [string]$start.retry_count }
283
489
  if ($start.resume_from_phase -ne $null) { $resumePhase = [string]$start.resume_from_phase }
490
+
491
+ if ($isGitRepository -and $originalBranch) {
492
+ Invoke-PrizmGitCommitPath $paths.ProjectRoot $listPath "chore($CurrentItemId): mark in_progress" | Out-Null
493
+ $candidateBranch = if ($devBranchOverride) { $devBranchOverride } else { New-PrizmDefaultDevBranchName $Kind $CurrentItemId }
494
+ if (New-PrizmDevBranch $paths.ProjectRoot $candidateBranch $originalBranch) {
495
+ $devBranchName = $candidateBranch
496
+ Write-PrizmInfo "Dev branch: $devBranchName"
497
+ } else {
498
+ Write-PrizmWarn "Failed to create dev branch; running on current branch: $originalBranch"
499
+ }
500
+ }
284
501
  }
285
502
 
503
+ $baseCommit = if ($isGitRepository) { Get-PrizmGitHead $paths.ProjectRoot } else { '' }
504
+
286
505
  $sessionDir = if ($dryRun) {
287
506
  Join-Path ([System.IO.Path]::GetTempPath()) "prizmkit-dry-run\$sessionId"
288
507
  } else {
@@ -321,35 +540,84 @@ function Invoke-PrizmPipeline {
321
540
 
322
541
  Write-PrizmInfo "Starting $cli session for $CurrentItemId ($sessionId)"
323
542
 
543
+ $progressJson = Join-Path $logsDir 'progress.json'
544
+ $parserProcess = Start-PrizmProgressParser -PythonCommand $python -ScriptsDir $paths.ScriptsDir -SessionLog $sessionLog -ProgressFile $progressJson -CliCommand $cli
545
+
324
546
  $job = Start-Job -ScriptBlock {
325
547
  param($commonPath, $cli, $promptPath, $sessionLog, $projectRoot, $model, $pidPath)
326
548
  . $commonPath
327
549
  Invoke-PrizmAiSession -CliCommand $cli -PromptPath $promptPath -LogPath $sessionLog -ProjectRoot $projectRoot -Model $model -PidPath $pidPath
328
550
  } -ArgumentList (Join-Path $paths.PipelineDir 'lib\common.ps1'), $cli, $promptPath, $sessionLog, $paths.ProjectRoot, $effectiveModel, $pidPath
329
551
 
330
- $completed = if ($timeoutSeconds -le 0) {
331
- Wait-Job $job
332
- } else {
333
- Wait-Job $job -Timeout $timeoutSeconds
334
- }
335
- if (-not $completed) {
336
- if (Test-Path $pidPath) {
337
- $rawPid = Get-Content $pidPath -ErrorAction SilentlyContinue | Select-Object -First 1
338
- $aiPid = 0
339
- if ([int]::TryParse($rawPid, [ref]$aiPid)) {
340
- Stop-PrizmProcessTreeById -ProcessId $aiPid
341
- }
552
+ $elapsedSeconds = 0
553
+ $staleSeconds = 0
554
+ $previousLogSize = 0
555
+ $wasTimedOut = $false
556
+ $staleKillMarker = Join-Path $logsDir 'stale-kill.json'
557
+ $wasStaleKilled = $false
558
+ while ($true) {
559
+ $remainingTimeout = if ($timeoutSeconds -gt 0) { $timeoutSeconds - $elapsedSeconds } else { $heartbeatInterval }
560
+ $waitSeconds = if ($timeoutSeconds -gt 0) { [Math]::Min($heartbeatInterval, [Math]::Max(1, $remainingTimeout)) } else { $heartbeatInterval }
561
+ $completed = Wait-Job $job -Timeout $waitSeconds
562
+ if ($completed) { break }
563
+
564
+ $elapsedSeconds += $waitSeconds
565
+ $currentLogSize = 0
566
+ if (Test-Path $sessionLog) {
567
+ $currentLogSize = [int64](Get-Item $sessionLog).Length
568
+ }
569
+ $growth = $currentLogSize - $previousLogSize
570
+ $previousLogSize = $currentLogSize
571
+ if ($growth -gt 0) {
572
+ $staleSeconds = 0
573
+ } else {
574
+ $staleSeconds += $waitSeconds
342
575
  }
576
+
577
+ if ($timeoutSeconds -gt 0 -and $elapsedSeconds -ge $timeoutSeconds) {
578
+ $wasTimedOut = $true
579
+ Stop-PrizmSessionProcess $pidPath
580
+ break
581
+ }
582
+
583
+ if ($staleKillThreshold -gt 0 -and $staleSeconds -ge $staleKillThreshold) {
584
+ $wasStaleKilled = $true
585
+ Write-PrizmWarn "Session stale-killed (no progress for ${staleKillThreshold}s)"
586
+ Write-PrizmStaleKillMarker $staleKillMarker $staleSeconds $staleKillThreshold
587
+ Stop-PrizmSessionProcess $pidPath
588
+ if ($staleKillGraceSeconds -gt 0) { Start-Sleep -Seconds $staleKillGraceSeconds }
589
+ break
590
+ }
591
+ }
592
+
593
+ $exitCode = 0
594
+ if ($wasTimedOut) {
595
+ Stop-Job $job
596
+ Remove-Job $job
597
+ $exitCode = 124
598
+ } elseif ($wasStaleKilled) {
343
599
  Stop-Job $job
344
600
  Remove-Job $job
345
- Invoke-PrizmPythonText $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', 'timed_out') + $maxRetryArgs)
346
- throw "AI session timed out after $timeoutSeconds seconds. Log: $sessionLog"
601
+ $exitCode = 143
602
+ } else {
603
+ try {
604
+ $exitCode = [int](Receive-Job $job)
605
+ } catch {
606
+ Write-PrizmWarn "AI session job failed: $($_.Exception.Message)"
607
+ $exitCode = 1
608
+ }
609
+ Remove-Job $job
347
610
  }
611
+ Stop-PrizmProgressParser $parserProcess
348
612
 
349
- $exitCode = [int](Receive-Job $job)
350
- Remove-Job $job
351
613
  $status = 'crashed'
352
- if ($exitCode -ne 0) {
614
+ if ($wasTimedOut) {
615
+ $status = 'timed_out'
616
+ Write-PrizmWarn "AI session timed out after $timeoutSeconds seconds"
617
+ } elseif ($wasStaleKilled -or (Test-Path $staleKillMarker)) {
618
+ Write-PrizmWarn "Session was stale-killed by heartbeat monitor (no progress for too long)"
619
+ Write-PrizmWarn "Stale-killed sessions are treated as failed; dev branch is preserved for inspection"
620
+ } elseif ($exitCode -ne 0) {
353
621
  Write-PrizmWarn "AI session exited with code $exitCode"
354
622
  } elseif (-not $isGitRepository) {
355
623
  Write-PrizmWarn "AI session exited cleanly, but project is not a git repository; cannot verify work was committed."
@@ -368,27 +636,57 @@ function Invoke-PrizmPipeline {
368
636
  Write-PrizmWarn "AI session exited cleanly but produced no commits and no changes."
369
637
  }
370
638
 
371
- Invoke-PrizmPythonText $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', $status) + $maxRetryArgs)
639
+ $mergeSucceeded = $true
640
+ if ($status -eq 'success') {
641
+ Invoke-PrizmPythonText $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', $status) + $maxRetryArgs)
642
+
643
+ if (Test-PrizmGitDirty $paths.ProjectRoot) {
644
+ if ($hadDirtyBaseline) {
645
+ Write-PrizmInfo "Auto-committing pipeline bookkeeping artifacts only."
646
+ Invoke-PrizmGitIncludeBookkeepingArtifacts $paths.ProjectRoot $stateDir $listPath
647
+ } else {
648
+ Write-PrizmInfo "Auto-committing remaining session artifacts."
649
+ Invoke-PrizmGitIncludeRemainingArtifacts $paths.ProjectRoot $CurrentItemId
650
+ }
651
+ }
372
652
 
373
- if ($status -eq 'success' -and (Test-PrizmGitDirty $paths.ProjectRoot)) {
374
- if ($hadDirtyBaseline) {
375
- Write-PrizmInfo "Auto-committing pipeline bookkeeping artifacts only."
376
- Invoke-PrizmGitIncludeBookkeepingArtifacts $paths.ProjectRoot $stateDir $listPath
377
- } else {
378
- Write-PrizmInfo "Auto-committing remaining session artifacts."
379
- Invoke-PrizmGitIncludeRemainingArtifacts $paths.ProjectRoot $CurrentItemId
653
+ if ($isGitRepository -and $devBranchName) {
654
+ if (Merge-PrizmDevBranch $paths.ProjectRoot $devBranchName $originalBranch $autoPush) {
655
+ $devBranchName = ''
656
+ } else {
657
+ $mergeSucceeded = $false
658
+ $status = 'merge_conflict'
659
+ Write-PrizmWarn "Auto-merge failed - dev branch preserved for inspection"
660
+ }
380
661
  }
662
+ } elseif ($isGitRepository -and $devBranchName) {
663
+ Write-PrizmWarn "Session failed - dev branch preserved for inspection: $devBranchName"
381
664
  }
382
665
 
383
- if ($status -eq 'success') {
666
+ if ($isGitRepository -and $originalBranch) {
667
+ Restore-PrizmOriginalBranch $paths.ProjectRoot $originalBranch $devBranchName | Out-Null
668
+ }
669
+
670
+ if ($status -ne 'success') {
671
+ Invoke-PrizmPythonText $python (@((Join-Path $paths.ScriptsDir $updateScript), $listOption, $listPath, '--state-dir', $stateDir, '--action', 'update', $idOption, $CurrentItemId, '--session-id', $sessionId, '--session-status', $status) + $maxRetryArgs)
672
+ if ($isGitRepository) {
673
+ Invoke-PrizmGitCommitPath $paths.ProjectRoot $listPath "chore($CurrentItemId): update $idName status" | Out-Null
674
+ }
675
+ }
676
+
677
+ if ($status -eq 'success' -and $mergeSucceeded) {
384
678
  Write-PrizmSuccess "$Kind item completed: $CurrentItemId"
385
679
  } else {
386
680
  Write-PrizmError "$Kind item failed: $CurrentItemId. Log: $sessionLog"
387
681
  }
388
- $script:PRIZM_ITEM_EXIT_CODE = if ($status -eq 'success') { 0 } else { 1 }
682
+ $script:PRIZM_ITEM_EXIT_CODE = if ($status -eq 'success' -and $mergeSucceeded) { 0 } else { 1 }
389
683
  return
390
684
  }
391
685
 
686
+ if (-not $dryRun) {
687
+ Invoke-PrizmLogCleanup
688
+ }
689
+
392
690
  if ($itemId) {
393
691
  Invoke-PrizmPipelineItem $itemId
394
692
  $global:PRIZM_EXIT_CODE = $script:PRIZM_ITEM_EXIT_CODE
@@ -407,6 +705,10 @@ function Invoke-PrizmPipeline {
407
705
  } else {
408
706
  Write-PrizmSuccess "Processed $processedCount $Kind item(s)."
409
707
  }
708
+ if (-not $dryRun) {
709
+ $deployExit = Invoke-PrizmDeploySession $Kind $listPath
710
+ if ($deployExit -ne 0 -and $lastExitCode -eq 0) { $lastExitCode = $deployExit }
711
+ }
410
712
  $global:PRIZM_EXIT_CODE = $lastExitCode
411
713
  return
412
714
  }
@@ -438,7 +740,7 @@ function Invoke-PrizmPipeline {
438
740
  $global:PRIZM_EXIT_CODE = $lastExitCode
439
741
  return
440
742
  }
441
- if ($lastExitCode -ne 0 -and $env:STOP_ON_FAILURE -eq '1') {
743
+ if ($lastExitCode -ne 0 -and $stopOnFailure) {
442
744
  $global:PRIZM_EXIT_CODE = $lastExitCode
443
745
  return
444
746
  }
@@ -41,6 +41,22 @@ for ($i = 0; $i -lt $remaining.Count; $i++) {
41
41
  }
42
42
  }
43
43
 
44
+ function Get-PrizmRecoveryIntEnv {
45
+ param([string]$Name, [int]$DefaultValue, [int]$MinimumValue = 0)
46
+ $value = [Environment]::GetEnvironmentVariable($Name, 'Process')
47
+ if (-not $value) { return $DefaultValue }
48
+ $parsed = 0
49
+ if (-not [int]::TryParse($value, [ref]$parsed) -or $parsed -lt $MinimumValue) {
50
+ throw "$Name must be an integer >= $MinimumValue`: $value"
51
+ }
52
+ return $parsed
53
+ }
54
+
55
+ $timeoutSeconds = Get-PrizmRecoveryIntEnv 'SESSION_TIMEOUT' 0 0
56
+ $heartbeatInterval = Get-PrizmRecoveryIntEnv 'HEARTBEAT_INTERVAL' 30 1
57
+ $staleKillThreshold = Get-PrizmRecoveryIntEnv 'STALE_KILL_THRESHOLD' 900 0
58
+ $staleKillGraceSeconds = Get-PrizmRecoveryIntEnv 'STALE_KILL_GRACE_SECONDS' 10 0
59
+
44
60
  $recoveryDetectScript = $null
45
61
  foreach ($candidate in @(
46
62
  (Join-Path $paths.ProjectRoot '.agents\skills\recovery-workflow\scripts\detect-recovery-state.py'),
@@ -60,17 +76,97 @@ if (-not $recoveryDetectScript) {
60
76
  $stateDir = Join-Path $paths.PrizmkitDir 'state\recovery'
61
77
  New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
62
78
  $detectionPath = Join-Path $stateDir 'detection.json'
63
- $promptPath = Join-Path $paths.PrizmkitDir 'state\recovery-bootstrap-prompt.md'
64
- $logPath = Join-Path $paths.PrizmkitDir 'state\recovery-session.log'
65
79
 
66
80
  Invoke-PrizmPythonText $python @($recoveryDetectScript, '--project-root', $paths.ProjectRoot) | Set-Content -Path $detectionPath -Encoding UTF8
67
81
  if ($command -eq 'detect') { Get-Content $detectionPath; exit 0 }
68
82
 
69
- Invoke-PrizmPythonText $python @((Join-Path $paths.ScriptsDir 'generate-recovery-prompt.py'), '--detection-json', $detectionPath, '--project-root', $paths.ProjectRoot, '--session-id', (New-PrizmSessionId 'recovery'), '--output', $promptPath)
83
+ $sessionId = New-PrizmSessionId 'recovery'
84
+ $sessionDir = Join-Path $stateDir "sessions\$sessionId"
85
+ $logsDir = Join-Path $sessionDir 'logs'
86
+ New-Item -ItemType Directory -Force -Path $logsDir | Out-Null
87
+ $sessionDetectionPath = Join-Path $sessionDir 'detection.json'
88
+ Copy-Item -Path $detectionPath -Destination $sessionDetectionPath -Force
89
+ $promptPath = Join-Path $sessionDir 'bootstrap-prompt.md'
90
+ $logPath = Join-Path $logsDir 'session.log'
91
+ $progressPath = Join-Path $logsDir 'progress.json'
92
+ $pidPath = Join-Path $logsDir 'ai.pid'
93
+ $staleKillMarker = Join-Path $logsDir 'stale-kill.json'
94
+
95
+ Invoke-PrizmPythonText $python @((Join-Path $paths.ScriptsDir 'generate-recovery-prompt.py'), '--detection-json', $sessionDetectionPath, '--project-root', $paths.ProjectRoot, '--session-id', $sessionId, '--output', $promptPath)
70
96
  if ($dryRun) { Get-Content $promptPath; exit 0 }
71
97
  $cli = Resolve-PrizmAiCli $paths.ProjectRoot $paths.PrizmkitDir
72
98
  $env:PRIZMKIT_PLATFORM = Get-PrizmPlatformFromProject $paths.ProjectRoot $paths.PrizmkitDir $cli
73
- $pidPath = Join-Path $paths.PrizmkitDir 'state\recovery-session.pid'
74
- $exitCode = Invoke-PrizmAiSession -CliCommand $cli -PromptPath $promptPath -LogPath $logPath -ProjectRoot $paths.ProjectRoot -Model $model -PidPath $pidPath
99
+ Write-PrizmInfo "Starting recovery session: $sessionId"
100
+ Write-PrizmInfo "Prompt: $promptPath"
101
+ Write-PrizmInfo "Log: $logPath"
102
+
103
+ $parserProcess = Start-PrizmProgressParser -PythonCommand $python -ScriptsDir $paths.ScriptsDir -SessionLog $logPath -ProgressFile $progressPath -CliCommand $cli
104
+ $job = Start-Job -ScriptBlock {
105
+ param($commonPath, $cli, $promptPath, $logPath, $projectRoot, $model, $pidPath)
106
+ . $commonPath
107
+ Invoke-PrizmAiSession -CliCommand $cli -PromptPath $promptPath -LogPath $logPath -ProjectRoot $projectRoot -Model $model -PidPath $pidPath
108
+ } -ArgumentList (Join-Path $paths.PipelineDir 'lib\common.ps1'), $cli, $promptPath, $logPath, $paths.ProjectRoot, $model, $pidPath
109
+
110
+ $elapsedSeconds = 0
111
+ $staleSeconds = 0
112
+ $previousLogSize = 0
113
+ $wasTimedOut = $false
114
+ $wasStaleKilled = $false
115
+ while ($true) {
116
+ $remainingTimeout = if ($timeoutSeconds -gt 0) { $timeoutSeconds - $elapsedSeconds } else { $heartbeatInterval }
117
+ $waitSeconds = if ($timeoutSeconds -gt 0) { [Math]::Min($heartbeatInterval, [Math]::Max(1, $remainingTimeout)) } else { $heartbeatInterval }
118
+ $completed = Wait-Job $job -Timeout $waitSeconds
119
+ if ($completed) { break }
120
+
121
+ $elapsedSeconds += $waitSeconds
122
+ $currentLogSize = 0
123
+ if (Test-Path $logPath) { $currentLogSize = [int64](Get-Item $logPath).Length }
124
+ $growth = $currentLogSize - $previousLogSize
125
+ $previousLogSize = $currentLogSize
126
+ if ($growth -gt 0) { $staleSeconds = 0 } else { $staleSeconds += $waitSeconds }
127
+
128
+ if ($timeoutSeconds -gt 0 -and $elapsedSeconds -ge $timeoutSeconds) {
129
+ $wasTimedOut = $true
130
+ Stop-PrizmSessionProcess $pidPath
131
+ break
132
+ }
133
+
134
+ if ($staleKillThreshold -gt 0 -and $staleSeconds -ge $staleKillThreshold) {
135
+ $wasStaleKilled = $true
136
+ Write-PrizmWarn "Recovery session stale-killed (no progress for ${staleKillThreshold}s)"
137
+ Write-PrizmStaleKillMarker $staleKillMarker $staleSeconds $staleKillThreshold
138
+ Stop-PrizmSessionProcess $pidPath
139
+ if ($staleKillGraceSeconds -gt 0) { Start-Sleep -Seconds $staleKillGraceSeconds }
140
+ break
141
+ }
142
+ }
143
+
144
+ $exitCode = 0
145
+ if ($wasTimedOut) {
146
+ Stop-Job $job
147
+ Remove-Job $job
148
+ $exitCode = 124
149
+ } elseif ($wasStaleKilled) {
150
+ Stop-Job $job
151
+ Remove-Job $job
152
+ $exitCode = 143
153
+ } else {
154
+ try {
155
+ $exitCode = [int](Receive-Job $job)
156
+ } catch {
157
+ Write-PrizmWarn "Recovery session job failed: $($_.Exception.Message)"
158
+ $exitCode = 1
159
+ }
160
+ Remove-Job $job
161
+ }
162
+ Stop-PrizmProgressParser $parserProcess
163
+
164
+ if (Test-Path $logPath) {
165
+ $lineCount = (Get-Content $logPath | Measure-Object -Line).Lines
166
+ $sizeKb = [int](([int64](Get-Item $logPath).Length) / 1024)
167
+ Write-PrizmInfo "Session log: $lineCount lines, ${sizeKb}KB"
168
+ }
169
+ if ($wasTimedOut) { Write-PrizmWarn "Recovery session timed out after $timeoutSeconds seconds." }
170
+ if ($wasStaleKilled -or (Test-Path $staleKillMarker)) { Write-PrizmWarn "Recovery session was stale-killed." }
75
171
  if ($exitCode -eq 0) { Write-PrizmSuccess "Recovery session completed." } else { Write-PrizmError "Recovery session failed. Log: $logPath" }
76
172
  exit $exitCode
@@ -73,6 +73,9 @@ class ProgressTracker:
73
73
  self.last_text_snippet = ""
74
74
  self.is_active = True
75
75
  self.errors = []
76
+ self.event_format = ""
77
+ self.active_subagent_count = 0
78
+ self.subagent_status_counts = Counter()
76
79
  self._text_buffer = ""
77
80
  self._in_tool_use = False
78
81
  self._current_tool_input_parts = []
@@ -87,8 +90,72 @@ class ProgressTracker:
87
90
  """
88
91
  event_type = event.get("type", "")
89
92
 
93
+ # ── Codex exec --json JSONL format ──────────────────────────
94
+ if event_type in (
95
+ "thread.started", "turn.started", "turn.completed",
96
+ "turn.failed", "item.started", "item.completed", "error",
97
+ ):
98
+ self.event_format = "codex-json"
99
+ self.is_active = True
100
+
101
+ if event_type == "turn.started":
102
+ self.message_count += 1
103
+
104
+ elif event_type in ("item.started", "item.completed"):
105
+ item = event.get("item", {})
106
+ item_type = item.get("type", "")
107
+
108
+ if item_type == "agent_message":
109
+ text = item.get("text", "")
110
+ if text.strip():
111
+ self.last_text_snippet = text.strip()[:120]
112
+ self._detect_phase(text)
113
+
114
+ elif item_type == "collab_tool_call":
115
+ tool_name = item.get("tool", "collab")
116
+ if event_type == "item.started":
117
+ self.current_tool = tool_name
118
+ self.tool_call_counts[tool_name] += 1
119
+ self.total_tool_calls += 1
120
+ elif item.get("status") == "completed":
121
+ self.current_tool = None
122
+ self._extract_tool_summary_from_dict(item)
123
+ self._update_subagent_status_counts(
124
+ item.get("agents_states", {})
125
+ )
126
+
127
+ prompt = item.get("prompt")
128
+ if prompt:
129
+ self._detect_phase(prompt)
130
+
131
+ else:
132
+ tool_name = item.get("tool") or item.get("name")
133
+ if tool_name:
134
+ if event_type == "item.started":
135
+ self.current_tool = tool_name
136
+ self.tool_call_counts[tool_name] += 1
137
+ self.total_tool_calls += 1
138
+ elif item.get("status") == "completed":
139
+ self.current_tool = None
140
+ self._extract_tool_summary_from_dict(item)
141
+
142
+ elif event_type == "turn.completed":
143
+ self.current_tool = None
144
+
145
+ elif event_type == "turn.failed":
146
+ error = event.get("error") or event.get("message") or "Codex turn failed"
147
+ self.errors.append(str(error))
148
+ self.current_tool = None
149
+
150
+ elif event_type == "error":
151
+ error = event.get("error") or event.get("message") or "Unknown error"
152
+ self.errors.append(str(error))
153
+
154
+ return
155
+
90
156
  # ── Claude Code verbose format ──────────────────────────────
91
157
  if event_type == "assistant":
158
+ self.event_format = self.event_format or "stream-json"
92
159
  self.message_count += 1
93
160
  self.is_active = True
94
161
  message = event.get("message", {})
@@ -113,16 +180,19 @@ class ProgressTracker:
113
180
 
114
181
  elif event_type == "tool_result" or event_type == "user":
115
182
  # tool_result contains output from tool execution
183
+ self.event_format = self.event_format or "stream-json"
116
184
  self.is_active = True
117
185
 
118
186
  elif event_type == "system":
119
187
  # System events (hooks, init, etc.) — track but don't count as messages
188
+ self.event_format = self.event_format or "stream-json"
120
189
  subtype = event.get("subtype", "")
121
190
  if subtype == "init":
122
191
  self.is_active = True
123
192
 
124
193
  # ── Claude API raw stream format ────────────────────────────
125
194
  elif event_type == "message_start":
195
+ self.event_format = self.event_format or "stream-json"
126
196
  self.message_count += 1
127
197
  self.is_active = True
128
198
 
@@ -256,14 +326,38 @@ class ProgressTracker:
256
326
  elif "prompt" in data:
257
327
  self.current_tool_input_summary = str(data["prompt"])[:100]
258
328
 
329
+ def _update_subagent_status_counts(self, agents_states):
330
+ """Track Codex subagent state counts from collab_tool_call items."""
331
+ counts = Counter()
332
+ active = 0
333
+ if isinstance(agents_states, dict):
334
+ for state in agents_states.values():
335
+ if not isinstance(state, dict):
336
+ continue
337
+ status = str(state.get("status", "unknown"))
338
+ counts[status] += 1
339
+ if status not in ("completed", "failed", "cancelled", "canceled"):
340
+ active += 1
341
+ message = state.get("message")
342
+ if message:
343
+ self.last_text_snippet = str(message).strip()[:120]
344
+ self._detect_phase(str(message))
345
+ self.subagent_status_counts = counts
346
+ self.active_subagent_count = active
347
+
259
348
  def to_dict(self):
260
349
  """Export current state as a dictionary for JSON serialization."""
261
350
  tool_calls = [
262
351
  {"name": name, "count": count}
263
352
  for name, count in self.tool_call_counts.most_common()
264
353
  ]
354
+ subagent_states = [
355
+ {"status": status, "count": count}
356
+ for status, count in self.subagent_status_counts.most_common()
357
+ ]
265
358
  return {
266
359
  "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
360
+ "event_format": self.event_format,
267
361
  "message_count": self.message_count,
268
362
  "current_tool": self.current_tool,
269
363
  "current_tool_input_summary": self.current_tool_input_summary,
@@ -271,6 +365,8 @@ class ProgressTracker:
271
365
  "detected_phases": self.detected_phases,
272
366
  "tool_calls": tool_calls,
273
367
  "total_tool_calls": self.total_tool_calls,
368
+ "active_subagent_count": self.active_subagent_count,
369
+ "subagent_states": subagent_states,
274
370
  "last_text_snippet": self.last_text_snippet,
275
371
  "is_active": self.is_active,
276
372
  "errors": self.errors[-10:], # Keep last 10 errors
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.63",
2
+ "version": "1.1.66",
3
3
  "skills": {
4
4
  "prizm-kit": {
5
5
  "description": "Full-lifecycle dev toolkit. Covers spec-driven development, Prizm context docs, code quality, debugging, deployment, and knowledge management.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.63",
3
+ "version": "1.1.66",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {