ai-code-engineer 0.1.0__py3-none-any.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.
- ai_code_engineer/__init__.py +2 -0
- ai_code_engineer/catalog.py +143 -0
- ai_code_engineer/chat.py +181 -0
- ai_code_engineer/cli.py +384 -0
- ai_code_engineer/config.py +405 -0
- ai_code_engineer/engine.py +1282 -0
- ai_code_engineer/errors.py +27 -0
- ai_code_engineer/git_integration.py +443 -0
- ai_code_engineer/gui.py +2646 -0
- ai_code_engineer/host.py +81 -0
- ai_code_engineer/ignore.py +269 -0
- ai_code_engineer/intent.py +222 -0
- ai_code_engineer/labels.py +871 -0
- ai_code_engineer/memory.py +91 -0
- ai_code_engineer/modes.py +156 -0
- ai_code_engineer/overrides.py +540 -0
- ai_code_engineer/planbook.py +192 -0
- ai_code_engineer/providers.py +404 -0
- ai_code_engineer/redaction.py +54 -0
- ai_code_engineer/repair.py +564 -0
- ai_code_engineer/report.py +352 -0
- ai_code_engineer/runner.py +854 -0
- ai_code_engineer/setup.py +386 -0
- ai_code_engineer/symbols.py +1286 -0
- ai_code_engineer/verification.py +218 -0
- ai_code_engineer/webapp/__init__.py +1 -0
- ai_code_engineer/webapp/__main__.py +45 -0
- ai_code_engineer/webapp/contract.py +36 -0
- ai_code_engineer/webapp/controller.py +3556 -0
- ai_code_engineer/webapp/fake.py +1141 -0
- ai_code_engineer/webapp/launch.py +108 -0
- ai_code_engineer/webapp/server.py +349 -0
- ai_code_engineer/webapp/static/app.css +780 -0
- ai_code_engineer/webapp/static/app.js +2118 -0
- ai_code_engineer/webapp/static/boot.js +19 -0
- ai_code_engineer/webapp/static/index.html +89 -0
- ai_code_engineer/webapp/static/tokens.css +173 -0
- ai_code_engineer/workspace.py +385 -0
- ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
- ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
- ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
- ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Conservative file access. Not a replacement for OS sandboxing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from . import ignore, runner, symbols
|
|
12
|
+
from .errors import MissingFileError, PolicyError
|
|
13
|
+
|
|
14
|
+
MAX_FILE_BYTES = 128 * 1024
|
|
15
|
+
TEXT_SUFFIXES = {".py", ".java", ".kt", ".kts", ".xml", ".gradle", ".md", ".txt",
|
|
16
|
+
".json", ".yaml", ".yml", ".toml", ".properties", ".sql", ".js",
|
|
17
|
+
".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".go", ".rs", ".css", ".html",
|
|
18
|
+
".sh", ".bat", ".cmd"}
|
|
19
|
+
# The suffix gate is a text-or-binary check, and a few real text files have no suffix at all.
|
|
20
|
+
# Refusing them does not protect anything: it stops an agent that scaffolds projects from
|
|
21
|
+
# writing the one file that keeps its own `target/` and `.venv/` out of the first commit.
|
|
22
|
+
# Credentials stay refused by the gates that run before this one (`ignore.refused_dir`: protected paths,
|
|
23
|
+
# credential names, NTFS aliases), and none of these names is in AGENT_RULE_FILES, so none is a hook an
|
|
24
|
+
# agent could leave behind.
|
|
25
|
+
TEXT_NAMES = {".gitignore", ".gitattributes", ".editorconfig", ".dockerignore",
|
|
26
|
+
"dockerfile", "makefile", "jenkinsfile", "license", "readme"}
|
|
27
|
+
# Instruction, rules and auto-launch config for the agents and editors that may open this
|
|
28
|
+
# folder next. A model that writes one gains a hook that outlives the session — read back
|
|
29
|
+
# as instructions, or executed on open — so none of them is ever writable. They are readable
|
|
30
|
+
# only where the suffix itself is one of the text kinds above: `mcp.json` yes, `cursorrules`
|
|
31
|
+
# not, and that asymmetry is the suffix gate doing its job, not this list's promise.
|
|
32
|
+
AGENT_RULE_FILES = {"agents.md", "agent.md", "skill.md", "skills.md", "qoder.md",
|
|
33
|
+
"claude.md", "cursor.md", "cursorrules", ".cursorrules",
|
|
34
|
+
"windsurfrules", ".windsurfrules", "gemini.md", "devin.md",
|
|
35
|
+
"aider.conf.yml", "codex.md",
|
|
36
|
+
"copilot-instructions.md", "mcp.json", ".mcp.json", "opencode.json"}
|
|
37
|
+
# Directories whose contents tools act on: .vscode/tasks.json runs programs,
|
|
38
|
+
# .mvn/jvm.config hands JVM options (including -javaagent) to every later build. Every one of
|
|
39
|
+
# these names begins with a dot, because that is what makes it a tool's own folder rather than
|
|
40
|
+
# a package somebody wrote — see ROOT_POLICY_DIRS for the plain-spelled half of the rule.
|
|
41
|
+
AGENT_CONFIG_DIRS = {".github", ".gitlab", ".circleci", ".vscode", ".zed", ".trae",
|
|
42
|
+
".qoder", ".claude", ".cursor", ".gemini", ".devin", ".aider",
|
|
43
|
+
".factory", ".continue", ".junie", ".kiro", ".opencode", ".agent",
|
|
44
|
+
".githooks", ".mvn"}
|
|
45
|
+
# An agent's own policy and skill folders are spelled plainly and sit at the top of the
|
|
46
|
+
# workspace. Anywhere deeper they are somebody's package: `com.acme.policies` is source code,
|
|
47
|
+
# and refusing to write it would refuse the task.
|
|
48
|
+
ROOT_POLICY_DIRS = {"policies", "skills"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def digest(raw: bytes) -> str:
|
|
52
|
+
return hashlib.sha256(raw).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Parsed declarations for files that have not changed since the last map, keyed by the
|
|
56
|
+
# resolved root so two projects never share rows. Advisory only: the map is context for a
|
|
57
|
+
# model, and the bytes on disk stay the authority for every hash check.
|
|
58
|
+
INDEX_CACHE: dict[tuple[str, str], tuple[int, int, dict]] = {}
|
|
59
|
+
INDEX_CACHE_LIMIT = symbols.MAX_FILES
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def clear_index_cache(root: Path | str | None = None) -> int:
|
|
63
|
+
"""Forget cached rows, for one project or for all of them. Returns how many went."""
|
|
64
|
+
key = str(Path(root).resolve()) if root is not None else None
|
|
65
|
+
doomed = [item for item in INDEX_CACHE if key is None or item[0] == key]
|
|
66
|
+
for item in doomed:
|
|
67
|
+
del INDEX_CACHE[item]
|
|
68
|
+
return len(doomed)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_link(path: Path) -> bool:
|
|
72
|
+
info = path.lstat()
|
|
73
|
+
return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & 0x400)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def ensure_project_dir(path: Path | str) -> Path:
|
|
77
|
+
"""Create the empty folder the user just chose for a new project.
|
|
78
|
+
|
|
79
|
+
Only a missing or empty folder qualifies, so this can never become a way to
|
|
80
|
+
start writing into an existing project without looking at it first.
|
|
81
|
+
"""
|
|
82
|
+
candidate = Path(path)
|
|
83
|
+
if not candidate.is_absolute():
|
|
84
|
+
raise PolicyError("Choose an absolute project folder.")
|
|
85
|
+
if candidate.parent == candidate or len(str(candidate).rstrip("\\/")) <= 3:
|
|
86
|
+
raise PolicyError("Choose a folder inside a drive, not the drive itself.")
|
|
87
|
+
if candidate.exists():
|
|
88
|
+
if not candidate.is_dir():
|
|
89
|
+
raise PolicyError("That path is a file. Choose a folder instead.")
|
|
90
|
+
if any(candidate.iterdir()):
|
|
91
|
+
raise PolicyError("That folder already contains files. Use Browse… to open it as an existing project.")
|
|
92
|
+
try:
|
|
93
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
raise PolicyError("Could not create the project folder: " + str(exc)[:160]) from None
|
|
96
|
+
return candidate.resolve()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Workspace:
|
|
100
|
+
def __init__(self, root: Path):
|
|
101
|
+
self.root = root.resolve(strict=True)
|
|
102
|
+
if not self.root.is_dir():
|
|
103
|
+
raise PolicyError("Workspace must be a directory.")
|
|
104
|
+
self._cache_key = str(self.root)
|
|
105
|
+
# What the last walk stopped at, counted by `files()` and spoken by `map_note()`. Zero until a
|
|
106
|
+
# walk has run, which is the honest answer to "what did you not show me".
|
|
107
|
+
self.skipped_generated = 0
|
|
108
|
+
self.skipped_ignored = 0
|
|
109
|
+
|
|
110
|
+
def path(self, relative: str, *, writable: bool = False) -> Path:
|
|
111
|
+
if not isinstance(relative, str) or not relative or len(relative) > 400:
|
|
112
|
+
raise PolicyError("Invalid workspace path.")
|
|
113
|
+
if "\\" in relative or ":" in relative or "\x00" in relative:
|
|
114
|
+
raise PolicyError("Use a relative path with forward slashes.")
|
|
115
|
+
parts = relative.split("/")
|
|
116
|
+
if PurePosixPath(relative).is_absolute() or any(
|
|
117
|
+
p in {"", ".", ".."} or p.rstrip(" .") != p for p in parts
|
|
118
|
+
):
|
|
119
|
+
raise PolicyError("Absolute paths, traversal and ambiguous paths are blocked.")
|
|
120
|
+
if any(ignore.refused_dir(p) for p in parts):
|
|
121
|
+
raise PolicyError("Protected path.")
|
|
122
|
+
if any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", p) for p in parts):
|
|
123
|
+
raise PolicyError("Device paths are blocked.")
|
|
124
|
+
current = self.root
|
|
125
|
+
for part in parts:
|
|
126
|
+
current = current / part
|
|
127
|
+
if current.exists() or current.is_symlink():
|
|
128
|
+
if is_link(current):
|
|
129
|
+
raise PolicyError("Symlinks and reparse points are blocked.")
|
|
130
|
+
resolved = current.resolve()
|
|
131
|
+
if not resolved.is_relative_to(self.root):
|
|
132
|
+
raise PolicyError("Path escapes workspace.")
|
|
133
|
+
# The name the model typed and the name NTFS finally opens are not always the
|
|
134
|
+
# same (case, aliases, mount points), so the resolved form is checked too.
|
|
135
|
+
if any(ignore.refused_dir(p) for p in resolved.relative_to(self.root).parts):
|
|
136
|
+
raise PolicyError("Protected path.")
|
|
137
|
+
if (current.suffix.lower() not in TEXT_SUFFIXES
|
|
138
|
+
and current.name.casefold() not in TEXT_NAMES):
|
|
139
|
+
# The name goes into the sentence because the model wrote it seconds earlier and is
|
|
140
|
+
# about to be told only that something was refused. Without it, a small model repeats
|
|
141
|
+
# the same action — measured twice in the ecommerce run, where two 5-minute turns
|
|
142
|
+
# ended BLOCKED on a path this gate had rejected by spelling.
|
|
143
|
+
raise PolicyError("Only supported text files are accessible: " + relative)
|
|
144
|
+
if writable and (current.name.casefold() in AGENT_RULE_FILES or
|
|
145
|
+
any(p.casefold() in AGENT_CONFIG_DIRS for p in parts) or
|
|
146
|
+
parts[0].casefold() in ROOT_POLICY_DIRS):
|
|
147
|
+
raise PolicyError("Instructions and policy files are read-only.")
|
|
148
|
+
return current
|
|
149
|
+
|
|
150
|
+
def read(self, relative: str) -> dict:
|
|
151
|
+
path = self.path(relative)
|
|
152
|
+
try:
|
|
153
|
+
info = path.stat()
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
raise MissingFileError("File does not exist: " + relative) from None
|
|
156
|
+
if not stat.S_ISREG(info.st_mode):
|
|
157
|
+
raise PolicyError("Path is not a regular file: " + relative)
|
|
158
|
+
if info.st_size > MAX_FILE_BYTES:
|
|
159
|
+
raise PolicyError("File exceeds the 128 KiB read limit: " + relative)
|
|
160
|
+
raw = path.read_bytes()
|
|
161
|
+
if len(raw) > MAX_FILE_BYTES or b"\x00" in raw:
|
|
162
|
+
raise PolicyError("File is too large or binary.")
|
|
163
|
+
try:
|
|
164
|
+
content = raw.decode("utf-8")
|
|
165
|
+
except UnicodeDecodeError as exc:
|
|
166
|
+
# The offset is the point: "not UTF-8" alone is not something anyone can act on, and a
|
|
167
|
+
# cp1252 `messages_ar.properties` is one byte per line away from a fix. There is no
|
|
168
|
+
# fallback decode here deliberately — this content can come back as a write, and
|
|
169
|
+
# re-encoding a file nobody chose to convert rewrites every non-ASCII byte in it.
|
|
170
|
+
raise PolicyError(
|
|
171
|
+
f"File '{relative}' is not valid UTF-8 (invalid byte 0x{raw[exc.start]:02x} at "
|
|
172
|
+
f"offset {exc.start}). Convert it to UTF-8, or keep it out of the task.") from exc
|
|
173
|
+
return {"path": relative, "sha256": digest(raw), "content": content}
|
|
174
|
+
|
|
175
|
+
def files(self, limit: int = 2000) -> list[str]:
|
|
176
|
+
"""Every path this tool is willing to look at, in one walk.
|
|
177
|
+
|
|
178
|
+
Three things decide together here, and they must keep deciding together: the directory names
|
|
179
|
+
`ignore` owns, the repository's own `.gitignore` (including nested ones, for the directories the
|
|
180
|
+
walk actually reaches), and the policy gate in `path()`. The map, the search and the composer's
|
|
181
|
+
context count all read this list, so a file that is invisible to one is invisible to all three —
|
|
182
|
+
which is the only version of that story a person can live with.
|
|
183
|
+
|
|
184
|
+
What was hidden as noise is counted and reported by `repo_map()`, because "where did my file
|
|
185
|
+
go" is a question a silent filter makes unanswerable. Credentials and policy paths stay
|
|
186
|
+
uncounted and unmentioned: they are refusals, not tidiness.
|
|
187
|
+
"""
|
|
188
|
+
found = []
|
|
189
|
+
self.skipped_generated = 0
|
|
190
|
+
self.skipped_ignored = 0
|
|
191
|
+
root_rules = ignore.Rules.load(self.root)
|
|
192
|
+
in_force = {"": root_rules} # rules for a directory, keyed by its own relative path
|
|
193
|
+
for base, dirs, names in os.walk(self.root, followlinks=False):
|
|
194
|
+
walked = Path(base).relative_to(self.root).as_posix()
|
|
195
|
+
own = "" if walked == "." else walked
|
|
196
|
+
rules = in_force.get(own.rsplit("/", 1)[0] if "/" in own else "", root_rules)
|
|
197
|
+
prefix = own + "/" if own else ""
|
|
198
|
+
if ".gitignore" in names and own:
|
|
199
|
+
try:
|
|
200
|
+
rules = rules.child((Path(base) / ".gitignore").read_text(
|
|
201
|
+
encoding="utf-8", errors="replace"))
|
|
202
|
+
except OSError:
|
|
203
|
+
pass
|
|
204
|
+
in_force[own] = rules
|
|
205
|
+
# A link is dropped here rather than in `ignore`: recognising one needs this root, and the
|
|
206
|
+
# decision "do not follow it out of the workspace" belongs to the gate, not to tidiness.
|
|
207
|
+
kept, hidden, skipped = ignore.walk_prune(
|
|
208
|
+
[name for name in dirs if not is_link(Path(base) / name)], rules)
|
|
209
|
+
self.skipped_generated += hidden
|
|
210
|
+
self.skipped_ignored += skipped
|
|
211
|
+
dirs[:] = kept
|
|
212
|
+
for name in sorted(names):
|
|
213
|
+
relative = prefix + name
|
|
214
|
+
if rules and rules.ignores(relative, False):
|
|
215
|
+
self.skipped_ignored += 1
|
|
216
|
+
continue
|
|
217
|
+
if ignore.generated_file(name):
|
|
218
|
+
self.skipped_generated += 1
|
|
219
|
+
continue
|
|
220
|
+
try:
|
|
221
|
+
self.path(relative)
|
|
222
|
+
except PolicyError:
|
|
223
|
+
continue
|
|
224
|
+
found.append(relative)
|
|
225
|
+
if len(found) >= limit:
|
|
226
|
+
return found
|
|
227
|
+
return found
|
|
228
|
+
|
|
229
|
+
def search(self, query: str) -> list[dict]:
|
|
230
|
+
if not isinstance(query, str) or not 1 <= len(query) <= 200:
|
|
231
|
+
raise PolicyError("Search query must contain 1–200 characters.")
|
|
232
|
+
results = []
|
|
233
|
+
for name in self.files():
|
|
234
|
+
try:
|
|
235
|
+
content = self.read(name)["content"]
|
|
236
|
+
except PolicyError:
|
|
237
|
+
continue
|
|
238
|
+
for line, value in enumerate(content.splitlines(), 1):
|
|
239
|
+
if query.casefold() in value.casefold():
|
|
240
|
+
results.append({"path": name, "line": line, "text": value[:300]})
|
|
241
|
+
if len(results) >= 40:
|
|
242
|
+
return results
|
|
243
|
+
return results
|
|
244
|
+
|
|
245
|
+
def repo_map(self) -> str:
|
|
246
|
+
files, rows = self.index()
|
|
247
|
+
# A folder with several builds in it gets its map spread across them: the budget is the same
|
|
248
|
+
# 12 000 characters either way, and in a reactor alphabetical order spends all of it on the
|
|
249
|
+
# first three modules.
|
|
250
|
+
return symbols.render(rows, files, spread_files=len(runner.projects(self.root)) > 1,
|
|
251
|
+
note=self.map_note())
|
|
252
|
+
|
|
253
|
+
def index(self) -> tuple[list[str], list[dict]]:
|
|
254
|
+
"""The visible files and the parsed declarations for the code among them.
|
|
255
|
+
|
|
256
|
+
Split out of `repo_map()` because a symbol query needs the rows without the prose: rendering
|
|
257
|
+
costs 12 000 characters of text, and `find_symbol` wants the structures behind them. The cache
|
|
258
|
+
in `_index_row` makes the second call in a turn nearly free, which is the whole reason both
|
|
259
|
+
surfaces can afford to ask.
|
|
260
|
+
"""
|
|
261
|
+
files = self.files(limit=symbols.MAX_FILES)
|
|
262
|
+
rows = []
|
|
263
|
+
indexed = set()
|
|
264
|
+
for name in files:
|
|
265
|
+
# Code gets its declarations; the handful of configuration files that describe the project
|
|
266
|
+
# get their facts. Everything else stays a line in the list, as it always was.
|
|
267
|
+
if not (symbols.indexable(name) or symbols.noteworthy(name)):
|
|
268
|
+
continue
|
|
269
|
+
row = self._index_row(name)
|
|
270
|
+
if row is not None:
|
|
271
|
+
indexed.add(name)
|
|
272
|
+
rows.append(row)
|
|
273
|
+
for stale in [key for key in list(INDEX_CACHE)
|
|
274
|
+
if key[0] == self._cache_key and key[1] not in indexed]:
|
|
275
|
+
del INDEX_CACHE[stale]
|
|
276
|
+
return files, rows
|
|
277
|
+
|
|
278
|
+
def sources(self, rows: list[dict]) -> list[tuple[str, str]]:
|
|
279
|
+
"""The text of every indexed file — code, and only code.
|
|
280
|
+
|
|
281
|
+
A reference search over the whole workspace would answer `register` with a Spring key in
|
|
282
|
+
`application.yml` and spend the hit budget on configuration; `search_code` is the verb for text,
|
|
283
|
+
this one is for names. A file the gates refuse (too large, not UTF-8) is left out rather than
|
|
284
|
+
failing the query, because a partial answer about references is still the answer being asked.
|
|
285
|
+
"""
|
|
286
|
+
out = []
|
|
287
|
+
for row in rows:
|
|
288
|
+
if row.get("kind") == "config":
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
out.append((row["path"], self.read(row["path"])["content"]))
|
|
292
|
+
except PolicyError:
|
|
293
|
+
continue
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
def map_note(self) -> str:
|
|
297
|
+
"""One line on what the walk left out, so an empty result is not mistaken for an empty project.
|
|
298
|
+
|
|
299
|
+
The counts come from the same walk that produced the list, so they cannot drift from it; a model
|
|
300
|
+
that knows 400 generated files were skipped will read a path by name instead of concluding the
|
|
301
|
+
code it was asked about does not exist.
|
|
302
|
+
"""
|
|
303
|
+
hidden = []
|
|
304
|
+
if self.skipped_generated:
|
|
305
|
+
hidden.append(str(self.skipped_generated) + " built or generated")
|
|
306
|
+
if self.skipped_ignored:
|
|
307
|
+
hidden.append(str(self.skipped_ignored) + " listed in .gitignore")
|
|
308
|
+
if not hidden:
|
|
309
|
+
return ""
|
|
310
|
+
return ("(not shown: " + " and ".join(hidden) + " path(s) the walk stopped at. "
|
|
311
|
+
"Name a path and read it if you need one.)")
|
|
312
|
+
|
|
313
|
+
def _index_row(self, relative: str) -> dict | None:
|
|
314
|
+
"""This file's declarations, reusing the last parse when the file still fits.
|
|
315
|
+
|
|
316
|
+
mtime and size are a heuristic: an equal-size edit inside one clock tick can be
|
|
317
|
+
missed. A stale row only makes the map slightly out of date — every write is still
|
|
318
|
+
checked against the hash of the bytes on disk.
|
|
319
|
+
"""
|
|
320
|
+
key = (self._cache_key, relative)
|
|
321
|
+
try:
|
|
322
|
+
info = (self.root / relative).stat()
|
|
323
|
+
except OSError:
|
|
324
|
+
return None
|
|
325
|
+
if len(INDEX_CACHE) >= INDEX_CACHE_LIMIT:
|
|
326
|
+
INDEX_CACHE.clear() # bounded convenience, not an LRU
|
|
327
|
+
cached = INDEX_CACHE.get(key)
|
|
328
|
+
if cached and cached[:2] == (info.st_mtime_ns, info.st_size):
|
|
329
|
+
return cached[2]
|
|
330
|
+
try:
|
|
331
|
+
content = self.read(relative)["content"]
|
|
332
|
+
except PolicyError:
|
|
333
|
+
return None
|
|
334
|
+
row = (symbols.parse(relative, content) if symbols.indexable(relative)
|
|
335
|
+
else symbols.config_row(relative, content))
|
|
336
|
+
if row is not None:
|
|
337
|
+
INDEX_CACHE[key] = (info.st_mtime_ns, info.st_size, row)
|
|
338
|
+
return row
|
|
339
|
+
|
|
340
|
+
def write(self, relative: str, content: str, expected: str | None) -> str:
|
|
341
|
+
path = self.path(relative, writable=True)
|
|
342
|
+
if not isinstance(content, str) or len(content.encode("utf-8")) > MAX_FILE_BYTES:
|
|
343
|
+
raise PolicyError("Invalid or oversized replacement.")
|
|
344
|
+
actual = self.read(relative)["sha256"] if path.exists() else None
|
|
345
|
+
if actual != expected:
|
|
346
|
+
raise PolicyError("File changed since review; regenerate the proposal.")
|
|
347
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
path = self.path(relative, writable=True)
|
|
349
|
+
raw = content.encode("utf-8")
|
|
350
|
+
fd, tmp = tempfile.mkstemp(prefix=".agent-write-", dir=path.parent)
|
|
351
|
+
try:
|
|
352
|
+
with os.fdopen(fd, "wb") as handle:
|
|
353
|
+
handle.write(raw)
|
|
354
|
+
handle.flush()
|
|
355
|
+
os.fsync(handle.fileno())
|
|
356
|
+
if path.exists():
|
|
357
|
+
os.chmod(tmp, path.stat().st_mode)
|
|
358
|
+
# Recheck before replacement; concurrent hostile filesystem access still
|
|
359
|
+
# requires OS isolation, and is outside this developer-local MVP.
|
|
360
|
+
self.path(relative, writable=True)
|
|
361
|
+
latest = self.read(relative)["sha256"] if path.exists() else None
|
|
362
|
+
if latest != expected:
|
|
363
|
+
raise PolicyError("Concurrent edit detected; replacement cancelled.")
|
|
364
|
+
os.replace(tmp, path)
|
|
365
|
+
finally:
|
|
366
|
+
if os.path.exists(tmp):
|
|
367
|
+
os.unlink(tmp)
|
|
368
|
+
return digest(raw)
|
|
369
|
+
|
|
370
|
+
def remove(self, relative: str, expected: str | None) -> None:
|
|
371
|
+
"""Delete one file a proposal named, under the same guards `write` runs.
|
|
372
|
+
|
|
373
|
+
The hash check is the reason this is a workspace verb and not an `unlink()` call: removing a
|
|
374
|
+
file that changed since review is the same race a write refuses, and the bytes nobody looked
|
|
375
|
+
at would be gone either way.
|
|
376
|
+
"""
|
|
377
|
+
path = self.path(relative, writable=True)
|
|
378
|
+
if not path.exists():
|
|
379
|
+
raise MissingFileError("File does not exist: " + relative)
|
|
380
|
+
if self.read(relative)["sha256"] != expected:
|
|
381
|
+
raise PolicyError("File changed since review; regenerate the proposal.")
|
|
382
|
+
self.path(relative, writable=True) # re-run the guards after the race window
|
|
383
|
+
if self.read(relative)["sha256"] != expected:
|
|
384
|
+
raise PolicyError("Concurrent edit detected; removal cancelled.")
|
|
385
|
+
path.unlink()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
ai_code_engineer/__init__.py,sha256=FmPQKfgbe4hLH7k7yY_tFWi7f4zvmcVfQTqVdHgmoKQ,99
|
|
2
|
+
ai_code_engineer/catalog.py,sha256=uXyNmTHoBOUUzpnEcBdBkq9P_nDCHAvIeiHlStwM0rI,7007
|
|
3
|
+
ai_code_engineer/chat.py,sha256=f-_izcLRD6jk-GcIDaJMSQtrHLQI6zVB0gb7mIbVQD4,8652
|
|
4
|
+
ai_code_engineer/cli.py,sha256=ueHIboIqf3Ts02oYaQIXx8jL4xAozoywt2rjCBVT_RM,20179
|
|
5
|
+
ai_code_engineer/config.py,sha256=m_5CAq7267zP-O9PnHJvY2dG1vDG6RnPDiQSpIXmpnI,20040
|
|
6
|
+
ai_code_engineer/engine.py,sha256=p2RkcCpitNGpp8aCRE-EkhCf5h_6KDvt2GztoBd1KEE,77632
|
|
7
|
+
ai_code_engineer/errors.py,sha256=qntzsZyp_h-Dg9AkInhFNvQXjUo1D4scHpOaQ2VvW1g,848
|
|
8
|
+
ai_code_engineer/git_integration.py,sha256=eqngEmDXaKe4C4NHYeZf0uYYc5m28QqSLfOCDxPfehc,20627
|
|
9
|
+
ai_code_engineer/gui.py,sha256=ujBF7_mU29IymYiFdUcQx8qJLl9AtyG0n6On6X3QllY,140349
|
|
10
|
+
ai_code_engineer/host.py,sha256=U57qftFUYKTMaM1hiGLduVMfGZ8kHE7jBc54XvnWh5I,4782
|
|
11
|
+
ai_code_engineer/ignore.py,sha256=J5xwsYUyKExmTbV0XANX7ITQQLi5tGG9c74rWryligo,13022
|
|
12
|
+
ai_code_engineer/intent.py,sha256=JopMi6FvbPA4JFOhk7TYbyxY_MfJr1wnRTflopZxbPU,12993
|
|
13
|
+
ai_code_engineer/labels.py,sha256=KQA0CEceUQ0LyegFoGIgPgIQ8j_Q-SI-8_sBVkzVSYE,55981
|
|
14
|
+
ai_code_engineer/memory.py,sha256=3meGtsyYI8ovKuWF034xy48lTjN_0sA6FlxA4arUXco,3566
|
|
15
|
+
ai_code_engineer/modes.py,sha256=L0Z3lCb3IqfveT5iNP4j0CKW8HIyxlSu62aY-X8T6qU,6542
|
|
16
|
+
ai_code_engineer/overrides.py,sha256=qxhhWrWwlzl_fogIKiMgGM7LFVvRtjwui2-OZUYA6sY,27181
|
|
17
|
+
ai_code_engineer/planbook.py,sha256=oaoVsKsZHqw8QFJr8G-7tv0KMLmhduHZh4czZnIHqIA,8033
|
|
18
|
+
ai_code_engineer/providers.py,sha256=ORJT1kK-f-GKkHwxHotzYqDLHjrYclOA0u1IyK2W7o8,21394
|
|
19
|
+
ai_code_engineer/redaction.py,sha256=0TqLPaLzArsqUqsoRFbz--zXukrVrLL6LD_7fWHqCSk,2921
|
|
20
|
+
ai_code_engineer/repair.py,sha256=oOUzWoc3JMpMNtZyEdsnyBdo1mFcpVLyBnhK06htqTc,29155
|
|
21
|
+
ai_code_engineer/report.py,sha256=B2tlgsx-5kmDmCaFyQZ6OND_QysK00uH2c-uE7dCaLg,19270
|
|
22
|
+
ai_code_engineer/runner.py,sha256=nz8HJABDSVDkNs_p7xBN32BElHb69Sf5PLqoDQN1Z-c,39886
|
|
23
|
+
ai_code_engineer/setup.py,sha256=zRWWncBE5VXHrPWCPG0v8RyAnz3xpu0yMxx8fEk5r1A,23339
|
|
24
|
+
ai_code_engineer/symbols.py,sha256=a9by4ZwQmgtmOiudcoh8eCTvy5W2xy18gwIbL_Y3N2g,62398
|
|
25
|
+
ai_code_engineer/verification.py,sha256=lIhhMytnDEuA7NMFGUFDEttGdDFtCbVH1jhBcLug5QU,9737
|
|
26
|
+
ai_code_engineer/workspace.py,sha256=m1IrW3YIK4LnlfBlRFoFXiMw3e4fRigN-LkVBO_NzHw,19967
|
|
27
|
+
ai_code_engineer/webapp/__init__.py,sha256=luFGr3RhX3birwA2LS0PEkwVVqW3g3Owrpj5P9ZKHWs,87
|
|
28
|
+
ai_code_engineer/webapp/__main__.py,sha256=cuMPrBiFuWqVW9eU_VOyrAraznFUG1-9LBwcCE6vhsU,1274
|
|
29
|
+
ai_code_engineer/webapp/contract.py,sha256=WR-y7bCGfSQIk0IttyOE4qKZdc6XMUWAdLtHW9zn6Is,1491
|
|
30
|
+
ai_code_engineer/webapp/controller.py,sha256=uIQAP45DEt8nDdnOVYNXRwJTF47JcKHDJIHQJyJEMlg,194631
|
|
31
|
+
ai_code_engineer/webapp/fake.py,sha256=uO3Gh6OoSWxFcCR58TaQo13QKE5eP8m13Dn-F6rDBGs,65968
|
|
32
|
+
ai_code_engineer/webapp/launch.py,sha256=WvQPzyOeFgMGY9147MKZ-bztTqskgny1TyHzPohc-_A,4390
|
|
33
|
+
ai_code_engineer/webapp/server.py,sha256=lU7u5hn9GBLNpOdwcOw7u3tnELXdb9TzsQdxRtNZXoA,16084
|
|
34
|
+
ai_code_engineer/webapp/static/app.css,sha256=OMYKaETWLLTU9FQu3DB5dehkr8fqoB07WLcKmK69PKQ,48160
|
|
35
|
+
ai_code_engineer/webapp/static/app.js,sha256=bbo4xywf1ArnyDRrUSKE7UdAwjzGOOkwZVeSJbgldZA,109470
|
|
36
|
+
ai_code_engineer/webapp/static/boot.js,sha256=OXGEPpzdi8TOMbw8359SHyaYz4eOd4Z9E7-Iyf3-EVk,1094
|
|
37
|
+
ai_code_engineer/webapp/static/index.html,sha256=ujPrWY_6FqIDaWA4qHGEdBBGAeRGP-RLfriqGw4uceM,3933
|
|
38
|
+
ai_code_engineer/webapp/static/tokens.css,sha256=mdue-1-pylxWjS5tPIF_cNDilxlE2QLzN5tFo84nS_w,10444
|
|
39
|
+
ai_code_engineer-0.1.0.dist-info/licenses/LICENSE,sha256=lmENOq1hWBTypWzFHalhT64yQ5eMBozcMuyUF_xByR0,1086
|
|
40
|
+
ai_code_engineer-0.1.0.dist-info/METADATA,sha256=7pCvYo9De17VTEP-cDxOhCjjqGtD1T4Wi5FtVFQlPx4,214
|
|
41
|
+
ai_code_engineer-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
42
|
+
ai_code_engineer-0.1.0.dist-info/entry_points.txt,sha256=UH2Bu59Ay1Su2zXfqc13aDetckIyUK5vh27pEiEMLW4,52
|
|
43
|
+
ai_code_engineer-0.1.0.dist-info/top_level.txt,sha256=tBDPUjzxAE0YjqHx8WsTVssvXM4lo7Nwzy4DeEXHdDU,17
|
|
44
|
+
ai_code_engineer-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohamed Saad (mohamedsaad208)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_code_engineer
|