From d750034e92c7bbda4c0ab830d8e949b058fb505c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 3 May 2026 15:26:05 +0000
Subject: [PATCH 1/4] Initial plan


From bdbd9f540dbfb28a910b8ce7b66444f4efd24444 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 3 May 2026 15:42:02 +0000
Subject: [PATCH 2/4] refactor: semantic function clustering in pkg/cli

- Move formatVolumeChange, formatPercentagePointChange, formatCountChange
  from audit_diff.go to audit_math_helpers.go (Finding 1)
- Create compile_external_tools.go with external tool runners moved from
  compile_batch_operations.go (Finding 2a)
- Move purgeOrphanedLockFiles, purgeInvalidFiles from compile_batch_operations.go
  to compile_post_processing.go (Finding 2b)
- Move displayScheduleWarnings, displaySafeUpdateWarnings, pruneStaleActionCacheEntries
  from compile_pipeline.go to compile_post_processing.go (Finding 2c)
- Move findAgentOutputFile, findAgentLogFile from logs_parsing_core.go
  to logs_utils.go (Finding 3)
- Move getRepositoryRelativePath, getAbsoluteWorkflowDir from
  compile_file_operations.go to helpers.go (Finding 4)

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0c2d3a48-7102-4ff2-80fe-91d71349bb5a

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
---
 pkg/cli/audit_diff.go               |  32 -----
 pkg/cli/audit_math_helpers.go       |  36 ++++-
 pkg/cli/compile_batch_operations.go | 195 +---------------------------
 pkg/cli/compile_external_tools.go   |  95 ++++++++++++++
 pkg/cli/compile_file_operations.go  |  39 ------
 pkg/cli/compile_pipeline.go         |  44 -------
 pkg/cli/compile_post_processing.go  | 153 +++++++++++++++++++++-
 pkg/cli/helpers.go                  |  41 ++++++
 pkg/cli/logs_parsing_core.go        | 182 --------------------------
 pkg/cli/logs_utils.go               | 184 ++++++++++++++++++++++++++
 10 files changed, 511 insertions(+), 490 deletions(-)
 create mode 100644 pkg/cli/compile_external_tools.go

diff --git a/pkg/cli/audit_diff.go b/pkg/cli/audit_diff.go
index 5a66e2a493a..82a6f4d22e3 100644
--- a/pkg/cli/audit_diff.go
+++ b/pkg/cli/audit_diff.go
@@ -7,7 +7,6 @@ import (
 	"math"
 	"path/filepath"
 	"sort"
-	"strconv"
 	"strings"
 	"time"
 
@@ -215,18 +214,6 @@ func classifyFirewallDomainStatus(stats DomainRequestStats) string {
 	return "unknown"
 }
 
-// formatVolumeChange formats the volume change as a human-readable string
-func formatVolumeChange(total1, total2 int) string {
-	if total1 == 0 {
-		return "+∞"
-	}
-	pctChange := (float64(total2-total1) / float64(total1)) * 100
-	if pctChange >= 0 {
-		return "+" + formatPercent(pctChange)
-	}
-	return formatPercent(pctChange)
-}
-
 // MCPToolDiffEntry represents the diff for a single MCP tool between two runs
 type MCPToolDiffEntry struct {
 	ServerName      string `json:"server_name"`
@@ -939,25 +926,6 @@ func computeTokenUsageDiff(tu1, tu2 *TokenUsageSummary) *TokenUsageDiff {
 	return diff
 }
 
-// formatPercentagePointChange formats the change between two ratio values (0.0-1.0) as a
-// percentage-point delta (e.g. "+1.5pp", "-2.3pp")
-func formatPercentagePointChange(ratio1, ratio2 float64) string {
-	delta := (ratio2 - ratio1) * 100
-	if delta >= 0 {
-		return fmt.Sprintf("+%.1fpp", delta)
-	}
-	return fmt.Sprintf("%.1fpp", delta)
-}
-
-// formatCountChange formats the absolute change in a count value (e.g. "+3", "-1")
-func formatCountChange(count1, count2 int) string {
-	delta := count2 - count1
-	if delta >= 0 {
-		return fmt.Sprintf("+%d", delta)
-	}
-	return strconv.Itoa(delta)
-}
-
 // loadRunSummaryForDiff loads or builds a RunSummary for a given run for use in diffing.
 // It first tries to load from a cached RunSummary (which includes MCP tool usage and run
 // metrics); otherwise it downloads artifacts and analyzes firewall logs, returning a partial
diff --git a/pkg/cli/audit_math_helpers.go b/pkg/cli/audit_math_helpers.go
index c91ef917cc2..b6f0473ed42 100644
--- a/pkg/cli/audit_math_helpers.go
+++ b/pkg/cli/audit_math_helpers.go
@@ -1,6 +1,9 @@
 package cli
 
-import "fmt"
+import (
+	"fmt"
+	"strconv"
+)
 
 // safePercent returns percentage of part/total, returning 0 when total is 0.
 func safePercent(part, total int) float64 {
@@ -14,3 +17,34 @@ func safePercent(part, total int) float64 {
 func formatPercent(pct float64) string {
 	return fmt.Sprintf("%.0f%%", pct)
 }
+
+// formatVolumeChange formats the volume change as a human-readable string
+func formatVolumeChange(total1, total2 int) string {
+	if total1 == 0 {
+		return "+∞"
+	}
+	pctChange := (float64(total2-total1) / float64(total1)) * 100
+	if pctChange >= 0 {
+		return "+" + formatPercent(pctChange)
+	}
+	return formatPercent(pctChange)
+}
+
+// formatPercentagePointChange formats the change between two ratio values (0.0-1.0) as a
+// percentage-point delta (e.g. "+1.5pp", "-2.3pp")
+func formatPercentagePointChange(ratio1, ratio2 float64) string {
+	delta := (ratio2 - ratio1) * 100
+	if delta >= 0 {
+		return fmt.Sprintf("+%.1fpp", delta)
+	}
+	return fmt.Sprintf("%.1fpp", delta)
+}
+
+// formatCountChange formats the absolute change in a count value (e.g. "+3", "-1")
+func formatCountChange(count1, count2 int) string {
+	delta := count2 - count1
+	if delta >= 0 {
+		return fmt.Sprintf("+%d", delta)
+	}
+	return strconv.Itoa(delta)
+}
diff --git a/pkg/cli/compile_batch_operations.go b/pkg/cli/compile_batch_operations.go
index 49c80e30679..4e33822a86d 100644
--- a/pkg/cli/compile_batch_operations.go
+++ b/pkg/cli/compile_batch_operations.go
@@ -1,194 +1,7 @@
-// This file provides batch operations for workflow compilation.
+// This file previously contained batch operations for workflow compilation.
 //
-// This file contains functions that perform batch operations on compiled workflows,
-// such as running linters, security scanners, and cleaning up orphaned files.
-//
-// # Organization Rationale
-//
-// These batch operation functions are grouped here because they:
-//   - Operate on multiple files at once
-//   - Run external tools (actionlint, zizmor, poutine)
-//   - Have a clear domain focus (batch operations)
-//   - Keep the main orchestrator focused on coordination
-//
-// # Key Functions
-//
-// Batch Linting:
-//   - RunActionlintOnFiles() - Run actionlint on multiple lock files
-//
-// File Cleanup:
-//   - purgeOrphanedLockFiles() - Remove orphaned .lock.yml files
-//   - purgeInvalidFiles() - Remove .invalid.yml files
-//
-// These functions abstract batch operations, allowing the main compile
-// orchestrator to focus on coordination while these handle batch processing.
+// Functions have been reorganized into more focused files:
+//   - compile_external_tools.go: external tool runners (actionlint, zizmor, poutine, runner-guard)
+//   - compile_post_processing.go: file cleanup/purge operations
 
 package cli
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"strings"
-
-	"github.com/github/gh-aw/pkg/console"
-	"github.com/github/gh-aw/pkg/logger"
-)
-
-var compileBatchOperationsLog = logger.New("cli:compile_batch_operations")
-
-// RunActionlintOnFiles runs actionlint on multiple lock files in a single batch.
-// This is more efficient than running actionlint once per file.
-func RunActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
-	return runBatchLockFileTool("actionlint", lockFiles, verbose, strict, runActionlintOnFiles)
-}
-
-// RunZizmorOnFiles runs zizmor on multiple lock files in a single batch.
-// This is more efficient than running zizmor once per file.
-func RunZizmorOnFiles(lockFiles []string, verbose bool, strict bool) error {
-	return runBatchLockFileTool("zizmor", lockFiles, verbose, strict, runZizmorOnFiles)
-}
-
-// RunPoutineOnDirectory runs poutine security scanner once on a directory.
-// Poutine scans all workflows in a directory, so it only needs to run once.
-func RunPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error {
-	return runPoutineOnDirectory(workflowDir, verbose, strict)
-}
-
-// RunRunnerGuardOnDirectory runs runner-guard taint analysis scanner once on a directory.
-// Runner-guard scans all workflows in a directory, so it only needs to run once.
-func RunRunnerGuardOnDirectory(workflowDir string, verbose bool, strict bool) error {
-	return runRunnerGuardOnDirectory(workflowDir, verbose, strict)
-}
-
-// runBatchLockFileTool runs a batch tool on lock files with uniform error handling
-func runBatchLockFileTool(toolName string, lockFiles []string, verbose bool, strict bool, runner func([]string, bool, bool) error) error {
-	if len(lockFiles) == 0 {
-		compileBatchOperationsLog.Printf("No lock files to process with %s", toolName)
-		return nil
-	}
-
-	compileBatchOperationsLog.Printf("Running batch %s on %d lock files", toolName, len(lockFiles))
-
-	if err := runner(lockFiles, verbose, strict); err != nil {
-		if strict {
-			return fmt.Errorf("%s failed: %w", toolName, err)
-		}
-		// In non-strict mode, errors are warnings
-		if verbose {
-			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("%s warnings: %v", toolName, err)))
-		}
-	}
-
-	return nil
-}
-
-// runBatchDirectoryTool runs a directory-based batch tool with uniform error handling
-func runBatchDirectoryTool(toolName string, workflowDir string, verbose bool, strict bool, runner func(string, bool, bool) error) error {
-	compileBatchOperationsLog.Printf("Running batch %s on directory: %s", toolName, workflowDir)
-
-	if err := runner(workflowDir, verbose, strict); err != nil {
-		if strict {
-			return fmt.Errorf("%s failed: %w", toolName, err)
-		}
-		// In non-strict mode, errors are warnings
-		if verbose {
-			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("%s warnings: %v", toolName, err)))
-		}
-	}
-
-	return nil
-}
-
-// purgeOrphanedLockFiles removes orphaned .lock.yml files
-// These are lock files that exist but don't have a corresponding .md file
-func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, verbose bool) error {
-	compileBatchOperationsLog.Printf("Purging orphaned lock files in %s", workflowsDir)
-
-	// Find all existing .lock.yml files
-	existingLockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.lock.yml"))
-	if err != nil {
-		return fmt.Errorf("failed to find existing lock files: %w", err)
-	}
-
-	if len(existingLockFiles) == 0 {
-		compileBatchOperationsLog.Print("No lock files found")
-		return nil
-	}
-
-	if verbose {
-		fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .lock.yml files", len(existingLockFiles))))
-	}
-
-	// Build a set of expected lock files
-	expectedLockFileSet := make(map[string]bool)
-	for _, expected := range expectedLockFiles {
-		expectedLockFileSet[expected] = true
-	}
-
-	// Find lock files that should be deleted (exist but aren't expected)
-	var orphanedFiles []string
-	for _, existing := range existingLockFiles {
-		// Skip .campaign.lock.yml files - they're handled by purgeOrphanedCampaignOrchestratorLockFiles
-		if strings.HasSuffix(existing, ".campaign.lock.yml") {
-			continue
-		}
-		if !expectedLockFileSet[existing] {
-			orphanedFiles = append(orphanedFiles, existing)
-		}
-	}
-
-	// Delete orphaned lock files
-	if len(orphanedFiles) > 0 {
-		for _, orphanedFile := range orphanedFiles {
-			if err := os.Remove(orphanedFile); err != nil {
-				fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned lock file %s: %v", filepath.Base(orphanedFile), err)))
-			} else {
-				fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Removed orphaned lock file: "+filepath.Base(orphanedFile)))
-			}
-		}
-		if verbose {
-			fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .lock.yml files", len(orphanedFiles))))
-		}
-	}
-
-	compileBatchOperationsLog.Printf("Purged %d orphaned lock files", len(orphanedFiles))
-	return nil
-}
-
-// purgeInvalidFiles removes all .invalid.yml files
-// These are temporary debugging artifacts that should not persist
-func purgeInvalidFiles(workflowsDir string, verbose bool) error {
-	compileBatchOperationsLog.Printf("Purging invalid files in %s", workflowsDir)
-
-	// Find all existing .invalid.yml files
-	existingInvalidFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.invalid.yml"))
-	if err != nil {
-		return fmt.Errorf("failed to find existing invalid files: %w", err)
-	}
-
-	if len(existingInvalidFiles) == 0 {
-		compileBatchOperationsLog.Print("No invalid files found")
-		return nil
-	}
-
-	if verbose {
-		fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .invalid.yml files", len(existingInvalidFiles))))
-	}
-
-	// Delete all .invalid.yml files
-	for _, invalidFile := range existingInvalidFiles {
-		if err := os.Remove(invalidFile); err != nil {
-			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove invalid file %s: %v", filepath.Base(invalidFile), err)))
-		} else {
-			fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Removed invalid file: "+filepath.Base(invalidFile)))
-		}
-	}
-
-	if verbose {
-		fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d .invalid.yml files", len(existingInvalidFiles))))
-	}
-
-	compileBatchOperationsLog.Printf("Purged %d invalid files", len(existingInvalidFiles))
-	return nil
-}
diff --git a/pkg/cli/compile_external_tools.go b/pkg/cli/compile_external_tools.go
new file mode 100644
index 00000000000..e10955eb582
--- /dev/null
+++ b/pkg/cli/compile_external_tools.go
@@ -0,0 +1,95 @@
+// This file provides external tool runners for workflow compilation.
+//
+// This file contains functions that invoke external analysis tools
+// (actionlint, zizmor, poutine, runner-guard) on compiled workflow files.
+//
+// # Organization Rationale
+//
+// These external tool runner functions are grouped here because they:
+//   - Invoke third-party analysis tools (not compilation logic)
+//   - Operate on compiled lock files as a post-compilation step
+//   - Have a clear domain focus (external tooling integration)
+//   - Keep compile_batch_operations.go focused on batch file management
+//
+// # Key Functions
+//
+// External Tool Runners:
+//   - RunActionlintOnFiles() - Run actionlint on multiple lock files
+//   - RunZizmorOnFiles() - Run zizmor on multiple lock files
+//   - RunPoutineOnDirectory() - Run poutine security scanner on a directory
+//   - RunRunnerGuardOnDirectory() - Run runner-guard taint analysis on a directory
+
+package cli
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/github/gh-aw/pkg/console"
+	"github.com/github/gh-aw/pkg/logger"
+)
+
+var compileExternalToolsLog = logger.New("cli:compile_external_tools")
+
+// RunActionlintOnFiles runs actionlint on multiple lock files in a single batch.
+// This is more efficient than running actionlint once per file.
+func RunActionlintOnFiles(lockFiles []string, verbose bool, strict bool) error {
+	return runBatchLockFileTool("actionlint", lockFiles, verbose, strict, runActionlintOnFiles)
+}
+
+// RunZizmorOnFiles runs zizmor on multiple lock files in a single batch.
+// This is more efficient than running zizmor once per file.
+func RunZizmorOnFiles(lockFiles []string, verbose bool, strict bool) error {
+	return runBatchLockFileTool("zizmor", lockFiles, verbose, strict, runZizmorOnFiles)
+}
+
+// RunPoutineOnDirectory runs poutine security scanner once on a directory.
+// Poutine scans all workflows in a directory, so it only needs to run once.
+func RunPoutineOnDirectory(workflowDir string, verbose bool, strict bool) error {
+	return runPoutineOnDirectory(workflowDir, verbose, strict)
+}
+
+// RunRunnerGuardOnDirectory runs runner-guard taint analysis scanner once on a directory.
+// Runner-guard scans all workflows in a directory, so it only needs to run once.
+func RunRunnerGuardOnDirectory(workflowDir string, verbose bool, strict bool) error {
+	return runRunnerGuardOnDirectory(workflowDir, verbose, strict)
+}
+
+// runBatchLockFileTool runs a batch tool on lock files with uniform error handling
+func runBatchLockFileTool(toolName string, lockFiles []string, verbose bool, strict bool, runner func([]string, bool, bool) error) error {
+	if len(lockFiles) == 0 {
+		compileExternalToolsLog.Printf("No lock files to process with %s", toolName)
+		return nil
+	}
+
+	compileExternalToolsLog.Printf("Running batch %s on %d lock files", toolName, len(lockFiles))
+
+	if err := runner(lockFiles, verbose, strict); err != nil {
+		if strict {
+			return fmt.Errorf("%s failed: %w", toolName, err)
+		}
+		// In non-strict mode, errors are warnings
+		if verbose {
+			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("%s warnings: %v", toolName, err)))
+		}
+	}
+
+	return nil
+}
+
+// runBatchDirectoryTool runs a directory-based batch tool with uniform error handling
+func runBatchDirectoryTool(toolName string, workflowDir string, verbose bool, strict bool, runner func(string, bool, bool) error) error {
+	compileExternalToolsLog.Printf("Running batch %s on directory: %s", toolName, workflowDir)
+
+	if err := runner(workflowDir, verbose, strict); err != nil {
+		if strict {
+			return fmt.Errorf("%s failed: %w", toolName, err)
+		}
+		// In non-strict mode, errors are warnings
+		if verbose {
+			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("%s warnings: %v", toolName, err)))
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/cli/compile_file_operations.go b/pkg/cli/compile_file_operations.go
index 9af6070234a..d8eec5f0101 100644
--- a/pkg/cli/compile_file_operations.go
+++ b/pkg/cli/compile_file_operations.go
@@ -48,45 +48,6 @@ import (
 
 var compileHelpersLog = logger.New("cli:compile_file_operations")
 
-// getRepositoryRelativePath converts an absolute file path to a repository-relative path
-// This ensures stable workflow identifiers regardless of where the repository is cloned
-func getRepositoryRelativePath(absPath string) (string, error) {
-	// Get the repository root for the specific file
-	repoRoot, err := findGitRootForPath(absPath)
-	if err != nil {
-		// If we can't get the repo root, just use the basename as fallback
-		compileHelpersLog.Printf("Warning: could not get repository root for %s: %v, using basename", absPath, err)
-		return filepath.Base(absPath), nil
-	}
-
-	// Convert both paths to absolute to ensure they can be compared
-	absPath, err = filepath.Abs(absPath)
-	if err != nil {
-		return "", fmt.Errorf("failed to get absolute path: %w", err)
-	}
-
-	// Get the relative path from repo root
-	relPath, err := filepath.Rel(repoRoot, absPath)
-	if err != nil {
-		return "", fmt.Errorf("failed to get relative path: %w", err)
-	}
-
-	// Normalize path separators to forward slashes for consistency across platforms
-	// This ensures the same hash value on Windows, Linux, and macOS
-	relPath = filepath.ToSlash(relPath)
-
-	return relPath, nil
-}
-
-// getAbsoluteWorkflowDir converts a relative workflow dir to absolute path
-func getAbsoluteWorkflowDir(workflowDir string, gitRoot string) string {
-	absWorkflowDir := workflowDir
-	if !filepath.IsAbs(absWorkflowDir) {
-		absWorkflowDir = filepath.Join(gitRoot, workflowDir)
-	}
-	return absWorkflowDir
-}
-
 // compileSingleFile compiles a single markdown workflow file and updates compilation statistics
 // If checkExists is true, the function will check if the file exists before compiling
 // Returns true if compilation was attempted (file exists or checkExists is false), false otherwise
diff --git a/pkg/cli/compile_pipeline.go b/pkg/cli/compile_pipeline.go
index 4ae19bc8d55..20d4f1ce0a1 100644
--- a/pkg/cli/compile_pipeline.go
+++ b/pkg/cli/compile_pipeline.go
@@ -427,30 +427,6 @@ func runPurgeOperations(workflowsDir string, data *purgeTrackingData, verbose bo
 	_ = purgeInvalidFiles(workflowsDir, verbose)
 }
 
-// displayScheduleWarnings displays any schedule warnings from the compiler
-func displayScheduleWarnings(compiler *workflow.Compiler, jsonOutput bool) {
-	scheduleWarnings := compiler.GetScheduleWarnings()
-	if len(scheduleWarnings) > 0 && !jsonOutput {
-		for _, warning := range scheduleWarnings {
-			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning))
-		}
-	}
-}
-
-// displaySafeUpdateWarnings displays any safe update warning prompts accumulated by the
-// compiler.  Each entry is a structured message that instructs the calling agent to:
-//   - Review new secrets/actions for malicious use
-//   - Add a security review note to the pull request description
-func displaySafeUpdateWarnings(compiler *workflow.Compiler, jsonOutput bool) {
-	warnings := compiler.GetSafeUpdateWarnings()
-	if len(warnings) == 0 || jsonOutput {
-		return
-	}
-	for _, w := range warnings {
-		fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w))
-	}
-}
-
 // runPostProcessing runs post-processing for specific files compilation
 func runPostProcessing(
 	compiler *workflow.Compiler,
@@ -538,26 +514,6 @@ func runPostProcessingForDirectory(
 	return nil
 }
 
-// pruneStaleActionCacheEntries removes stale gh-aw-actions entries from the
-// action cache whose version does not match the compiler's current version.
-// This prevents actions-lock.json from accumulating entries for old compiler
-// releases that are no longer referenced by any compiled workflow.
-func pruneStaleActionCacheEntries(compiler *workflow.Compiler, actionCache *workflow.ActionCache) {
-	if actionCache == nil {
-		return
-	}
-
-	// Determine the effective version: actionTag takes precedence when explicitly
-	// set (e.g., via --action-tag for testing against a specific release), otherwise
-	// fall back to the compiler's built-in version from the binary.
-	version := compiler.GetActionTag()
-	if version == "" {
-		version = compiler.GetVersion()
-	}
-
-	actionCache.PruneStaleGHAWEntries(version, compiler.EffectiveActionsRepo())
-}
-
 // outputResults outputs compilation results in the requested format
 func outputResults(
 	stats *CompilationStats,
diff --git a/pkg/cli/compile_post_processing.go b/pkg/cli/compile_post_processing.go
index db0f2a477c6..4746c45fd0b 100644
--- a/pkg/cli/compile_post_processing.go
+++ b/pkg/cli/compile_post_processing.go
@@ -1,13 +1,16 @@
 // This file provides post-processing operations for workflow compilation.
 //
 // This file contains functions that perform post-compilation operations such as
-// generating Dependabot manifests and maintenance workflows.
+// generating Dependabot manifests, maintenance workflows, file cleanup, and
+// displaying compiler warnings.
 //
 // # Organization Rationale
 //
 // These post-processing functions are grouped here because they:
 //   - Run after workflow compilation completes
 //   - Generate auxiliary files and manifests
+//   - Clean up orphaned or invalid workflow files
+//   - Display accumulated warnings from the compiler
 //   - Have a clear domain focus (post-compilation processing)
 //   - Keep the main orchestrator focused on coordination
 //
@@ -17,6 +20,15 @@
 //   - generateDependabotManifestsWrapper() - Generate Dependabot manifests
 //   - generateMaintenanceWorkflowWrapper() - Generate maintenance workflow
 //
+// Cleanup:
+//   - purgeOrphanedLockFiles() - Remove orphaned .lock.yml files
+//   - purgeInvalidFiles() - Remove .invalid.yml files
+//
+// Warnings and Cache:
+//   - displayScheduleWarnings() - Display schedule warnings from the compiler
+//   - displaySafeUpdateWarnings() - Display safe update warning prompts
+//   - pruneStaleActionCacheEntries() - Remove stale gh-aw-actions cache entries
+//
 // These functions abstract post-processing operations, allowing the main compile
 // orchestrator to focus on coordination while these handle generation and validation.
 
@@ -25,6 +37,8 @@ package cli
 import (
 	"fmt"
 	"os"
+	"path/filepath"
+	"strings"
 
 	"github.com/github/gh-aw/pkg/console"
 	"github.com/github/gh-aw/pkg/logger"
@@ -85,3 +99,140 @@ func generateMaintenanceWorkflowWrapper(
 
 	return nil
 }
+
+// purgeOrphanedLockFiles removes orphaned .lock.yml files
+// These are lock files that exist but don't have a corresponding .md file
+func purgeOrphanedLockFiles(workflowsDir string, expectedLockFiles []string, verbose bool) error {
+	compilePostProcessingLog.Printf("Purging orphaned lock files in %s", workflowsDir)
+
+	// Find all existing .lock.yml files
+	existingLockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.lock.yml"))
+	if err != nil {
+		return fmt.Errorf("failed to find existing lock files: %w", err)
+	}
+
+	if len(existingLockFiles) == 0 {
+		compilePostProcessingLog.Print("No lock files found")
+		return nil
+	}
+
+	if verbose {
+		fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .lock.yml files", len(existingLockFiles))))
+	}
+
+	// Build a set of expected lock files
+	expectedLockFileSet := make(map[string]bool)
+	for _, expected := range expectedLockFiles {
+		expectedLockFileSet[expected] = true
+	}
+
+	// Find lock files that should be deleted (exist but aren't expected)
+	var orphanedFiles []string
+	for _, existing := range existingLockFiles {
+		// Skip .campaign.lock.yml files - they're handled by purgeOrphanedCampaignOrchestratorLockFiles
+		if strings.HasSuffix(existing, ".campaign.lock.yml") {
+			continue
+		}
+		if !expectedLockFileSet[existing] {
+			orphanedFiles = append(orphanedFiles, existing)
+		}
+	}
+
+	// Delete orphaned lock files
+	if len(orphanedFiles) > 0 {
+		for _, orphanedFile := range orphanedFiles {
+			if err := os.Remove(orphanedFile); err != nil {
+				fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove orphaned lock file %s: %v", filepath.Base(orphanedFile), err)))
+			} else {
+				fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Removed orphaned lock file: "+filepath.Base(orphanedFile)))
+			}
+		}
+		if verbose {
+			fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d orphaned .lock.yml files", len(orphanedFiles))))
+		}
+	}
+
+	compilePostProcessingLog.Printf("Purged %d orphaned lock files", len(orphanedFiles))
+	return nil
+}
+
+// purgeInvalidFiles removes all .invalid.yml files
+// These are temporary debugging artifacts that should not persist
+func purgeInvalidFiles(workflowsDir string, verbose bool) error {
+	compilePostProcessingLog.Printf("Purging invalid files in %s", workflowsDir)
+
+	// Find all existing .invalid.yml files
+	existingInvalidFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.invalid.yml"))
+	if err != nil {
+		return fmt.Errorf("failed to find existing invalid files: %w", err)
+	}
+
+	if len(existingInvalidFiles) == 0 {
+		compilePostProcessingLog.Print("No invalid files found")
+		return nil
+	}
+
+	if verbose {
+		fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d existing .invalid.yml files", len(existingInvalidFiles))))
+	}
+
+	// Delete all .invalid.yml files
+	for _, invalidFile := range existingInvalidFiles {
+		if err := os.Remove(invalidFile); err != nil {
+			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to remove invalid file %s: %v", filepath.Base(invalidFile), err)))
+		} else {
+			fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Removed invalid file: "+filepath.Base(invalidFile)))
+		}
+	}
+
+	if verbose {
+		fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Purged %d .invalid.yml files", len(existingInvalidFiles))))
+	}
+
+	compilePostProcessingLog.Printf("Purged %d invalid files", len(existingInvalidFiles))
+	return nil
+}
+
+// displayScheduleWarnings displays any schedule warnings from the compiler
+func displayScheduleWarnings(compiler *workflow.Compiler, jsonOutput bool) {
+	scheduleWarnings := compiler.GetScheduleWarnings()
+	if len(scheduleWarnings) > 0 && !jsonOutput {
+		for _, warning := range scheduleWarnings {
+			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warning))
+		}
+	}
+}
+
+// displaySafeUpdateWarnings displays any safe update warning prompts accumulated by the
+// compiler.  Each entry is a structured message that instructs the calling agent to:
+//   - Review new secrets/actions for malicious use
+//   - Add a security review note to the pull request description
+func displaySafeUpdateWarnings(compiler *workflow.Compiler, jsonOutput bool) {
+	warnings := compiler.GetSafeUpdateWarnings()
+	if len(warnings) == 0 || jsonOutput {
+		return
+	}
+	for _, w := range warnings {
+		fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w))
+	}
+}
+
+// pruneStaleActionCacheEntries removes stale gh-aw-actions entries from the
+// action cache whose version does not match the compiler's current version.
+// This prevents actions-lock.json from accumulating entries for old compiler
+// releases that are no longer referenced by any compiled workflow.
+func pruneStaleActionCacheEntries(compiler *workflow.Compiler, actionCache *workflow.ActionCache) {
+	if actionCache == nil {
+		return
+	}
+
+	// Determine the effective version: actionTag takes precedence when explicitly
+	// set (e.g., via --action-tag for testing against a specific release), otherwise
+	// fall back to the compiler's built-in version from the binary.
+	version := compiler.GetActionTag()
+	if version == "" {
+		version = compiler.GetVersion()
+	}
+
+	actionCache.PruneStaleGHAWEntries(version, compiler.EffectiveActionsRepo())
+}
diff --git a/pkg/cli/helpers.go b/pkg/cli/helpers.go
index 4019067a03a..1b3de94294c 100644
--- a/pkg/cli/helpers.go
+++ b/pkg/cli/helpers.go
@@ -1,7 +1,9 @@
 package cli
 
 import (
+	"fmt"
 	"os"
+	"path/filepath"
 	"strings"
 
 	"github.com/github/gh-aw/pkg/logger"
@@ -19,6 +21,45 @@ func getParentDir(path string) string {
 	return path[:idx]
 }
 
+// getRepositoryRelativePath converts an absolute file path to a repository-relative path
+// This ensures stable workflow identifiers regardless of where the repository is cloned
+func getRepositoryRelativePath(absPath string) (string, error) {
+	// Get the repository root for the specific file
+	repoRoot, err := findGitRootForPath(absPath)
+	if err != nil {
+		// If we can't get the repo root, just use the basename as fallback
+		helpersLog.Printf("Warning: could not get repository root for %s: %v, using basename", absPath, err)
+		return filepath.Base(absPath), nil
+	}
+
+	// Convert both paths to absolute to ensure they can be compared
+	absPath, err = filepath.Abs(absPath)
+	if err != nil {
+		return "", fmt.Errorf("failed to get absolute path: %w", err)
+	}
+
+	// Get the relative path from repo root
+	relPath, err := filepath.Rel(repoRoot, absPath)
+	if err != nil {
+		return "", fmt.Errorf("failed to get relative path: %w", err)
+	}
+
+	// Normalize path separators to forward slashes for consistency across platforms
+	// This ensures the same hash value on Windows, Linux, and macOS
+	relPath = filepath.ToSlash(relPath)
+
+	return relPath, nil
+}
+
+// getAbsoluteWorkflowDir converts a relative workflow dir to absolute path
+func getAbsoluteWorkflowDir(workflowDir string, gitRoot string) string {
+	absWorkflowDir := workflowDir
+	if !filepath.IsAbs(absWorkflowDir) {
+		absWorkflowDir = filepath.Join(gitRoot, workflowDir)
+	}
+	return absWorkflowDir
+}
+
 // readSourceRepoFromFile reads the 'source' frontmatter field from a local workflow file
 // and returns the "owner/repo" portion (e.g. "github/gh-aw"). Returns "" if the file
 // cannot be read, has no source field, or the field is not in the expected format.
diff --git a/pkg/cli/logs_parsing_core.go b/pkg/cli/logs_parsing_core.go
index 3f16af4b5f7..0778cebbe91 100644
--- a/pkg/cli/logs_parsing_core.go
+++ b/pkg/cli/logs_parsing_core.go
@@ -15,11 +15,8 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
-	"strings"
 
 	"github.com/github/gh-aw/pkg/console"
-	"github.com/github/gh-aw/pkg/constants"
-	"github.com/github/gh-aw/pkg/fileutil"
 	"github.com/github/gh-aw/pkg/logger"
 	"github.com/github/gh-aw/pkg/workflow"
 )
@@ -111,182 +108,3 @@ func extractEngineFromAwInfo(infoFilePath string, verbose bool) workflow.CodingA
 	logsParsingCoreLog.Printf("Successfully extracted engine: %s", engine.GetID())
 	return engine
 }
-
-// findAgentOutputFile searches for a file named agent_output.json within the logDir tree.
-// Returns the first path found (depth-first) and a boolean indicating success.
-func findAgentOutputFile(logDir string) (string, bool) {
-	var foundPath string
-	if walkErr := filepath.Walk(logDir, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			logsParsingCoreLog.Printf("walk error at %s: %v", path, err)
-			return nil
-		}
-		if info == nil {
-			return nil
-		}
-		if !info.IsDir() && strings.EqualFold(info.Name(), constants.AgentOutputArtifactName) {
-			foundPath = path
-			return errWalkStop
-		}
-		return nil
-	}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
-		fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", logDir, walkErr)))
-	}
-	if foundPath == "" {
-		return "", false
-	}
-	return foundPath, true
-}
-
-// findAgentLogFile searches for agent logs within the logDir.
-// It uses engine.GetLogFileForParsing() to determine which log file to use:
-//   - If GetLogFileForParsing() returns a non-empty value that doesn't point to agent-stdio.log,
-//     look for files in the "agent_output" artifact directory (before flattening)
-//     or in the flattened location (after flattening)
-//   - Otherwise, look for the "agent-stdio.log" artifact file
-//
-// Returns the first path found and a boolean indicating success.
-func findAgentLogFile(logDir string, engine workflow.CodingAgentEngine) (string, bool) {
-	// Use GetLogFileForParsing to determine which log file to use
-	logFileForParsing := engine.GetLogFileForParsing()
-
-	// If the engine specifies a log file that isn't the default agent-stdio.log,
-	// look in the agent_output artifact directory or flattened location
-	if logFileForParsing != "" && logFileForParsing != defaultAgentStdioLogPath {
-		// Check for agent_output directory (artifact, before flattening)
-		agentOutputDir := filepath.Join(logDir, "agent_output")
-		if fileutil.DirExists(agentOutputDir) {
-			// Find the first file in this directory
-			var foundFile string
-			if walkErr := filepath.Walk(agentOutputDir, func(path string, info os.FileInfo, err error) error {
-				if err != nil {
-					logsParsingCoreLog.Printf("walk error at %s: %v", path, err)
-					return nil
-				}
-				if info == nil {
-					return nil
-				}
-				if !info.IsDir() && foundFile == "" {
-					foundFile = path
-					return errWalkStop
-				}
-				return nil
-			}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
-				fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", agentOutputDir, walkErr)))
-			}
-			if foundFile != "" {
-				return foundFile, true
-			}
-		}
-
-		// Check for flattened location (after flattening)
-		// The engine's log path is absolute (e.g., /tmp/gh-aw/sandbox/agent/logs/)
-		// After flattening, it's at logDir/sandbox/agent/logs/
-		// Strip /tmp/gh-aw/ prefix to get the relative path
-		const tmpGhAwPrefix = "/tmp/gh-aw/"
-		if after, ok := strings.CutPrefix(logFileForParsing, tmpGhAwPrefix); ok {
-			relPath := after
-			flattenedDir := filepath.Join(logDir, relPath)
-			logsParsingCoreLog.Printf("Checking flattened location for logs: %s", flattenedDir)
-			if fileutil.DirExists(flattenedDir) {
-				// Prefer events.jsonl (structured Copilot session format) over debug .log files.
-				// Walk the full tree: stop immediately when events.jsonl is found (preferred),
-				// but keep walking after a .log match in case events.jsonl appears later.
-				var foundEventsJsonl, foundLogFile string
-				if walkErr := filepath.Walk(flattenedDir, func(path string, info os.FileInfo, err error) error {
-					if err != nil {
-						logsParsingCoreLog.Printf("walk error at %s: %v", path, err)
-						return nil
-					}
-					if info == nil {
-						return nil
-					}
-					if !info.IsDir() {
-						if info.Name() == "events.jsonl" && foundEventsJsonl == "" {
-							foundEventsJsonl = path
-							logsParsingCoreLog.Printf("Found events.jsonl file: %s", path)
-							return errWalkStop
-						} else if strings.HasSuffix(info.Name(), ".log") && foundLogFile == "" {
-							foundLogFile = path
-							logsParsingCoreLog.Printf("Found session log file: %s", path)
-						}
-					}
-					return nil
-				}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
-					fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", flattenedDir, walkErr)))
-				}
-				if foundEventsJsonl != "" {
-					return foundEventsJsonl, true
-				}
-				if foundLogFile != "" {
-					return foundLogFile, true
-				}
-			}
-		}
-
-		// Fallback: search recursively in logDir for events.jsonl, session*.log or process*.log files
-		// This handles cases where the artifact structure is different than expected
-		// Note: Copilot changed from session-*.log to process-*.log naming convention
-		logsParsingCoreLog.Printf("Searching recursively in %s for events.jsonl, session*.log or process*.log files", logDir)
-		var foundEventsJsonl, foundLogFile string
-		if walkErr := filepath.Walk(logDir, func(path string, info os.FileInfo, err error) error {
-			if err != nil {
-				logsParsingCoreLog.Printf("walk error at %s: %v", path, err)
-				return nil
-			}
-			if info == nil {
-				return nil
-			}
-			fileName := info.Name()
-			if !info.IsDir() {
-				if fileName == "events.jsonl" && foundEventsJsonl == "" {
-					foundEventsJsonl = path
-					logsParsingCoreLog.Printf("Found events.jsonl via recursive search: %s", path)
-					return errWalkStop
-				} else if (strings.HasPrefix(fileName, "session") || strings.HasPrefix(fileName, "process")) && strings.HasSuffix(fileName, ".log") && foundLogFile == "" {
-					foundLogFile = path
-					logsParsingCoreLog.Printf("Found Copilot log file via recursive search: %s", path)
-				}
-			}
-			return nil
-		}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
-			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", logDir, walkErr)))
-		}
-		if foundEventsJsonl != "" {
-			return foundEventsJsonl, true
-		}
-		if foundLogFile != "" {
-			return foundLogFile, true
-		}
-	}
-
-	// Default to agent-stdio.log
-	agentStdioLog := filepath.Join(logDir, "agent-stdio.log")
-	if fileutil.FileExists(agentStdioLog) {
-		return agentStdioLog, true
-	}
-
-	// Also check for nested agent-stdio.log in case it's in a subdirectory
-	var foundPath string
-	if walkErr := filepath.Walk(logDir, func(path string, info os.FileInfo, err error) error {
-		if err != nil {
-			logsParsingCoreLog.Printf("walk error at %s: %v", path, err)
-			return nil
-		}
-		if info == nil {
-			return nil
-		}
-		if !info.IsDir() && info.Name() == "agent-stdio.log" {
-			foundPath = path
-			return errWalkStop
-		}
-		return nil
-	}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
-		fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", logDir, walkErr)))
-	}
-	if foundPath != "" {
-		return foundPath, true
-	}
-
-	return "", false
-}
diff --git a/pkg/cli/logs_utils.go b/pkg/cli/logs_utils.go
index 269c9746ad6..61e1b78fd0b 100644
--- a/pkg/cli/logs_utils.go
+++ b/pkg/cli/logs_utils.go
@@ -3,18 +3,23 @@
 //
 // Key responsibilities:
 //   - Discovering agentic workflow names from .lock.yml files
+//   - Locating agent log files and output artifacts within downloaded artifact trees
 //   - Utility functions for slice operations
 
 package cli
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
 
 	"github.com/github/gh-aw/pkg/console"
+	"github.com/github/gh-aw/pkg/constants"
+	"github.com/github/gh-aw/pkg/fileutil"
 	"github.com/github/gh-aw/pkg/logger"
+	"github.com/github/gh-aw/pkg/workflow"
 )
 
 var logsUtilsLog = logger.New("cli:logs_utils")
@@ -83,3 +88,182 @@ func getAgenticWorkflowNames(verbose bool) ([]string, error) {
 
 	return workflowNames, nil
 }
+
+// findAgentOutputFile searches for a file named agent_output.json within the logDir tree.
+// Returns the first path found (depth-first) and a boolean indicating success.
+func findAgentOutputFile(logDir string) (string, bool) {
+	var foundPath string
+	if walkErr := filepath.Walk(logDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			logsUtilsLog.Printf("walk error at %s: %v", path, err)
+			return nil
+		}
+		if info == nil {
+			return nil
+		}
+		if !info.IsDir() && strings.EqualFold(info.Name(), constants.AgentOutputArtifactName) {
+			foundPath = path
+			return errWalkStop
+		}
+		return nil
+	}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
+		fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", logDir, walkErr)))
+	}
+	if foundPath == "" {
+		return "", false
+	}
+	return foundPath, true
+}
+
+// findAgentLogFile searches for agent logs within the logDir.
+// It uses engine.GetLogFileForParsing() to determine which log file to use:
+//   - If GetLogFileForParsing() returns a non-empty value that doesn't point to agent-stdio.log,
+//     look for files in the "agent_output" artifact directory (before flattening)
+//     or in the flattened location (after flattening)
+//   - Otherwise, look for the "agent-stdio.log" artifact file
+//
+// Returns the first path found and a boolean indicating success.
+func findAgentLogFile(logDir string, engine workflow.CodingAgentEngine) (string, bool) {
+	// Use GetLogFileForParsing to determine which log file to use
+	logFileForParsing := engine.GetLogFileForParsing()
+
+	// If the engine specifies a log file that isn't the default agent-stdio.log,
+	// look in the agent_output artifact directory or flattened location
+	if logFileForParsing != "" && logFileForParsing != defaultAgentStdioLogPath {
+		// Check for agent_output directory (artifact, before flattening)
+		agentOutputDir := filepath.Join(logDir, "agent_output")
+		if fileutil.DirExists(agentOutputDir) {
+			// Find the first file in this directory
+			var foundFile string
+			if walkErr := filepath.Walk(agentOutputDir, func(path string, info os.FileInfo, err error) error {
+				if err != nil {
+					logsUtilsLog.Printf("walk error at %s: %v", path, err)
+					return nil
+				}
+				if info == nil {
+					return nil
+				}
+				if !info.IsDir() && foundFile == "" {
+					foundFile = path
+					return errWalkStop
+				}
+				return nil
+			}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
+				fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", agentOutputDir, walkErr)))
+			}
+			if foundFile != "" {
+				return foundFile, true
+			}
+		}
+
+		// Check for flattened location (after flattening)
+		// The engine's log path is absolute (e.g., /tmp/gh-aw/sandbox/agent/logs/)
+		// After flattening, it's at logDir/sandbox/agent/logs/
+		// Strip /tmp/gh-aw/ prefix to get the relative path
+		const tmpGhAwPrefix = "/tmp/gh-aw/"
+		if after, ok := strings.CutPrefix(logFileForParsing, tmpGhAwPrefix); ok {
+			relPath := after
+			flattenedDir := filepath.Join(logDir, relPath)
+			logsUtilsLog.Printf("Checking flattened location for logs: %s", flattenedDir)
+			if fileutil.DirExists(flattenedDir) {
+				// Prefer events.jsonl (structured Copilot session format) over debug .log files.
+				// Walk the full tree: stop immediately when events.jsonl is found (preferred),
+				// but keep walking after a .log match in case events.jsonl appears later.
+				var foundEventsJsonl, foundLogFile string
+				if walkErr := filepath.Walk(flattenedDir, func(path string, info os.FileInfo, err error) error {
+					if err != nil {
+						logsUtilsLog.Printf("walk error at %s: %v", path, err)
+						return nil
+					}
+					if info == nil {
+						return nil
+					}
+					if !info.IsDir() {
+						if info.Name() == "events.jsonl" && foundEventsJsonl == "" {
+							foundEventsJsonl = path
+							logsUtilsLog.Printf("Found events.jsonl file: %s", path)
+							return errWalkStop
+						} else if strings.HasSuffix(info.Name(), ".log") && foundLogFile == "" {
+							foundLogFile = path
+							logsUtilsLog.Printf("Found session log file: %s", path)
+						}
+					}
+					return nil
+				}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
+					fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", flattenedDir, walkErr)))
+				}
+				if foundEventsJsonl != "" {
+					return foundEventsJsonl, true
+				}
+				if foundLogFile != "" {
+					return foundLogFile, true
+				}
+			}
+		}
+
+		// Fallback: search recursively in logDir for events.jsonl, session*.log or process*.log files
+		// This handles cases where the artifact structure is different than expected
+		// Note: Copilot changed from session-*.log to process-*.log naming convention
+		logsUtilsLog.Printf("Searching recursively in %s for events.jsonl, session*.log or process*.log files", logDir)
+		var foundEventsJsonl, foundLogFile string
+		if walkErr := filepath.Walk(logDir, func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				logsUtilsLog.Printf("walk error at %s: %v", path, err)
+				return nil
+			}
+			if info == nil {
+				return nil
+			}
+			fileName := info.Name()
+			if !info.IsDir() {
+				if fileName == "events.jsonl" && foundEventsJsonl == "" {
+					foundEventsJsonl = path
+					logsUtilsLog.Printf("Found events.jsonl via recursive search: %s", path)
+					return errWalkStop
+				} else if (strings.HasPrefix(fileName, "session") || strings.HasPrefix(fileName, "process")) && strings.HasSuffix(fileName, ".log") && foundLogFile == "" {
+					foundLogFile = path
+					logsUtilsLog.Printf("Found Copilot log file via recursive search: %s", path)
+				}
+			}
+			return nil
+		}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
+			fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", logDir, walkErr)))
+		}
+		if foundEventsJsonl != "" {
+			return foundEventsJsonl, true
+		}
+		if foundLogFile != "" {
+			return foundLogFile, true
+		}
+	}
+
+	// Default to agent-stdio.log
+	agentStdioLog := filepath.Join(logDir, "agent-stdio.log")
+	if fileutil.FileExists(agentStdioLog) {
+		return agentStdioLog, true
+	}
+
+	// Also check for nested agent-stdio.log in case it's in a subdirectory
+	var foundPath string
+	if walkErr := filepath.Walk(logDir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			logsUtilsLog.Printf("walk error at %s: %v", path, err)
+			return nil
+		}
+		if info == nil {
+			return nil
+		}
+		if !info.IsDir() && info.Name() == "agent-stdio.log" {
+			foundPath = path
+			return errWalkStop
+		}
+		return nil
+	}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
+		fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", logDir, walkErr)))
+	}
+	if foundPath != "" {
+		return foundPath, true
+	}
+
+	return "", false
+}

From 3283228c3ac6eb34b6aee44e04fddf098d6ddc42 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Sun, 3 May 2026 16:07:10 +0000
Subject: [PATCH 3/4] docs(adr): add draft ADR-29952 for semantic function
 clustering in pkg/cli

Generated by the Design Decision Gate workflow to document the decision
to reorganize pkg/cli by semantic cohesion rather than point-of-first-use.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 ...52-semantic-function-clustering-pkg-cli.md | 73 +++++++++++++++++++
 1 file changed, 73 insertions(+)
 create mode 100644 docs/adr/29952-semantic-function-clustering-pkg-cli.md

diff --git a/docs/adr/29952-semantic-function-clustering-pkg-cli.md b/docs/adr/29952-semantic-function-clustering-pkg-cli.md
new file mode 100644
index 00000000000..c147de59fab
--- /dev/null
+++ b/docs/adr/29952-semantic-function-clustering-pkg-cli.md
@@ -0,0 +1,73 @@
+# ADR-29952: Semantic Function Clustering in pkg/cli
+
+**Date**: 2026-05-03
+**Status**: Draft
+**Deciders**: Unknown (automated refactor by copilot-swe-agent)
+
+---
+
+## Part 1 — Narrative (Human-Friendly)
+
+### Context
+
+The `pkg/cli` package in this repository had grown organically, with utility functions placed near their first point of use rather than alongside semantically related functions. This produced four concrete cohesion findings: format helpers living in `audit_diff.go` instead of `audit_math_helpers.go`, external tool runners (actionlint, zizmor, poutine, runner-guard) mixed into a general-purpose `compile_batch_operations.go`, file-discovery functions in a parsing-focused `logs_parsing_core.go`, and path utilities in the compilation-specific `compile_file_operations.go`. The result was files with weak cohesion, long import lists, and poor discoverability for new contributors.
+
+### Decision
+
+We will reorganize `pkg/cli` by moving each function to the file whose existing responsibility most closely matches that function's domain, a practice known as semantic function clustering. Concretely: format helpers move to `audit_math_helpers.go`; external tool runners move to a new `compile_external_tools.go`; post-processing cleanup and warning display move to `compile_post_processing.go`; and path utilities move to `helpers.go`. The old `compile_batch_operations.go` becomes a stub with redirect comments pointing to the new locations.
+
+### Alternatives Considered
+
+#### Alternative 1: Leave Functions at Their Point of First Use
+
+The status quo — functions remain in the file where they were first written, close to the call site that introduced them. This is simple and requires no migration work, but it produces the cohesion violations observed: unrelated functions accumulate in files, making each file harder to reason about and extending import graphs unnecessarily (e.g., `logs_parsing_core.go` importing `fileutil` solely to support a discovery function).
+
+#### Alternative 2: Consolidate All Utilities into a Single `utils.go`
+
+A common Go pattern is to create a single `utils.go` (or `helpers.go`) catch-all. This avoids cohesion violations by brute force but introduces a different anti-pattern: a file with no coherent theme that grows without bound. It also does not express domain intent; `compile_external_tools.go` signals "these are external tool integrations" in a way that `utils.go` never could.
+
+### Consequences
+
+#### Positive
+- Each file in `pkg/cli` now has a narrower, better-defined responsibility, making it easier to locate the right file when adding or debugging a function.
+- Import graphs are cleaner: `logs_parsing_core.go` drops `strings`, `constants`, and `fileutil` imports that were only needed for the moved discovery functions.
+- New contributors can infer where to put a function from file names alone (e.g., a new external tool runner clearly belongs in `compile_external_tools.go`).
+
+#### Negative
+- The number of files in `pkg/cli` increases, which can feel heavyweight for a single package.
+- `compile_batch_operations.go` becomes a comment-only stub rather than being deleted, preserving a redirect that may confuse readers until a follow-up PR removes it.
+- Function moves break `git blame` lineage; the original authorship and rationale are only recoverable by looking at the commit that introduced the function in its old location.
+
+#### Neutral
+- No public API surface changes: all exported functions (`RunActionlintOnFiles`, `RunZizmorOnFiles`, `RunPoutineOnDirectory`, `RunRunnerGuardOnDirectory`) are re-exported from the new file, preserving callers.
+- Tests are unaffected because Go's same-package compilation treats all `.go` files in `pkg/cli` as a single compilation unit.
+
+---
+
+## Part 2 — Normative Specification (RFC 2119)
+
+> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
+
+### File Organization
+
+1. Functions in `pkg/cli` **MUST** be placed in the file whose stated responsibility most closely matches the function's domain, not in the file where the function was first needed.
+2. A function **MUST NOT** be placed in a file solely because it is called from that file; co-location with semantically related functions takes precedence.
+3. Format and math helpers for audit reporting **MUST** reside in `audit_math_helpers.go`.
+4. External tool runner functions (actionlint, zizmor, poutine, runner-guard) **MUST** reside in `compile_external_tools.go`.
+5. Post-compilation cleanup, warning display, and action cache pruning **MUST** reside in `compile_post_processing.go`.
+6. General path and repository utilities **MUST** reside in `helpers.go`.
+7. Log file discovery functions **MUST** reside in `logs_utils.go`, not in parsing-specific files.
+
+### Cohesion Maintenance
+
+1. When a new utility function is added to `pkg/cli`, the author **MUST** identify the file whose existing responsibilities most closely match before creating a new file.
+2. A new file in `pkg/cli` **SHOULD** be created only when no existing file's responsibility covers the new function's domain and the function is unlikely to be a one-off.
+3. Import lists in a file **SHOULD NOT** contain packages that are only needed by a function whose domain belongs elsewhere; such imports are a signal that the function **SHOULD** be moved.
+
+### Conformance
+
+An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.
+
+---
+
+*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25283930938) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

From e18e31925e5f1a0aca014fae9e5effe9f444ed27 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 3 May 2026 16:14:42 +0000
Subject: [PATCH 4/4] fix: address reviewer feedback on logs_utils.go and
 logs_parsing_core.go

- Update logs_parsing_core.go header comment to reflect current scope
  (removed stale references to locating agent log files)
- Fix findAgentLogFile to handle engine log paths that point to a file
  (not just a directory) after /tmp/gh-aw/ prefix stripping:
  case 1: direct file match at flattened path
  case 2: directory walk for events.jsonl/.log files (existing)
  case 3: fallback search by base filename across logDir

Agent-Logs-Url: https://github.com/github/gh-aw/sessions/22826460-76f1-4160-aca0-1ce3e88914cf

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 pkg/cli/logs_parsing_core.go |  5 ++--
 pkg/cli/logs_utils.go        | 46 ++++++++++++++++++++++++++++++------
 2 files changed, 41 insertions(+), 10 deletions(-)

diff --git a/pkg/cli/logs_parsing_core.go b/pkg/cli/logs_parsing_core.go
index 0778cebbe91..da326106fa9 100644
--- a/pkg/cli/logs_parsing_core.go
+++ b/pkg/cli/logs_parsing_core.go
@@ -1,11 +1,10 @@
 // This file provides command-line interface functionality for gh-aw.
 // This file (logs_parsing_core.go) contains core log parsing functions
-// for locating and extracting engine configuration from workflow logs.
+// for extracting engine configuration from workflow logs.
 //
 // Key responsibilities:
 //   - Parsing aw_info.json to extract engine configuration
-//   - Locating agent log files and output artifacts
-//   - Supporting multiple artifact layouts (before/after flattening)
+//   - Registering the errWalkStop sentinel used by walk-based helpers in this package
 
 package cli
 
diff --git a/pkg/cli/logs_utils.go b/pkg/cli/logs_utils.go
index 61e1b78fd0b..e68d67ce30e 100644
--- a/pkg/cli/logs_utils.go
+++ b/pkg/cli/logs_utils.go
@@ -157,20 +157,27 @@ func findAgentLogFile(logDir string, engine workflow.CodingAgentEngine) (string,
 		}
 
 		// Check for flattened location (after flattening)
-		// The engine's log path is absolute (e.g., /tmp/gh-aw/sandbox/agent/logs/)
-		// After flattening, it's at logDir/sandbox/agent/logs/
+		// The engine's log path is absolute (e.g., /tmp/gh-aw/sandbox/agent/logs/ for a
+		// directory, or /tmp/gh-aw/pi-streaming.jsonl for a direct file).
+		// After flattening, it's at logDir/<relative-path>.
 		// Strip /tmp/gh-aw/ prefix to get the relative path
 		const tmpGhAwPrefix = "/tmp/gh-aw/"
 		if after, ok := strings.CutPrefix(logFileForParsing, tmpGhAwPrefix); ok {
 			relPath := after
-			flattenedDir := filepath.Join(logDir, relPath)
-			logsUtilsLog.Printf("Checking flattened location for logs: %s", flattenedDir)
-			if fileutil.DirExists(flattenedDir) {
+			flattenedPath := filepath.Join(logDir, relPath)
+			logsUtilsLog.Printf("Checking flattened location for logs: %s", flattenedPath)
+			// Case 1: the engine log path points to a specific file (e.g. pi-streaming.jsonl)
+			if fileutil.FileExists(flattenedPath) {
+				logsUtilsLog.Printf("Found engine log file at flattened path: %s", flattenedPath)
+				return flattenedPath, true
+			}
+			// Case 2: the engine log path points to a directory — walk it for known log formats
+			if fileutil.DirExists(flattenedPath) {
 				// Prefer events.jsonl (structured Copilot session format) over debug .log files.
 				// Walk the full tree: stop immediately when events.jsonl is found (preferred),
 				// but keep walking after a .log match in case events.jsonl appears later.
 				var foundEventsJsonl, foundLogFile string
-				if walkErr := filepath.Walk(flattenedDir, func(path string, info os.FileInfo, err error) error {
+				if walkErr := filepath.Walk(flattenedPath, func(path string, info os.FileInfo, err error) error {
 					if err != nil {
 						logsUtilsLog.Printf("walk error at %s: %v", path, err)
 						return nil
@@ -190,7 +197,7 @@ func findAgentLogFile(logDir string, engine workflow.CodingAgentEngine) (string,
 					}
 					return nil
 				}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
-					fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", flattenedDir, walkErr)))
+					fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", flattenedPath, walkErr)))
 				}
 				if foundEventsJsonl != "" {
 					return foundEventsJsonl, true
@@ -199,6 +206,31 @@ func findAgentLogFile(logDir string, engine workflow.CodingAgentEngine) (string,
 					return foundLogFile, true
 				}
 			}
+			// Case 3: fall back to searching logDir for a file whose base name matches
+			// the engine's log file name (handles unusual artifact layouts)
+			targetBase := filepath.Base(logFileForParsing)
+			logsUtilsLog.Printf("Searching logDir %s for engine log file by base name: %s", logDir, targetBase)
+			var foundByBase string
+			if walkErr := filepath.Walk(logDir, func(path string, info os.FileInfo, err error) error {
+				if err != nil {
+					logsUtilsLog.Printf("walk error at %s: %v", path, err)
+					return nil
+				}
+				if info == nil {
+					return nil
+				}
+				if !info.IsDir() && info.Name() == targetBase && foundByBase == "" {
+					foundByBase = path
+					return errWalkStop
+				}
+				return nil
+			}); walkErr != nil && !errors.Is(walkErr, errWalkStop) {
+				fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("filesystem error walking %s: %v", logDir, walkErr)))
+			}
+			if foundByBase != "" {
+				logsUtilsLog.Printf("Found engine log file by base name: %s", foundByBase)
+				return foundByBase, true
+			}
 		}
 
 		// Fallback: search recursively in logDir for events.jsonl, session*.log or process*.log files
