localcode 0.4.0__py3-none-macosx_13_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,230 @@
1
+ """Detect a module that was created, imported, and then left empty.
2
+
3
+ The failure this exists for: a build run that wrote ten real files and one
4
+ `src/lib/fsrs.ts` containing exactly `// placeholder`, imported it from the page
5
+ that needed it, and finished. The scheduling core — the entire point of the app
6
+ — was never written, and the README advertised it as a feature.
7
+
8
+ The signal is deliberately narrow: a file whose whole body is comments AND that
9
+ something else imports. Either half alone is noisy. An empty file nobody
10
+ references is usually intentional (a package marker, a barrel not wired up yet);
11
+ a file with real content is fine however sparse. The conjunction — "you created
12
+ this module, you import it, and there is nothing in it" — is a defect
13
+ essentially every time, which is what makes it safe to block a turn on.
14
+
15
+ Kept out of `loop.py` for the same reason `project_check_gate` is: the loop has
16
+ no test seam and this needs one.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+
23
+ __all__ = ["HollowModuleGate", "hollow_imported_modules", "is_hollow_source"]
24
+
25
+ # Families whose "no executable content" question is well defined. Interpreted
26
+ # config/markup (JSON, YAML, Markdown, CSS) is excluded: an empty one is data,
27
+ # not a missing implementation.
28
+ _C_FAMILY = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs",
29
+ ".java", ".swift", ".c", ".h", ".cpp", ".hpp", ".cs", ".kt"}
30
+ _HASH_FAMILY = {".py", ".rb", ".sh", ".bash"}
31
+ _CODE_EXTS = _C_FAMILY | _HASH_FAMILY
32
+
33
+ # Files that are legitimately empty by convention — never a missing module.
34
+ _EXEMPT_NAMES = {"__init__.py", "conftest.py", "setup.py", "py.typed"}
35
+ _EXEMPT_DIR_PARTS = {"tests", "test", "__tests__", "migrations", "fixtures",
36
+ "node_modules", ".git", "dist", "build", "vendor"}
37
+
38
+ _MAX_BYTES = 64_000 # a file this size is not a stub; don't read further
39
+
40
+
41
+ def _strip_c_comments(text: str) -> str:
42
+ """Remove `//` and `/* */`, preserving string literals so a `"//"` inside a
43
+ string still counts as content."""
44
+ out: list[str] = []
45
+ i, n = 0, len(text)
46
+ quote = ""
47
+ while i < n:
48
+ c = text[i]
49
+ if quote:
50
+ out.append(c)
51
+ if c == "\\" and i + 1 < n:
52
+ out.append(text[i + 1])
53
+ i += 2
54
+ continue
55
+ if c == quote:
56
+ quote = ""
57
+ i += 1
58
+ continue
59
+ if c in "\"'`":
60
+ quote = c
61
+ out.append(c)
62
+ i += 1
63
+ continue
64
+ if c == "/" and i + 1 < n and text[i + 1] == "/":
65
+ while i < n and text[i] != "\n":
66
+ i += 1
67
+ continue
68
+ if c == "/" and i + 1 < n and text[i + 1] == "*":
69
+ i += 2
70
+ while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
71
+ i += 1
72
+ i += 2
73
+ continue
74
+ out.append(c)
75
+ i += 1
76
+ return "".join(out)
77
+
78
+
79
+ def _strip_hash_comments(text: str) -> str:
80
+ out = []
81
+ for line in text.splitlines():
82
+ quote = ""
83
+ cut = len(line)
84
+ for idx, ch in enumerate(line):
85
+ if quote:
86
+ if ch == quote and line[idx - 1: idx] != "\\":
87
+ quote = ""
88
+ elif ch in "\"'":
89
+ quote = ch
90
+ elif ch == "#":
91
+ cut = idx
92
+ break
93
+ out.append(line[:cut])
94
+ return "\n".join(out)
95
+
96
+
97
+ def is_hollow_source(path: str) -> bool:
98
+ """True when `path` is a code file with no executable content at all.
99
+
100
+ Comments and whitespace are stripped; anything left — including a bare
101
+ docstring, which is at least an intentional statement — counts as content.
102
+ A file that cannot be read is NOT reported: this must never invent a defect.
103
+ """
104
+ ext = os.path.splitext(path)[1].lower()
105
+ if ext not in _CODE_EXTS:
106
+ return False
107
+ if path.endswith(".d.ts"):
108
+ return False
109
+ if os.path.basename(path) in _EXEMPT_NAMES:
110
+ return False
111
+ parts = {p.lower() for p in path.replace("\\", "/").split("/")}
112
+ if parts & _EXEMPT_DIR_PARTS:
113
+ return False
114
+ try:
115
+ if os.path.getsize(path) > _MAX_BYTES:
116
+ return False
117
+ with open(path, encoding="utf-8", errors="replace") as fh:
118
+ text = fh.read()
119
+ except Exception:
120
+ return False
121
+ stripped = (_strip_hash_comments(text) if ext in _HASH_FAMILY
122
+ else _strip_c_comments(text))
123
+ return not stripped.strip()
124
+
125
+
126
+ def _import_patterns(stem: str) -> list[re.Pattern[str]]:
127
+ """Ways another file could name this module. Matching on the STEM keeps this
128
+ language-agnostic; the surrounding syntax keeps it from matching prose."""
129
+ s = re.escape(stem)
130
+ return [
131
+ # from './lib/fsrs' | from "../fsrs.js" | require('./fsrs')
132
+ re.compile(rf"""(?:from|require\s*\()\s*['"][^'"]*\b{s}(?:\.[a-z]+)?['"]"""),
133
+ # import './fsrs'
134
+ re.compile(rf"""import\s+['"][^'"]*\b{s}(?:\.[a-z]+)?['"]"""),
135
+ # python: import fsrs | from fsrs import x | from .lib.fsrs import x
136
+ re.compile(rf"^\s*(?:from\s+[.\w]*\b{s}\b|import\s+(?:[.\w]+\s*,\s*)*{s}\b)",
137
+ re.MULTILINE),
138
+ ]
139
+
140
+
141
+ def hollow_imported_modules(repo_root: str, changed_files) -> list[str]:
142
+ """Repo-relative paths of modules changed this turn that are empty AND
143
+ imported from somewhere else in the project.
144
+
145
+ `changed_files` is whatever the loop tracks for the turn — an iterable of
146
+ paths, absolute or repo-relative. Anything unreadable is skipped silently.
147
+ """
148
+ root = os.path.abspath(repo_root)
149
+ hollow: list[tuple[str, str]] = [] # (abs_path, stem)
150
+ for raw in changed_files or []:
151
+ p = str(raw)
152
+ absolute = p if os.path.isabs(p) else os.path.join(root, p)
153
+ if not os.path.isfile(absolute):
154
+ continue
155
+ rel = os.path.relpath(absolute, root)
156
+ if rel.startswith(".."):
157
+ continue
158
+ if is_hollow_source(absolute):
159
+ hollow.append((absolute, os.path.splitext(os.path.basename(absolute))[0]))
160
+ if not hollow:
161
+ return []
162
+
163
+ # Scan the project once for importers, skipping the hollow files themselves
164
+ # (a module importing its own name proves nothing).
165
+ hollow_abs = {a for a, _ in hollow}
166
+ patterns = {stem: _import_patterns(stem) for _, stem in hollow}
167
+ imported: set[str] = set()
168
+ for base, dirs, files in os.walk(root):
169
+ dirs[:] = [d for d in dirs if d not in _EXEMPT_DIR_PARTS and not d.startswith(".")]
170
+ for name in files:
171
+ if os.path.splitext(name)[1].lower() not in _CODE_EXTS:
172
+ continue
173
+ fpath = os.path.join(base, name)
174
+ if fpath in hollow_abs:
175
+ continue
176
+ try:
177
+ if os.path.getsize(fpath) > _MAX_BYTES * 8:
178
+ continue
179
+ with open(fpath, encoding="utf-8", errors="replace") as fh:
180
+ text = fh.read()
181
+ except Exception:
182
+ continue
183
+ for stem, pats in patterns.items():
184
+ if stem in imported:
185
+ continue
186
+ if any(pat.search(text) for pat in pats):
187
+ imported.add(stem)
188
+ if len(imported) == len(patterns):
189
+ break
190
+ return sorted(
191
+ os.path.relpath(a, root) for a, stem in hollow if stem in imported
192
+ )
193
+
194
+
195
+ class HollowModuleGate:
196
+ """Turn-level state: are there still imported-but-empty modules?
197
+
198
+ Mirrors `ProjectCheckGate`. The nudge is bounded so a model that cannot
199
+ implement the module doesn't spin, but running out of nudges does NOT make
200
+ the app whole — `blocks_completion` keeps holding, and the reason rides the
201
+ final result text so it reaches both the TUI and `--json`.
202
+ """
203
+
204
+ def __init__(self, max_retries: int = 2) -> None:
205
+ self.max_retries = max_retries
206
+ self.paths: list[str] = []
207
+ self.retries = 0
208
+
209
+ def mark(self, paths) -> None:
210
+ self.paths = sorted(set(paths))
211
+
212
+ def clear(self) -> None:
213
+ """A later round found nothing hollow — the modules were implemented."""
214
+ self.paths = []
215
+
216
+ def consume_retry(self) -> bool:
217
+ if self.retries >= self.max_retries:
218
+ return False
219
+ self.retries += 1
220
+ return True
221
+
222
+ def blocks_completion(self) -> bool:
223
+ return bool(self.paths)
224
+
225
+ def result_note(self) -> str:
226
+ if not self.paths:
227
+ return ""
228
+ listed = "\n".join(f" - {p}" for p in self.paths)
229
+ return ("\n\nThese modules are imported but contain no code — the feature "
230
+ f"they implement does not exist:\n{listed}")
@@ -0,0 +1,414 @@
1
+ """Lifecycle hooks for LocalCode's agent loop.
2
+
3
+ The loop is responsible for sequencing; policy lives in small hook
4
+ functions here that can be tested and replaced independently.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ import re
11
+ from typing import Any
12
+ from ..evidence import EvidenceRegistry
13
+ __all__ = [
14
+ "TurnState",
15
+ "EvidenceLedger",
16
+ "QualityVerdict",
17
+ "before_turn",
18
+ "before_model",
19
+ "after_tool",
20
+ "quality_monitor",
21
+ "completion_gate",
22
+ "ran_build_or_test",
23
+ "_changed_code_files",
24
+ "CompletionEvidence",
25
+ "deterministic_completion",
26
+ ]
27
+
28
+
29
+ @dataclass
30
+ class TurnState:
31
+ user_text: str
32
+ goal_state: Any
33
+ task_state: Any = None
34
+ changed_files: list[str] = field(default_factory=list)
35
+ bash_history: list[tuple[str, str]] = field(default_factory=list)
36
+ tools_called: list[str] = field(default_factory=list)
37
+ evidence: "EvidenceLedger" = field(default_factory=lambda: EvidenceLedger())
38
+ verification_registry: EvidenceRegistry = field(default_factory=EvidenceRegistry)
39
+
40
+
41
+ @dataclass
42
+ class EvidenceLedger:
43
+ """Structured proof gathered during a turn.
44
+
45
+ The model may narrate intent, but completion depends on this ledger:
46
+ explicit tool facts, runtime probes, and observed errors.
47
+ """
48
+ verified_urls: list[str] = field(default_factory=list)
49
+ changed_paths: list[str] = field(default_factory=list)
50
+ unresolved: list[str] = field(default_factory=list)
51
+ runtime_errors: list[str] = field(default_factory=list)
52
+ empty_probe_count: int = 0
53
+ zero_data_count: int = 0
54
+
55
+ def add_tool_result(self, tool_name: str, args: dict[str, Any], result: str, facts: dict[str, Any]) -> None:
56
+ text = str(result or "").strip()
57
+ lower = text.lower()
58
+ compact = re.sub(r"\s+", "", text)
59
+ command = str(args.get("command", "") or "")
60
+ path = facts.get("path")
61
+ if isinstance(path, str) and path and facts.get("changed"):
62
+ self._add_unique(self.changed_paths, path)
63
+ url = facts.get("url")
64
+ if facts.get("verification_signal") and isinstance(url, str) and url:
65
+ self._add_unique(self.verified_urls, url)
66
+ if "traceback " in lower or "exception" in lower or facts.get("error_type") in {"syntax_error", "verification_failed"}:
67
+ self._add_unique(self.runtime_errors, lower[:200])
68
+ self._add_unique(self.unresolved, "runtime-error")
69
+ if (("curl" in command.lower() or "/api/" in command.lower()) and compact in {"[]", "{}", "null", "\"\"", ""}):
70
+ self.empty_probe_count += 1
71
+ self._add_unique(self.unresolved, "empty-api-response")
72
+ if re.search(r"\b(loaded|found|returned|has)\s+0\b", lower) or re.search(r"\b0\s+(items|records|rows|entries|levels|results)\b", lower):
73
+ self.zero_data_count += 1
74
+ self._add_unique(self.unresolved, "zero-runtime-data")
75
+
76
+ def blockers(self) -> list[str]:
77
+ return list(dict.fromkeys(self.unresolved))
78
+
79
+ @staticmethod
80
+ def _add_unique(items: list[str], value: str) -> None:
81
+ if value and value not in items:
82
+ items.append(value)
83
+
84
+
85
+ @dataclass
86
+ class QualityVerdict:
87
+ ok: bool
88
+ reason: str = ""
89
+ correction: str = ""
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class CompletionEvidence:
94
+ criterion: str
95
+ evidence: tuple[str, ...] = ()
96
+ satisfied: bool = False
97
+
98
+
99
+ def deterministic_completion(criteria: list[CompletionEvidence]) -> QualityVerdict:
100
+ if not criteria:
101
+ return QualityVerdict(False, "no-success-criteria", "Define verifiable outcomes first.")
102
+ missing = [item.criterion for item in criteria if not item.satisfied or not item.evidence]
103
+ if missing:
104
+ return QualityVerdict(False, "missing-completion-evidence", "Gather evidence for: " + "; ".join(missing[:5]))
105
+ return QualityVerdict(True, "success-criteria-satisfied", "Stop: further polish is out of scope.")
106
+
107
+
108
+ def before_turn(state: TurnState) -> None:
109
+ """Reserved hook for per-turn initialization."""
110
+ return None
111
+
112
+
113
+ def before_model(messages: list[dict[str, Any]], state: TurnState) -> list[dict[str, Any]]:
114
+ """Transform messages before each model call."""
115
+ return messages
116
+
117
+
118
+ def after_tool(tool_name: str, args: dict, result: str, state: TurnState) -> str:
119
+ """Post-process a tool result before it is fed back to the model."""
120
+ return result
121
+
122
+
123
+ def quality_monitor(content: str, state: TurnState) -> QualityVerdict:
124
+ """Detect generic bad completions and return a correction if needed."""
125
+ lower = (content or "").lower()
126
+ if not content.strip():
127
+ return QualityVerdict(ok=True)
128
+ if (
129
+ not state.changed_files
130
+ and state.goal_state.goal_type in {"build_app", "edit_existing", "general_task"}
131
+ and any(phrase in lower for phrase in ("would you like me to", "should i ", "i can implement"))
132
+ and not getattr(state.goal_state, "allows_blocking_question", True)
133
+ ):
134
+ return QualityVerdict(
135
+ ok=False,
136
+ reason="permission-question-without-action",
137
+ correction=(
138
+ "SYSTEM: The previous response asked for permission instead of "
139
+ "making the requested local code change. Continue with tools now. "
140
+ "Ask only if a real missing decision blocks implementation."
141
+ ),
142
+ )
143
+ if "http://localhost:[" in lower or "127.0.0.1:[" in lower:
144
+ return QualityVerdict(
145
+ ok=False,
146
+ reason="fake-placeholder-url",
147
+ correction=(
148
+ "SYSTEM: The previous response contained a placeholder URL. "
149
+ "Use tools to determine the real port/URL, then report the exact value."
150
+ ),
151
+ )
152
+ unsupported = _unsupported_final_claims(content, state)
153
+ if unsupported:
154
+ return QualityVerdict(
155
+ ok=False,
156
+ reason="unsupported-final-claims",
157
+ correction=(
158
+ "SYSTEM: The previous completion summary claimed features that are "
159
+ f"not evidenced in changed files: {', '.join(unsupported)}. "
160
+ "Either implement and verify those features now, or remove the "
161
+ "unsupported claims from the final summary. Do not overstate what was built."
162
+ ),
163
+ )
164
+ return QualityVerdict(ok=True)
165
+
166
+
167
+ def completion_gate(
168
+ *,
169
+ repo_root: Path | str,
170
+ state: TurnState,
171
+ build_stage: str,
172
+ has_runtime_verification: bool,
173
+ partial_handoff: bool,
174
+ blocking_question: bool,
175
+ ) -> str | None:
176
+ """Return a generic completion-block reason, or None if completion is valid."""
177
+ if state.goal_state.goal_type == "edit_existing" and not blocking_question:
178
+ if not state.changed_files:
179
+ return "no-edit-applied"
180
+ if _changed_code_files(state.changed_files) and not has_runtime_verification:
181
+ return "unverified-edit"
182
+ if state.goal_state.goal_type == "build_app" and state.changed_files and not blocking_question:
183
+ if build_stage != "verified":
184
+ return f"stage-{build_stage}"
185
+ if partial_handoff:
186
+ return "partial-handoff"
187
+ if not has_runtime_verification:
188
+ return "unverified-app-build"
189
+ unresolved = state.evidence.blockers() or _recent_unresolved_verification_signals(state)
190
+ if unresolved:
191
+ return f"unresolved-verification:{', '.join(unresolved[:3])}"
192
+ missing_features = _requested_feature_gaps(state)
193
+ if missing_features:
194
+ return f"missing-requested-features:{', '.join(missing_features[:3])}"
195
+ score, deductions = _completion_score(
196
+ state=state,
197
+ has_runtime_verification=has_runtime_verification,
198
+ missing_features=missing_features,
199
+ )
200
+ if score < 4:
201
+ return f"low-completion-score:{score}:{', '.join(deductions[:3])}"
202
+ if (
203
+ not state.changed_files
204
+ and partial_handoff
205
+ and not getattr(state.goal_state, "allows_blocking_question", True)
206
+ ):
207
+ return "action-required-followup"
208
+ return None
209
+
210
+
211
+ def _completion_score(
212
+ *,
213
+ state: TurnState,
214
+ has_runtime_verification: bool,
215
+ missing_features: list[str],
216
+ ) -> tuple[int, list[str]]:
217
+ score = 0
218
+ deductions: list[str] = []
219
+ if state.changed_files:
220
+ score += 2
221
+ else:
222
+ deductions.append("no_changed_files")
223
+ if has_runtime_verification:
224
+ score += 2
225
+ else:
226
+ deductions.append("no_runtime_verification")
227
+ if not missing_features:
228
+ score += 1
229
+ else:
230
+ deductions.append("missing_requested_features")
231
+ recent_errors = [
232
+ result for _, result in state.bash_history[-3:]
233
+ if str(result).startswith("[exit code ") or "Traceback " in str(result)
234
+ ]
235
+ recent_errors.extend(state.evidence.runtime_errors[-2:])
236
+ if recent_errors:
237
+ score -= 1
238
+ deductions.append("recent_unresolved_errors")
239
+ recent_tools = state.tools_called[-8:]
240
+ if len(recent_tools) >= 6 and len(set(recent_tools)) <= 2:
241
+ score -= 1
242
+ deductions.append("repeated_tool_churn")
243
+ return score, deductions
244
+
245
+
246
+ def _recent_unresolved_verification_signals(state: TurnState) -> list[str]:
247
+ """Detect recent verification probes that prove a build is not actually ready.
248
+
249
+ This stays generic: it does not know about a specific app domain. It only
250
+ treats explicit runtime/probe evidence such as empty API responses, zero
251
+ loaded records, or tracebacks as blockers for creation-task completion.
252
+ """
253
+ if state.goal_state.goal_type != "build_app":
254
+ return []
255
+ unresolved: list[str] = []
256
+ for command, result in state.bash_history[-10:]:
257
+ cmd = str(command).lower()
258
+ text = str(result).strip()
259
+ lower = text.lower()
260
+ compact = re.sub(r"\s+", "", text)
261
+ if "traceback " in lower or "exception" in lower:
262
+ unresolved.append("runtime-error")
263
+ continue
264
+ if ("curl" in cmd or "/api/" in cmd) and compact in {"[]", "{}", "null", "\"\"", ""}:
265
+ unresolved.append("empty-api-response")
266
+ continue
267
+ if re.search(r"\b(loaded|found|returned|has)\s+0\b", lower):
268
+ unresolved.append("zero-runtime-data")
269
+ continue
270
+ if re.search(r"\b0\s+(items|records|rows|entries|levels|results)\b", lower):
271
+ unresolved.append("zero-runtime-data")
272
+ continue
273
+ return list(dict.fromkeys(unresolved))
274
+
275
+
276
+ _BUILD_TEST_TOKENS = (
277
+ "tsc", "vitest", "jest", "pytest", "mypy", "ruff", "eslint", "cargo",
278
+ "go build", "go test", "make ", "make\t", "gradle", "mvn", "compile",
279
+ "npm run build", "yarn build", "pnpm build", "npm test", "npm run test",
280
+ "vite build", "next build", "ng build", "go vet", "rustc", "javac",
281
+ )
282
+
283
+
284
+ def ran_build_or_test(bash_history: list[tuple[str, str]]) -> bool:
285
+ """True if any command this turn was a build / typecheck / test / lint —
286
+ evidence the model actually compiled its code (vs just writing files).
287
+ Deliberately narrow: a dev-server launch (`npm run dev`) is NOT a build."""
288
+ for command, _ in bash_history:
289
+ c = str(command).lower()
290
+ if any(tok in c for tok in _BUILD_TEST_TOKENS):
291
+ return True
292
+ return False
293
+
294
+
295
+ def _changed_code_files(paths: list[str]) -> bool:
296
+ code_suffixes = {
297
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
298
+ ".go", ".rs", ".java", ".kt", ".swift", ".c", ".cc", ".cpp",
299
+ ".h", ".hpp", ".cs", ".rb", ".php", ".lua", ".dart", ".scala",
300
+ ".sh", ".bash", ".zsh", ".ps1", ".sql", ".html", ".css",
301
+ ".vue", ".svelte", ".astro",
302
+ }
303
+ for path in paths:
304
+ suffix = Path(str(path)).suffix.lower()
305
+ if suffix in code_suffixes:
306
+ return True
307
+ return False
308
+
309
+
310
+ def _unsupported_final_claims(content: str, state: TurnState) -> list[str]:
311
+ """Reject concrete feature claims absent from changed-file evidence.
312
+
313
+ This is intentionally generic. It does not try to understand every
314
+ possible app domain; it catches high-signal phrases where claiming a
315
+ feature without code evidence is worse than staying quiet.
316
+ """
317
+ if state.goal_state.goal_type not in {"build_app", "edit_existing"}:
318
+ return []
319
+ corpus = _changed_file_corpus(state.changed_files)
320
+ if not corpus:
321
+ return []
322
+
323
+ checks: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = (
324
+ ("localStorage persistence", ("localstorage",), ("localstorage",)),
325
+ ("browser speech synthesis", ("speechsynthesis", "speech synthesis"), ("speechsynthesis", "speechsynthesisutterance")),
326
+ ("text-to-speech", ("text-to-speech", "tts"), ("speechsynthesis", "speechsynthesisutterance", "gtts", "pyttsx3")),
327
+ ("true/false quiz", ("true/false", "true false"), ("true/false", "true false", "boolean", "is_correct === true")),
328
+ ("fill-in-the-blank quiz", ("fill in the blank", "fill-in-the-blank"), ("fill in the blank", "fill-in-the-blank", "blank")),
329
+ ("OAuth/authentication", ("oauth", "authentication", "login"), ("oauth", "auth", "login", "session")),
330
+ ("database", ("database", "sqlite", "postgres", "mysql"), ("sqlite", "postgres", "mysql", "sqlalchemy", "database")),
331
+ )
332
+ unsupported: list[str] = []
333
+ text = content.lower()
334
+ for label, claim_terms, evidence_terms in checks:
335
+ if any(term in text for term in claim_terms) and not any(term in corpus for term in evidence_terms):
336
+ unsupported.append(label)
337
+
338
+ count_claims = re.findall(r"\b(\d+)\s*\+\s+(?:words|items|questions|tests|examples)\b", text)
339
+ if count_claims:
340
+ numbers = [int(n) for n in re.findall(r"\b\d+\b", corpus)]
341
+ max_number = max(numbers) if numbers else 0
342
+ for claim in count_claims:
343
+ if int(claim) > max_number:
344
+ unsupported.append(f"{claim}+ item count")
345
+ return unsupported[:4]
346
+
347
+
348
+ def _requested_feature_gaps(state: TurnState) -> list[str]:
349
+ """High-confidence request/evidence checks for build completion.
350
+
351
+ This is deliberately generic. It only gates on common capability words
352
+ where code evidence is straightforward, avoiding domain-specific rules.
353
+ The model can still choose any stack/language; it just cannot declare
354
+ completion after verification if obvious requested capabilities are
355
+ absent from the files it changed.
356
+ """
357
+ if state.goal_state.goal_type != "build_app":
358
+ return []
359
+ request = (state.user_text or "").lower()
360
+ corpus = _changed_file_corpus(state.changed_files)
361
+ if not request or not corpus:
362
+ return []
363
+ checks: tuple[tuple[str, tuple[str, ...], tuple[str, ...]], ...] = (
364
+ (
365
+ "audio/listening",
366
+ ("audio", "sound", "listen", "pronunciation", "tts", "speech", "voice"),
367
+ ("audio", "speechsynthesis", "speechsynthesisutterance", "tts", "voice", "utterance", "pyttsx3", "gtts", "howler"),
368
+ ),
369
+ (
370
+ "quiz/testing",
371
+ ("quiz", "exam", "multiple choice", "question", "test me"),
372
+ ("quiz", "question", "answer", "score", "choice", "exam", "correct"),
373
+ ),
374
+ (
375
+ "persistence/progress",
376
+ ("save", "saved", "persist", "progress", "remember"),
377
+ ("localstorage", "sessionstorage", "sqlite", "database", "json.dump", "writefile", "save", "progress"),
378
+ ),
379
+ (
380
+ "authentication",
381
+ ("login", "sign in", "signup", "sign up", "auth", "oauth"),
382
+ ("login", "signin", "signup", "auth", "oauth", "session", "jwt", "password"),
383
+ ),
384
+ (
385
+ "search/filtering",
386
+ ("search", "filter", "sort"),
387
+ ("search", "filter", "sort", "query"),
388
+ ),
389
+ )
390
+ missing: list[str] = []
391
+ for label, request_terms, evidence_terms in checks:
392
+ if any(term in request for term in request_terms) and not any(term in corpus for term in evidence_terms):
393
+ missing.append(label)
394
+ return missing
395
+
396
+
397
+ def _changed_file_corpus(paths: list[str], *, max_chars: int = 160_000) -> str:
398
+ chunks: list[str] = []
399
+ total = 0
400
+ for raw in paths[:40]:
401
+ path = Path(str(raw))
402
+ if not path.exists() or not path.is_file():
403
+ continue
404
+ if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip"}:
405
+ continue
406
+ try:
407
+ text = path.read_text(errors="replace")
408
+ except Exception:
409
+ continue
410
+ chunks.append(text[: max(0, max_chars - total)])
411
+ total += len(chunks[-1])
412
+ if total >= max_chars:
413
+ break
414
+ return "\n".join(chunks).lower()