wizard-central-toolkit 0.2.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 (77) hide show
  1. wiz_central_toolkit/__init__.py +2 -0
  2. wiz_central_toolkit/bannerwiz/__main__.py +6 -0
  3. wiz_central_toolkit/bannerwiz/cli.py +79 -0
  4. wiz_central_toolkit/bannerwiz/lib/__init__.py +1 -0
  5. wiz_central_toolkit/bannerwiz/lib/banner_wizard.py +999 -0
  6. wiz_central_toolkit/bannerwiz/lib/banner_wrapper.py +199 -0
  7. wiz_central_toolkit/bannerwiz/lib/cli.py +79 -0
  8. wiz_central_toolkit/codeguardwiz/__init__.py +1 -0
  9. wiz_central_toolkit/codeguardwiz/__main__.py +19 -0
  10. wiz_central_toolkit/codeguardwiz/cli.py +971 -0
  11. wiz_central_toolkit/codeguardwiz/codeguard_wizard.py +269 -0
  12. wiz_central_toolkit/codeguardwiz/rust_engine.py +348 -0
  13. wiz_central_toolkit/codeguardwiz/static_engine.py +307 -0
  14. wiz_central_toolkit/common/__init__.py +1 -0
  15. wiz_central_toolkit/debugwiz/__init__.py +1 -0
  16. wiz_central_toolkit/debugwiz/__main__.py +360 -0
  17. wiz_central_toolkit/debugwiz/debug_wizard.py +364 -0
  18. wiz_central_toolkit/diffwiz/__init__.py +0 -0
  19. wiz_central_toolkit/diffwiz/__main__.py +21 -0
  20. wiz_central_toolkit/diffwiz/cli.py +134 -0
  21. wiz_central_toolkit/diffwiz/lib/__init__.py +1 -0
  22. wiz_central_toolkit/diffwiz/lib/cli.py +144 -0
  23. wiz_central_toolkit/diffwiz/lib/diff_wizard.py +89 -0
  24. wiz_central_toolkit/godfatherwiz/__init__.py +0 -0
  25. wiz_central_toolkit/godfatherwiz/__main__.py +18 -0
  26. wiz_central_toolkit/godfatherwiz/ai.py +136 -0
  27. wiz_central_toolkit/godfatherwiz/lib/__init__.py +1 -0
  28. wiz_central_toolkit/godfatherwiz/lib/cli.py +925 -0
  29. wiz_central_toolkit/godfatherwiz/lib/godfather_wizard.py +1552 -0
  30. wiz_central_toolkit/jsonwiz/__init__.py +0 -0
  31. wiz_central_toolkit/jsonwiz/__main__.py +21 -0
  32. wiz_central_toolkit/jsonwiz/cli.py +4 -0
  33. wiz_central_toolkit/jsonwiz/lib/__init__.py +1 -0
  34. wiz_central_toolkit/jsonwiz/lib/cli.py +138 -0
  35. wiz_central_toolkit/jsonwiz/lib/json_wizard.py +133 -0
  36. wiz_central_toolkit/pythonwiz/__init__.py +0 -0
  37. wiz_central_toolkit/pythonwiz/__main__.py +21 -0
  38. wiz_central_toolkit/pythonwiz/lib/__init__.py +1 -0
  39. wiz_central_toolkit/pythonwiz/lib/cli.py +247 -0
  40. wiz_central_toolkit/pythonwiz/lib/python_wizard.py +353 -0
  41. wiz_central_toolkit/readwiz/__init__.py +5 -0
  42. wiz_central_toolkit/readwiz/__main__.py +15 -0
  43. wiz_central_toolkit/readwiz/cli.py +46 -0
  44. wiz_central_toolkit/readwiz/lib/__init__.py +1 -0
  45. wiz_central_toolkit/readwiz/lib/cli.py +47 -0
  46. wiz_central_toolkit/readwiz/lib/read_wizard.py +121 -0
  47. wiz_central_toolkit/rustwiz/__init__.py +5 -0
  48. wiz_central_toolkit/rustwiz/__main__.py +7 -0
  49. wiz_central_toolkit/rustwiz/cli.py +4 -0
  50. wiz_central_toolkit/rustwiz/lib/__init__.py +1 -0
  51. wiz_central_toolkit/rustwiz/lib/cli.py +53 -0
  52. wiz_central_toolkit/rustwiz/lib/rust_wizard.py +128 -0
  53. wiz_central_toolkit/tmuxwiz/__init__.py +0 -0
  54. wiz_central_toolkit/tmuxwiz/__main__.py +15 -0
  55. wiz_central_toolkit/tmuxwiz/cli.py +7 -0
  56. wiz_central_toolkit/tmuxwiz/lib/__init__.py +1 -0
  57. wiz_central_toolkit/tmuxwiz/lib/cli.py +10 -0
  58. wiz_central_toolkit/tmuxwiz/lib/tmux_wizard.py +229 -0
  59. wiz_central_toolkit/todowiz/__init__.py +0 -0
  60. wiz_central_toolkit/todowiz/__main__.py +22 -0
  61. wiz_central_toolkit/todowiz/cli.py +4 -0
  62. wiz_central_toolkit/todowiz/lib/__init__.py +1 -0
  63. wiz_central_toolkit/todowiz/lib/cli.py +82 -0
  64. wiz_central_toolkit/todowiz/lib/todo_wizard.py +134 -0
  65. wiz_central_toolkit/tools_registry.py +62 -0
  66. wiz_central_toolkit/wiz/__init__.py +0 -0
  67. wiz_central_toolkit/wiz/__main__.py +141 -0
  68. wiz_central_toolkit/writewiz/__init__.py +1 -0
  69. wiz_central_toolkit/writewiz/__main__.py +9 -0
  70. wiz_central_toolkit/writewiz/cli.py +264 -0
  71. wiz_central_toolkit/writewiz/write_wizard.py +384 -0
  72. wizard_central_toolkit-0.2.0.dist-info/METADATA +203 -0
  73. wizard_central_toolkit-0.2.0.dist-info/RECORD +77 -0
  74. wizard_central_toolkit-0.2.0.dist-info/WHEEL +5 -0
  75. wizard_central_toolkit-0.2.0.dist-info/entry_points.txt +50 -0
  76. wizard_central_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
  77. wizard_central_toolkit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1552 @@
1
+ """godfather-wizard brain — the Grand Master, fleet dispatcher & REPL.
2
+
3
+ FATHER LAW:
4
+ He owns no logic. He routes intents to his citizens via shell.
5
+ Every mutation is staged through the Write Wizard consent wall; the
6
+ 'y' keystroke belongs to the Governor alone. Routing is a plain,
7
+ deterministic keyword table — anything unmapped is out of scope.
8
+ The REPL exposes all /slashcommands from the fleet; voice is enabled
9
+ on all responses when VOICE_ENABLED=true. The Godfather Wizard
10
+ governs execution and conversation.
11
+ """
12
+
13
+ import datetime as dt
14
+ import difflib
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import os
20
+ from collections import Counter
21
+ from importlib import metadata as importlib_metadata
22
+ from pathlib import Path
23
+
24
+ HOME = Path.home()
25
+ BIN = HOME / "bin"
26
+ BANK = HOME / ".godfather"
27
+ LEDGER = BANK / "ledger.jsonl"
28
+ ROUTER_DB = BANK / "routes.json"
29
+ STATE_FILE = BANK / "session_state.json"
30
+ VOICE_ENABLED = os.environ.get("VOICE_ENABLED", "true").lower() == "true"
31
+
32
+ FATHER_LAW = """FATHER LAW (Godfather Wizard, the Grand Master)
33
+ 1. DELEGATION: re-implements nothing; invokes siblings where
34
+ they live: the bin lane (~/bin) first, the installed package
35
+ second; a sibling found nowhere is honestly absent.
36
+ 2. TRUTH FIRST: multi-step runs anchor facts with the Godfather Wizard's own senses before acting.
37
+ 3. CONSENT WALL: mutations are staged via write_wizard propose; the
38
+ Godfather Wizard never supplies the 'y'. The commit keystroke
39
+ belongs to the Governor.
40
+ 4. DETERMINISTIC ROUTES: run dispatches via the keyword table;
41
+ unmapped intents return 'out of scope', never improvised.
42
+ 5. VERDICTS: plain [OK]/[FAIL]/out of scope, exit 0/1/2, no tracebacks.
43
+ 6. The Godfather Wizard governs execution + conversation.
44
+ 7. EVERY DISPATCH IS JOURNALED: all routing events recorded to ledger.
45
+ 8. REPL MODE exposes all /slashcommands from the fleet under one roof.
46
+ 9. VOICE ENABLED: when VOICE_ENABLED=true, every REPL response speaks.
47
+ 10. BANNER ON LAUNCH: every godfather_wizard repl shows the Sovereign mural.
48
+ """
49
+
50
+ # ------------------------------------------------------------------
51
+ # NAME FORMS — one citizen, two spellings (AVS, 2026-11-09). The
52
+ # full underscored form (godfather_wizard) is the wizard's
53
+ # name-command; the short form (godfatherwiz) is its alias. Both
54
+ # invoke the same citizen everywhere: dispatch, stored route banks
55
+ # written under either spelling, and the oracle's TOOL lines.
56
+ # ------------------------------------------------------------------
57
+
58
+ def _citizen_key(name):
59
+ """The canonical key for a citizen name in either spelling."""
60
+ if name.endswith("_wizard"):
61
+ return name[: -len("_wizard")] + "wiz"
62
+ return name
63
+
64
+
65
+ def _full_form(key):
66
+ """The full-form command for a citizen key (the short spelling)."""
67
+ if key != "wiz" and key.endswith("wiz"):
68
+ return key[: -len("wiz")] + "_wizard"
69
+ return key
70
+
71
+
72
+ def _short_form(name):
73
+ """The short spelling for a citizen name in either spelling."""
74
+ return _citizen_key(name)
75
+
76
+
77
+ def _ensure_bank():
78
+ BANK.mkdir(parents=True, exist_ok=True)
79
+ if not LEDGER.exists():
80
+ LEDGER.touch()
81
+ if not ROUTER_DB.exists():
82
+ default = {
83
+ "syntax/pycheck": ["python_wizard", "check"],
84
+ "diff/compare": ["diff_wizard", "files"],
85
+ "json/validate": ["json_wizard", "validate"],
86
+ "list/listing/whatshere": ["godfather_wizard", "listing"],
87
+ "models/ollama": ["godfather_wizard", "models"],
88
+ "todos/tasklist": ["todo_wizard", "list"],
89
+ "tmux/sessions": ["tmux_wizard", "list"],
90
+ "symlinks/bins": ["godfather_wizard", "symlinks"],
91
+ "diag/audit": ["debug_wizard", "audit"],
92
+ "heal/repair": ["godfather_wizard", "heal"],
93
+ }
94
+ ROUTER_DB.write_text(json.dumps(default, indent=2))
95
+ if not STATE_FILE.exists():
96
+ STATE_FILE.write_text(json.dumps({"session": 1, "slang": {}}, indent=2))
97
+
98
+ def load_routes():
99
+ _ensure_bank()
100
+ try:
101
+ return json.loads(ROUTER_DB.read_text())
102
+ except (json.JSONDecodeError, OSError):
103
+ return {}
104
+
105
+ def save_routes(routes):
106
+ _ensure_bank()
107
+ ROUTER_DB.write_text(json.dumps(routes, indent=2))
108
+
109
+ def _now():
110
+ return dt.datetime.now().isoformat(timespec="seconds")
111
+
112
+ def journal(event, target, detail=""):
113
+ _ensure_bank()
114
+ row = {"at": _now(), "event": event, "target": str(target), "detail": detail}
115
+ with LEDGER.open("a", encoding="utf-8") as f:
116
+ f.write(json.dumps(row, sort_keys=True) + "\n")
117
+
118
+ def ledger_rows():
119
+ if not LEDGER.exists():
120
+ return []
121
+ rows = []
122
+ for line in LEDGER.read_text(encoding="utf-8").splitlines():
123
+ try:
124
+ rows.append(json.loads(line))
125
+ except json.JSONDecodeError:
126
+ rows.append({"at": "?", "event": "CORRUPT_ROW", "target": line, "detail": ""})
127
+ return rows
128
+
129
+ def installed_sibling_names():
130
+ """Wizard names this installation itself provides (entry points).
131
+
132
+ A pip-installed copy of the toolkit registers its wizards as entry
133
+ points; those names are the fleet a user of the sampler actually has.
134
+ Returns an empty set when no distribution metadata is present (for
135
+ example when running from a bare source tree).
136
+ """
137
+ dist = None
138
+ for dist_name in ("wizard-central-toolkit", "wiz-central-toolkit"):
139
+ try:
140
+ dist = importlib_metadata.distribution(dist_name)
141
+ break
142
+ except importlib_metadata.PackageNotFoundError:
143
+ continue
144
+ if dist is None:
145
+ return set()
146
+ names = set()
147
+ for ep in dist.entry_points:
148
+ if ep.group in ("console_scripts", "wiz_central_toolkit"):
149
+ names.add(ep.name)
150
+ return names
151
+
152
+
153
+ def resolve_sibling(name):
154
+ """Find a sibling wizard's executable, or None.
155
+
156
+ The sovereign fleet's bin lane (~/bin) wins when the citizen is there;
157
+ otherwise the PATH is asked, which is where a package installer puts
158
+ the console scripts. A name that resolves nowhere is honestly absent.
159
+ """
160
+ exe = BIN / name
161
+ if exe.exists():
162
+ return exe
163
+ found = shutil.which(name)
164
+ return Path(found) if found else None
165
+
166
+
167
+ def call_sibling(cmd_list, stdin=None):
168
+ exe = resolve_sibling(cmd_list[0])
169
+ if exe is None:
170
+ return 2, f"[FAIL] sibling not found (bin lane or PATH): {cmd_list[0]}"
171
+ try:
172
+ r = subprocess.run([str(exe)] + cmd_list[1:], input=stdin,
173
+ capture_output=True, text=True, timeout=180)
174
+ return r.returncode, (r.stdout + r.stderr).strip()
175
+ except (subprocess.TimeoutExpired, OSError) as e:
176
+ return 1, f"[FAIL] sibling call failed: {e}"
177
+
178
+ def _ollama_roster():
179
+ """Model names a live Ollama daemon serves; [] when none serves."""
180
+ try:
181
+ r = subprocess.run(["ollama", "list"], capture_output=True,
182
+ text=True, timeout=60)
183
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
184
+ return []
185
+ if r.returncode != 0:
186
+ return []
187
+ return [ln.split()[0] for ln in r.stdout.strip().splitlines()[1:]
188
+ if ln.strip()]
189
+
190
+ def resolve_model():
191
+ """Honest model pick: env override if real, else first model from the live roster."""
192
+ wanted = os.environ.get("GODFATHER_MODEL", "").strip()
193
+ names = _ollama_roster()
194
+ if wanted and wanted in names:
195
+ return wanted
196
+ if names:
197
+ return names[0]
198
+ return wanted or "llama3.2:1b" # last resort; if this is a ghost the oracle will say so
199
+
200
+ def seated_model():
201
+ """The model a chat turn may consult, or None when no seat serves.
202
+
203
+ A seat is real only when a daemon is actually serving it — an
204
+ env var naming an unserved model is a wish, not a seat.
205
+ """
206
+ names = _ollama_roster()
207
+ if not names:
208
+ return None
209
+ wanted = os.environ.get("GODFATHER_MODEL", "").strip()
210
+ return wanted if wanted in names else names[0]
211
+
212
+ MODEL_TASKS = ("default", "brainstorm", "build", "debug", "diff",
213
+ "audit")
214
+ _CODEGEN_TASKS = ("brainstorm", "build", "debug", "diff")
215
+
216
+ # ------------------------------------------------------------------
217
+ # PROVIDERS — the six seats a model can occupy. Ollama is local and
218
+ # needs no key. The five cloud providers are the user's opt-in:
219
+ # each is seated only with the user's own API key, read from the
220
+ # environment at call time. A key is never stored in the bank,
221
+ # never journaled, and never printed — displays say only whether
222
+ # one is set.
223
+ # ------------------------------------------------------------------
224
+
225
+ PROVIDERS = {
226
+ "ollama": {
227
+ "display": "Ollama", "kind": "local", "host": "127.0.0.1:11434",
228
+ "url": "http://127.0.0.1:11434/api/chat", "key_envs": (),
229
+ },
230
+ "meta": {
231
+ "display": "Meta (Muse)", "kind": "cloud",
232
+ "host": "api.meta.ai",
233
+ "url": "https://api.meta.ai/v1/chat/completions",
234
+ "key_envs": ("MODEL_API_KEY", "META_API_KEY"),
235
+ },
236
+ "anthropic": {
237
+ "display": "Anthropic", "kind": "cloud",
238
+ "host": "api.anthropic.com",
239
+ "url": "https://api.anthropic.com/v1/messages",
240
+ "key_envs": ("ANTHROPIC_API_KEY",),
241
+ },
242
+ "openai": {
243
+ "display": "OpenAI", "kind": "cloud", "host": "api.openai.com",
244
+ "url": "https://api.openai.com/v1/chat/completions",
245
+ "key_envs": ("OPENAI_API_KEY",),
246
+ },
247
+ "google": {
248
+ "display": "Google", "kind": "cloud",
249
+ "host": "generativelanguage.googleapis.com",
250
+ "url": "https://generativelanguage.googleapis.com"
251
+ "/v1beta/openai/chat/completions",
252
+ "key_envs": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
253
+ },
254
+ "deepseek": {
255
+ "display": "DeepSeek", "kind": "cloud",
256
+ "host": "api.deepseek.com",
257
+ "url": "https://api.deepseek.com/v1/chat/completions",
258
+ "key_envs": ("DEEPSEEK_API_KEY",),
259
+ },
260
+ }
261
+ PROVIDER_ORDER = ("ollama", "meta", "anthropic", "openai", "google",
262
+ "deepseek")
263
+
264
+
265
+ def parse_seat(value):
266
+ """Split a stored seat value into (provider_id, model).
267
+
268
+ A bare name is an Ollama model — including tagged names like
269
+ 'llama3.2:1b', whose colon belongs to the model, not to a
270
+ provider. Only a head that names one of the six providers
271
+ makes the value provider-qualified.
272
+ """
273
+ text = str(value).strip()
274
+ head, sep, tail = text.partition(":")
275
+ if sep and head.lower() in PROVIDERS:
276
+ return head.lower(), tail
277
+ return "ollama", text
278
+
279
+
280
+ def seat_label(seat):
281
+ """'provider · model' — how a seat is named wherever it speaks."""
282
+ provider_id, model = seat
283
+ return f"{provider_id} · {model}"
284
+
285
+
286
+ def _provider_key(provider_id):
287
+ """(key, env_name) for a cloud provider, read live from the env.
288
+
289
+ env_name is the variable that supplied the key, or the
290
+ provider's primary variable when none is set — so an honest
291
+ line can name what is missing without ever touching a value.
292
+ """
293
+ envs = PROVIDERS[provider_id]["key_envs"]
294
+ for env in envs:
295
+ value = os.environ.get(env, "").strip()
296
+ if value:
297
+ return value, env
298
+ return None, (envs[0] if envs else "")
299
+
300
+
301
+ # Ollama model tags (the part after the colon in a bare name) are
302
+ # version-shaped: they start with a digit ('7b', '1b') or are one
303
+ # of a few plain words. A 'word:rest' value whose tail is not
304
+ # tag-shaped reads as a provider attempt, and an unknown provider
305
+ # is refused honestly rather than stored as a phantom Ollama name.
306
+ _OLLAMA_TAG_WORDS = ("latest", "mini", "small", "medium", "large",
307
+ "instruct", "chat", "base")
308
+
309
+
310
+ def _seat_value_refusal(value):
311
+ """None when a models.json value may be stored, else the refusal.
312
+
313
+ Bare Ollama names always store. A provider:model value stores
314
+ when its provider is one of the six. Anything else that looks
315
+ like a provider attempt is refused, listing the six.
316
+ """
317
+ if ":" not in value:
318
+ return None
319
+ head, tail = value.split(":", 1)
320
+ if head.lower() in PROVIDERS:
321
+ return None
322
+ if any(ch.isdigit() for ch in head) or "." in head:
323
+ return None # 'llama3.2:1b', 'qwen2.5-coder:7b' — model-shaped
324
+ first = tail.split("-", 1)[0].strip().lower()
325
+ if tail[:1].isdigit() or first in _OLLAMA_TAG_WORDS:
326
+ return None # 'codellama:7b', 'phi3:mini', 'mistral:latest'
327
+ return (f"[FAIL] unknown provider '{head}' — a seat is a bare "
328
+ f"Ollama model name, or provider:model with provider "
329
+ f"one of: {', '.join(PROVIDER_ORDER)}")
330
+
331
+
332
+ def _models_path():
333
+ """The per-task model store in the godfather bank."""
334
+ return BANK / "models.json"
335
+
336
+
337
+ def load_model_assignments():
338
+ """Task -> model assignments from ~/.godfather/models.json.
339
+
340
+ Only the six known task keys and non-empty string values are
341
+ honored; a missing or malformed store is simply no assignments.
342
+ Reading never creates the bank.
343
+ """
344
+ try:
345
+ data = json.loads(_models_path().read_text(encoding="utf-8"))
346
+ except (json.JSONDecodeError, OSError):
347
+ return {}
348
+ if not isinstance(data, dict):
349
+ return {}
350
+ return {task: str(data[task]).strip() for task in MODEL_TASKS
351
+ if isinstance(data.get(task), str) and data[task].strip()}
352
+
353
+
354
+ def save_model_assignments(assignments):
355
+ """Persist known task assignments to the godfather bank."""
356
+ _ensure_bank()
357
+ clean = {task: str(assignments[task]).strip()
358
+ for task in MODEL_TASKS
359
+ if isinstance(assignments.get(task), str)
360
+ and assignments[task].strip()}
361
+ _models_path().write_text(json.dumps(clean, indent=2) + "\n",
362
+ encoding="utf-8")
363
+ return clean
364
+
365
+
366
+ def set_model_assignment(task, model):
367
+ """Assign one task's model. Returns (rc, text)."""
368
+ if task not in MODEL_TASKS:
369
+ return 2, (f"[FAIL] unknown model task: {task} "
370
+ f"(choose one of: {', '.join(MODEL_TASKS)})")
371
+ model = str(model).strip()
372
+ if not model:
373
+ return 2, "[FAIL] model assignment requires a model name"
374
+ refusal = _seat_value_refusal(model)
375
+ if refusal:
376
+ return 2, refusal
377
+ assignments = load_model_assignments()
378
+ assignments[task] = model
379
+ save_model_assignments(assignments)
380
+ journal("MODEL_SET", task, model)
381
+ return 0, f"[OK] {task} model set to {model}"
382
+
383
+
384
+ def unset_model_assignment(task):
385
+ """Remove one task's model assignment. Returns (rc, text)."""
386
+ if task not in MODEL_TASKS:
387
+ return 2, (f"[FAIL] unknown model task: {task} "
388
+ f"(choose one of: {', '.join(MODEL_TASKS)})")
389
+ assignments = load_model_assignments()
390
+ old = assignments.pop(task, None)
391
+ save_model_assignments(assignments)
392
+ journal("MODEL_UNSET", task, old or "no assignment was set")
393
+ if old is None:
394
+ return 0, f"[OK] no {task} model assignment was set"
395
+ return 0, f"[OK] {task} model assignment removed ({old})"
396
+
397
+
398
+ def _assigned_model(task, assignments=None):
399
+ """The explicit assignment seat_for would use, before fallback."""
400
+ assignments = load_model_assignments() if assignments is None \
401
+ else assignments
402
+ if task != "default":
403
+ return assignments.get(task) or assignments.get("default")
404
+ return assignments.get("default")
405
+
406
+
407
+ def seat_for(task):
408
+ """The live seat for one task as (provider, model), or None.
409
+
410
+ Resolution is assignment first (the task's own entry, then
411
+ the store's default), then the existing live-roster pick. A
412
+ bare assignment is an Ollama model and must be in the live
413
+ roster; a provider:model assignment is a cloud seat and is
414
+ configured when the provider's key is in the environment —
415
+ the first real call is the proof of it, and its errors are
416
+ honest. An explicit assignment that does not serve is NOT
417
+ silently replaced: the seat is absent until that model
418
+ serves (or its key is set).
419
+ """
420
+ if task not in MODEL_TASKS:
421
+ return None
422
+ assigned = _assigned_model(task)
423
+ if assigned:
424
+ provider_id, model = parse_seat(assigned)
425
+ if provider_id == "ollama":
426
+ return ("ollama", model) if model in _ollama_roster() else None
427
+ key, _env = _provider_key(provider_id)
428
+ return (provider_id, model) if key else None
429
+ active = seated_model()
430
+ return ("ollama", active) if active else None
431
+
432
+
433
+ def seat_unavailable_line(task):
434
+ """One plain, factual line explaining why a task has no seat."""
435
+ assigned = _assigned_model(task)
436
+ if assigned:
437
+ provider_id, model = parse_seat(assigned)
438
+ if provider_id != "ollama":
439
+ _key, primary = _provider_key(provider_id)
440
+ return (f"no {task} model seat is serving — seat "
441
+ f"{provider_id}:{model} assigned but {primary} "
442
+ f"is not set")
443
+ names = _ollama_roster()
444
+ if names:
445
+ return (f"no {task} model seat is serving — assigned model "
446
+ f"'{assigned}' is not in the live Ollama roster")
447
+ return (f"no {task} model seat is serving — assigned model "
448
+ f"'{assigned}' is not serving")
449
+ return ("no model seat is configured or serving — "
450
+ f"the {task} feature needs a live model")
451
+
452
+
453
+ def seated_models():
454
+ """The seats answer (/models): providers, roster, assignments."""
455
+ names = _ollama_roster()
456
+ assignments = load_model_assignments()
457
+ lines = []
458
+ any_seat = any(seat_for(task) is not None for task in MODEL_TASKS)
459
+ if not names and not any_seat:
460
+ lines.append("no model seat is configured or serving — "
461
+ "the fleet runs seatless")
462
+ elif names:
463
+ active = seated_model()
464
+ lines.append("[OK] model seats — Ollama is serving:")
465
+ for n in names:
466
+ lines.append(f" {n} <- seated" if n == active else f" {n}")
467
+ else:
468
+ lines.append("Ollama is not serving — no local roster "
469
+ "(a configured cloud seat may still serve)")
470
+ lines.append("providers:")
471
+ for provider_id in PROVIDER_ORDER:
472
+ spec = PROVIDERS[provider_id]
473
+ if provider_id == "ollama":
474
+ status = (f"serving — {len(names)} model(s)" if names
475
+ else "not serving")
476
+ else:
477
+ key, _env = _provider_key(provider_id)
478
+ status = "key set" if key else "key not set"
479
+ lines.append(f" {spec['display']} ({spec['kind']} — "
480
+ f"{spec['host']}): {status}")
481
+ lines.append("model assignments (~/.godfather/models.json):")
482
+ for task in MODEL_TASKS:
483
+ assigned = assignments.get(task)
484
+ if assigned:
485
+ provider_id, model = parse_seat(assigned)
486
+ if provider_id == "ollama":
487
+ state = "serving" if model in names else "not serving"
488
+ else:
489
+ key, _env = _provider_key(provider_id)
490
+ state = "key set" if key else "key not set"
491
+ lines.append(f" {task}: {assigned} ({state})")
492
+ else:
493
+ effective = seat_for(task)
494
+ detail = (f"effective seat: {seat_label(effective)}"
495
+ if effective else "no effective seat")
496
+ lines.append(f" {task}: (not set — {detail})")
497
+ lines.append("guide: PROVIDERS.md — where to get each provider's "
498
+ "key, and how to set up local models with Ollama")
499
+ return 0, "\n".join(lines)
500
+
501
+ def call_ollama(prompt, model=None):
502
+ """Consult the oracle: ollamawiz first when it exists, Ollama
503
+ binary as honest fallback. When ollamawiz resolves nowhere, the
504
+ doomed sibling call is skipped silently — same destination,
505
+ one less corpse on the road."""
506
+ actual_model = model or resolve_model()
507
+ if resolve_sibling("ollamawiz") is not None:
508
+ rc, out = call_sibling(["ollamawiz", "ask", actual_model, prompt])
509
+ if rc == 0 and out and not out.startswith("[FAIL]"):
510
+ return rc, out
511
+ try:
512
+ r = subprocess.run(["ollama", "run", actual_model, prompt],
513
+ capture_output=True, text=True, timeout=300)
514
+ if r.returncode == 0 and r.stdout.strip():
515
+ return 0, r.stdout.strip()
516
+ return 1, (r.stderr or "[FAIL] oracle returned nothing").strip()
517
+ except FileNotFoundError:
518
+ return 1, "[FAIL] neither ollamawiz ask nor ollama binary available"
519
+ except subprocess.TimeoutExpired:
520
+ return 1, "[FAIL] oracle timed out"
521
+
522
+ def speak_if_enabled(text):
523
+ """Speak text if VOICE_ENABLED=true, otherwise silent."""
524
+ if not VOICE_ENABLED:
525
+ return
526
+ rc, out = call_sibling(["voicewiz", "speak", text]) # the whole word, uncut
527
+ # Silent success expected
528
+
529
+ # ------------------------------------------------------------------
530
+ # INTERNAL SENSES — truthwiz's office, absorbed into godfatherwiz
531
+ # (R2 ruling: absorb where the purpose lives — Law 2's anchor is
532
+ # godfather's own duty, so its senses live here now). Every sense
533
+ # is read-only, returns call_sibling's (rc, text) shape, and a
534
+ # missing path is a plain [FAIL], exit 1 — never a traceback.
535
+ # ------------------------------------------------------------------
536
+
537
+ _READ_CAP = 20000 # chars a single `read` will show before truncating
538
+
539
+ def _human_bytes(n):
540
+ if n < 1024:
541
+ return f"{n} bytes"
542
+ if n < 1024 ** 2:
543
+ return f"{n} bytes ({n / 1024:.1f} KB)"
544
+ if n < 1024 ** 3:
545
+ return f"{n} bytes ({n / 1024 ** 2:.1f} MB)"
546
+ return f"{n} bytes ({n / 1024 ** 3:.1f} GB)"
547
+
548
+ def _sense_target(raw):
549
+ return Path(str(raw)).expanduser()
550
+
551
+ def _read_text_guarded(p):
552
+ """(text, None) for a readable text file, else (None, fail_line).
553
+
554
+ A NUL byte marks the file binary even when its bytes happen to
555
+ decode — control bytes are not text a person can read.
556
+ """
557
+ try:
558
+ data = p.read_bytes()
559
+ except OSError as e:
560
+ return None, f"[FAIL] cannot read {p}: {e.strerror or e}"
561
+ if b"\x00" in data:
562
+ return None, f"[FAIL] not a text file: {p}"
563
+ try:
564
+ return data.decode("utf-8"), None
565
+ except UnicodeDecodeError:
566
+ return None, f"[FAIL] not a text file: {p}"
567
+
568
+ def _walk_sizes(root):
569
+ """(total_bytes, file_count) under root, symlinks not followed."""
570
+ total, count = 0, 0
571
+ for dirpath, _dirnames, filenames in os.walk(root):
572
+ for name in filenames:
573
+ try:
574
+ total += os.lstat(os.path.join(dirpath, name)).st_size
575
+ count += 1
576
+ except OSError:
577
+ continue
578
+ return total, count
579
+
580
+ def sense_brief(raw_path):
581
+ """Ground truth: what the path is, its size, and its shape."""
582
+ p = _sense_target(raw_path)
583
+ if not p.exists() and not p.is_symlink():
584
+ return 1, f"[FAIL] no such path: {p}"
585
+ lines = [f"[BRIEF] {p}"]
586
+ if p.is_symlink():
587
+ lines.append(f"link: symlink -> {os.readlink(p)}")
588
+ if p.is_dir():
589
+ try:
590
+ entries = sorted(p.iterdir(), key=lambda e: e.name)
591
+ except OSError as e:
592
+ return 1, f"[FAIL] cannot list {p}: {e.strerror or e}"
593
+ dirs = sum(1 for e in entries if e.is_dir() and not e.is_symlink())
594
+ links = sum(1 for e in entries if e.is_symlink())
595
+ files = len(entries) - dirs - links
596
+ total, deep_files = _walk_sizes(p)
597
+ lines.append("type: directory")
598
+ lines.append(f"entries: {len(entries)} "
599
+ f"({dirs} dirs, {files} files, {links} symlinks)")
600
+ lines.append(f"size: {_human_bytes(total)} across {deep_files} files")
601
+ lines.append("listing:")
602
+ for e in entries[:20]:
603
+ if e.is_symlink():
604
+ lines.append(f" {e.name} -> {os.readlink(e)}")
605
+ elif e.is_dir():
606
+ lines.append(f" {e.name}/")
607
+ else:
608
+ lines.append(f" {e.name}")
609
+ if len(entries) > 20:
610
+ lines.append(f" ... (+{len(entries) - 20} more)")
611
+ return 0, "\n".join(lines)
612
+ st = p.stat()
613
+ lines.append("type: file")
614
+ lines.append(f"size: {_human_bytes(st.st_size)}")
615
+ text, _err = _read_text_guarded(p)
616
+ if text is not None:
617
+ lines.append(f"lines: {len(text.splitlines())}")
618
+ else:
619
+ lines.append("lines: (binary — not counted)")
620
+ return 0, "\n".join(lines)
621
+
622
+ def sense_listing(raw_path):
623
+ """The directory's entries, one per line, dirs marked."""
624
+ p = _sense_target(raw_path)
625
+ if not p.exists() and not p.is_symlink():
626
+ return 1, f"[FAIL] no such path: {p}"
627
+ if not p.is_dir():
628
+ return 0, p.name
629
+ try:
630
+ entries = sorted(p.iterdir(), key=lambda e: e.name)
631
+ except OSError as e:
632
+ return 1, f"[FAIL] cannot list {p}: {e.strerror or e}"
633
+ if not entries:
634
+ return 0, "(empty directory)"
635
+ out = []
636
+ for e in entries:
637
+ if e.is_symlink():
638
+ out.append(f"{e.name} -> {os.readlink(e)}")
639
+ elif e.is_dir():
640
+ out.append(f"{e.name}/")
641
+ else:
642
+ out.append(e.name)
643
+ return 0, "\n".join(out)
644
+
645
+ def sense_read(raw_path):
646
+ """A file's text, capped — truncation is stated, never silent."""
647
+ p = _sense_target(raw_path)
648
+ if not p.exists() and not p.is_symlink():
649
+ return 1, f"[FAIL] no such path: {p}"
650
+ if p.is_dir():
651
+ return 1, f"[FAIL] {p} is a directory — use listing for directories"
652
+ text, err = _read_text_guarded(p)
653
+ if text is None:
654
+ return 1, err
655
+ if len(text) > _READ_CAP:
656
+ return 0, text[:_READ_CAP] + \
657
+ f"\n... [truncated: showing first {_READ_CAP} of {len(text)} chars]"
658
+ return 0, text
659
+
660
+ def sense_head(raw_path, n=20):
661
+ """The first n lines of a text file."""
662
+ p = _sense_target(raw_path)
663
+ if not p.exists() and not p.is_symlink():
664
+ return 1, f"[FAIL] no such path: {p}"
665
+ if p.is_dir():
666
+ return 1, f"[FAIL] {p} is a directory — use listing for directories"
667
+ text, err = _read_text_guarded(p)
668
+ if text is None:
669
+ return 1, err
670
+ all_lines = text.splitlines()
671
+ shown = all_lines[:n]
672
+ if len(all_lines) > n:
673
+ shown.append(f"... (first {n} of {len(all_lines)} lines)")
674
+ return 0, "\n".join(shown)
675
+
676
+ def sense_find(root, name_part):
677
+ """Paths under root whose name contains name_part (case-insensitive)."""
678
+ p = _sense_target(root)
679
+ if not p.exists() and not p.is_symlink():
680
+ return 1, f"[FAIL] no such path: {p}"
681
+ needle = str(name_part).lower()
682
+ hits = []
683
+ if p.is_dir():
684
+ for dirpath, dirnames, filenames in os.walk(p):
685
+ for name in dirnames + filenames:
686
+ if needle in name.lower():
687
+ hits.append(os.path.join(dirpath, name))
688
+ elif needle in p.name.lower():
689
+ hits.append(str(p))
690
+ hits.sort()
691
+ if not hits:
692
+ return 0, f"(no matches for '{name_part}' under {p})"
693
+ if len(hits) > 200:
694
+ return 0, "\n".join(hits[:200]) + \
695
+ f"\n... (+{len(hits) - 200} more matches)"
696
+ return 0, "\n".join(hits)
697
+
698
+ def sense_du(raw_path):
699
+ """Disk usage, in human terms."""
700
+ p = _sense_target(raw_path)
701
+ if not p.exists() and not p.is_symlink():
702
+ return 1, f"[FAIL] no such path: {p}"
703
+ if p.is_dir():
704
+ total, count = _walk_sizes(p)
705
+ return 0, f"{_human_bytes(total)} across {count} files — {p}"
706
+ return 0, f"{_human_bytes(os.lstat(p).st_size)} — {p}"
707
+
708
+ def sense_stat(raw_path):
709
+ """Type, size, and modification time."""
710
+ p = _sense_target(raw_path)
711
+ if not p.exists() and not p.is_symlink():
712
+ return 1, f"[FAIL] no such path: {p}"
713
+ st = os.lstat(p)
714
+ if p.is_symlink():
715
+ kind = "symlink"
716
+ elif p.is_dir():
717
+ kind = "directory"
718
+ elif p.is_file():
719
+ kind = "file"
720
+ else:
721
+ kind = "other"
722
+ lines = [f"[STAT] {p}",
723
+ f"type: {kind}",
724
+ f"size: {_human_bytes(st.st_size)}",
725
+ "modified: " + dt.datetime.fromtimestamp(st.st_mtime)
726
+ .isoformat(timespec="seconds")]
727
+ if p.is_symlink():
728
+ lines.append(f"target: {os.readlink(p)}")
729
+ return 0, "\n".join(lines)
730
+
731
+ def sense_symlinks(raw_path):
732
+ """Symlinks at or under the path, each with its target."""
733
+ p = _sense_target(raw_path)
734
+ if not p.exists() and not p.is_symlink():
735
+ return 1, f"[FAIL] no such path: {p}"
736
+ found = []
737
+ if p.is_symlink():
738
+ found.append(p)
739
+ elif p.is_dir():
740
+ for dirpath, dirnames, filenames in os.walk(p):
741
+ for name in dirnames + filenames:
742
+ e = Path(dirpath) / name
743
+ if e.is_symlink():
744
+ found.append(e)
745
+ if not found:
746
+ return 0, f"(no symlinks at {p})"
747
+ return 0, "\n".join(f"{e} -> {os.readlink(e)}" for e in sorted(found))
748
+
749
+ SENSE_VERBS = ("brief", "listing", "read", "head", "find", "du", "stat",
750
+ "symlinks")
751
+ _PATH_SENSES = ("brief", "listing", "read", "du", "stat", "symlinks")
752
+
753
+ def call_sense(verb, args):
754
+ """Dispatch one absorbed sense. args are raw string tokens.
755
+
756
+ Path senses default to '.' when no path is given — the anchor's
757
+ home ground, the same default truth_anchor has always used.
758
+ Returns (rc, text), call_sibling's shape.
759
+ """
760
+ args = [str(a) for a in args]
761
+ if verb in _PATH_SENSES:
762
+ raw = args[0] if args else "."
763
+ fn = {"brief": sense_brief, "listing": sense_listing,
764
+ "read": sense_read, "du": sense_du, "stat": sense_stat,
765
+ "symlinks": sense_symlinks}[verb]
766
+ return fn(raw)
767
+ if verb == "head":
768
+ if not args:
769
+ return 2, "[FAIL] head requires a file"
770
+ n = 20
771
+ if len(args) > 1:
772
+ try:
773
+ n = int(args[1])
774
+ except ValueError:
775
+ return 2, "[FAIL] head line count must be a number"
776
+ return sense_head(args[0], n)
777
+ if verb == "find":
778
+ if len(args) < 2:
779
+ return 2, "[FAIL] find requires a root and a name part"
780
+ return sense_find(args[0], args[1])
781
+ return 2, f"out of scope - unknown sense: {verb}"
782
+
783
+ def call_internal(parts):
784
+ """Serve an in-house capability, or return None.
785
+
786
+ parts = ['godfather_wizard', <verb>, <args...>] where <verb> is
787
+ one of the absorbed truth office's senses, or 'models' (the
788
+ seats answer); anything else is not internal and the caller
789
+ falls back to call_sibling. The alias spelling 'godfatherwiz'
790
+ names the same in-house citizen and is served identically.
791
+ """
792
+ if len(parts) >= 2 and parts[0] in ("godfatherwiz", "godfather_wizard"):
793
+ if parts[1] in SENSE_VERBS:
794
+ return call_sense(parts[1], parts[2:])
795
+ if parts[1] == "models":
796
+ return seated_models()
797
+ return None
798
+
799
+ def truth_anchor(path="."):
800
+ """Law 2: anchor ground truth before multi-step action.
801
+
802
+ Served by the absorbed senses — godfatherwiz's own brief, in-house.
803
+ """
804
+ return sense_brief(str(Path(path).expanduser()))
805
+
806
+ _MUTATION_WORDS = {"write", "fix", "create", "delete", "remove",
807
+ "modify", "edit", "rm", "heal", "repair"}
808
+
809
+ # writewiz verbs that change files when executed. propose stages a
810
+ # draft — a suggestion, behind no wall; the rest of the read/inspect
811
+ # family changes nothing. commit lands the write; discard, rollback
812
+ # and purge-old destroy or restore; protect/unprotect rewrite the
813
+ # protection record itself.
814
+ _WRITEWIZ_WRITE_VERBS = {"commit", "discard", "rollback", "purge-old",
815
+ "protect", "unprotect"}
816
+
817
+ def pipeline_is_write_class(pipeline):
818
+ """True when executing this pipeline would change files.
819
+
820
+ The wall's true scope (house law): read-only routes run free,
821
+ staging routes are suggestions and run free, and only a route
822
+ that would actually land a change is write-class.
823
+ """
824
+ if not pipeline:
825
+ return False
826
+ head = _short_form(pipeline[0])
827
+ sub = pipeline[1] if len(pipeline) > 1 else ""
828
+ if head == "writewiz":
829
+ return sub in _WRITEWIZ_WRITE_VERBS
830
+ if head == "codeguardwiz" and sub == "fix":
831
+ # fix stages through writewiz by default; only its explicit
832
+ # --write lane lands a change directly.
833
+ return "--write" in pipeline[2:]
834
+ if head == "repairwiz" and sub in ("commit", "apply"):
835
+ return True
836
+ return False
837
+
838
+ def _path_tokens(directive, keys):
839
+ """Directive tokens that name existing paths ride as arguments."""
840
+ out = []
841
+ for tok in directive.split():
842
+ if tok.lower() in keys:
843
+ continue
844
+ if Path(tok).expanduser().exists():
845
+ out.append(tok)
846
+ return out
847
+
848
+ def classify_route(directive, args):
849
+ """Resolve a directive. Returns (pipeline, reason, wall).
850
+
851
+ - (None, reason, False): no route matches — out of scope.
852
+ - (None, reason, True): unmapped mutation intent — refused at
853
+ the wall, as it always has been.
854
+ - (pipeline, reason, False): a read-class or staging route —
855
+ runs free, whatever words the sentence carried.
856
+ - (pipeline, reason, True): a write-class route — the caller
857
+ asks twice before anything executes.
858
+ Explicit args win; otherwise directive tokens naming existing
859
+ paths ride along as the pipeline's arguments.
860
+ """
861
+ d = directive.lower()
862
+ routes = load_routes()
863
+ for keywords, pipeline in routes.items():
864
+ keys = keywords.split("/")
865
+ if any(k in d for k in keys):
866
+ ride = list(args) if args else _path_tokens(directive, keys)
867
+ full = list(pipeline) + ride
868
+ if pipeline_is_write_class(full):
869
+ return full, f"write-class route: {keywords}", True
870
+ return full, f"matched route: {keywords}", False
871
+ if any(w in d for w in _MUTATION_WORDS):
872
+ return None, "mutation intent detected — route through write_wizard", True
873
+ return None, "no route matches this intent", False
874
+
875
+ def route_directive(directive, args):
876
+ """Deterministic dispatch. Returns (pipeline, reason) or None."""
877
+ pipeline, reason, wall = classify_route(directive, args)
878
+ if wall:
879
+ return ("WALL", reason)
880
+ return (pipeline, reason)
881
+
882
+ def fleet_census():
883
+ """Health sweep: the siblings that can actually be invoked.
884
+
885
+ Counts the bin lane's executables plus every wizard this
886
+ installation provides (its entry points) that resolves to a real
887
+ executable. One citizen under two spellings (godfatherwiz /
888
+ godfather_wizard) is ONE citizen, listed by its full-form
889
+ command when that spelling invokes, else by the spelling that
890
+ does. One meaning of 'present', shared by fleet, siblings,
891
+ census and status: installed and invocable.
892
+ """
893
+ raw = {w.name for w in BIN.glob("*wiz")}
894
+ raw |= {w.name for w in BIN.glob("*_wizard")}
895
+ raw |= {n for n in installed_sibling_names()
896
+ if (n.endswith("wiz") or n.endswith("_wizard"))
897
+ and resolve_sibling(n) is not None}
898
+ spellings = {}
899
+ for name in raw:
900
+ spellings.setdefault(_citizen_key(name), set()).add(name)
901
+ names = []
902
+ for key in sorted(spellings):
903
+ full = _full_form(key)
904
+ names.append(full if full in spellings[key] else key)
905
+ return len(names), names
906
+
907
+
908
+ def fleet_invocable_names():
909
+ """Every spelling that invokes a citizen — the census names
910
+ plus their alias spellings. The oracle's whitelist and the
911
+ REPL's /wiz matching accept either spelling of a citizen."""
912
+ _count, names = fleet_census()
913
+ out = set(names)
914
+ for n in names:
915
+ out.add(_citizen_key(n))
916
+ out.add(_full_form(_citizen_key(n)))
917
+ return out
918
+
919
+ def list_all_slash_commands():
920
+ """Return mapping of /slash -> wizard command.
921
+
922
+ One key per slash. The values are dispatch pipelines in
923
+ full-form commands (the REPL splits them into argv). /diff
924
+ carries ONE entry — the Diff Wizard's two-file comparison;
925
+ the codegen review sense of /diff is dispatched by argument
926
+ shape in the REPL before this table is consulted, and /help
927
+ names it on its own line.
928
+ """
929
+ # Consolidated fleet-wide slash-command registry
930
+ slash_map = {
931
+ "/truth": "godfather_wizard brief",
932
+ "/ls": "godfather_wizard listing",
933
+ "/read": "godfather_wizard read",
934
+ "/pycheck": "python_wizard check",
935
+ "/fix": "godfather_wizard heal",
936
+ "/heal": "godfather_wizard heal",
937
+ "/todo": "todo_wizard add",
938
+ "/todos": "todo_wizard list",
939
+ "/diff": "diff_wizard files",
940
+ "/models": "godfather_wizard models",
941
+ "/brainstorm": "godfather_wizard brainstorm (model seat; hands off)",
942
+ "/build": "godfather_wizard build (proposal -> diff -> double ask -> write_wizard)",
943
+ "/debug": "godfather_wizard debug (proposal -> diff -> double ask -> write_wizard)",
944
+ "/banner": "banner_wizard",
945
+ "/codeguard": "codeguard_wizard",
946
+ "/json": "json_wizard",
947
+ "/rust": "rust_wizard",
948
+ "/tmux": "tmux_wizard",
949
+ "/write": "write_wizard",
950
+ "/verbs": "read_wizard verbs",
951
+ "/tasks": "todo_wizard list",
952
+ "/audit": "debug_wizard audit",
953
+ "/diag": "debug_wizard classify",
954
+ "/bins": "godfather_wizard symlinks",
955
+ "/history": "godfather_wizard history",
956
+ "/stats": "godfather_wizard stats",
957
+ "/routes": "godfather_wizard routes",
958
+ "/fleet": "godfather_wizard fleet",
959
+ "/law": "godfather_wizard law",
960
+ "/help": "godfather_wizard help",
961
+ "/status": "godfather_wizard status",
962
+ "/census": "godfather_wizard census",
963
+ }
964
+ return slash_map
965
+
966
+ def session_load():
967
+ _ensure_bank()
968
+ try:
969
+ return json.loads(STATE_FILE.read_text())
970
+ except (json.JSONDecodeError, OSError):
971
+ return {"session": 1}
972
+
973
+ def session_save(state):
974
+ _ensure_bank()
975
+ STATE_FILE.write_text(json.dumps(state, indent=2))
976
+
977
+ def schedule_task(intent, delay_secs=0):
978
+ """Queue a task with timestamp."""
979
+ _ensure_bank()
980
+ task_id = hash(f"{intent}{_now()}") % 100000
981
+ task = {"id": task_id, "intent": intent, "scheduled_at": _now(),
982
+ "delay_sec": delay_secs, "status": "queued"}
983
+ tasks = load_routes()
984
+ tasks[f"task_{task_id}"] = task
985
+ save_routes(tasks)
986
+ journal("SCHEDULED", str(task_id), intent)
987
+ return task_id
988
+
989
+ def queue_tasks():
990
+ """Show scheduled tasks."""
991
+ _ensure_bank()
992
+ tasks = load_routes()
993
+ return [v for k, v in tasks.items() if k.startswith("task_")]
994
+
995
+ def render_banner():
996
+ """Sovereign mural: figlet + rich 16-color, guaranteed to fit the terminal."""
997
+ import shutil
998
+ try:
999
+ from pyfiglet import Figlet
1000
+ from rich.console import Console
1001
+ from rich.text import Text
1002
+ except ImportError:
1003
+ print("=== GODFATHER — GRAND MASTER OF THE SOVEREIGN FLEET ===")
1004
+ return
1005
+
1006
+ width = shutil.get_terminal_size((100, 24)).columns
1007
+ text = "GODFATHER"
1008
+ subtitle = "G R A N D M A S T E R O F T H E S O V E R E I G N F L E E T"
1009
+
1010
+ # Width-fit: walk fonts until the art fits with a 2-col margin
1011
+ art = None
1012
+ for font in ("slant", "smslant", "small", "term", "digital"):
1013
+ candidate = Figlet(font=font, width=width).renderText(text).rstrip("\n")
1014
+ longest = max((len(ln) for ln in candidate.splitlines()), default=0)
1015
+ if longest <= width - 2:
1016
+ art = candidate
1017
+ break
1018
+ if art is None: # terminal too narrow for any font — plain, clean, honest
1019
+ print("=== GODFATHER — GRAND MASTER OF THE SOVEREIGN FLEET ===")
1020
+ return
1021
+
1022
+ console = Console(highlight=False)
1023
+ console.print(Text(art, style="bright_magenta bold"))
1024
+ if len(subtitle) <= width - 2:
1025
+ console.print(Text(subtitle, style="bright_yellow"))
1026
+ else:
1027
+ console.print(Text("GRAND MASTER", style="bright_yellow"))
1028
+ _, _census_names = fleet_census()
1029
+ console.print(Text(f"{width} cols | Sovereign Fleet: {len(_census_names)} citizens, zero lies",
1030
+ style="bright_cyan"))
1031
+
1032
+ # ------------------------------------------------------------------
1033
+ # ORACLE WITH HANDS — the Governor's tool channel for the cockpit
1034
+ # ------------------------------------------------------------------
1035
+
1036
+ ORACLE_SPINE = """You command the Sovereign Fleet — check AVAILABLE TOOLS below for the live citizen count and capacity.
1037
+ You act as the central dispatcher and reasoning engine for all interactive wizard queries.
1038
+ You command the real CLI wizards listed below on this machine. You have NO eyes and NO hands
1039
+ of your own. Your only way to act or observe is the TOOL channel.
1040
+
1041
+ TOOL CHANNEL — to request one real action, reply with a single line, exactly:
1042
+ TOOL: <command>
1043
+ where <command> is a real wizard invocation from the AVAILABLE TOOLS list.
1044
+
1045
+ Your eyes are godfather_wizard's own senses — brief, listing, read, head,
1046
+ find, du, stat, symlinks — invoked as pipelines, e.g.
1047
+ "godfather_wizard listing <path>". They are read-only and always lawful.
1048
+
1049
+ LAWS:
1050
+ - ONE tool call per reply. Then STOP and wait for the true result.
1051
+ - NEVER invent output, file names, or states you have not been shown.
1052
+ - NEVER use shell commands (ls, cat, rm, pwd...) — they are forbidden and rejected.
1053
+ - When you have enough truth, drop the channel and answer plainly.
1054
+ - If no tool fits, answer plainly and say so.
1055
+
1056
+ WORKED EXAMPLE — if the user asks "what files are in ~/wizard_central?",
1057
+ your ENTIRE reply must be exactly this one line:
1058
+ TOOL: godfather_wizard listing __HOME__/wizard_central
1059
+
1060
+ Then stop. The true result will be given to you, and you summarize it.
1061
+ """
1062
+
1063
+ import os as _os_home
1064
+ ORACLE_SPINE = ORACLE_SPINE.replace("__HOME__", _os_home.path.expanduser("~"))
1065
+
1066
+ def _oracle_write_consent(tool_line, parts):
1067
+ """The double ask (house law) for a write-class TOOL line.
1068
+
1069
+ Staging and read lanes never reach here — pipeline_is_write_class
1070
+ has already cleared them to run free. First the plain ask; then a
1071
+ typed confirmation naming the pipeline. Anything but the two
1072
+ exact answers — including EOF — is a no.
1073
+ """
1074
+ action = " ".join(parts)
1075
+ try:
1076
+ first = input(f"\n⚠️ oracle wants to WRITE: {tool_line}\n"
1077
+ "this changes files. proceed? [y/N] ")
1078
+ except (KeyboardInterrupt, EOFError):
1079
+ return False
1080
+ if first.strip().lower() != "y":
1081
+ return False
1082
+ try:
1083
+ typed = input(f"confirm by typing: yes, {action}\n> ")
1084
+ except (KeyboardInterrupt, EOFError):
1085
+ return False
1086
+ return typed.strip() == f"yes, {action}"
1087
+
1088
+ def _ollama_chat_api(messages, model):
1089
+ """Direct stdlib chat call — no external deps, honest failures."""
1090
+ import json as _j
1091
+ import urllib.request
1092
+ import urllib.error
1093
+ payload = _j.dumps({"model": model, "messages": messages,
1094
+ "stream": False}).encode()
1095
+ req = urllib.request.Request(
1096
+ "http://127.0.0.1:11434/api/chat",
1097
+ data=payload, headers={"Content-Type": "application/json"})
1098
+ import os as _os
1099
+ _timeout = int(_os.environ.get("WIZ_OLLAMA_TIMEOUT", "600"))
1100
+ with urllib.request.urlopen(req, timeout=_timeout) as r:
1101
+ data = _j.loads(r.read().decode())
1102
+ return data.get("message", {}).get("content", "")
1103
+
1104
+ # ------------------------------------------------------------------
1105
+ # SEAT CHAT — the one door every model turn walks through. A seat
1106
+ # is (provider, model): Ollama underneath is the local call above;
1107
+ # the cloud providers are stdlib-urllib calls to their own APIs
1108
+ # with the user's own key. Failures raise RuntimeError carrying a
1109
+ # clean line — status and the provider's own message, trimmed;
1110
+ # never a traceback, and never the key.
1111
+ # ------------------------------------------------------------------
1112
+
1113
+ def _cloud_timeout():
1114
+ try:
1115
+ return int(os.environ.get("WIZ_CLOUD_TIMEOUT", "120"))
1116
+ except ValueError:
1117
+ return 120
1118
+
1119
+
1120
+ def _post_json(url, payload, headers, provider_id):
1121
+ """POST one JSON body and return the parsed JSON reply."""
1122
+ import urllib.request
1123
+ import urllib.error
1124
+ display = PROVIDERS[provider_id]["display"]
1125
+ req = urllib.request.Request(
1126
+ url, data=json.dumps(payload).encode(), headers=headers)
1127
+ try:
1128
+ with urllib.request.urlopen(req, timeout=_cloud_timeout()) as r:
1129
+ raw = r.read().decode("utf-8", "replace")
1130
+ except urllib.error.HTTPError as e:
1131
+ detail = ""
1132
+ try:
1133
+ parsed = json.loads(e.read().decode("utf-8", "replace"))
1134
+ err = parsed.get("error")
1135
+ if isinstance(err, dict):
1136
+ detail = str(err.get("message") or "")
1137
+ elif isinstance(err, str):
1138
+ detail = err
1139
+ except Exception:
1140
+ detail = ""
1141
+ detail = " ".join(detail.split())[:200]
1142
+ raise RuntimeError(
1143
+ f"{display} seat returned HTTP {e.code}"
1144
+ + (f": {detail}" if detail else "")) from None
1145
+ except (urllib.error.URLError, OSError) as e:
1146
+ raise RuntimeError(f"{display} seat unreachable ({e})") from None
1147
+ try:
1148
+ return json.loads(raw)
1149
+ except ValueError:
1150
+ raise RuntimeError(
1151
+ f"{display} seat returned an unreadable response") from None
1152
+
1153
+
1154
+ def _openai_style_chat(provider_id, model, messages):
1155
+ """Chat completions in the OpenAI shape (meta/openai/google/deepseek)."""
1156
+ spec = PROVIDERS[provider_id]
1157
+ key, env = _provider_key(provider_id)
1158
+ if not key:
1159
+ raise RuntimeError(
1160
+ f"{spec['display']} seat is not configured — {env} is not set")
1161
+ data = _post_json(
1162
+ spec["url"], {"model": model, "messages": messages},
1163
+ {"Authorization": f"Bearer {key}",
1164
+ "Content-Type": "application/json"}, provider_id)
1165
+ try:
1166
+ content = data["choices"][0]["message"]["content"]
1167
+ except (KeyError, IndexError, TypeError):
1168
+ content = None
1169
+ if not isinstance(content, str):
1170
+ raise RuntimeError(
1171
+ f"{spec['display']} seat returned an unexpected "
1172
+ "response shape")
1173
+ return content
1174
+
1175
+
1176
+ def _anthropic_chat(model, messages):
1177
+ """Anthropic messages API: system text travels out-of-band,
1178
+ and the reply's content is a list of blocks — text blocks join."""
1179
+ spec = PROVIDERS["anthropic"]
1180
+ key, env = _provider_key("anthropic")
1181
+ if not key:
1182
+ raise RuntimeError(
1183
+ f"{spec['display']} seat is not configured — {env} is not set")
1184
+ system = "\n\n".join(str(m.get("content", "")) for m in messages
1185
+ if m.get("role") == "system")
1186
+ convo = [{"role": m.get("role"), "content": m.get("content")}
1187
+ for m in messages if m.get("role") != "system"]
1188
+ payload = {"model": model, "max_tokens": 4096, "messages": convo}
1189
+ if system:
1190
+ payload["system"] = system
1191
+ data = _post_json(
1192
+ spec["url"], payload,
1193
+ {"x-api-key": key, "anthropic-version": "2023-06-01",
1194
+ "Content-Type": "application/json"}, "anthropic")
1195
+ blocks = data.get("content")
1196
+ if not isinstance(blocks, list):
1197
+ raise RuntimeError(
1198
+ "Anthropic seat returned an unexpected response shape")
1199
+ return "".join(b.get("text", "") for b in blocks
1200
+ if isinstance(b, dict) and b.get("type") == "text")
1201
+
1202
+
1203
+ def call_seat_chat(seat, messages):
1204
+ """One chat turn for a seat (provider, model). Returns the reply
1205
+ text; failures raise with a clean, honest line."""
1206
+ provider_id, model = seat
1207
+ if provider_id == "ollama":
1208
+ return _ollama_chat_api(messages, model)
1209
+ if provider_id == "anthropic":
1210
+ return _anthropic_chat(model, messages)
1211
+ if provider_id in PROVIDERS:
1212
+ return _openai_style_chat(provider_id, model, messages)
1213
+ raise RuntimeError(f"unknown seat provider: {provider_id}")
1214
+
1215
+ # ------------------------------------------------------------------
1216
+ # CODEGEN SUITE — devrepl's modes, rebuilt on the fleet consent wall
1217
+ # ------------------------------------------------------------------
1218
+
1219
+ CODEGEN_SYSTEM_PROMPTS = {
1220
+ "brainstorm": (
1221
+ "You are the brainstorm seat in godfather_wizard. Converse plainly "
1222
+ "and help the person think. You have no tools and no hands in "
1223
+ "this mode: do not claim to read, write, run, or change "
1224
+ "anything."
1225
+ ),
1226
+ "build": (
1227
+ "You are the build seat in godfather_wizard. Create the complete "
1228
+ "content requested for one file. Reply with only the complete "
1229
+ "file content, with no explanation and no markdown fence."
1230
+ ),
1231
+ "debug": (
1232
+ "You are the debug seat in godfather_wizard. Repair the file "
1233
+ "using the supplied file content and error context. Reply "
1234
+ "with only the complete repaired file content, with no "
1235
+ "explanation and no markdown fence."
1236
+ ),
1237
+ "diff": (
1238
+ "You are the diff-review seat in godfather_wizard. Propose the "
1239
+ "complete revised content for one file so the person can "
1240
+ "review a diff. Reply with only the complete file content, "
1241
+ "with no explanation and no markdown fence."
1242
+ ),
1243
+ }
1244
+
1245
+
1246
+ def _clean_codegen_reply(reply):
1247
+ """Deterministic reply parsing for a complete-content seat.
1248
+
1249
+ Only a fence that surrounds the whole reply is removed. All
1250
+ other text — including its final newline — is the seat's exact
1251
+ proposed content.
1252
+ """
1253
+ if not isinstance(reply, str) or not reply.strip():
1254
+ return ""
1255
+ lines = reply.splitlines(True)
1256
+ if len(lines) >= 2 and lines[0].strip().startswith("```") \
1257
+ and lines[-1].strip() == "```":
1258
+ return "".join(lines[1:-1])
1259
+ return reply
1260
+
1261
+
1262
+ def brainstorm_reply(text, memory):
1263
+ """One hands-off brainstorm turn. Returns (rc, reply, memory).
1264
+
1265
+ This path deliberately never inspects TOOL: lines and never
1266
+ calls the oracle/tool machinery. A seat's words remain words.
1267
+ """
1268
+ seat = seat_for("brainstorm")
1269
+ if seat is None:
1270
+ return 1, seat_unavailable_line("brainstorm"), memory
1271
+ work = list(memory or [])
1272
+ if not work:
1273
+ work.append({"role": "system",
1274
+ "content": CODEGEN_SYSTEM_PROMPTS["brainstorm"]})
1275
+ work.append({"role": "user", "content": text})
1276
+ try:
1277
+ reply = call_seat_chat(seat, work)
1278
+ except Exception as e:
1279
+ return 1, (f"[FAIL] brainstorm seat unreachable "
1280
+ f"({type(e).__name__}: {e})"), memory
1281
+ if not isinstance(reply, str) or not reply.strip():
1282
+ return 1, "[FAIL] brainstorm seat returned nothing", memory
1283
+ reply = reply.strip()
1284
+ work.append({"role": "assistant", "content": reply})
1285
+ return 0, reply, work
1286
+
1287
+
1288
+ def codegen_current_content(path):
1289
+ """(content, error_line) for build/debug/diff's current truth."""
1290
+ if not path.exists():
1291
+ return "", None
1292
+ if not path.is_file():
1293
+ return None, f"[FAIL] not a file: {path}"
1294
+ return _read_text_guarded(path)
1295
+
1296
+
1297
+ def codegen_proposal(task, path, instruction, current, extra_context=""):
1298
+ """Ask one codegen seat for complete content.
1299
+
1300
+ Returns (proposal, error_line). A proposal is inert data —
1301
+ path, instruction, exact content, task, and model — until a
1302
+ person approves its diff at the writewiz wall.
1303
+ """
1304
+ if task not in ("build", "debug", "diff"):
1305
+ return None, f"[FAIL] unknown codegen task: {task}"
1306
+ seat = seat_for(task)
1307
+ if seat is None:
1308
+ return None, seat_unavailable_line(task)
1309
+ user = [f"Path: {path}", f"Instruction: {instruction}"]
1310
+ if extra_context:
1311
+ user += ["", "Error context:", extra_context]
1312
+ user += ["", "Current complete content:",
1313
+ current if current else "(the file does not exist yet)"]
1314
+ messages = [
1315
+ {"role": "system", "content": CODEGEN_SYSTEM_PROMPTS[task]},
1316
+ {"role": "user", "content": "\n".join(user)},
1317
+ ]
1318
+ try:
1319
+ reply = call_seat_chat(seat, messages)
1320
+ except Exception as e:
1321
+ return None, (f"[FAIL] {task} seat unreachable "
1322
+ f"({type(e).__name__}: {e})")
1323
+ content = _clean_codegen_reply(reply)
1324
+ if not content:
1325
+ return None, f"[FAIL] {task} seat returned no usable content"
1326
+ return {
1327
+ "task": task,
1328
+ "path": str(path),
1329
+ "instruction": instruction,
1330
+ "content": content,
1331
+ "model": seat[1],
1332
+ "seat": seat,
1333
+ }, None
1334
+
1335
+
1336
+ def render_codegen_diff(path, current, proposed):
1337
+ """A unified diff for display. Colour never enters staged data."""
1338
+ diff = difflib.unified_diff(
1339
+ current.splitlines(True), proposed.splitlines(True),
1340
+ fromfile=f"{path} (current)",
1341
+ tofile=f"{path} (model proposal)")
1342
+ lines = list(diff)
1343
+ if not lines:
1344
+ return "(no changes — the proposal matches the current file)"
1345
+ use_color = sys.stdout.isatty() and "NO_COLOR" not in os.environ
1346
+ if not use_color:
1347
+ return "".join(lines).rstrip("\n")
1348
+ colored = []
1349
+ for line in lines:
1350
+ stripped = line.rstrip("\n")
1351
+ if stripped.startswith("+") and not stripped.startswith("+++"):
1352
+ colored.append(f"\033[32m{stripped}\033[0m")
1353
+ elif stripped.startswith("-") and not stripped.startswith("---"):
1354
+ colored.append(f"\033[31m{stripped}\033[0m")
1355
+ elif stripped.startswith("@@"):
1356
+ colored.append(f"\033[36m{stripped}\033[0m")
1357
+ else:
1358
+ colored.append(stripped)
1359
+ return "\n".join(colored)
1360
+
1361
+
1362
+ def confirm_codegen_write(target):
1363
+ """Codegen's double ask: plain yes, then name the file in type."""
1364
+ try:
1365
+ first = input(
1366
+ f"⚠️ write the model's proposal to {target}? [y/N] ")
1367
+ except (KeyboardInterrupt, EOFError):
1368
+ return False
1369
+ if first.strip().lower() != "y":
1370
+ return False
1371
+ try:
1372
+ typed = input(f"confirm by typing: yes, write {target}\n> ")
1373
+ except (KeyboardInterrupt, EOFError):
1374
+ return False
1375
+ return typed.strip() == f"yes, write {target}"
1376
+
1377
+
1378
+ def stage_codegen_content(target, content):
1379
+ """Stage exact content through writewiz. Returns (ok, detail)."""
1380
+ try:
1381
+ from wiz_central_toolkit.writewiz.write_wizard import \
1382
+ propose as _propose
1383
+ draft, _meta, t = _propose(str(target), content)
1384
+ return True, f"{draft.name} -> {t} ({len(content)} chars quarantined)"
1385
+ except ImportError:
1386
+ pass
1387
+ except OSError as e:
1388
+ return False, f"write_wizard propose failed: {e}"
1389
+ rc, out = call_sibling(["write_wizard", "propose", str(target)],
1390
+ stdin=content)
1391
+ if rc != 0:
1392
+ return False, out or "write_wizard propose failed"
1393
+ return True, out
1394
+
1395
+
1396
+ def land_codegen_content(target):
1397
+ """Land a staged draft through writewiz commit. Returns (ok, detail).
1398
+
1399
+ The person's live double ask has just named this file; that is
1400
+ the consent commit records. Writewiz still runs its own gate,
1401
+ backup, journal, and post-write verification inside the wall.
1402
+ """
1403
+ try:
1404
+ from wiz_central_toolkit.writewiz import write_wizard as _ww
1405
+ except ImportError:
1406
+ return False, ("write_wizard not importable — the staged proposal "
1407
+ f"waits in quarantine; land it with: "
1408
+ f"write_wizard commit {target}")
1409
+ draft, meta = _ww._draft_path(Path(target))
1410
+ if not draft.exists():
1411
+ return False, "no staged draft found in write_wizard's quarantine"
1412
+ ok, msg, _t = _ww.commit(
1413
+ draft, meta, confirm_reader=lambda prompt="": "y")
1414
+ return ok, msg
1415
+
1416
+
1417
+ def build_tool_manifest():
1418
+ """Real fleet roster from disk — no fabricated commands."""
1419
+ _, names = fleet_census()
1420
+ # names only — probe hints bloated the context; the oracle doesn't need them
1421
+ return ", ".join(names)
1422
+
1423
+ def oracle_tool_loop(question, model, memory, max_rounds=5):
1424
+ """Chat with hands: tool calls execute REAL wizards, results feed back in.
1425
+ Returns (final_reply, memory). Memory is this session's conversation.
1426
+ `model` is a seat (provider, model); a bare name still works and
1427
+ means that Ollama model."""
1428
+ import os as _os
1429
+ if _os.environ.get("WIZ_AI_PROVIDER", "").strip().lower() == "none":
1430
+ return ("[FAIL] AI disabled (WIZ_AI_PROVIDER=none)", memory)
1431
+ seat = model if isinstance(model, tuple) else ("ollama", model)
1432
+ if not memory:
1433
+ memory.append({"role": "system",
1434
+ "content": ORACLE_SPINE + "\nAVAILABLE TOOLS:\n" + build_tool_manifest()})
1435
+ memory.append({"role": "user", "content": question})
1436
+
1437
+ for round_i in range(max_rounds):
1438
+ try:
1439
+ reply = call_seat_chat(seat, memory)
1440
+ except Exception as e:
1441
+ return (f"[FAIL] oracle unreachable ({type(e).__name__}: {e})", memory)
1442
+ memory.append({"role": "assistant", "content": reply})
1443
+
1444
+ tool_line = None
1445
+ for line in reply.splitlines():
1446
+ if line.strip().upper().startswith("TOOL:"):
1447
+ tool_line = line.strip()[5:].strip()
1448
+ break
1449
+ if not tool_line:
1450
+ return reply, memory # a plain answer — done
1451
+
1452
+ parts = [word.replace("~", __import__("os").path.expanduser("~"))
1453
+ if word.startswith("~") else word
1454
+ for word in tool_line.split()]
1455
+ if not parts:
1456
+ continue
1457
+
1458
+ # consent wall, at its true scope: staging and read lanes
1459
+ # run free and never prompt; a write-class lane asks twice
1460
+ # (the same test godfatherwiz run lives by).
1461
+ if pipeline_is_write_class(parts):
1462
+ if not _oracle_write_consent(tool_line, parts):
1463
+ memory.append({"role": "user", "content":
1464
+ "GOVERNOR DENIED this action. Proceed without it."})
1465
+ continue
1466
+
1467
+ print(f"🔧 {tool_line}")
1468
+
1469
+ # INTERNAL FIRST — godfatherwiz's own in-house hands (the
1470
+ # senses, the seats answer) execute in-process. They are
1471
+ # always lawful: the fleet census must never be able to
1472
+ # reject godfatherwiz himself.
1473
+ internal = call_internal(parts)
1474
+
1475
+ # FLEET WHITELIST — only real citizens may act. No shell reach-arounds.
1476
+ # Common shell verbs are TRANSLATED to their lawful fleet equivalents.
1477
+ SHELL_TRANSLATIONS = {
1478
+ "ls": ["godfather_wizard", "listing"],
1479
+ "dir": ["godfather_wizard", "listing"],
1480
+ "cat": ["godfather_wizard", "read"],
1481
+ "head": ["godfather_wizard", "head"],
1482
+ "pwd": ["godfather_wizard", "brief"],
1483
+ "find": ["godfather_wizard", "find"],
1484
+ "du": ["godfather_wizard", "du"],
1485
+ "stat": ["godfather_wizard", "stat"],
1486
+ "grep": ["godfather_wizard", "read"],
1487
+ }
1488
+ if internal is None:
1489
+ fleet_names = fleet_invocable_names()
1490
+ if parts[0] not in fleet_names:
1491
+ if parts[0] in SHELL_TRANSLATIONS:
1492
+ new_parts = SHELL_TRANSLATIONS[parts[0]] + parts[1:]
1493
+ print(f"[TRANSLATE] '{parts[0]}' → '{' '.join(new_parts[:2])}' (lawful fleet equivalent)")
1494
+ parts = new_parts
1495
+ internal = call_internal(parts)
1496
+ else:
1497
+ memory.append({"role": "user", "content":
1498
+ f"REJECTED: '{parts[0]}' is NOT a fleet tool. Shell commands are "
1499
+ f"forbidden. Your ONLY way to see files is exactly this line:\n"
1500
+ f"TOOL: godfather_wizard listing <path>\n"
1501
+ f"Emit that line now with the real path, or answer plainly."})
1502
+ print(f"[REJECT] '{parts[0]}' is not a citizen — bouncing back")
1503
+ continue
1504
+ if parts[0] not in fleet_names:
1505
+ memory.append({"role": "user", "content":
1506
+ f"REJECTED: '{parts[0]}' is not a fleet tool. Answer plainly instead."})
1507
+ print(f"[REJECT] '{parts[0]}' is not a citizen — bouncing back")
1508
+ continue
1509
+
1510
+ # DUPLICATE SHIELD — same command twice = answer from what we already have
1511
+ sig = " ".join(parts)
1512
+ if not hasattr(oracle_tool_loop, "_last_calls"):
1513
+ oracle_tool_loop._last_calls = set()
1514
+ if sig in oracle_tool_loop._last_calls:
1515
+ oracle_tool_loop._dup_count = getattr(oracle_tool_loop, "_dup_count", 0) + 1
1516
+ if oracle_tool_loop._dup_count >= 2:
1517
+ # small brains can't pivot — present the truth ourselves, honestly
1518
+ last_result = getattr(oracle_tool_loop, "_last_result", "(none)")
1519
+ print("[FORCED SUMMARY] oracle won't stop repeating — presenting raw truth")
1520
+ return ("[RAW TRUTH — oracle could not summarize, presented verbatim]\n"
1521
+ + last_result, memory)
1522
+ memory.append({"role": "user", "content":
1523
+ f"DUPLICATE: you already ran '{sig}'. STOP calling tools. Your next "
1524
+ f"reply must be a PLAIN ANSWER with no TOOL: line. Summarize the "
1525
+ f"result you already received. Nothing else."})
1526
+ print("[DEDUP] identical tool call suppressed — final warning issued")
1527
+ continue
1528
+ oracle_tool_loop._last_calls.add(sig)
1529
+
1530
+ if internal is not None:
1531
+ rc, out = internal
1532
+ else:
1533
+ rc, out = call_sibling(parts)
1534
+ result = (out or "").strip() or "(no output)"
1535
+ oracle_tool_loop._last_result = result
1536
+
1537
+ # CONTEXT DIET — small seats have 4096-token windows; feed a digest,
1538
+ # not the raw firehose. Tell the oracle it was truncated.
1539
+ MAX_RESULT_LINES = 60
1540
+ lines = result.splitlines()
1541
+ truncated_note = ""
1542
+ if len(lines) > MAX_RESULT_LINES:
1543
+ result = "\n".join(lines[:MAX_RESULT_LINES])
1544
+ truncated_note = ("\n(result truncated to first "
1545
+ f"{MAX_RESULT_LINES} of {len(lines)} lines)")
1546
+
1547
+ memory.append({"role": "user", "content":
1548
+ f"TOOL RESULT ({tool_line}, exit {rc}):\n{result}"
1549
+ + truncated_note})
1550
+
1551
+ return ("[FAIL] oracle exhausted tool rounds without a plain answer "
1552
+ "(run /model to pick a stronger seat)", memory)