1"""Work verification utilities for little-loops.

2

3 Contains shared functions for verifying that actual implementation work

4 was done, used by both issue_manager (ll-auto) and worker_pool (ll-parallel).

5"""

6

7 from __future__ import annotations

8

9 import subprocess

10 from typing import TYPE_CHECKING

11

12 if TYPE_CHECKING:

13 from little_loops.logger import Logger

14

15

16# Directories that are excluded when verifying work was done.

17# Changes to files in these directories don't count as "real work".

18 EXCLUDED_DIRECTORIES = (

19 ".issues/",

20 "issues/", # Support non-dotted variant (issues.base_dir = "issues")

21 ".speckit/",

22 "thoughts/",

23 ".worktrees/",

24 ".auto-manage",

25)

26

27

28 def filter_excluded_files(files: list[str]) -> list[str]:

29 """Filter out files in excluded directories.

30

31 Args:

32 files: List of file paths to filter

33

34 Returns:

35 List of files not in excluded directories

36 """

37 return [

38 f

39 for f in files

40 if f and not any(f.startswith(excluded) for excluded in EXCLUDED_DIRECTORIES)

41 ]

42

43

44 def verify_work_was_done(

45 logger: Logger,

46 changed_files: list[str] | None = None,

47 baseline_sha: str | None = None,

48) -> bool:

49 """Verify that actual work was done (not just issue file moves).

50

51 Returns True if there's evidence of implementation work - changes to files

52 outside of excluded directories like .issues/, thoughts/, etc.

53

54 This prevents marking issues as "completed" when no actual fix was implemented.

55

56 Args:

57 logger: Logger for output

58 changed_files: Optional list of changed files. If not provided,

59 will detect via git diff commands.

60 baseline_sha: Optional git SHA captured before Phase 2 began. When provided

61 and the working tree is clean, checks for commits made since this SHA

62 (covers the case where the agent commits mid-phase and exits cleanly).

63

64 Returns:

65 True if meaningful file changes were detected

66 """

67 # If changed_files provided, use them directly (ll-parallel case)

68 if changed_files is not None:

69 meaningful_changes = filter_excluded_files(changed_files)

70 if meaningful_changes:

71 logger.info(

72 f"Found {len(meaningful_changes)} file(s) changed: {meaningful_changes[:5]}"

73 )

74 return True

75 # Log which excluded files were modified for diagnostic purposes

76 excluded_files = [f for f in changed_files if f]

77 logger.warning(

78 f"No meaningful changes detected - only excluded files modified: {excluded_files[:10]}"

79 )

80 return False

81

82 # Otherwise detect via git (ll-auto case)

83 all_excluded_files: list[str] = []

84 try:

85 # Check for uncommitted changes

86 result = subprocess.run(

87 ["git", "diff", "--name-only"],

88 capture_output=True,

89 text=True,

90 )

91 if result.returncode == 0:

92 files = result.stdout.strip().split("\n")

93 meaningful_changes = filter_excluded_files(files)

94 if meaningful_changes:

95 logger.info(

96 f"Found {len(meaningful_changes)} file(s) changed: {meaningful_changes[:5]}"

97 )

98 return True

99 # Collect excluded files for diagnostic logging

100 all_excluded_files.extend([f for f in files if f])

101

102 # Also check staged changes

103 result = subprocess.run(

104 ["git", "diff", "--cached", "--name-only"],

105 capture_output=True,

106 text=True,

107 )

108 if result.returncode == 0:

109 staged = result.stdout.strip().split("\n")

110 meaningful_staged = filter_excluded_files(staged)

111 if meaningful_staged:

112 logger.info(

113 f"Found {len(meaningful_staged)} staged file(s): {meaningful_staged[:5]}"

114 )

115 return True

116 # Collect excluded files for diagnostic logging

117 all_excluded_files.extend([f for f in staged if f and f not in all_excluded_files])

118

119 # Check commits made since baseline (covers mid-phase commits in ll-auto)

120 if baseline_sha:

121 try:

122 current_head = subprocess.run(

123 ["git", "rev-parse", "HEAD"],

124 capture_output=True,

125 text=True,

126 )

127 if current_head.returncode == 0 and current_head.stdout.strip() != baseline_sha:

128 result = subprocess.run(

129 ["git", "diff", "--name-only", f"{baseline_sha}..HEAD"],

130 capture_output=True,

131 text=True,

132 )

133 if result.returncode == 0:

134 committed = result.stdout.strip().split("\n")

135 meaningful_committed = filter_excluded_files(committed)

136 if meaningful_committed:

137 logger.info(

138 f"Found {len(meaningful_committed)} file(s) committed since "

139 f"baseline: {meaningful_committed[:5]}"

140 )

141 return True

142 all_excluded_files.extend(

143 [f for f in committed if f and f not in all_excluded_files]

144 )

145 except Exception as e:

146 logger.error(f"Could not check committed changes: {e}")

147

148 # Log which excluded files were modified for diagnostic purposes

149 if all_excluded_files:

150 logger.warning(

151 f"No meaningful changes detected - only excluded files modified: "

152 f"{all_excluded_files[:10]}"

153 )

154 else:

155 logger.warning("No meaningful changes detected - no files modified")

156 return False

157

158 except Exception as e:

159 logger.error(f"Could not verify work: {e}")

160 # Be conservative - don't assume work was done if we can't verify

161 return False