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
ai_code_engineer/host.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The seam between the two windows: four verbs, and the one question they must ask alike.
|
|
2
|
+
|
|
3
|
+
Tk and the web window agree on nothing about presentation — one owns a `StringVar` and a
|
|
4
|
+
`messagebox`, the other a snapshot dict and an SSE channel. What they must not be allowed to
|
|
5
|
+
disagree about is *what a moment means*. Four verbs cover everything either window does to show
|
|
6
|
+
itself, and the review that named them counted the drift in the old tree: setting a status
|
|
7
|
+
(`self.status.set()` ×52 against `self.status =` ×72), adding a conversation row
|
|
8
|
+
(`chat_message` ×15 against `_add` ×19), asking (`messagebox.*` ×6 against `self.confirm` ×6) and
|
|
9
|
+
streaming (`events.put` ×7 against `_emit` ×13).
|
|
10
|
+
|
|
11
|
+
Those four are the contract below, and both windows now carry them under these names. Being honest
|
|
12
|
+
about how far that goes: the verbs are the sanctioned way out to the screen, and the count of raw
|
|
13
|
+
primitives is ratcheted in `tests/test_host.py` rather than reduced to zero. Converting 88 status
|
|
14
|
+
assignments in one pass is the rewrite this module exists to make unnecessary; what matters is that
|
|
15
|
+
the number cannot grow, that a *decision* — the Apply dialog, the write warning, a sentence either
|
|
16
|
+
window may have to say — goes through a verb, and that the names exist so a test can ask the other
|
|
17
|
+
window the same question.
|
|
18
|
+
|
|
19
|
+
The second half of this file is the part that had actually drifted, and the fix that made the
|
|
20
|
+
contract worth having. Both windows assemble their own Apply dialog and had diverged in a way no
|
|
21
|
+
test could see: the web window passed `repair.must_ask()`'s reason in and Tk dropped both the reason
|
|
22
|
+
and the `warning` field the builder returned, so the same proposal warned about different things
|
|
23
|
+
depending on which window happened to be open. Tk also called `must_ask(session)` without the
|
|
24
|
+
prior-task half of the rule, leaving that branch unreachable there — and it has no Auto-Apply, so
|
|
25
|
+
that difference is a feature gap, not drift, and is recorded as one.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Host:
|
|
31
|
+
"""What a window owes the code that runs behind it.
|
|
32
|
+
|
|
33
|
+
Deliberately not an ABC: `gui.AgentApp` already inherits from Tk, and forcing both
|
|
34
|
+
presentation layers under one base is the rewrite this module exists to make unnecessary.
|
|
35
|
+
The names are the contract, and `tests/test_host.py` holds both windows to them.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def say(self, text: str) -> None:
|
|
39
|
+
"""Put one sentence on the window's status surface."""
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def line(self, role: str, author: str, text: str) -> None:
|
|
43
|
+
"""Add one row to the conversation."""
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
|
|
46
|
+
def ask(self, title: str, message: str, warning: str = "", ok_label: str = "Continue") -> bool:
|
|
47
|
+
"""Ask the user something and block for the answer. `warning` is the part that must never
|
|
48
|
+
be dropped by the window that finds it inconvenient."""
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def stream(self, event: dict) -> None:
|
|
52
|
+
"""Forward one progress event to whatever is watching."""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def apply_prompt(session: dict, notice: str = "", reason: str = "", again: str = "") -> dict:
|
|
57
|
+
"""The Apply dialog as data: {title, message, warning, ok_label}.
|
|
58
|
+
|
|
59
|
+
`notice` is `repair.removal_notice()` — the files this proposal empties or deletes — and
|
|
60
|
+
`reason` is `repair.must_ask()`, the one case where writing needs saying twice because the
|
|
61
|
+
proposal would erase work. They overlap: an emptying proposal is also a must-ask one, so the
|
|
62
|
+
reason is appended only where it would otherwise be the only thing the user reads.
|
|
63
|
+
|
|
64
|
+
`again` is the repair loop's notice, which names a command that executes the project's own
|
|
65
|
+
code and so belongs in the same breath as the write it follows.
|
|
66
|
+
"""
|
|
67
|
+
changes = (session or {}).get("changes", []) or []
|
|
68
|
+
root = (session or {}).get("root", "")
|
|
69
|
+
body = notice.rstrip("\n")
|
|
70
|
+
# A dialog that says "Write 2 file(s)" about a proposal that removes one of them is the card
|
|
71
|
+
# lying about the irreversible half, so the two verbs are counted apart.
|
|
72
|
+
removed = sum(1 for change in changes if change.get("delete"))
|
|
73
|
+
written = len(changes) - removed
|
|
74
|
+
lead = " and ".join(filter(None, [f"Write {written}" if written else "",
|
|
75
|
+
f"remove {removed}" if removed else ""])) or "Write 0"
|
|
76
|
+
message = ((body + "\n\n" if body else "") + lead + " file(s) to\n" + str(root) +
|
|
77
|
+
(again if again else "") +
|
|
78
|
+
(("\n\n" + reason) if reason and not body else "") +
|
|
79
|
+
"\n\nYou can roll back afterwards as long as the files are not edited later.")
|
|
80
|
+
return {"title": "Apply changes", "message": message, "warning": reason,
|
|
81
|
+
"ok_label": "Apply changes"}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""One owner for the question "is this path worth looking at".
|
|
2
|
+
|
|
3
|
+
Three lists used to answer it — `workspace.BLOCKED_PARTS`, `runner.PROJECT_SKIP`,
|
|
4
|
+
`controller.SKIP_DIRS` — copied from each other by hand, so they drifted: the build directories the
|
|
5
|
+
planner refused were hidden in the picker for a different reason, and nothing in any of them knew that
|
|
6
|
+
this tool writes its own chat transcripts and its own preview folder into the workspace it is asked to
|
|
7
|
+
index. Measured on this repository before the split was closed: 400 of 591 files the map walked were
|
|
8
|
+
`.agent-webview/`, `.agent-chats/` and `.design-preview/`, and the repository map came back at 12 206
|
|
9
|
+
characters against a 12 000 budget — the model's view of this project was mostly this project's own
|
|
10
|
+
output.
|
|
11
|
+
|
|
12
|
+
The three questions are genuinely different, so this module keeps three compositions over one set of
|
|
13
|
+
names. What was wrong was not the difference; it was that a name had to be edited in three places to
|
|
14
|
+
have an effect in one.
|
|
15
|
+
|
|
16
|
+
Nothing here is a security boundary on its own. `refused_dir` is the predicate the read and write gates
|
|
17
|
+
use, and it must stay a **superset** of what `BLOCKED_PARTS` held, so moving a build directory from the
|
|
18
|
+
policy list to the tidiness list cannot quietly make it readable.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# Credentials, VCS internals, and the surfaces an agent could leave a hook on. Refusing these is a
|
|
26
|
+
# policy decision, not a tidiness one, and it does not change because a repository asked.
|
|
27
|
+
PROTECTED_DIRS = {
|
|
28
|
+
".git", ".env", ".ssh", ".aws", ".azure", ".gnupg", ".codex", ".agents", ".agent-runs",
|
|
29
|
+
".agent-projects.json", ".agent-modes.json", ".agent-plans", ".agent-memory",
|
|
30
|
+
# The signed rows and the key that signs them. The signature is what actually refuses a row a model
|
|
31
|
+
# wrote, but a file the operator set with their own hands is not the model's to rewrite either.
|
|
32
|
+
".agent-overrides.json", ".agent-overrides.key",
|
|
33
|
+
".venv", "venv", ".gradle", ".m2",
|
|
34
|
+
".idea",
|
|
35
|
+
}
|
|
36
|
+
# Matches files that *are* credentials, not source that merely names one — `token.json` yes,
|
|
37
|
+
# `JwtTokenProvider.java` no. The pattern is the one `workspace.py` always used, moved rather than
|
|
38
|
+
# copied so the read gate and the walk cannot disagree about what `.env` means.
|
|
39
|
+
SECRET_NAME = re.compile(
|
|
40
|
+
r"(^\.env($|\.)|(^|[-_.])(credentials?|secrets?|passwords?|tokens?|keys?|private[-_]?key)s?($|[-_.]))",
|
|
41
|
+
re.I)
|
|
42
|
+
# NTFS keeps an 8.3 alias for every name it had to shorten, and the alias opens the same bytes.
|
|
43
|
+
# "TOKEN~1.JSON" would otherwise be a shape the checks above never saw, so it is refused by pattern.
|
|
44
|
+
SHORT_NAME = re.compile(r"~\d{1,2}(?:\.[^.]+)?$", re.I)
|
|
45
|
+
|
|
46
|
+
# Built, vendored, or written by this tool itself. Out of the walk, because a map full of them is how a
|
|
47
|
+
# real source file gets truncated out of the model's context. Not out of reach: a caller that names one
|
|
48
|
+
# explicitly can still read it — these are noise, not secrets.
|
|
49
|
+
GENERATED_DIRS = {
|
|
50
|
+
"node_modules", "target", "build", "dist", "site-packages", "eggs", "bower_components",
|
|
51
|
+
"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".next", ".nuxt",
|
|
52
|
+
".turbo", ".sass-cache", "coverage", "htmlcov", ".terraform",
|
|
53
|
+
# The tool's own output. `.agent-runs` is protected above; these three were in no list at all.
|
|
54
|
+
".agent-webview", ".agent-chats", ".design-preview",
|
|
55
|
+
}
|
|
56
|
+
# A directory whose name says it holds generated code: Maven's `generated-sources`, protobuf's `gen`,
|
|
57
|
+
# a hand-rolled `src/generated`. Matched against the whole segment, so `chargeback-api` is safe.
|
|
58
|
+
GENERATED_SEGMENT = re.compile(r"^(gen|generated|generated[-_].*|.*[-_]generated)$", re.I)
|
|
59
|
+
# Files that are a minified or compiled form of something else. Matched against the whole name, so
|
|
60
|
+
# `app.min.js` goes and `admin.js` stays.
|
|
61
|
+
GENERATED_SUFFIXES = (".min.js", ".min.css", ".bundle.js", ".lock", ".map", ".iml", ".g.cs",
|
|
62
|
+
".g.i.cs", ".designer.cs", ".generated.cs", "_pb2.py", "_pb2_grpc.py")
|
|
63
|
+
|
|
64
|
+
# "Could a build be run in this folder?" is a different question, with one extra honest answer: a .NET
|
|
65
|
+
# tree keeps output in `bin`, a Rails app keeps executables there too, so those names are a reason not
|
|
66
|
+
# to *offer* a folder and not a reason to refuse a file the operator named.
|
|
67
|
+
PROJECT_EXTRA_DIRS = {"out", "bin", "obj", "vendor", "packages", "deps", "_build", ".direnv", ".mvn"}
|
|
68
|
+
# The folder picker walks a drive root, so it needs a second reason to hide a folder: it is the
|
|
69
|
+
# machine, not the workspace. These are `controller.SKIP_DIRS` verbatim.
|
|
70
|
+
OS_TREES = {"program files", "windows", "system32", "appdata", "downloads", "$recycle.bin",
|
|
71
|
+
"programdata", "perflogs", "recovery", "windowsapps", "drvstore"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def protected_dir(part: str) -> bool:
|
|
75
|
+
return str(part).casefold() in PROTECTED_DIRS
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def credential(part: str) -> bool:
|
|
79
|
+
return bool(SECRET_NAME.search(str(part)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def alias(part: str) -> bool:
|
|
83
|
+
return bool(SHORT_NAME.search(str(part)))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def generated_dir(part: str) -> bool:
|
|
87
|
+
folded = str(part).casefold()
|
|
88
|
+
return folded in GENERATED_DIRS or bool(GENERATED_SEGMENT.match(folded))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def generated_file(name: str) -> bool:
|
|
92
|
+
return str(name).casefold().endswith(GENERATED_SUFFIXES)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def refused_dir(part: str) -> bool:
|
|
96
|
+
"""A directory the walk never enters and the path gate never resolves through."""
|
|
97
|
+
return protected_dir(part) or generated_dir(part) or credential(part) or alias(part)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def project_dir(part: str) -> bool:
|
|
101
|
+
"""`refused_dir` plus the output names that only disqualify a folder as a project root."""
|
|
102
|
+
return refused_dir(part) or str(part).casefold() in PROJECT_EXTRA_DIRS
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def picker_dir(part: str) -> bool:
|
|
106
|
+
"""`refused_dir` plus the operating system's own trees."""
|
|
107
|
+
return refused_dir(part) or str(part).casefold() in OS_TREES
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _segment(pattern: str) -> str:
|
|
111
|
+
"""One path segment of a gitignore pattern to regex: `*` and `?` stop at a slash."""
|
|
112
|
+
out = []
|
|
113
|
+
index = 0
|
|
114
|
+
while index < len(pattern):
|
|
115
|
+
char = pattern[index]
|
|
116
|
+
if char == "*":
|
|
117
|
+
out.append("[^/]*")
|
|
118
|
+
elif char == "?":
|
|
119
|
+
out.append("[^/]")
|
|
120
|
+
elif char == "[":
|
|
121
|
+
close = pattern.find("]", index + 1)
|
|
122
|
+
if close < 0:
|
|
123
|
+
out.append(re.escape(char))
|
|
124
|
+
else:
|
|
125
|
+
inner = pattern[index + 1:close]
|
|
126
|
+
if inner.startswith("!"):
|
|
127
|
+
inner = "^" + inner[1:]
|
|
128
|
+
out.append("[" + inner + "]")
|
|
129
|
+
index = close
|
|
130
|
+
else:
|
|
131
|
+
out.append(re.escape(char))
|
|
132
|
+
index += 1
|
|
133
|
+
return "".join(out)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Rules:
|
|
137
|
+
"""A parsed `.gitignore`: last match wins, and an ignored directory takes its children with it.
|
|
138
|
+
|
|
139
|
+
Implemented is git's syntax as it is actually used — `*` and `?` that stop at a slash, `**` that
|
|
140
|
+
crosses one, a trailing `/` for directories only, a leading or inner `/` to anchor, `[abc]` and
|
|
141
|
+
`[!abc]` classes, `#` comments, and `!` to put something back — plus a nested `.gitignore` for every
|
|
142
|
+
directory the walk reaches. Not implemented: git's own escapes and character-class edge cases,
|
|
143
|
+
`.git/info/exclude`, and the global `core.excludesFile`, all of which live outside the folder this
|
|
144
|
+
tool was handed and would make a granted folder's map depend on this machine's configuration.
|
|
145
|
+
|
|
146
|
+
Git is never consulted. `git_program()` may return `None` and a granted folder is often not a
|
|
147
|
+
repository at all, so the parser is the rule rather than the fallback — which is the difference
|
|
148
|
+
between a map that is stable and one that changes when a checkout of git appears.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, rows: list[tuple[bool, re.Pattern, bool]] | None = None,
|
|
152
|
+
origin: str = "") -> None:
|
|
153
|
+
self.rows = rows or []
|
|
154
|
+
self.origin = origin
|
|
155
|
+
|
|
156
|
+
def __bool__(self) -> bool:
|
|
157
|
+
return bool(self.rows)
|
|
158
|
+
|
|
159
|
+
def child(self, text: str) -> "Rules":
|
|
160
|
+
"""These rules plus one directory's own file, which refines them.
|
|
161
|
+
|
|
162
|
+
A nested `.gitignore` wins over the one above it, and git decides that by trying patterns last
|
|
163
|
+
first, so the child's lines go on the end of the list. The parent's rows are kept whole rather
|
|
164
|
+
than mutated, which is also what makes a subdirectory's rules stop applying when the walk leaves
|
|
165
|
+
it: each directory carries only the chain that reaches it.
|
|
166
|
+
"""
|
|
167
|
+
return Rules(self.rows + self.parse(text), self.origin)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def compile_pattern(pattern: str) -> re.Pattern | None:
|
|
171
|
+
body = pattern.lstrip("/")
|
|
172
|
+
# An inner slash anchors the pattern to the directory the file sat in; without one it may
|
|
173
|
+
# match at any depth. `logs/` alone has no inner slash, so it matches every `logs` directory.
|
|
174
|
+
anchored = "/" in body.rstrip("/")
|
|
175
|
+
pieces: list[str] = []
|
|
176
|
+
segments = body.split("/")
|
|
177
|
+
for position, segment in enumerate(segments):
|
|
178
|
+
last = position == len(segments) - 1
|
|
179
|
+
if not segment:
|
|
180
|
+
continue
|
|
181
|
+
if segment == "**":
|
|
182
|
+
# `**/x` reaches any depth, `x/**` takes everything below, `a/**/b` also matches `a/b`.
|
|
183
|
+
pieces.append(".*" if last else "(?:[^/]*/)*")
|
|
184
|
+
continue
|
|
185
|
+
pieces.append(_segment(segment))
|
|
186
|
+
if not last:
|
|
187
|
+
pieces.append("/")
|
|
188
|
+
stem = "".join(pieces)
|
|
189
|
+
if not stem:
|
|
190
|
+
return None
|
|
191
|
+
return re.compile(("" if anchored else "^(?:[^/]*/)*") + stem + "$")
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def parse(cls, text: str) -> list[tuple[bool, re.Pattern, bool]]:
|
|
195
|
+
rows = []
|
|
196
|
+
for line in str(text or "").splitlines():
|
|
197
|
+
pattern = line.rstrip("\r")
|
|
198
|
+
if not pattern.strip() or pattern.lstrip().startswith("#"):
|
|
199
|
+
continue
|
|
200
|
+
pattern = pattern.strip()
|
|
201
|
+
negate = pattern.startswith("!")
|
|
202
|
+
if negate:
|
|
203
|
+
pattern = pattern[1:].strip()
|
|
204
|
+
directory_only = pattern.endswith("/")
|
|
205
|
+
pattern = pattern.rstrip("/")
|
|
206
|
+
if not pattern:
|
|
207
|
+
continue
|
|
208
|
+
compiled = cls.compile_pattern(pattern)
|
|
209
|
+
if compiled is not None:
|
|
210
|
+
rows.append((negate, compiled, directory_only))
|
|
211
|
+
return rows
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def load(cls, root: Path, limit_bytes: int = 64 * 1024) -> "Rules":
|
|
215
|
+
"""The root rules, or none at all: a file that cannot be read is no rules, not a failed task."""
|
|
216
|
+
try:
|
|
217
|
+
path = Path(root) / ".gitignore"
|
|
218
|
+
if not path.is_file() or path.stat().st_size > limit_bytes:
|
|
219
|
+
return cls()
|
|
220
|
+
return cls(cls.parse(path.read_text(encoding="utf-8", errors="replace")), ".gitignore")
|
|
221
|
+
except OSError:
|
|
222
|
+
return cls()
|
|
223
|
+
|
|
224
|
+
def _matched(self, path: str, is_dir: bool) -> bool:
|
|
225
|
+
"""One path, all the rules, last match wins — which is git's whole precedence model."""
|
|
226
|
+
decided = False
|
|
227
|
+
for negate, pattern, directory_only in self.rows:
|
|
228
|
+
if directory_only and not is_dir:
|
|
229
|
+
continue
|
|
230
|
+
if pattern.match(path):
|
|
231
|
+
decided = not negate
|
|
232
|
+
return decided
|
|
233
|
+
|
|
234
|
+
def ignores(self, relative: str, is_dir: bool) -> bool:
|
|
235
|
+
"""Whether git would call this path uninteresting.
|
|
236
|
+
|
|
237
|
+
An excluded directory takes everything under it, and no `!` line inside it can put a file back:
|
|
238
|
+
git says so explicitly ("It is not possible to re-include a file if a parent directory of that
|
|
239
|
+
file is excluded"), and a tool that disagreed with that would list files a person cannot add to
|
|
240
|
+
a commit. So the ancestors are decided first, and only a path whose whole chain survived gets
|
|
241
|
+
its own last-match answer.
|
|
242
|
+
"""
|
|
243
|
+
parts = [p for p in str(relative).replace("\\", "/").split("/") if p]
|
|
244
|
+
for depth in range(1, len(parts)):
|
|
245
|
+
if self._matched("/".join(parts[:depth]), True):
|
|
246
|
+
return True
|
|
247
|
+
return self._matched("/".join(parts), is_dir)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def walk_prune(dirs, rules: Rules) -> tuple[list[str], int, int]:
|
|
251
|
+
"""The directories to descend into, and how many were left out for which reason.
|
|
252
|
+
|
|
253
|
+
Shared by every caller that walks a project, so the map, the search and the count the composer
|
|
254
|
+
prints cannot each disagree about what a folder holds. The counts travel out with the decision
|
|
255
|
+
because they are the only way a reader can tell "there is nothing here" from "something was hidden"
|
|
256
|
+
— a silent filter turns a missing file into a wrong answer from a model that believes the map.
|
|
257
|
+
"""
|
|
258
|
+
kept, generated, ignored = [], 0, 0
|
|
259
|
+
for name in dirs:
|
|
260
|
+
if generated_dir(name):
|
|
261
|
+
generated += 1
|
|
262
|
+
continue
|
|
263
|
+
if rules and rules.ignores(name, True):
|
|
264
|
+
ignored += 1
|
|
265
|
+
continue
|
|
266
|
+
if protected_dir(name) or credential(name) or alias(name):
|
|
267
|
+
continue
|
|
268
|
+
kept.append(name)
|
|
269
|
+
return sorted(kept), generated, ignored
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""The write-intent axis: three positions, and what each one refuses.
|
|
2
|
+
|
|
3
|
+
`Chat` answers in prose and reads the folder as context. `Read-only` does the same but promises the
|
|
4
|
+
stronger thing: nothing is proposed, nothing is written, and a project command runs only after a clear
|
|
5
|
+
yes for that command. `Change` proposes a diff for review — and the per-folder Auto-Apply switch sits
|
|
6
|
+
on top of it, which is why the switch is not a fourth position here.
|
|
7
|
+
|
|
8
|
+
Both windows ask this module instead of each holding its own rule. Every sentence is written once, in
|
|
9
|
+
two languages: the last time two surfaces worded "this conversation cannot write" separately, one of
|
|
10
|
+
them was refusing a different thing than the other.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .labels import say
|
|
15
|
+
|
|
16
|
+
CHAT, READ, CHANGE = "chat", "read", "change"
|
|
17
|
+
MODES = (CHAT, READ, CHANGE)
|
|
18
|
+
|
|
19
|
+
# What the badge and the picker call each position.
|
|
20
|
+
LABELS = {CHAT: "Chat", READ: "Read-only", CHANGE: "Change"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalise(value) -> str:
|
|
24
|
+
"""The mode this value names, or chat for anything unrecognised.
|
|
25
|
+
|
|
26
|
+
A stored preference from an older version, or a payload with a typo, cannot leave a window in a
|
|
27
|
+
state where neither promise holds. Chat is the safe reading: it writes nothing.
|
|
28
|
+
"""
|
|
29
|
+
text = str(value or "").strip().lower()
|
|
30
|
+
return text if text in MODES else CHAT
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_only(mode) -> bool:
|
|
34
|
+
return normalise(mode) == READ
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def label(mode) -> str:
|
|
38
|
+
return LABELS[normalise(mode)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# What the header line of each window prints for the position: the name, then the promise it keeps.
|
|
42
|
+
PROMISE = {CHAT: "reads as context", READ: "writes nothing", CHANGE: "reviewed diff"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def subtitle(mode) -> str:
|
|
46
|
+
"""`chat · reads as context` — the mode in the header, in the mode's own words."""
|
|
47
|
+
mode = normalise(mode)
|
|
48
|
+
return LABELS[mode].lower() + " · " + PROMISE[mode]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def needs_folder(mode, *, arabic: bool = False) -> str:
|
|
52
|
+
"""Why two of the three positions cannot be chosen over an empty window."""
|
|
53
|
+
mode = normalise(mode)
|
|
54
|
+
if mode == CHANGE:
|
|
55
|
+
return say(arabic,
|
|
56
|
+
en="Choose a project in the sidebar before asking for reviewed changes.",
|
|
57
|
+
ar="اختر مشروعا في الشريط الجانبي قبل طلب فروق تراجعها.")
|
|
58
|
+
return say(arabic,
|
|
59
|
+
en="Choose a project in the sidebar before asking for a read-only analysis.",
|
|
60
|
+
ar="اختر مشروعا في الشريط الجانبي قبل طلب فحص للقراءة فقط.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------ the refusals
|
|
64
|
+
|
|
65
|
+
def switched(mode, *, arabic: bool = False, project: str = "") -> str:
|
|
66
|
+
"""What choosing this position means, said as the window's own next line."""
|
|
67
|
+
suffix = f" ({project})" if project else ""
|
|
68
|
+
mode = normalise(mode)
|
|
69
|
+
if mode == READ:
|
|
70
|
+
return say(arabic,
|
|
71
|
+
en=("Read-only: I read this folder, search it, map it and explain what I find. I create "
|
|
72
|
+
"no proposal and write nothing, and your project's own command runs only when you "
|
|
73
|
+
"say yes to that one command." + suffix),
|
|
74
|
+
ar=("وضع القراءة فقط: أقرأ هذا المجلد وأفتشه وأبني خريطته وأشرح ما أجد فيه. لا أنشئ أي "
|
|
75
|
+
"اقتراح ولا أكتب أي شيء، وأمر المشروع نفسه لا يعمل إلا عندما توافق على ذلك الأمر "
|
|
76
|
+
"بعينه." + suffix))
|
|
77
|
+
if mode == CHANGE:
|
|
78
|
+
return say(arabic,
|
|
79
|
+
en="Change mode: Send proposes a diff you review before any file is written." + suffix,
|
|
80
|
+
ar="وضع التعديل: الإرسال يقترح فروقا تراجعها قبل كتابة أي ملف." + suffix)
|
|
81
|
+
return say(arabic,
|
|
82
|
+
en="Chat mode: Send answers in prose and cannot write files.",
|
|
83
|
+
ar="وضع الدردشة: الإرسال يجيب بالنص ولا يكتب ملفات.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def unchecked(*, arabic: bool = False) -> str:
|
|
87
|
+
"""Read-only switched back off, in the window that holds it as a switch rather than a badge."""
|
|
88
|
+
return say(arabic,
|
|
89
|
+
en="Read-only is off. Send proposes a diff you review before any file is written.",
|
|
90
|
+
ar="وضع القراءة فقط مطفأ. الإرسال يقترح فروقا تراجعها قبل كتابة أي ملف.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def answered(*, arabic: bool = False) -> str:
|
|
94
|
+
"""Where a read-only answer ends. Both windows close an analysis with this, because the sentence
|
|
95
|
+
before it told the operator to switch modes after every single question."""
|
|
96
|
+
return say(arabic,
|
|
97
|
+
en="Answered in Read-only mode. Nothing was written.",
|
|
98
|
+
ar="أجبت في وضع القراءة فقط. لم يُكتب شيء.")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def no_proposal(*, arabic: bool = False) -> str:
|
|
102
|
+
"""Why a message that asks for a change gets an explanation instead of a diff."""
|
|
103
|
+
return say(arabic,
|
|
104
|
+
en=("This conversation is in Read-only mode, so there is no proposal to build. Ask what is "
|
|
105
|
+
"wrong, where it is, and what a fix would touch — and switch the badge to Change when you "
|
|
106
|
+
"want a diff to review."),
|
|
107
|
+
ar=("هذه المحادثة في وضع القراءة فقط، لذلك لا يُبنى أي اقتراح. اسألني ما الخطأ وأين وماذا "
|
|
108
|
+
"سيلمس الإصلاح، وحوّل الشارة إلى وضع التعديل عندما تريد فروقا تراجعها."))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def no_write(what: str = "Apply", *, arabic: bool = False) -> str:
|
|
112
|
+
"""Apply and Roll back both change files, so both stop here."""
|
|
113
|
+
return say(arabic,
|
|
114
|
+
en=(f"{what} writes to the folder, and this conversation is in Read-only mode. Switch to "
|
|
115
|
+
"Change mode to review and write the diff."),
|
|
116
|
+
ar=(f"{what} يكتب في المجلد، والمحادثة في وضع القراءة فقط. حوّلها إلى وضع التعديل لترى "
|
|
117
|
+
"الفروق وتكتبها."))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def no_fix_round(*, arabic: bool = False) -> str:
|
|
121
|
+
""""Run & fix" spends a round on a proposal, which is the one thing this mode will not make."""
|
|
122
|
+
return say(arabic,
|
|
123
|
+
en=("Read-only runs the command when you say yes, but it does not run a fix round: the output "
|
|
124
|
+
"of a round is a proposal. The failure and its whole output are above — ask what they mean."),
|
|
125
|
+
ar=("وضع القراءة فقط يشغّل الأمر عندما توافق، لكنه لا ينفّذ جولة إصلاح، لأن ناتج الجولة "
|
|
126
|
+
"اقتراح. رسالة الفشل ومخرجها الكامل بالأعلى؛ اسأل عن معناهما."))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def no_auto_apply(*, arabic: bool = False) -> str:
|
|
130
|
+
return say(arabic,
|
|
131
|
+
en=("A read-only conversation cannot arm Auto-Apply: the switch writes what the model "
|
|
132
|
+
"proposes without a click. Turn it on while the folder is in Change mode."),
|
|
133
|
+
ar=("لا تستطيع محادثة في وضع القراءة فقط أن تفعّل الكتابة التلقائية: المفتاح يكتب ما "
|
|
134
|
+
"يقترحه النموذج دون نقرة. فعّله والمجلد في وضع التعديل."))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------ the one permission
|
|
138
|
+
|
|
139
|
+
def run_ask(command: str, *, arabic: bool = False, project: str = "") -> str:
|
|
140
|
+
"""The explicit yes this mode requires before a project's own code executes.
|
|
141
|
+
|
|
142
|
+
Named by command, because "run something?" is not a choice anyone can predict the outcome of, and
|
|
143
|
+
this is the sentence an operator reads before a build starts inside a production checkout.
|
|
144
|
+
"""
|
|
145
|
+
where = f" في {project}" if project else ""
|
|
146
|
+
return say(arabic,
|
|
147
|
+
en=("This folder is in Read-only mode, which runs nothing until asked. Running this executes "
|
|
148
|
+
f"the project's own code with your permissions{f' in {project}' if project else ''}:\n\n"
|
|
149
|
+
f" {command}\n\nIt cannot write to your files — that stays refused — but it can read "
|
|
150
|
+
"them, and a build runs whatever its scripts do."),
|
|
151
|
+
ar=("هذا المجلد في وضع القراءة فقط، أي أنه لا يشغّل شيئا قبل أن يُسأل. التشغيل هنا ينفّذ كود "
|
|
152
|
+
f"المشروع نفسه بصلاحياتك{where}:\n\n {command}\n\nلن يكتب في ملفاتك، هذا يبقى مرفوضا، "
|
|
153
|
+
"لكنه قد يقرأها، والبناء ينفّذ أي شيء تفعله سكربتاته."))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_declined(*, arabic: bool = False) -> str:
|
|
157
|
+
return say(arabic,
|
|
158
|
+
en="Nothing ran. Read-only starts no command until you say yes to that one.",
|
|
159
|
+
ar="لم يُنفَّذ شيء. وضع القراءة فقط لا يشغّل أمرا إلا بموافقتك عليه.")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------ the durable declaration
|
|
163
|
+
|
|
164
|
+
# Which surface wrote the declaration. Stored as a code, spoken in either language, because a row that
|
|
165
|
+
# reads "the command line" in an Arabic window is half a sentence.
|
|
166
|
+
SOURCES = {"web": ("the web window", "نافذة الويب"),
|
|
167
|
+
"gui": ("the desktop window", "نافذة سطح المكتب"),
|
|
168
|
+
"cli": ("the command line", "سطر الأوامر"),
|
|
169
|
+
"saved": ("a window before this one", "نافذة قبل هذه")}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def source(code: str, *, arabic: bool = False) -> str:
|
|
173
|
+
"""Who set this folder, in the language the rest of the line is in."""
|
|
174
|
+
pair = SOURCES.get(str(code or ""))
|
|
175
|
+
return (pair[1] if arabic else pair[0]) if pair else str(code or "")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def declared(by: str = "", at: str = "", *, arabic: bool = False, project: str = "") -> str:
|
|
179
|
+
"""Why the write stopped at a fact about the folder rather than at this window's badge.
|
|
180
|
+
|
|
181
|
+
Names the surface and the minute, because "refused" without a who and a when reads like a bug, and
|
|
182
|
+
the operator's next move is to find whoever set it.
|
|
183
|
+
"""
|
|
184
|
+
who, when = source(by, arabic=arabic), str(at or "")
|
|
185
|
+
bits = [part for part in (who, when) if part]
|
|
186
|
+
tail = " (" + " · ".join(bits) + ")" if bits else ""
|
|
187
|
+
where = f" ({project})" if project else ""
|
|
188
|
+
if arabic:
|
|
189
|
+
return ("المجلد" + where + " معلَن أنه للقراءة فقط" + tail +
|
|
190
|
+
". لا سطح من سطوح هذه الأداة يكتب فيه طالما الإعلان قائم: لا اقتراح، ولا تنفيذ ولا "
|
|
191
|
+
"تراجع، ولا جولة إصلاح، ولا فرع أو استرجاع في git.")
|
|
192
|
+
return ("This folder" + where + " is declared Read-only" + tail +
|
|
193
|
+
". No surface of this tool writes it while that stands: not a proposal, not Apply or Roll "
|
|
194
|
+
"back, not a fix round, and not a git branch or a restore.")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def lift(folder: str, *, arabic: bool = False) -> str:
|
|
198
|
+
"""How to end the declaration, said as the exact line to type.
|
|
199
|
+
|
|
200
|
+
A refusal that does not name its own remedy teaches the operator to go looking in a config file for
|
|
201
|
+
a switch that is not there, and the next one gets set by accident.
|
|
202
|
+
"""
|
|
203
|
+
return say(arabic,
|
|
204
|
+
en=("To lift it, run: agent read-only --off \"" + str(folder) + "\" — or set the badge "
|
|
205
|
+
"to Change in a window. Lifting is asked for by name on purpose: nothing here lifts "
|
|
206
|
+
"itself."),
|
|
207
|
+
ar=("للرفع، شغّل: agent read-only --off \"" + str(folder) +
|
|
208
|
+
"\" أو حوّل الشارة إلى وضع التعديل من أي نافذة. الرفع يُطلب بالاسم عمدًا، ولا شيء "
|
|
209
|
+
"هنا يرفع نفسه."))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def followed(by: str = "", at: str = "", *, arabic: bool = False) -> str:
|
|
213
|
+
"""The line a window prints when the folder it is showing was declared elsewhere after it opened."""
|
|
214
|
+
who = source(by, arabic=arabic)
|
|
215
|
+
tail = (" (" + who + " · " + str(at or "") + ")") if at else (" (" + who + ")" if who else "")
|
|
216
|
+
return say(arabic,
|
|
217
|
+
en=("The badge follows the declaration, not the other way round: this folder was set "
|
|
218
|
+
"Read-only" + tail + " after this window opened it. Every write is refused from here "
|
|
219
|
+
"on, and choosing Change is what lifts the declaration."),
|
|
220
|
+
ar=("الشارة تتبع الإعلان وليس العكس: المجلد أُعلِن أنه للقراءة فقط" + tail +
|
|
221
|
+
" بعد أن فتحته هذه النافذة. كل كتابة مرفوضة من هنا فصاعدًا، واختيار وضع التعديل هو "
|
|
222
|
+
"ما يرفع الإعلان."))
|