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.
Files changed (44) hide show
  1. ai_code_engineer/__init__.py +2 -0
  2. ai_code_engineer/catalog.py +143 -0
  3. ai_code_engineer/chat.py +181 -0
  4. ai_code_engineer/cli.py +384 -0
  5. ai_code_engineer/config.py +405 -0
  6. ai_code_engineer/engine.py +1282 -0
  7. ai_code_engineer/errors.py +27 -0
  8. ai_code_engineer/git_integration.py +443 -0
  9. ai_code_engineer/gui.py +2646 -0
  10. ai_code_engineer/host.py +81 -0
  11. ai_code_engineer/ignore.py +269 -0
  12. ai_code_engineer/intent.py +222 -0
  13. ai_code_engineer/labels.py +871 -0
  14. ai_code_engineer/memory.py +91 -0
  15. ai_code_engineer/modes.py +156 -0
  16. ai_code_engineer/overrides.py +540 -0
  17. ai_code_engineer/planbook.py +192 -0
  18. ai_code_engineer/providers.py +404 -0
  19. ai_code_engineer/redaction.py +54 -0
  20. ai_code_engineer/repair.py +564 -0
  21. ai_code_engineer/report.py +352 -0
  22. ai_code_engineer/runner.py +854 -0
  23. ai_code_engineer/setup.py +386 -0
  24. ai_code_engineer/symbols.py +1286 -0
  25. ai_code_engineer/verification.py +218 -0
  26. ai_code_engineer/webapp/__init__.py +1 -0
  27. ai_code_engineer/webapp/__main__.py +45 -0
  28. ai_code_engineer/webapp/contract.py +36 -0
  29. ai_code_engineer/webapp/controller.py +3556 -0
  30. ai_code_engineer/webapp/fake.py +1141 -0
  31. ai_code_engineer/webapp/launch.py +108 -0
  32. ai_code_engineer/webapp/server.py +349 -0
  33. ai_code_engineer/webapp/static/app.css +780 -0
  34. ai_code_engineer/webapp/static/app.js +2118 -0
  35. ai_code_engineer/webapp/static/boot.js +19 -0
  36. ai_code_engineer/webapp/static/index.html +89 -0
  37. ai_code_engineer/webapp/static/tokens.css +173 -0
  38. ai_code_engineer/workspace.py +385 -0
  39. ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
  40. ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
  41. ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
  42. ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
  43. ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,386 @@
1
+ """The first-run audit: what this machine can reach, in the order a new operator needs it.
2
+
3
+ `agent doctor` already answers some of this and `agent demo` already proves the red line, but each of
4
+ them answers one question and neither says what to do next. This module **sequences the probes that
5
+ exist** — no second reachability check, no second model list, no second demo — and gives every row the
6
+ sentence both windows will print, in the language the operator asked in.
7
+
8
+ Nothing here writes to a project and nothing here asks a question: the caller decides whether a row is
9
+ shown (doctor) or acted on (setup), and the wizard in the CLI and in the windows share these rows.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import importlib.util
14
+ import json
15
+ from pathlib import Path
16
+ import sys
17
+
18
+ from . import catalog, config, git_integration, intent, runner
19
+ from .errors import AgentError
20
+ from .labels import say
21
+
22
+ REQUIRED_PYTHON = (3, 11)
23
+
24
+ # The order a first run needs: what this machine is, what it can run, who answers, what models exist,
25
+ # which folder is granted, proof that the red line works, what the tool promises, and what Send may do.
26
+ STEP_IDS = ("runtime", "toolchain", "provider", "model", "project", "demo", "policy", "position")
27
+
28
+ OK, WARN, BAD, INFO = "ok", "warn", "bad", "info"
29
+
30
+ # The five promises the operator is being asked to accept, in the order they can be checked.
31
+ POLICY = (
32
+ ("Nothing is written until you approve a diff you have read.",
33
+ "مفيش كتابة قبل ما توافق على فرق قراتها."),
34
+ ("No code leaves this device unless you approved that provider, for that message.",
35
+ "مفيش كود بيطلع من الجهاز ده غير بموافقتك على البروفايدر ده، للرسالة دي."),
36
+ ("An API key lives in memory only. It is never written to a file or a log.",
37
+ "مفتاح الـ API بيعيش في الرا memory بس. ما بيتكتبش في ملف ولا في سجل."),
38
+ ("An apply inside a git folder gets a checkpoint commit, and the card rolls it back.",
39
+ "التطبيق جوّه مجلد git بياخد commit مرجعي، والكارت بيرجّعه."),
40
+ ("Everything a task did is on disk: agent export-session writes the report.",
41
+ "كل ما عملته المهمة متسجل على القرص: agent export-session بيكتب التقرير."),
42
+ )
43
+
44
+
45
+ def row(step: str, status: str, *, arabic: bool, en: str, ar: str,
46
+ advice_en: str = "", advice_ar: str = "") -> dict:
47
+ """One line of the audit. A row without advice is a row nobody can act on."""
48
+ return {"id": step, "status": status, "text": say(arabic, en=en, ar=ar),
49
+ "advice": say(arabic, en=advice_en, ar=advice_ar) if advice_en else ""}
50
+
51
+
52
+ def reach(kind: config.Kind, endpoint: str = "", api_key: str | None = None):
53
+ """Ask one provider what it has. Returns ``(entries, source, error)``.
54
+
55
+ Shared by `cli.doctor` and `audit` so the two cannot disagree about whether Ollama answered — and
56
+ so the first run never reports a model as installed on the strength of a fallback list.
57
+ """
58
+ try:
59
+ entries, source = catalog.models_for(kind, endpoint, api_key)
60
+ except (AgentError, OSError) as exc:
61
+ return [], "", str(exc)
62
+ return entries, source, ""
63
+
64
+
65
+ def runtime_row(*, arabic: bool) -> dict:
66
+ version = sys.version_info
67
+ named = "{}.{}.{}".format(version.major, version.minor, version.micro)
68
+ if version < REQUIRED_PYTHON:
69
+ return row("runtime", BAD, arabic=arabic,
70
+ en=f"Python {named} is too old for this tool.",
71
+ ar=f"بايثون {named} أقدم من اللي الأداة دي محتاجاها.",
72
+ advice_en="Install Python {}.{} or newer, then run this again.".format(*REQUIRED_PYTHON),
73
+ advice_ar="نصّب بايثون {}.{} أو أحدث، وارجع شغّل الأمر ده.".format(*REQUIRED_PYTHON))
74
+ tk = importlib.util.find_spec("tkinter") is not None
75
+ if not tk:
76
+ return row("runtime", WARN, arabic=arabic,
77
+ en=f"Python {named} is new enough. The desktop window cannot start: Tk is missing.",
78
+ ar=f"بايثون {named} كفاية. نافذة سطح المكتب مش هتشتغل: مكتبة Tk مش موجودة.",
79
+ advice_en="The web window needs nothing more. For the desktop one, install the "
80
+ "tk package for this Python.",
81
+ advice_ar="نافذة الويب مش محتاجة حاجة تانية. للنافذة دي، نصّب حزمة tk لبايثون ده.")
82
+ return row("runtime", OK, arabic=arabic,
83
+ en=f"Python {named}, standard library only, and the desktop window can start.",
84
+ ar=f"بايثون {named} كفاية، ومن غير أي مكتبة خارجية، ونافذة سطح المكتب تقدر تشتغل.")
85
+
86
+
87
+ def toolchain_row(*, arabic: bool) -> dict:
88
+ installed = runner.available()
89
+ labels = [runner.RECIPES[name]["label"] for name in installed]
90
+ others = [name for name in installed if name not in ("python-unittest", "python-pytest")]
91
+ listed = ", ".join(labels) or "none"
92
+ if not others:
93
+ return row("toolchain", WARN, arabic=arabic,
94
+ en=f"Commands this machine can run: {listed}.",
95
+ ar=f"أوامر الجهاز ده يقدر يشغلها: {listed}.",
96
+ advice_en="A Java, Node, Go or Rust folder will show no command until its own tool "
97
+ "is on PATH.",
98
+ advice_ar="مجلد Java أو Node أو Go أو Rust هيفضل من غير أمر لحد ما أداة نفسها تبقى "
99
+ "على الـ PATH.")
100
+ return row("toolchain", OK, arabic=arabic,
101
+ en=f"Commands this machine can run: {listed}.",
102
+ ar=f"أوامر الجهاز ده يقدر يشغلها: {listed}.")
103
+
104
+
105
+ def provider_rows(kind: config.Kind, endpoint: str, api_key: str | None, model: str,
106
+ *, arabic: bool, probe: bool = True) -> list[dict]:
107
+ """Whether the provider answers, and whether anything is there to ask.
108
+
109
+ The model row reads the *live* list only. A provider that ships fallback names answers "not
110
+ confirmed by a live request", because a first run that promises a model nobody pulled fails on the
111
+ first Send rather than here.
112
+
113
+ `probe=False` builds the two rows without touching the network — the card a window shows before the
114
+ operator asks for anything must not be the reason a request left the machine.
115
+ """
116
+ if not probe:
117
+ return [row("provider", INFO, arabic=arabic, en="The provider has not been asked yet.",
118
+ ar="البروفايدر ما اتسألش لحد دلوقتي."),
119
+ row("model", INFO, arabic=arabic, en="The model list has not been read yet.",
120
+ ar="قائمة الموديلات ما اتقريش لحد دلوقتي.",
121
+ advice_en="Run the checks to ask the provider what it has.",
122
+ advice_ar="شغّل الفحوص عشان تسأل البروفايدر عند إيه.")]
123
+ label = kind.label
124
+ try:
125
+ checked = config.check_endpoint(kind, endpoint)
126
+ except AgentError as exc:
127
+ return [row("provider", BAD, arabic=arabic, en=f"{label}: the endpoint is refused.",
128
+ ar=f"{label}: العنوان مرفوض.", advice_en=str(exc), advice_ar=str(exc)),
129
+ row("model", INFO, arabic=arabic, en="No model was checked.",
130
+ ar="مفيش موديل اتفحص.")]
131
+ entries, source, error = reach(kind, checked, api_key)
132
+ if error:
133
+ start = f"Cannot reach {label} at {checked}."
134
+ pull_ar = f"مفيش اتصال بـ {label} على {checked}."
135
+ if kind.key == config.OLLAMA.key:
136
+ advice = ("Start it with `ollama serve`, check the port, or choose another provider.")
137
+ advice_ar = "شغّله بـ `ollama serve`، أو اتأكد من البورت، أو اختار بروفايدر تاني."
138
+ else:
139
+ advice = (f"Start {label} and load a model in it, or choose another provider.")
140
+ advice_ar = f"شغّل {label} وحمّل فيه موديل، أو اختار بروفايدر تاني."
141
+ return [row("provider", BAD, arabic=arabic, en=start, ar=pull_ar,
142
+ advice_en=advice, advice_ar=advice_ar),
143
+ row("model", INFO, arabic=arabic, en="No model list to choose from.",
144
+ ar="مفيش قائمة موديلات اتقريت منها.")]
145
+ live = source == catalog.LIVE
146
+ local = [entry for entry in entries if not entry.get("cloud")]
147
+ rows = [row("provider", OK, arabic=arabic,
148
+ en=f"{label} answers at {checked}.", ar=f"{label} بيجاوب على {checked}.")]
149
+ if not entries:
150
+ if kind.shape == "ollama":
151
+ advice = ("Pull a model that fits this machine, for example "
152
+ "`ollama pull qwen2.5-coder:1.5b`.")
153
+ advice_ar = ("حمّل موديل يناسب الجهاز ده، زي "
154
+ "`ollama pull qwen2.5-coder:1.5b`.")
155
+ else:
156
+ advice = f"Load a model in {label} first."
157
+ advice_ar = f"حمّل موديل في {label} الأول."
158
+ return rows + [row("model", BAD, arabic=arabic,
159
+ en=f"{label} is reachable but has no models.",
160
+ ar=f"{label} بيجاوب بس مفيهوش موديلات.",
161
+ advice_en=advice, advice_ar=advice_ar)]
162
+ if not live:
163
+ return rows + [row("model", WARN, arabic=arabic,
164
+ en=(f"{len(entries)} names came from this tool, not from {label}: the live "
165
+ "list could not be reached."),
166
+ ar=(f"{len(entries)} اسم من الأداة نفسها، مش من {label}: القائمة الحية "
167
+ "ما وصلتش."),
168
+ advice_en="Treat the first request as the test; a name may no longer exist.",
169
+ advice_ar="اعتبر أول طلب هو الاختبار؛ من الممكن اسم منها ما بقاش موجود.")]
170
+ if not local:
171
+ return rows + [row("model", WARN, arabic=arabic,
172
+ en=f"{len(entries)} models, and every one of them is billed or remote.",
173
+ ar=f"{len(entries)} موديل، وكلها مدفوعة أو على جهاز تاني.",
174
+ advice_en="A local model keeps the code on this device. Choosing a remote "
175
+ "one needs the policy step approved first.",
176
+ advice_ar="الموديل المحلي بيخلي الكود على الجهاز ده. اختيار واحد بعيد محتاج "
177
+ "موافقة على الخطوات الأول.")]
178
+ chosen = model if any(entry["id"] == model for entry in entries) else local[0]["id"]
179
+ return rows + [row("model", OK, arabic=arabic,
180
+ en=(f"{len(entries)} models ({len(local)} of them local). Using {chosen}."),
181
+ ar=f"{len(entries)} موديل ({len(local)} منهم محليين). هيستخدم {chosen}.")]
182
+
183
+
184
+ def project_row(repo: str, *, arabic: bool) -> dict:
185
+ if not str(repo or "").strip():
186
+ return row("project", INFO, arabic=arabic,
187
+ en="No folder has been granted yet.",
188
+ ar="مفيش مجلد اتفتح لحد دلوقتي.",
189
+ advice_en="Open one from the sidebar, or pass --repo here. Granting a folder is what "
190
+ "lets a task read it — nothing is read until you ask for a change.",
191
+ advice_ar="افتح واحد من الشريط الجانبي، أو حدد --repo هنا. منح المجلد هو اللي بيسيب "
192
+ "المهمة تقرأه — مفيش قراءة قبل ما تطلب تغيير.")
193
+ path = Path(str(repo).strip())
194
+ if not path.is_dir():
195
+ return row("project", BAD, arabic=arabic, en=f"{path.name} is not a folder I can read.",
196
+ ar=f"{path.name} مش مجلد أقدر أقراه.",
197
+ advice_en="Choose an existing folder, or clear it to work without a project.",
198
+ advice_ar="اختار مجلد موجود، أو امسحه عشان تشتغل من غير مشروع.")
199
+ try:
200
+ targets = runner.targets(path)
201
+ except (AgentError, OSError) as exc:
202
+ return row("project", BAD, arabic=arabic, en=f"{path.name} could not be scanned.",
203
+ ar=f"{path.name} ما اتقريش.", advice_en=str(exc)[:160], advice_ar=str(exc)[:160])
204
+ commands = "; ".join(f"{runner.RECIPES[name]['label']} in {row_['label']}"
205
+ for row_ in targets for name in row_["recipes"])
206
+ git = git_integration.status(str(path))["repo"]
207
+ if not targets:
208
+ return row("project", WARN, arabic=arabic,
209
+ en=f"{path.name} is granted, but no runnable command was found in it.",
210
+ ar=f"{path.name} اتفتح، بس مفيش أمر قابل للتشغيل فيه.",
211
+ advice_en="The Checks card stays hidden and a run will be refused. Add the tool for "
212
+ "this kind of project, or use the static check.",
213
+ advice_ar="كارت الفحوص هيفضل مخفي والتشغيل هيترفض. ضيف أداة النوع ده من المشاريع، أو "
214
+ "استخدم الفحص الساكن.")
215
+ return row("project", OK, arabic=arabic,
216
+ en=f"{path.name}: {commands}" + (" · under git" if git else " · not a git folder"),
217
+ ar=f"{path.name}: {commands}" + (" · تحت git" if git else " · مش مجلد git"))
218
+
219
+
220
+ def demo_row(result: dict, *, arabic: bool) -> dict:
221
+ passed = result.get("proposal_apply_rollback") == "passed"
222
+ return row("demo", OK if passed else BAD, arabic=arabic,
223
+ en=("A proposal was applied, checked and rolled back in a temporary folder — no model "
224
+ "was asked and none of your files were touched." if passed else
225
+ "That proof did not complete: " + str(result.get("note", ""))[:120]),
226
+ ar=("مقترح اتطبّق واتفحص واتراجع في مجلد مؤقت — مفيش موديل اتسأل ومفيش ملف من ملفاتك "
227
+ "اتلمس." if passed else
228
+ "الدليل ده ما كملش: " + str(result.get("note", ""))[:120]),
229
+ advice_en="" if passed else "Report this as a bug; the tool refuses to write when its own "
230
+ "proof does not hold.",
231
+ advice_ar="" if passed else "بلّغ عن ده كبيغ؛ الأداة بترفض تكتب لما دليلها نفسه ما يمشيش.")
232
+
233
+
234
+ def policy_row(*, arabic: bool) -> dict:
235
+ """The five promises, numbered so an operator can be asked which one they did not read.
236
+
237
+ Each language reads its own half of `POLICY`: the pairs are (English, Arabic), and a row that
238
+ interpolated the pair itself would print both languages in one line.
239
+ """
240
+ side = 1 if arabic else 0
241
+ return row("policy", INFO, arabic=arabic,
242
+ en="What this tool promises: " + " ".join(
243
+ f"({i + 1}) {line[0]}" for i, line in enumerate(POLICY)),
244
+ ar="اللي الأداة دي بتلتزم بيه: " + " ".join(
245
+ f"({i + 1}) {line[side]}" for i, line in enumerate(POLICY)))
246
+
247
+
248
+ def position_row(*, arabic: bool) -> dict:
249
+ names = " / ".join(intent.label(mode) for mode in intent.MODES)
250
+ return row("position", INFO, arabic=arabic,
251
+ en=(f"Choose what Send may become — {names}. Chat answers in prose, Read-only explains "
252
+ "a folder and builds no proposal, Change proposes a diff you review. Auto-Apply is a "
253
+ "switch on top of Change, not a fourth choice."),
254
+ ar=(f"اختار الـ Send يقدر يبقى إيه — {names}. الدردشة تجيب بالنص، وضع القراءة فقط يشرح "
255
+ "المجلد وما يبنیش اقتراح، ووضع التعديل يقترح فروقا تراجعها. الكتابة التلقائية مفتاح "
256
+ "فوق وضع التعديل ومش اختيار رابع."))
257
+
258
+
259
+ def audit(*, repo: str = "", provider: str = "", endpoint: str = "", api_key: str | None = None,
260
+ model: str = "", arabic: bool = False, demo: dict | None = None,
261
+ probe: bool = True) -> list[dict]:
262
+ """Every row a first run needs, in order.
263
+
264
+ `demo` is passed in rather than run here: a report that creates a temporary project and rolls it
265
+ back is the wizard's job when the operator asks for it, not a side effect of asking what is
266
+ installed. `probe` decides whether the provider is asked, for the same reason.
267
+ """
268
+ kind = config.kind_for(provider) or config.DEFAULT_KIND
269
+ rows = [runtime_row(arabic=arabic), toolchain_row(arabic=arabic)]
270
+ rows += provider_rows(kind, endpoint, api_key, model, arabic=arabic, probe=probe)
271
+ rows.append(project_row(repo, arabic=arabic))
272
+ rows.append(demo_row(demo, arabic=arabic) if demo else
273
+ row("demo", INFO, arabic=arabic,
274
+ en="The offline proof has not been run yet.",
275
+ ar="دليل الشغل من غير نت ما اتعملش لحد دلوقتي.",
276
+ advice_en="Run it from the setup step: it writes only to a temporary folder.",
277
+ advice_ar="شغّله من خطوة الإعداد: بيكتب في مجلد مؤقت بس."))
278
+ rows.append(policy_row(arabic=arabic))
279
+ rows.append(position_row(arabic=arabic))
280
+ return rows
281
+
282
+
283
+ def run_demo() -> dict:
284
+ """The offline proof: propose, apply, check, roll back — in a folder that is not the user's.
285
+
286
+ It lives here because the wizard in the terminal and the card in the window both have to be able to
287
+ offer it, and neither should import the other. The engine is imported inside the call so asking
288
+ "what is installed" does not load the planning machinery.
289
+ """
290
+ import json as _json
291
+ import tempfile
292
+
293
+ from .engine import apply_proposal, load_session, plan, rollback
294
+ from .verification import verify
295
+ from .workspace import Workspace
296
+
297
+ class DemoProvider:
298
+ model = "deterministic-demo-no-llm"
299
+ turn = 0
300
+
301
+ def generate(self, messages):
302
+ self.turn += 1
303
+ if self.turn == 1:
304
+ return _json.dumps({"action": "read_file", "path": "calculator.py"})
305
+ return _json.dumps({"action": "propose", "summary": "Fix addition in a synthetic fixture.",
306
+ "checks": ["Run addition tests in an isolated worker."],
307
+ "changes": [{"path": "calculator.py",
308
+ "content": "def add(a, b):\n return a + b\n"}]})
309
+
310
+ with tempfile.TemporaryDirectory(prefix="ai-agent-demo-") as temp:
311
+ root = Path(temp) / "repo"
312
+ root.mkdir()
313
+ (root / "calculator.py").write_text("def add(a, b):\n return a - b\n", encoding="utf-8")
314
+ settings = config.Settings()
315
+ session_path = plan(Workspace(root), "Fix add", DemoProvider(), settings, Path(temp) / "runs",
316
+ progress=lambda _: None)
317
+ proposal = load_session(session_path)
318
+ apply_proposal(session_path, proposal["proposal_hash"])
319
+ result = verify(session_path)
320
+ changed = (root / "calculator.py").read_text() == "def add(a, b):\n return a + b\n"
321
+ rollback(session_path, proposal["proposal_hash"])
322
+ restored = (root / "calculator.py").read_text() == "def add(a, b):\n return a - b\n"
323
+ return {"proposal_apply_rollback": "passed" if changed and restored else "failed",
324
+ "static_checks": result, "llm_used": False,
325
+ "note": "Synthetic demo only. No project code executed. No build/test verification claimed."}
326
+
327
+
328
+ # A status that only exists as a colour is invisible in a terminal and unreadable to a screen reader,
329
+ # so every rendering of a row carries the word as well.
330
+ MARK = {OK: "[ok]", WARN: "[!]", BAD: "[x]", INFO: "[i]"}
331
+
332
+
333
+ def render(rows: list[dict]) -> str:
334
+ """The audit as text one line per fact, advice indented under the row it belongs to."""
335
+ out = []
336
+ for item in rows:
337
+ out.append(f"{MARK.get(item['status'], '[?]')} {item['text']}")
338
+ if item["advice"]:
339
+ out.append(" -> " + item["advice"])
340
+ return "\n".join(out)
341
+
342
+
343
+ def counts(rows: list[dict]) -> dict:
344
+ """How many rows say what. A first run that ends in three red lines is not a success."""
345
+ out = {status: 0 for status in (OK, WARN, BAD, INFO)}
346
+ for row_ in rows:
347
+ out[row_["status"]] = out.get(row_["status"], 0) + 1
348
+ return out
349
+
350
+
351
+ def tally(counts: dict, *, arabic: bool = False) -> str:
352
+ """`3 ok · 1 to watch · 0 blocking` — the count line both windows print and the card shows."""
353
+ return say(arabic,
354
+ en="{} ok · {} to watch · {} blocking".format(counts[OK], counts[WARN], counts[BAD]),
355
+ ar="{} تمام · {} تحت الملاحظة · {} مانع".format(counts[OK], counts[WARN], counts[BAD]))
356
+
357
+
358
+ def checks_done(counts: dict, *, arabic: bool = False) -> str:
359
+ """The one sentence a finished check produces. Both windows had written it themselves, which left
360
+ the Tk one saying it in English to a person reading Arabic."""
361
+ return say(arabic, en="Checks done: " + tally(counts) + ".",
362
+ ar="الفحوص خلصت: " + tally(counts, arabic=True) + ".")
363
+
364
+
365
+ def proof_done(passed: bool, note: str = "", *, arabic: bool = False) -> str:
366
+ """The verdict of the offline proof, in the same words whichever surface ran it."""
367
+ return say(arabic,
368
+ en=("The proof held: a proposal was applied, checked and rolled back in a temporary "
369
+ "folder." if passed else "The proof did not complete: " + str(note)[:120]),
370
+ ar=("الدليل نجح: مقترح اتطبّق واتفحص واتراجع في مجلد مؤقت." if passed else
371
+ "الدليل ما كملش: " + str(note)[:120]))
372
+
373
+
374
+ def first_run(app_dir: Path) -> bool:
375
+ """Whether this machine has ever granted a folder.
376
+
377
+ Deliberately about the registry and nothing else: a person who has projects listed does not want a
378
+ wizard, and one who has never named a folder has no way to know where to start.
379
+ """
380
+ try:
381
+ data = json.loads((Path(app_dir) / ".agent-projects.json").read_text(encoding="utf-8"))
382
+ except (OSError, ValueError):
383
+ return True
384
+ # A file the controller cannot read either — a list where it expects a mapping — counts as no
385
+ # registry: the wizard is one click to dismiss, and silence is not.
386
+ return not isinstance(data, dict) or not data.get("projects")