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.
- localcode/__init__.py +11 -0
- localcode/__main__.py +5 -0
- localcode/_subproc_env.py +54 -0
- localcode/agent/__init__.py +177 -0
- localcode/agent/app_tasks.py +157 -0
- localcode/agent/constants.py +275 -0
- localcode/agent/context.py +1152 -0
- localcode/agent/goal.py +244 -0
- localcode/agent/helpers.py +1036 -0
- localcode/agent/hollow_module.py +230 -0
- localcode/agent/hooks.py +414 -0
- localcode/agent/loop.py +2855 -0
- localcode/agent/plateau.py +297 -0
- localcode/agent/project_check_gate.py +91 -0
- localcode/agent/prompt_context.py +264 -0
- localcode/agent/prompts.py +368 -0
- localcode/agent/reasoning_loop.py +79 -0
- localcode/agent/recovery.py +606 -0
- localcode/agent/recovery_controller.py +25 -0
- localcode/agent/round_policy.py +23 -0
- localcode/agent/sections.py +241 -0
- localcode/agent/state_machine.py +100 -0
- localcode/agent/streaming.py +324 -0
- localcode/agent/tool_execution.py +368 -0
- localcode/agent/tool_orchestration.py +54 -0
- localcode/agent/turn_finalization.py +135 -0
- localcode/app.py +1595 -0
- localcode/approvals.py +17 -0
- localcode/auto_compact.py +176 -0
- localcode/autonomy.py +103 -0
- localcode/bin/__init__.py +0 -0
- localcode/bin/llama-server +0 -0
- localcode/bin/localcode-ui +4 -0
- localcode/bootstrap.py +1191 -0
- localcode/cache.py +194 -0
- localcode/checkpoint.py +375 -0
- localcode/compact.py +73 -0
- localcode/compaction.py +341 -0
- localcode/composer.py +72 -0
- localcode/config.py +521 -0
- localcode/context.py +161 -0
- localcode/display.py +58 -0
- localcode/embeddings.py +480 -0
- localcode/entrypoint.py +536 -0
- localcode/errors.py +350 -0
- localcode/events.py +409 -0
- localcode/evidence.py +102 -0
- localcode/execution_policy.py +48 -0
- localcode/features.py +308 -0
- localcode/formatting.py +91 -0
- localcode/headless_json.py +265 -0
- localcode/health.py +288 -0
- localcode/hf_quants.py +203 -0
- localcode/history.py +594 -0
- localcode/indexer.py +144 -0
- localcode/injection_defense.py +204 -0
- localcode/launcher.py +373 -0
- localcode/logging_utils.py +28 -0
- localcode/lsp.py +153 -0
- localcode/mcp/__init__.py +88 -0
- localcode/mcp/_bridge.py +61 -0
- localcode/mcp/_config.py +33 -0
- localcode/mcp/_transports.py +128 -0
- localcode/mcp/client.py +395 -0
- localcode/memory_guard.py +291 -0
- localcode/model_config.py +288 -0
- localcode/model_delete.py +444 -0
- localcode/model_families.py +279 -0
- localcode/models.py +236 -0
- localcode/models_catalog.py +901 -0
- localcode/notebook.py +123 -0
- localcode/output.py +403 -0
- localcode/patching.py +65 -0
- localcode/paths.py +303 -0
- localcode/performance.py +623 -0
- localcode/permissions.py +79 -0
- localcode/permissions_v2.py +310 -0
- localcode/plans.py +198 -0
- localcode/process_registry.py +271 -0
- localcode/protocol/__init__.py +56 -0
- localcode/protocol/events.py +116 -0
- localcode/protocol/jsonl.py +153 -0
- localcode/protocol/outcomes.py +231 -0
- localcode/reasoning_capabilities.py +59 -0
- localcode/recommendations.py +87 -0
- localcode/recovery.py +134 -0
- localcode/redaction.py +164 -0
- localcode/runtime.py +2331 -0
- localcode/server_manager.py +1069 -0
- localcode/session.py +296 -0
- localcode/shell.py +83 -0
- localcode/skills/debug.md +21 -0
- localcode/skills/edit-verified.md +30 -0
- localcode/skills/explain.md +19 -0
- localcode/skills/finish-verified.md +11 -0
- localcode/skills/git-commit-safely.md +30 -0
- localcode/skills/locate.md +21 -0
- localcode/skills/plan-task.md +51 -0
- localcode/skills/review.md +22 -0
- localcode/skills/run-tests.md +24 -0
- localcode/skills.py +728 -0
- localcode/snapshots.py +195 -0
- localcode/sounds.py +50 -0
- localcode/telemetry.py +273 -0
- localcode/theme.py +137 -0
- localcode/thermal.py +160 -0
- localcode/thinking.py +114 -0
- localcode/tool_router.py +246 -0
- localcode/toolkit.py +1241 -0
- localcode/tools/__init__.py +400 -0
- localcode/tools/agent.py +259 -0
- localcode/tools/append_file.py +55 -0
- localcode/tools/background_process.py +115 -0
- localcode/tools/base.py +168 -0
- localcode/tools/bash.py +1160 -0
- localcode/tools/code_navigation.py +97 -0
- localcode/tools/edit_diff.py +80 -0
- localcode/tools/edit_file.py +477 -0
- localcode/tools/facts.py +149 -0
- localcode/tools/glob_tool.py +60 -0
- localcode/tools/grep.py +65 -0
- localcode/tools/inspect_symbol.py +266 -0
- localcode/tools/launch_app.py +93 -0
- localcode/tools/list_files.py +42 -0
- localcode/tools/multi_edit.py +167 -0
- localcode/tools/plan_mode.py +86 -0
- localcode/tools/project_check.py +685 -0
- localcode/tools/read_file.py +261 -0
- localcode/tools/read_state.py +223 -0
- localcode/tools/skill_tool.py +41 -0
- localcode/tools/syntax_check.py +216 -0
- localcode/tools/todo_write.py +200 -0
- localcode/tools/tool_call_repair.py +157 -0
- localcode/tools/web_fetch.py +182 -0
- localcode/tools/web_search.py +45 -0
- localcode/tools/write_file.py +281 -0
- localcode/tui/__init__.py +1 -0
- localcode/tui/app.py +485 -0
- localcode/tui/bridge.py +78 -0
- localcode/tui/clipboard_image.py +153 -0
- localcode/tui/paste_collapse.py +95 -0
- localcode/tui/screens/__init__.py +0 -0
- localcode/tui/screens/chat.py +5052 -0
- localcode/tui/screens/confirm.py +123 -0
- localcode/tui/screens/mode_picker.py +79 -0
- localcode/tui/screens/model_picker.py +962 -0
- localcode/tui/screens/setup.py +891 -0
- localcode/tui/styles/__init__.py +0 -0
- localcode/tui/styles/app.tcss +197 -0
- localcode/tui/widgets/__init__.py +0 -0
- localcode/tui/widgets/approval.py +2 -0
- localcode/tui/widgets/chat_log.py +2063 -0
- localcode/tui/widgets/messages/__init__.py +21 -0
- localcode/tui/widgets/messages/diff.py +287 -0
- localcode/tui/widgets/voice_visualizer.py +122 -0
- localcode/turn_diff.py +180 -0
- localcode/ui/FORK_COMMIT +1 -0
- localcode/ui/__init__.py +58 -0
- localcode/ui/launch.py +204 -0
- localcode/ui/picker_cli.py +165 -0
- localcode/ui/plugin/localcode.ts +626 -0
- localcode/ui/ports.py +42 -0
- localcode/ui/server_cmd.py +89 -0
- localcode/ui/supervisor.py +736 -0
- localcode/undo.py +121 -0
- localcode/verification.py +87 -0
- localcode/voice.py +948 -0
- localcode-0.4.0.dist-info/METADATA +160 -0
- localcode-0.4.0.dist-info/RECORD +173 -0
- localcode-0.4.0.dist-info/WHEEL +5 -0
- localcode-0.4.0.dist-info/entry_points.txt +3 -0
- localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
- 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}")
|
localcode/agent/hooks.py
ADDED
|
@@ -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()
|