Repository: palantir/gradle-failure-reports
Pull request #264: LocalRecommendationsPlugin that adds recommendations for failures
Author: login: crogoz
Labels: name: autorelease
name: merge when ready
Created: 2025-05-13T09:24:36Z | Merged: 2025-05-27T14:44:44Z

## Before this PR
<!-- What's wrong with the current state of the world and why change it now? -->

## After this PR
<!-- User-facing outcomes this PR delivers go below -->
- FLUp for bumping `gradle daemon = 21` (internal threads [here](https://pl.ntr/2tw) and [here](https://pl.ntr/2tx)). Github issue tracked [here](https://github.com/gradle/gradle/issues/10483).
- Available only for Gradle versions >= 8.8 
- Adds an error message and throws an explanation with the reason for the failure & possible fixes. 

==COMMIT_MSG==
LocalRecommendationsPlugin that adds recommendations for failures
==COMMIT_MSG==

internal buildscan [here](https://pl.ntr/2tz)
![Screenshot 2025-05-13 at 3 52 25 PM](https://github.com/user-attachments/assets/556de83e-e639-4974-82d2-0fe3c1c32683)

## Possible downsides?
<!-- Please describe any way users could be negatively affected by this PR. -->




Diff:
diff --git a/changelog/@unreleased/pr-264.v2.yml b/changelog/@unreleased/pr-264.v2.yml
new file mode 100644
index 00000000..a651bfb1
--- /dev/null
+++ b/changelog/@unreleased/pr-264.v2.yml
@@ -0,0 +1,5 @@
+type: improvement
+improvement:
+  description: LocalRecommendationsPlugin that adds recommendations for failures
+  links:
+  - https://github.com/palantir/gradle-failure-reports/pull/264
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/BuildFailureReporter.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/BuildFailureReporter.java
index df37101c..c4bda7e9 100644
--- a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/BuildFailureReporter.java
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/BuildFailureReporter.java
@@ -16,14 +16,11 @@
 
 package com.palantir.gradle.failurereports;
 
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.palantir.gradle.failurereports.common.FailureReport;
 import com.palantir.gradle.failurereports.junit.JunitReporter;
 import java.io.File;
 import java.io.IOException;
-import java.util.Collection;
-import java.util.List;
 import java.util.Optional;
 import java.util.stream.Collectors;
 import org.gradle.api.Task;
@@ -33,7 +30,6 @@
 import org.gradle.api.tasks.TaskExecutionException;
 import org.gradle.api.tasks.compile.JavaCompile;
 import org.gradle.api.tasks.testing.Test;
-import org.gradle.execution.MultipleBuildFailures;
 
 public final class BuildFailureReporter {
 
@@ -51,7 +47,7 @@ public static void report(File outputFile, Throwable buildThrowable) {
 
     private static void reportFailures(File outputFile, Throwable buildThrowable) throws IOException {
         ImmutableList.Builder<FailureReport> failureReports = ImmutableList.builder();
-        for (TaskExecutionException taskExecutionException : getTaskExecutionExceptions(buildThrowable)) {
+        for (TaskExecutionException taskExecutionException : BuildFailures.getTaskExecutionExceptions(buildThrowable)) {
             Task task = taskExecutionException.getTask();
             if (task instanceof JavaCompile) {
                 // TODO(crogoz): use compileFailuresService to report the errors once everything is on gradle >= 8.6
@@ -68,20 +64,5 @@ private static void reportFailures(File outputFile, Throwable buildThrowable) th
         JunitReporter.reportFailures(outputFile, failureReports.build());
     }
 
-    private static List<TaskExecutionException> getTaskExecutionExceptions(Throwable buildThrowable) {
-        ImmutableList.Builder<Throwable> rootExceptions = ImmutableList.builder();
-        if (buildThrowable instanceof MultipleBuildFailures) {
-            rootExceptions.addAll(((MultipleBuildFailures) buildThrowable).getCauses());
-        } else {
-            rootExceptions.add(buildThrowable);
-        }
-        return rootExceptions.build().stream()
-                .map(Throwables::getCausalChain)
-                .flatMap(Collection::stream)
-                .filter(throwable -> throwable instanceof TaskExecutionException)
-                .map(throwable -> (TaskExecutionException) throwable)
-                .collect(Collectors.toList());
-    }
-
     private BuildFailureReporter() {}
 }
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/BuildFailures.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/BuildFailures.java
new file mode 100644
index 00000000..d94bf1c0
--- /dev/null
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/BuildFailures.java
@@ -0,0 +1,45 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.gradle.api.tasks.TaskExecutionException;
+import org.gradle.execution.MultipleBuildFailures;
+
+public final class BuildFailures {
+
+    public static List<TaskExecutionException> getTaskExecutionExceptions(Throwable buildThrowable) {
+        ImmutableList.Builder<Throwable> rootExceptions = ImmutableList.builder();
+        if (buildThrowable instanceof MultipleBuildFailures) {
+            rootExceptions.addAll(((MultipleBuildFailures) buildThrowable).getCauses());
+        } else {
+            rootExceptions.add(buildThrowable);
+        }
+        return rootExceptions.build().stream()
+                .map(Throwables::getCausalChain)
+                .flatMap(Collection::stream)
+                .filter(throwable -> throwable instanceof TaskExecutionException)
+                .map(throwable -> (TaskExecutionException) throwable)
+                .collect(Collectors.toList());
+    }
+
+    private BuildFailures() {}
+}
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsFlowActionsPlugin.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsFlowActionsPlugin.java
index d8c6580f..1c186fce 100644
--- a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsFlowActionsPlugin.java
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsFlowActionsPlugin.java
@@ -35,7 +35,7 @@ public abstract class FailureReportsFlowActionsPlugin implements Plugin<Project>
 
     @Override
     public final void apply(Project project) {
-        if (!PluginResources.shouldApplyPlugin(project)) {
+        if (!PluginResources.isRunningOnInitialCircleNode(project)) {
             return;
         }
         FailureReportsExtension failureReportsExtension =
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsProjectsPlugin.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsProjectsPlugin.java
index 9f0747f0..c93dc2ef 100644
--- a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsProjectsPlugin.java
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsProjectsPlugin.java
@@ -33,7 +33,7 @@ public final class FailureReportsProjectsPlugin implements Plugin<Project> {
 
     @Override
     public void apply(Project project) {
-        if (!PluginResources.shouldApplyPlugin(project)) {
+        if (!PluginResources.isRunningOnInitialCircleNode(project)) {
             return;
         }
         FailureReportsExtension failureReportsExtension =
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsRootPlugin.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsRootPlugin.java
index 569022cc..c35916a0 100644
--- a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsRootPlugin.java
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/FailureReportsRootPlugin.java
@@ -24,26 +24,41 @@
 import org.gradle.api.Project;
 import org.gradle.api.initialization.Settings;
 import org.gradle.api.invocation.Gradle;
-import org.gradle.util.GradleVersion;
 
 public final class FailureReportsRootPlugin implements Plugin<Project> {
 
-    private static final GradleVersion GRADLE_FLOW_ACTIONS_ENABLED = GradleVersion.version("8.6");
-
     @Override
     public void apply(Project project) {
-        if (!PluginResources.shouldApplyPlugin(project)) {
-            return;
-        }
         if (project.getRootProject() != project) {
             throw new IllegalArgumentException("com.palantir.failure-reports must be applied to the root project only");
         }
+
+        applyLocalRecommendationsPlugin(project);
+
+        applyFailureReportsPlugin(project);
+    }
+
+    private static void applyLocalRecommendationsPlugin(Project project) {
+        if (!PluginResources.isRunningLocally(project)) {
+            return;
+        }
+        if (!PluginResources.canUseFlowActions(project)) {
+            return;
+        }
+        project.getPluginManager().apply(LocalFailuresRecommendationsPlugin.class);
+    }
+
+    private static void applyFailureReportsPlugin(Project project) {
+        if (!PluginResources.isRunningOnInitialCircleNode(project)) {
+            return;
+        }
+
         FailureReportsExtension failureReportsExtension =
                 ExtensionUtils.maybeCreate(project, "failureReports", FailureReportsExtension.class);
         CompileFailuresService.getSharedCompileFailuresService(project, failureReportsExtension);
 
         project.allprojects(subproject -> subproject.getPluginManager().apply(FailureReportsProjectsPlugin.class));
-        if (GradleVersion.version(project.getGradle().getGradleVersion()).compareTo(GRADLE_FLOW_ACTIONS_ENABLED) >= 0) {
+        if (PluginResources.canUseFlowActions(project)) {
             project.getPluginManager().apply(FailureReportsFlowActionsPlugin.class);
         } else {
             project.getGradle().addBuildListener(new BuildListener() {
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/LocalFailureRecommendationFlowAction.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/LocalFailureRecommendationFlowAction.java
new file mode 100644
index 00000000..b90828e9
--- /dev/null
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/LocalFailureRecommendationFlowAction.java
@@ -0,0 +1,66 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports;
+
+import com.google.common.collect.ImmutableList;
+import com.palantir.gradle.failurereports.LocalFailureRecommendationFlowAction.Parameters;
+import com.palantir.gradle.failurereports.handlers.ExecCommandNotFoundFailure;
+import com.palantir.gradle.failurereports.handlers.ExecCommandNotFoundHandler;
+import java.util.List;
+import org.gradle.api.GradleException;
+import org.gradle.api.Task;
+import org.gradle.api.flow.BuildWorkResult;
+import org.gradle.api.flow.FlowAction;
+import org.gradle.api.flow.FlowParameters;
+import org.gradle.api.logging.Logger;
+import org.gradle.api.logging.Logging;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.TaskExecutionException;
+
+public final class LocalFailureRecommendationFlowAction implements FlowAction<Parameters> {
+
+    private static Logger log = Logging.getLogger(LocalFailureRecommendationFlowAction.class);
+    private static ExecCommandNotFoundHandler failureHandler = new ExecCommandNotFoundHandler();
+
+    interface Parameters extends FlowParameters {
+        @Input
+        Property<BuildWorkResult> getBuildResult();
+    }
+
+    @Override
+    public void execute(Parameters parameters) {
+        parameters
+                .getBuildResult()
+                .get()
+                .getFailure()
+                .ifPresent(LocalFailureRecommendationFlowAction::checkRecommendations);
+    }
+
+    public static void checkRecommendations(Throwable buildThrowable) {
+        ImmutableList.Builder<ExecCommandNotFoundFailure> expandedFailuresBuilder = ImmutableList.builder();
+        for (TaskExecutionException taskExecutionException : BuildFailures.getTaskExecutionExceptions(buildThrowable)) {
+            Task task = taskExecutionException.getTask();
+            failureHandler.handle(task, taskExecutionException).ifPresent(expandedFailuresBuilder::add);
+        }
+
+        List<ExecCommandNotFoundFailure> expandedFailures = expandedFailuresBuilder.build();
+        if (!expandedFailures.isEmpty()) {
+            throw new GradleException(ExecCommandNotFoundFailure.renderMessage(expandedFailures));
+        }
+    }
+}
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/LocalFailuresRecommendationsPlugin.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/LocalFailuresRecommendationsPlugin.java
new file mode 100644
index 00000000..9cc6ea89
--- /dev/null
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/LocalFailuresRecommendationsPlugin.java
@@ -0,0 +1,43 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports;
+
+import com.palantir.gradle.failurereports.util.PluginResources;
+import javax.inject.Inject;
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.flow.FlowProviders;
+import org.gradle.api.flow.FlowScope;
+
+public abstract class LocalFailuresRecommendationsPlugin implements Plugin<Project> {
+
+    @Inject
+    protected abstract FlowScope getFlowScope();
+
+    @Inject
+    protected abstract FlowProviders getFlowProviders();
+
+    @Override
+    public final void apply(Project project) {
+        if (!PluginResources.isRunningLocally(project)) {
+            return;
+        }
+        getFlowScope().always(LocalFailureRecommendationFlowAction.class, spec -> {
+            spec.getParameters().getBuildResult().set(getFlowProviders().getBuildWorkResult());
+        });
+    }
+}
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/handlers/ExecCommandNotFoundFailure.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/handlers/ExecCommandNotFoundFailure.java
new file mode 100644
index 00000000..66410779
--- /dev/null
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/handlers/ExecCommandNotFoundFailure.java
@@ -0,0 +1,73 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports.handlers;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public record ExecCommandNotFoundFailure(String taskPath, String missingCommand, boolean commandFoundOnPath) {
+
+    public static String renderMessage(List<ExecCommandNotFoundFailure> failures) {
+        String taskPaths = failures.stream()
+                .map(ExecCommandNotFoundFailure::taskPath)
+                .distinct()
+                .collect(Collectors.joining(", "));
+        String executables = failures.stream()
+                .map(ExecCommandNotFoundFailure::missingCommand)
+                .distinct()
+                .collect(Collectors.joining(", "));
+        String commandsToRun = failures.stream()
+                .map(ExecCommandNotFoundFailure::missingCommand)
+                .distinct()
+                .map(command -> String.format("\t- %s", command))
+                .collect(Collectors.joining("\n"));
+        String tasksToRun = failures.stream()
+                .map(ExecCommandNotFoundFailure::taskPath)
+                .distinct()
+                .collect(Collectors.joining(" "));
+        Set<String> foundCommandsOnPath = failures.stream()
+                .filter(ExecCommandNotFoundFailure::commandFoundOnPath)
+                .map(ExecCommandNotFoundFailure::missingCommand)
+                .collect(Collectors.toSet());
+        String foundCommandsMessage = foundCommandsOnPath.isEmpty()
+                ? ""
+                : String.format(
+                        "The command(s) `%s` are present in the PATH, but executing the task via Gradle does not"
+                                + "recognize it.",
+                        String.join(", ", foundCommandsOnPath));
+        return String.format(
+                """
+                Execution of `%s` failed.
+                %s
+                This issue might occur due to a Gradle bug (https://github.com/gradle/gradle/issues/10483)
+                when running an Exec task on the Daemon Java version 21 on macOS, which prevents the
+                executable(s) `%s` from being located in the PATH.
+
+                To determine if the issue is related to Gradle, execute the following commands in bash:
+                %s
+                If the command completes successfully, it indicates that the Gradle bug is responsible.
+
+                A quick workaround is running:
+                    - `./gradlew --stop` to stop all the Gradle Daemons
+                    - `./gradlew %s` from the *terminal* (NOT through Intellij)
+
+                To resolve this issue, please migrate the Exec tasks and ExecSpecs usages to BetterExec:
+                https://github.com/palantir/better-exec/tree/develop?tab=readme-ov-file#usage.""",
+                taskPaths, foundCommandsMessage, executables, commandsToRun, tasksToRun);
+    }
+}
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/handlers/ExecCommandNotFoundHandler.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/handlers/ExecCommandNotFoundHandler.java
new file mode 100644
index 00000000..4f148be9
--- /dev/null
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/handlers/ExecCommandNotFoundHandler.java
@@ -0,0 +1,70 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports.handlers;
+
+import com.google.common.base.Throwables;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.gradle.api.Task;
+import org.gradle.api.logging.Logger;
+import org.gradle.api.logging.Logging;
+
+public final class ExecCommandNotFoundHandler {
+
+    private static Logger log = Logging.getLogger(ExecCommandNotFoundHandler.class);
+    private static final Pattern NO_SUCH_COMMAND = Pattern.compile("Cannot run program \"(.+)\" .*");
+
+    public Optional<ExecCommandNotFoundFailure> handle(Task task, Throwable throwable) {
+        Optional<String> command = maybeGetMissingCommand(throwable);
+        if (command.isEmpty()) {
+            return Optional.empty();
+        }
+        boolean commandFound = commandExists(command.get());
+        return Optional.of(new ExecCommandNotFoundFailure(task.getPath(), command.get(), commandFound));
+    }
+
+    private static Optional<String> maybeGetMissingCommand(Throwable throwable) {
+        Throwable rootCause = Throwables.getRootCause(throwable);
+        if (!(rootCause instanceof IOException && rootCause.getMessage().contains("No such file or directory"))) {
+            return Optional.empty();
+        }
+        return Throwables.getCausalChain(throwable).stream()
+                .map(error -> {
+                    Matcher matcher = NO_SUCH_COMMAND.matcher(error.getMessage());
+                    if (matcher.find()) {
+                        return Optional.of(matcher.group(1));
+                    }
+                    return Optional.<String>empty();
+                })
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .findFirst();
+    }
+
+    private static boolean commandExists(String commandName) {
+        try {
+            ProcessBuilder builder = new ProcessBuilder("sh", "-c", "command -v " + commandName);
+            Process process = builder.start();
+            int exitCode = process.waitFor();
+            return exitCode == 0;
+        } catch (IOException | InterruptedException e) {
+            return false;
+        }
+    }
+}
diff --git a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/util/PluginResources.java b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/util/PluginResources.java
index ca8c6bff..97c4578b 100644
--- a/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/util/PluginResources.java
+++ b/gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/util/PluginResources.java
@@ -19,13 +19,15 @@
 import com.palantir.gradle.utils.environmentvariables.EnvironmentVariables;
 import java.util.Optional;
 import org.gradle.api.Project;
+import org.gradle.util.GradleVersion;
 
 public final class PluginResources {
 
+    public static final GradleVersion GRADLE_FLOW_ACTIONS_ENABLED = GradleVersion.version("8.6");
     private static final String CIRCLE_NODE_INDEX = "CIRCLE_NODE_INDEX";
     private static final Integer INITIAL_CIRCLE_NODE = 0;
 
-    public static boolean shouldApplyPlugin(Project project) {
+    public static boolean isRunningOnInitialCircleNode(Project project) {
         EnvironmentVariables environmentVariables = project.getObjects().newInstance(EnvironmentVariables.class);
         if (!environmentVariables.isCi().get()) {
             return false;
@@ -37,6 +39,16 @@ public static boolean shouldApplyPlugin(Project project) {
                 .orElse(true);
     }
 
+    public static boolean canUseFlowActions(Project project) {
+        return GradleVersion.version(project.getGradle().getGradleVersion()).compareTo(GRADLE_FLOW_ACTIONS_ENABLED)
+                >= 0;
+    }
+
+    public static boolean isRunningLocally(Project project) {
+        EnvironmentVariables environmentVariables = project.getObjects().newInstance(EnvironmentVariables.class);
+        return !environmentVariables.isCi().get();
+    }
+
     private static Optional<Integer> maybeGetCircleNode(EnvironmentVariables environmentVariables) {
         return Optional.ofNullable(environmentVariables
                         .envVarOrFromTestingProperty(CIRCLE_NODE_INDEX)
diff --git a/gradle-failure-reports/src/test/groovy/com/palantir/gradle/failurereports/LocalFailuresRecommendationsIntegrationSpec.groovy b/gradle-failure-reports/src/test/groovy/com/palantir/gradle/failurereports/LocalFailuresRecommendationsIntegrationSpec.groovy
new file mode 100644
index 00000000..4b2c3a9f
--- /dev/null
+++ b/gradle-failure-reports/src/test/groovy/com/palantir/gradle/failurereports/LocalFailuresRecommendationsIntegrationSpec.groovy
@@ -0,0 +1,68 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports
+
+import nebula.test.IntegrationSpec
+import nebula.test.functional.ExecutionResult
+import org.assertj.core.util.Throwables
+
+class LocalFailuresRecommendationsIntegrationSpec extends IntegrationSpec {
+    
+    def '#gradleVersionNumber: reproduce gradle/issues/10483'() {
+        setup:
+        gradleVersion = gradleVersionNumber
+        // language=gradle
+        buildFile << '''
+            apply plugin: 'com.palantir.failure-reports'
+        '''.stripIndent(true)
+
+        file('gradle.properties') << """
+            __TESTING = true
+            __TESTING_CI = false
+        """.stripIndent(true)
+
+        //language=Groovy
+        addSubproject("myProject", '''import org.gradle.api.DefaultTask
+            apply plugin: 'java'
+            
+            tasks.register("myExecTask", Exec.class, task -> {
+                task.setCommandLine("non-existing")
+            })
+            
+            tasks.register("execSpecTask", DefaultTask.class, task -> {
+                doFirst {
+                    project.exec {
+                      executable = "non-existing-execSpec"
+                    }
+                }
+            })
+        '''.stripIndent(true))
+        when:
+        ExecutionResult result = runTasksWithFailure('myExecTask', 'execSpecTask', '--continue')
+
+        then:
+        result.failure.causes.size() == 3
+        result.standardError.contains("Execution of `:myProject:myExecTask, :myProject:execSpecTask` failed.")
+        result.standardError.contains("gradle/issues/10483")
+        result.standardError.contains("`./gradlew :myProject:myExecTask :myProject:execSpecTask` from the *terminal*")
+        result.standardError.contains("- non-existing")
+        result.standardError.contains("- non-existing-execSpec")
+
+        where:
+        gradleVersionNumber << List.of("8.8")
+    }
+}
diff --git a/settings.gradle b/settings.gradle
index 8ad4dc11..e58ea7dc 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -13,4 +13,3 @@ rootProject.name = 'gradle-failure-reports-root'
 include 'gradle-failure-reports'
 include 'gradle-failure-reports-exceptions'
 include 'gradle-failure-reports-common'
-
diff --git a/versions.lock b/versions.lock
index b779f7f9..41061e86 100644
--- a/versions.lock
+++ b/versions.lock
@@ -10,7 +10,7 @@ com.google.guava:guava:33.4.8-jre (2 constraints: db1ebb7f)
 com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava (1 constraints: bd17c918)
 com.google.j2objc:j2objc-annotations:3.0.0 (1 constraints: 150aeab4)
 com.palantir.gradle.auto-parallelizable:auto-parallelizable-annotations:1.4.0 (1 constraints: 0705fc35)
-com.palantir.gradle.utils:environment-variables:0.10.0 (1 constraints: 3305233b)
+com.palantir.gradle.utils:environment-variables:0.11.0 (1 constraints: 3405263b)
 one.util:streamex:0.8.3 (1 constraints: 0d050636)
 org.codehaus.woodstox:stax2-api:4.2.2 (2 constraints: 6a278bd2)
 org.immutables:value:2.10.1 (1 constraints: 3605303b)
diff --git a/versions.props b/versions.props
index 91ac71a5..1921cb1a 100644
--- a/versions.props
+++ b/versions.props
@@ -6,7 +6,7 @@ one.util:streamex = 0.8.3
 com.palantir.gradle.auto-parallelizable:* = 1.4.0
 com.google.guava:guava = 33.4.8-jre
 org.apache.commons:commons-lang3 = 3.17.0
-com.palantir.gradle.utils:environment-variables = 0.10.0
+com.palantir.gradle.utils:environment-variables = 0.11.0
 
 # conflict resolution
 com.google.code.findbugs:jsr305 = 3.0.2


Reviews:
user: login: crogoz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T13:56:32Z
user: login: felixdesouza
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:16:24Z

Review comments:
diff_hunk: @@ -0,0 +1,70 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports.handlers;
+
+import com.google.common.base.Throwables;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.gradle.api.Task;
+import org.gradle.api.logging.Logger;
+import org.gradle.api.logging.Logging;
+
+public final class ExecCommandNotFoundHandler {
+
+    private static Logger log = Logging.getLogger(ExecCommandNotFoundHandler.class);
+    private static final Pattern NO_SUCH_COMMAND = Pattern.compile("Cannot run program \"(.+)\" .*");
+
+    public Optional<ExecCommandNotFoundFailure> handle(Task task, Throwable throwable) {
+        Optional<String> command = maybeGetMissingCommand(throwable);
+        if (command.isEmpty()) {
+            return Optional.empty();
+        }
+        boolean commandFound = commandExists(command.get());
+        return Optional.of(new ExecCommandNotFoundFailure(task.getPath(), command.get(), commandFound));
+    }
+
+    private static Optional<String> maybeGetMissingCommand(Throwable throwable) {
+        Throwable rootCause = Throwables.getRootCause(throwable);
+        if (!(rootCause instanceof IOException && rootCause.getMessage().contains("No such file or directory"))) {
+            return Optional.empty();
+        }
+        return Throwables.getCausalChain(throwable).stream()
+                .map(error -> {
+                    Matcher matcher = NO_SUCH_COMMAND.matcher(error.getMessage());
+                    if (matcher.find()) {
+                        return Optional.of(matcher.group(1));
+                    }
+                    return Optional.<String>empty();
path: gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/handlers/ExecCommandNotFoundHandler.java
user: login: crogoz
body: best case effort  - I can extract easily the command form Exec tasks, however when people use project.exec inside a default task I couldn't find a way to extract the command line in a nicer way. 
created_at: 2025-05-13T13:56:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+/*
+ * (c) Copyright 2025 Palantir Technologies Inc. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.palantir.gradle.failurereports;
+
+import com.palantir.gradle.failurereports.util.PluginResources;
+import javax.inject.Inject;
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.flow.FlowProviders;
+import org.gradle.api.flow.FlowScope;
+
+public abstract class LocalFailuresRecommendationsPlugin implements Plugin<Project> {
+
+    @Inject
+    protected abstract FlowScope getFlowScope();
path: gradle-failure-reports/src/main/java/com/palantir/gradle/failurereports/LocalFailuresRecommendationsPlugin.java
user: login: felixdesouza
body: TIL you can inject not only this but a bunch of other Build/Global scoped things directly into the plugin!
created_at: 2025-05-13T15:16:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: changelog-app[bot]
created_at: 2025-05-13T09:24:40Z
body: ### Generate changelog in <changelog-dir>`changelog/@unreleased`</changelog-dir>
<details>
<summary>What do the change types mean?</summary>

- `feature`: A new feature of the service.
- `improvement`: An incremental improvement in the functionality or operation of the service.
- `fix`: Remedies the incorrect behaviour of a component of the service in a backwards-compatible way.
- `break`: Has the potential to break consumers of this service's API, inclusive of both Palantir services
and external consumers of the service's API (e.g. customer-written software or integrations).
- `deprecation`: Advertises the intention to remove service functionality without any change to the
operation of the service itself.
- `manualTask`: Requires the possibility of manual intervention (running a script, eyeballing configuration,
performing database surgery, ...) at the time of upgrade for it to succeed.
- `migration`: A fully automatic upgrade migration task with no engineer input required.

_Note: only one type should be chosen._
</details>
<details>
<summary>How are new versions calculated?</summary>

- ❗The `break` and `manual task` changelog types will result in a major release!
- 🐛 The `fix` changelog type will result in a minor release in most cases, and a patch release version for patch branches. This behaviour is configurable in autorelease.
- ✨ All others will result in a minor version release.
</details>

**Type**
<changelog-type>
- [ ] Feature
- [x] Improvement
- [ ] Fix
- [ ] Break
- [ ] Deprecation
- [ ] Manual task
- [ ] Migration
</changelog-type>

**Description**
<changelog-description>
LocalRecommendationsPlugin that adds recommendations for failures
</changelog-description><changelog-links></changelog-links><changelog-tags></changelog-tags>

**Check the box to generate changelog(s)**
- [x] Generate changelog entry

author_association: NONE
user: login: felixdesouza
created_at: 2025-05-27T12:27:54Z
body: 👍 
author_association: CONTRIBUTOR
user: login: autorelease3[bot]
created_at: 2025-05-27T14:44:52Z
body: Released [1.14.0](https://github.com/palantir/gradle-failure-reports/releases/tag/1.14.0)

author_association: NONE