substratum-cli 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 (42) hide show
  1. substratum_cli/README.md +51 -0
  2. substratum_cli/__init__.py +6 -0
  3. substratum_cli/__main__.py +7 -0
  4. substratum_cli/_vendor/__init__.py +0 -0
  5. substratum_cli/_vendor/_pro_test_derive.py +169 -0
  6. substratum_cli/account.py +62 -0
  7. substratum_cli/cli.py +185 -0
  8. substratum_cli/codes.py +114 -0
  9. substratum_cli/commands/__init__.py +0 -0
  10. substratum_cli/commands/apply.py +49 -0
  11. substratum_cli/commands/auth.py +37 -0
  12. substratum_cli/commands/commit.py +40 -0
  13. substratum_cli/commands/explain.py +63 -0
  14. substratum_cli/commands/failing.py +80 -0
  15. substratum_cli/commands/fix.py +48 -0
  16. substratum_cli/commands/history.py +15 -0
  17. substratum_cli/commands/init_cmd.py +155 -0
  18. substratum_cli/commands/replay.py +68 -0
  19. substratum_cli/commands/scaffold.py +71 -0
  20. substratum_cli/commands/show.py +18 -0
  21. substratum_cli/commands/undo.py +28 -0
  22. substratum_cli/commands/verify.py +193 -0
  23. substratum_cli/commands/write.py +42 -0
  24. substratum_cli/config.py +49 -0
  25. substratum_cli/diffs.py +106 -0
  26. substratum_cli/engine.py +154 -0
  27. substratum_cli/gitio.py +102 -0
  28. substratum_cli/langscan.py +110 -0
  29. substratum_cli/product_ops.py +639 -0
  30. substratum_cli/refusals.py +127 -0
  31. substratum_cli/remote.py +140 -0
  32. substratum_cli/render.py +332 -0
  33. substratum_cli/result.py +185 -0
  34. substratum_cli/run_store.py +162 -0
  35. substratum_cli/runid.py +69 -0
  36. substratum_cli/session.py +558 -0
  37. substratum_cli/style.py +128 -0
  38. substratum_cli-0.1.0.dist-info/METADATA +69 -0
  39. substratum_cli-0.1.0.dist-info/RECORD +42 -0
  40. substratum_cli-0.1.0.dist-info/WHEEL +5 -0
  41. substratum_cli-0.1.0.dist-info/entry_points.txt +2 -0
  42. substratum_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,558 @@
1
+ """The persistent session: `substratum` with no args. Two problems, one build.
2
+
3
+ 1. Warm library. The session is ONE process. It loads the v15 library once (in a background
4
+ thread at startup, via engine.warm()) and every command in the loop reuses it, so the second
5
+ fix and every fix after is fast. No per-invocation reload.
6
+ 2. Claude-Code-style loop. You cd into a repo, run `substratum`, and drive it interactively:
7
+ `fix <test>`, `verify`, `apply`, `show`, `scaffold-test`, etc., with history and streaming.
8
+
9
+ The loop dispatches to the SAME command functions the one-shot CLI uses, so behavior, the object
10
+ model, the run store, and the gate are all identical. The session adds no new engine path.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shlex
16
+ import sys
17
+ import threading
18
+ import types
19
+ from typing import Optional
20
+
21
+ from . import codes
22
+ from . import config as _config
23
+ from . import engine
24
+ from . import gitio
25
+ from . import render as _render
26
+ from . import result as _result
27
+
28
+ _GREEN = "\033[32m"; _CYAN = "\033[36m"; _DIM = "\033[2m"; _BOLD = "\033[1m"; _RESET = "\033[0m"
29
+
30
+ _COMMANDS = ["fix", "failing", "write", "verify", "apply", "undo", "commit", "runs", "history",
31
+ "show", "explain", "replay", "attest", "scaffold-test", "summarize", "init", "doctor",
32
+ "coverage", "help", "exit", "quit"]
33
+
34
+ _ALL_COMMANDS = set(_COMMANDS) | {"summary", "scaffold", "log", "hist", "tests",
35
+ "login", "logout", "whoami", "?", "h", "q", "e", "bye"}
36
+
37
+ # commands whose argument is a run-id (for tab-completion + defaulting to the last run)
38
+ _RUNID_CMDS = {"apply", "undo", "commit", "show", "explain", "replay", "attest"}
39
+
40
+
41
+ class Session:
42
+ def __init__(self, repo: str, color: bool = True):
43
+ self.repo = os.path.abspath(repo)
44
+ self.color = color and sys.stdout.isatty()
45
+ self.last_run: Optional[str] = None
46
+ self.last_applied: Optional[str] = None
47
+ self._history: list = [] # session-scoped command history (bash-style !N / !! recall)
48
+ self._warm_done = threading.Event()
49
+ self._warm_ok = False
50
+
51
+ # ---- warm library (background) --------------------------------------
52
+ def start_warm(self) -> None:
53
+ def _w():
54
+ try:
55
+ self._warm_ok = engine.warm()
56
+ except Exception:
57
+ self._warm_ok = False
58
+ finally:
59
+ self._warm_done.set()
60
+ self._announce_warm() # ALWAYS resolve: ready OR "loads on first use", never silent
61
+ threading.Thread(target=_w, daemon=True).start()
62
+
63
+ def _announce_warm(self) -> None:
64
+ """Print a one-line completion notice (on its own line) and redraw the prompt, so the banner's
65
+ background 'loading' state always resolves visibly instead of appearing stuck."""
66
+ from . import style as S
67
+ if self._warm_ok:
68
+ line = self._paint(" " + S.g("lamp") + " library ready", S.PROVEN)
69
+ else:
70
+ line = self._paint(" " + S.g("dot") + " library will load on first use", S.CHROME)
71
+ try:
72
+ sys.stdout.write("\n" + line + "\n")
73
+ sys.stdout.flush()
74
+ import readline
75
+ readline.redisplay()
76
+ except Exception:
77
+ pass
78
+
79
+ def _await_warm(self) -> None:
80
+ """fix needs the library; block on the warm if it is still loading, with a note."""
81
+ if not self._warm_done.is_set():
82
+ self._say(" loading the library (once per session)...", _DIM)
83
+ self._warm_done.wait()
84
+
85
+ # ---- rendering helpers ----------------------------------------------
86
+ def _paint(self, s: str, c: str) -> str:
87
+ return f"{c}{s}{_RESET}" if self.color else s
88
+
89
+ def _say(self, s: str, c: str = "") -> None:
90
+ sys.stdout.write((self._paint(s, c) if c else s) + "\n")
91
+ sys.stdout.flush()
92
+
93
+ def _render(self, res: "_result.SubstratumResult") -> None:
94
+ sys.stdout.write(_render.render_human(res, color="always" if self.color else "never"))
95
+ if res.verdict in (codes.VERIFIED, codes.PASS) and res.run_id.startswith("sub_"):
96
+ self.last_run = res.run_id
97
+
98
+ # ---- banner (the Sovereign Seal letterhead) --------------------------
99
+ def banner(self) -> None:
100
+ from . import style as S
101
+ c = self.color
102
+ pn = S.Painter(c)
103
+ name = os.path.basename(self.repo)
104
+ langs, files = self._repo_summary()
105
+ lib = engine.lib_roots.py_library_root()
106
+ libname = os.path.basename(lib) if lib else "none"
107
+ warm_lit = engine.is_warm()
108
+ dot = pn(S.g("dot"), S.CHROME)
109
+ # honest, self-resolving state: a live "warming" label the banner can never update reads as
110
+ # stuck; instead say it loads in the background (a "library ready" line prints when done).
111
+ if warm_lit:
112
+ state = pn(S.g("lamp") + " ready", S.PROVEN)
113
+ else:
114
+ state = pn("loading in background", S.CHROME)
115
+ self._say(pn(S.g("brand") + " SUBSTRATUM", S.BOLD) +
116
+ pn(f" proof-carrying code engine", S.LABEL))
117
+ self._say(pn(S.g("bar") * S.width(), S.CHROME))
118
+ self._say(f" {pn('REPO', S.LABEL)} {name} {pn(f'{files} files {dot} ' + (langs or ''), S.LABEL)}")
119
+ self._say(f" {pn('LIBRARY', S.LABEL)} {libname} {state}")
120
+ self._say(pn(S.g("bar") * S.width(), S.CHROME))
121
+ self._say(pn(" fix " + dot + " write " + dot + " verify " + dot + " summarize " + dot
122
+ + " apply " + dot + " undo " + dot + " commit " + dot + " runs " + dot
123
+ + " help " + dot + " /exit", S.LABEL))
124
+ self._say(pn(" new here? ", S.CHROME) + pn("just ask", S.GOLD)
125
+ + pn(' — ', S.CHROME) + pn('"what does this codebase do?"', S.GOLD)
126
+ + pn(" or type ", S.CHROME) + pn("summarize", S.GOLD))
127
+
128
+ def _repo_summary(self):
129
+ # SAME census as `summarize` (langscan.LANGS, prune dist/build) so the banner headline
130
+ # never contradicts the summarize CODEBASE line. Returns ("294 ts · 8 py · 2 js", total).
131
+ from . import langscan
132
+ counts: dict = {}
133
+ for root, dirs, fs in os.walk(self.repo):
134
+ dirs[:] = [d for d in dirs if d not in
135
+ (".git", "node_modules", ".venv", "venv", "__pycache__", ".substratum",
136
+ "dist", "build")]
137
+ for f in fs:
138
+ lang = langscan.LANGS.get(os.path.splitext(f)[1])
139
+ if lang:
140
+ counts[lang] = counts.get(lang, 0) + 1
141
+ total = sum(counts.values())
142
+ brk = " ".join(f"{n} {k}" for k, n in sorted(counts.items(), key=lambda x: -x[1]))
143
+ return brk, total
144
+
145
+ # ---- the dispatcher (pure; the REPL and tests both call this) --------
146
+ def handle(self, line: str):
147
+ """Return (result_or_None, keep_going). Streams stages for fix."""
148
+ line = line.strip()
149
+ if not line:
150
+ return None, True
151
+ # bash-style session-history recall: !! (last), !N (by index), !<prefix> (last match)
152
+ if line.startswith("!") and line != "!":
153
+ recalled = self._recall(line[1:])
154
+ if recalled is None:
155
+ self._say(f" no history match for {line}", _CYAN)
156
+ return None, True
157
+ self._say(" " + self._paint(recalled, _DIM)) # echo what is being re-run
158
+ line = recalled
159
+ try:
160
+ parts = shlex.split(line)
161
+ except ValueError:
162
+ self._say(" parse error", _CYAN)
163
+ return None, True
164
+ cmd, args = parts[0], parts[1:]
165
+ if cmd and cmd[0] in "/:\\": # Claude-Code-style slash/colon commands: /exit, :q, ...
166
+ cmd = cmd[1:]
167
+ # record every real command in the session history (not history/exit itself)
168
+ if cmd.lower() not in ("history", "hist", "exit", "quit", "q", "e", "bye"):
169
+ self._history.append(line)
170
+
171
+ # natural-language prompt ("can you summarize this codebase?") -> route to a command,
172
+ # deterministically (closed-class, no LLM), echoing the interpretation. Structured commands
173
+ # (a known leading verb) bypass this entirely.
174
+ if cmd.lower() not in _ALL_COMMANDS:
175
+ routed = self._route_nl(line)
176
+ if routed is None:
177
+ self._say(" not a command. try: summarize " + "·" +
178
+ " fix <test> · verify · write \"...\" · help", _CYAN)
179
+ return None, True
180
+ cmd, args = routed
181
+ else:
182
+ cmd = cmd.lower()
183
+
184
+ if cmd in ("exit", "quit", "q", "e", "bye"):
185
+ return None, False
186
+ if cmd in ("help", "?", "h"):
187
+ self._help()
188
+ return None, True
189
+ if cmd == "coverage" or cmd == "init":
190
+ from .commands.init_cmd import cmd_init
191
+ return cmd_init(self.repo, run_flake=(cmd == "init")), True
192
+ if cmd == "doctor":
193
+ from .commands.init_cmd import cmd_doctor
194
+ return cmd_doctor(self.repo), True
195
+ if cmd in ("failing", "tests"):
196
+ return self._failing(args), True
197
+ if cmd == "fix":
198
+ return self._fix(args), True
199
+ if cmd == "write":
200
+ return self._write(args), True
201
+ if cmd in ("summarize", "summary"):
202
+ from . import product_ops
203
+ self._say(" reading the codebase...", _DIM)
204
+ return product_ops.op_summarize_repo(self.repo), True
205
+ if cmd == "verify":
206
+ return self._verify(args), True
207
+ if cmd in ("login", "logout", "whoami"):
208
+ from .commands import auth
209
+ {"login": auth.cmd_login, "logout": auth.cmd_logout, "whoami": auth.cmd_whoami}[cmd]()
210
+ return None, True
211
+ if cmd in ("history", "hist"):
212
+ self._history_cmd()
213
+ return None, True
214
+ if cmd in ("runs", "log"):
215
+ from .commands.history import cmd_runs
216
+ return cmd_runs(self.repo), True
217
+ if cmd == "apply":
218
+ return self._run_id_cmd("apply", args), True
219
+ if cmd == "undo":
220
+ return self._run_id_cmd("undo", args), True
221
+ if cmd == "commit":
222
+ return self._run_id_cmd("commit", args), True
223
+ if cmd == "show":
224
+ return self._run_id_cmd("show", args), True
225
+ if cmd == "explain":
226
+ return self._run_id_cmd("explain", args), True
227
+ if cmd == "replay":
228
+ return self._run_id_cmd("replay", args), True
229
+ if cmd == "attest":
230
+ return self._run_id_cmd("attest", args), True
231
+ if cmd in ("scaffold-test", "scaffold"):
232
+ from .commands.scaffold import cmd_scaffold_test
233
+ if not args:
234
+ self._say(" usage: scaffold-test <file.py::fn>", _CYAN)
235
+ return None, True
236
+ return cmd_scaffold_test(self.repo, args[0]), True
237
+ self._say(f" unknown command: {cmd} (try `help`)", _CYAN)
238
+ return None, True
239
+
240
+ # ---- session-scoped command history (bash-style recall) -------------
241
+ def _recall(self, token: str):
242
+ h = self._history
243
+ if not h:
244
+ return None
245
+ token = token.strip()
246
+ if token in ("", "!"): # !! -> the last command
247
+ return h[-1]
248
+ if token.lstrip("-").isdigit(): # !N (1-indexed) or !-N (from the end)
249
+ i = int(token)
250
+ idx = len(h) + i if i < 0 else i - 1
251
+ return h[idx] if 0 <= idx < len(h) else None
252
+ for c in reversed(h): # !<prefix> -> most recent match
253
+ if c.startswith(token):
254
+ return c
255
+ return None
256
+
257
+ def _history_cmd(self):
258
+ if not self._history:
259
+ self._say(" no commands yet this session", _DIM)
260
+ return
261
+ self._say(self._paint(" session history", _BOLD))
262
+ start = max(1, len(self._history) - 29)
263
+ for i, c in enumerate(self._history[-30:], start):
264
+ self._say(f" {self._paint(str(i).rjust(3), _DIM)} {c}")
265
+ self._say(self._paint(" re-run: !<n> · !! · !<prefix>", _DIM))
266
+
267
+ # ---- natural-language router (closed-class, deterministic, no LLM) ----
268
+ def _route_nl(self, line: str):
269
+ """Map a plain prompt to (command, args). Regex/keyword only; echoes the INTERPRETATION
270
+ (the CSF parser discipline). Returns None if no confident route."""
271
+ import re
272
+ low = line.lower().strip().rstrip("?.! ")
273
+
274
+ def interp(text):
275
+ self._say(f" interpreted as: {text}", _DIM)
276
+
277
+ # explicit `fix <id>` wins first, so a named test is never swallowed by summarize/verify.
278
+ # id = pytest nodeid / a .py path / a real test_* token (word-bounded, not any 'test' substring)
279
+ ids = [w for w in line.split()
280
+ if ("::" in w or w.endswith(".py") or re.search(r"(^|[/_])test_", w)
281
+ or re.search(r"_test($|[.:])", w)) and not w.startswith("-")]
282
+ if re.search(r"\bfix\b", low) and ids:
283
+ interp("fix " + " ".join(ids))
284
+ return ("fix", ids)
285
+ # discover failing tests (an observation) -- distinct from `fix <id>` and from `verify`
286
+ if "fix" not in low and (
287
+ re.search(r"\bfailing tests?\b", low)
288
+ or re.search(r"\b(find|show|list|what|which|any|are there|run)\b"
289
+ r".{0,24}\b(failing|broken|red|the)?\s*(tests?|suite)\b", low)
290
+ or re.search(r"\bwhat(?:'s| is| are)\b.{0,20}\b(failing|broken)\b", low)):
291
+ interp("find failing tests")
292
+ return ("failing", [])
293
+ # summarize the codebase -- bare describe/overview/what MUST name a codebase object
294
+ # (so "describe the algorithm" does NOT route); a literal 'summarize'/'tldr' is enough alone.
295
+ if re.search(r"\b(summar\w+|tl;?dr)\b", low) or \
296
+ re.search(r"\b(describe|overview|walk me through|what(?:'s| is| does| do| are)?)\b"
297
+ r".{0,40}\b(code|codebase|repo|repository|project|module|files?)\b", low):
298
+ interp("summarize the codebase")
299
+ return ("summarize", [])
300
+ # verify current changes -- 'verify' is unambiguous; gate/check/review/validate need a change object
301
+ if re.search(r"\bverif(?:y|ies)\b", low) or \
302
+ re.search(r"\b(gate|check|review|validate)\b.{0,30}"
303
+ r"\b(change|changes|diff|edit|edits|patch|work|code)\b", low):
304
+ interp("verify the current changes")
305
+ return ("verify", [])
306
+ # fix, no id named yet -- only on an explicit fix+failure phrase, not a bare 'fix'
307
+ if re.search(r"\bfix\b.{0,30}\b(test|tests|failing|failure|broken|red)\b", low) or \
308
+ re.search(r"\b(failing|broken)\b.{0,20}\btest", low):
309
+ interp("fix (name the failing test)")
310
+ return ("fix", [])
311
+ # run history / the proof ledger
312
+ if low in ("runs", "log") or \
313
+ re.search(r"\b(run history|recent runs|show (me )?(the )?runs|history of runs|"
314
+ r"the ledger|what have you (done|run|fixed|verified))\b", low):
315
+ interp("show run history")
316
+ return ("runs", [])
317
+ # undo / revert the last applied run
318
+ if re.search(r"\b(undo|revert|roll\s?back|take (it|that) back)\b", low):
319
+ interp("undo the last applied run")
320
+ return ("undo", [])
321
+ # commit the last verified run
322
+ if re.search(r"\bcommit\b", low):
323
+ interp("commit the last verified run")
324
+ return ("commit", [])
325
+ # coverage / capabilities
326
+ if re.search(r"\b(coverage|what can you (do|gate|handle)|what do you (support|cover))\b", low):
327
+ interp("show the coverage contract")
328
+ return ("coverage", [])
329
+ if re.search(r"\b(^help$|how do i|what commands|list commands)\b", low) or low == "help":
330
+ interp("help")
331
+ return ("help", [])
332
+ # write intent needs a signature -> guide rather than guess it
333
+ if re.search(r"\b(write|create|build|make|generate|implement)\b.{0,40}"
334
+ r"\b(function|method|class|code|helper)\b", low):
335
+ self._say(' to write from intent, name the shape: '
336
+ 'write "<what it should do>" --sig "name(args)"', _CYAN)
337
+ return None
338
+ return None
339
+
340
+ # ---- command bodies --------------------------------------------------
341
+ def _failing(self, args):
342
+ """Discover failing tests by running the repo's suite, then offer to fix one/all inline."""
343
+ from .commands.failing import cmd_failing
344
+ path = args[0] if args else None
345
+ self._say(" running the test suite (read-only)...", _DIM)
346
+ res = cmd_failing(self.repo, path=path)
347
+ f = (res.data or {}).get("failing") or []
348
+ if not f:
349
+ return res # all-pass / could-not-run -> just render it
350
+ self._render(res) # show the failing list
351
+ n = len(f)
352
+ try:
353
+ pick = input(self._paint(f" fix which? [1-{n} / a=all / enter to skip] ",
354
+ _CYAN)).strip().lower()
355
+ except (EOFError, KeyboardInterrupt):
356
+ sys.stdout.write("\n")
357
+ return None
358
+ if pick in ("a", "all"):
359
+ self._await_warm()
360
+ from . import product_ops
361
+
362
+ def emit(stage, text):
363
+ self._say(f" {stage:7} {text}", _DIM)
364
+ return product_ops.op_fix_from_failing_test(self.repo, f, emit=emit)
365
+ if pick.isdigit() and 1 <= int(pick) <= n:
366
+ return self._fix([f[int(pick) - 1]])
367
+ return None # skipped: the list was already shown
368
+
369
+ def _fix(self, args):
370
+ from . import product_ops
371
+ if not args:
372
+ self._say(" usage: fix <test-id> [<test-id> ...]", _CYAN)
373
+ return None
374
+ self._await_warm()
375
+
376
+ def emit(stage, text):
377
+ self._say(f" {stage:7} {text}", _DIM)
378
+ return product_ops.op_fix_from_failing_test(self.repo, list(args), emit=emit)
379
+
380
+ def _write(self, args):
381
+ from . import product_ops
382
+ # write <intent...> --sig "name(args)" [--into file]
383
+ sig, into, words = "", None, []
384
+ i = 0
385
+ while i < len(args):
386
+ if args[i] == "--sig" and i + 1 < len(args):
387
+ sig = args[i + 1]; i += 2; continue
388
+ if args[i] == "--into" and i + 1 < len(args):
389
+ into = args[i + 1]; i += 2; continue
390
+ words.append(args[i]); i += 1
391
+ intent = " ".join(words).strip().strip('"').strip("'")
392
+ if not intent or not sig:
393
+ self._say(' usage: write "<intent>" --sig "name(args)" [--into <file>]', _CYAN)
394
+ return None
395
+ self._await_warm()
396
+
397
+ def emit(stage, text):
398
+ self._say(f" {stage:7} {text}", _DIM)
399
+ res = product_ops.op_write_from_intent(self.repo, intent, sig, into=into, emit=emit)
400
+
401
+ # interactive clarification: show the plain-English options, take a pick, re-resolve
402
+ cl = res.data.get("clarify") if res.data else None
403
+ if cl and cl.get("options"):
404
+ self._render(res)
405
+ n = len(cl["options"])
406
+ try:
407
+ pick = input(self._paint(f" which? [1-{n}] (enter to skip) ", _CYAN)).strip()
408
+ except (EOFError, KeyboardInterrupt):
409
+ sys.stdout.write("\n")
410
+ return None
411
+ if pick.isdigit() and 1 <= int(pick) <= n:
412
+ chosen = cl["options"][int(pick) - 1]
413
+ self._say(f" resolving: {chosen['text']}", _DIM)
414
+ return product_ops.op_write_from_intent(
415
+ self.repo, intent, sig, into=into, emit=emit,
416
+ prefer_purpose=chosen.get("purpose"))
417
+ return None # skipped: the ASK was already shown
418
+ return res
419
+
420
+ def _verify(self, args):
421
+ from .commands.verify import run_verify
422
+ # flags: --staged, --test <t> (repeatable). default = working-tree diff.
423
+ staged = "--staged" in args
424
+ tests = [args[i + 1] for i, a in enumerate(args) if a == "--test" and i + 1 < len(args)]
425
+ diff = gitio.staged_diff(self.repo) if staged else gitio.diff_head(self.repo)
426
+ if not diff.strip():
427
+ self._say(" no changes to verify (working tree clean)", _DIM)
428
+ return None
429
+ return run_verify(self.repo, diff, tuple(tests))
430
+
431
+ def _run_id_cmd(self, name, args):
432
+ rid = args[0] if args else (self.last_applied or self.last_run)
433
+ if not rid:
434
+ self._say(f" usage: {name} <run-id> (or run a fix first)", _CYAN)
435
+ return None
436
+ if name == "apply":
437
+ from .commands.apply import cmd_apply
438
+ res = cmd_apply(self.repo, rid)
439
+ if res.verdict in (codes.VERIFIED, codes.PASS):
440
+ self.last_applied = rid # undo/commit default to the last applied run
441
+ return res
442
+ if name == "undo":
443
+ from .commands.undo import cmd_undo
444
+ res = cmd_undo(self.repo, rid)
445
+ if res.verdict in (codes.VERIFIED, codes.PASS):
446
+ self.last_applied = None
447
+ return res
448
+ if name == "commit":
449
+ from .commands.commit import cmd_commit
450
+ return cmd_commit(self.repo, rid)
451
+ if name == "show":
452
+ from .commands.show import cmd_show
453
+ return cmd_show(self.repo, rid)
454
+ if name == "replay":
455
+ from .commands.replay import cmd_replay
456
+ return cmd_replay(self.repo, rid)
457
+ from .commands.explain import cmd_explain, cmd_attest
458
+ return cmd_attest(self.repo, rid) if name == "attest" else cmd_explain(self.repo, rid)
459
+
460
+ def _help(self):
461
+ self._say(self._paint(" commands:", _BOLD))
462
+ for line in [
463
+ " failing run the suite, list failing tests, offer to fix one/all",
464
+ " fix <test> produce a verified fix for a failing test (or a named refusal)",
465
+ " write \"<intent>\" --sig \"name(args)\" derive code from intent (asks if ambiguous)",
466
+ " summarize describe this codebase in plain English",
467
+ " verify [--staged] gate your current changes (add --test <id> to name the warrant)",
468
+ " apply [<run>] apply a verified run (defaults to the last fix)",
469
+ " undo / commit [<run>] revert an applied run, or commit it with a proof message",
470
+ " runs the proof store: run history + refusal->verified ledger",
471
+ " history commands run this session (re-run with !<n> / !! / !<prefix>)",
472
+ " show / explain / replay / attest [<run>]",
473
+ " scaffold-test <t> emit a runnable failing test that pins intent",
474
+ " coverage | init what this repo can gate vs refuse; init also flake-scans",
475
+ " doctor harness / env / air-gap checks",
476
+ " help | /exit",
477
+ "",
478
+ " or just ask: \"can you summarize this codebase?\" \"verify my changes\" \"fix <test>\"",
479
+ ]:
480
+ self._say(self._paint(line, _DIM))
481
+
482
+ # ---- the loop --------------------------------------------------------
483
+ def repl(self) -> int:
484
+ global _COMPLETER_REPO
485
+ _COMPLETER_REPO = self.repo # so tab-completion can offer this repo's run ids
486
+ try:
487
+ import readline # noqa: F401 (history + arrow keys, tab-complete below)
488
+ readline.set_completer(_completer)
489
+ readline.set_completer_delims(" \t")
490
+ readline.parse_and_bind("tab: complete")
491
+ # slash-preview: a single tab reveals + narrows the command menu as you type
492
+ readline.parse_and_bind("set show-all-if-ambiguous on")
493
+ readline.parse_and_bind("set show-all-if-unmodified on")
494
+ readline.parse_and_bind("set completion-ignore-case on")
495
+ except Exception:
496
+ pass
497
+ self.start_warm()
498
+ self.banner()
499
+ from . import style as S
500
+ prompt = (S.GOLD + S.g("brand") + " " + S.RESET) if self.color else (S.g("brand") + " ")
501
+ while True:
502
+ try:
503
+ line = input(prompt)
504
+ except EOFError:
505
+ sys.stdout.write("\n")
506
+ return 0
507
+ except KeyboardInterrupt:
508
+ sys.stdout.write("\n")
509
+ continue
510
+ try:
511
+ res, keep = self.handle(line)
512
+ if res is not None:
513
+ self._render(res)
514
+ except KeyboardInterrupt:
515
+ # Ctrl-C cancels the CURRENT action and drops back to the prompt; the warm library
516
+ # (loaded once, the whole point of the session) survives. Never a traceback.
517
+ sys.stdout.write("\n")
518
+ self._say(" cancelled (session still warm)", _CYAN)
519
+ continue
520
+ except Exception as e: # a command fault must not kill the session
521
+ self._say(f" error: {e}", _CYAN)
522
+ continue
523
+ if not keep:
524
+ return 0
525
+
526
+
527
+ _COMPLETER_REPO = None # set by repl() so the completer can offer this repo's run ids
528
+
529
+
530
+ def _completer(text, state):
531
+ # completing the ARGUMENT of a run-id command -> offer stored run ids
532
+ try:
533
+ import readline
534
+ line = readline.get_line_buffer()
535
+ except Exception:
536
+ line = text
537
+ parts = line.split()
538
+ first = parts[0].lstrip("/:\\").lower() if parts else ""
539
+ completing_arg = bool(parts) and (len(parts) >= 2 or line.endswith(" "))
540
+ if first in _RUNID_CMDS and completing_arg and _COMPLETER_REPO:
541
+ try:
542
+ from . import run_store
543
+ ids = [r for r in run_store.run_ids(_COMPLETER_REPO) if r.startswith(text)]
544
+ except Exception:
545
+ ids = []
546
+ return ids[state] if state < len(ids) else None
547
+ # otherwise complete the command verb (slash/colon-prefixed too: '/f' -> '/fix')
548
+ pfx = text[:1] if text[:1] in "/:" else ""
549
+ body = text[len(pfx):].lower()
550
+ opts = [pfx + c for c in _COMMANDS if c.startswith(body)]
551
+ return opts[state] if state < len(opts) else None
552
+
553
+
554
+ def run_session(repo: str, color: bool = True) -> int:
555
+ if not gitio.is_git_repo(os.path.abspath(repo)):
556
+ sys.stderr.write("substratum: not a git repository. cd into a repo first.\n")
557
+ return codes.EXIT_USAGE
558
+ return Session(repo, color=color).repl()