prizmkit 1.1.62 → 1.1.64

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,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