oc_chat
Loading...
Searching...
No Matches
session_mode.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/session_mode.py
4
5"""
6The confirmation mode, the budget table, and what this session remembers (SHELL-04).
7
8Purpose
9-------
10Plan 283. The Tier-2 ask used to have one useful answer: ``y``, meaning "run it
11once, under the 300 s the model's clamp allows". That made a worker inside
12oc_chat write helper scripts to shard a twelve-minute suite around the cap,
13which is the cap's defect rather than the worker's. The operator ruled on
142026-09-15 (SHELL-04, amended v1.1 on 2026-09-17) for a wider set of answers and
15for a MODE the human can switch on from the terminal at any moment. This module
16holds the three pieces of state that ruling needs and nothing else:
17
18- the **mode** (``ask`` or ``auto``), module state exactly like the plan-237
19 checkpoint latches, settable only from the terminal;
20- the **budget table**, which says how long each program is worth waiting for by
21 default, and the one key rule that maps an argv onto it;
22- the **remembered answers**, one per program, holding the ``a`` (always) and
23 ``d`` (never) answers for the rest of this session.
24
25What this module deliberately is NOT
26------------------------------------
27It is not authority. Nothing here decides whether a command may run — the argv
28policy decides that, and :func:`oc_chat.tools.shell.human_confirmed_command`
29remains the single authorisation seam. A remembered answer and the Auto mode
30decide only whether the human is ASKED, exactly as the plan-235 directory grant
31does, and both are consulted only after the react-loop's TTY check has already
32established that a human is present.
33
34It is also not a permission profile. ORCH-03 will add a tracked, persisted one;
35until then :func:`remembered_answer` is the single function a profile would feed,
36with the process-scoped store below as its only backend. That is why the lookup
37is one function rather than a dict read scattered through the resolver.
38
39Responsibilities
40----------------
41- :func:`current_mode` / :func:`set_mode` / :func:`auto_mode_active` /
42 :func:`auto_mode_available` — the mode and the permissive-only rule.
43- :func:`program_key` / :func:`table_budget_for` / :func:`default_budget_for` —
44 the key rule and the budget table, written once.
45- :func:`remembered_answer` / :func:`remember_answer` / :func:`forget_remembered`
46 / :func:`remembered_programs` — the per-program memory.
47- :func:`auto_answer_for` — what Auto mode answers for a program.
48- :func:`ask_budget_extension` — the expiry prompt, with its grace window.
49- :func:`read_typed_budget` — the ``t`` key's line read.
50- :func:`reset_session_mode` — the session-change seam.
51
52Contracts
53---------
54- The mode is settable ONLY through :func:`set_mode`, which the ``/mode``
55 command, the ``--auto`` launch flag and the ``u`` key at the Tier-2 ask call.
56 No tool, no model output, no settings file and no environment variable reaches
57 it — unlike the risk profile, there is deliberately no env spelling at all.
58- ``auto`` is inert outside the ``permissive`` risk profile, like the plan-235
59 grant. The STATE is left alone there; only its effect is withdrawn, so
60 returning to ``permissive`` does not silently resurrect a forgotten mode
61 without the operator noticing it on the ``/mode`` output.
62- State is process-lifetime and is NEVER persisted to disk or session metadata;
63 a fresh process starts in ``ask`` with an empty memory. It is cleared at both
64 seams where F67's ``reset_session_trust`` is called.
65- An interrupt, a timeout or an unusable entry at any prompt here is the
66 CONSERVATIVE answer — no extension, no budget, deny — never a silent continue.
67
68Dependencies
69------------
70- oc_diagnostics (structured debug logging via _dbg)
71- oc_chat.core.risk_profile (lazy; the permissive-only rule)
72
73Diagnostics
74-----------
75Domain: OC_CHAT.CORE
76Levels:
77 L1 — never (a mode change is an operator decision, not a fault)
78 L2 — mode / memory lifecycle events
79 L3 — per-decision detail
80 L4 — module load
81"""
82from __future__ import annotations
83
84import os
85import sys
86import time
87from dataclasses import dataclass
88from pathlib import Path
89from typing import Any, Sequence
90
91from oc_diagnostics import _dbg
92
93_dbg("OC_CHAT.CORE", "module", "loaded", 4)
94
95
96#: The two modes. ``ask`` puts every confirmable call in front of the human;
97#: ``auto`` answers the routine ones and still never answers the ruled set.
98ASK = "ask"
99AUTO = "auto"
100
101#: Every mode name :func:`set_mode` will accept.
102VALID_MODES: frozenset[str] = frozenset({ASK, AUTO})
103
104#: What a program is worth waiting for when nobody says otherwise: the M8
105#: ceiling's own value, so an unlisted program behaves exactly as it did before
106#: this plan existed.
107DEFAULT_BUDGET_S: int = 300
108
109#: The table. Keys are :func:`program_key` outputs; a key that is not here falls
110#: back to its bare program word (so every ``oct`` subcommand inherits ``oct``),
111#: and then to :data:`DEFAULT_BUDGET_S`. These four are the long-running
112#: commands the ruling names: a suite and the toolchain that drives one.
113DEFAULT_BUDGETS: dict[str, int] = {
114 "pytest": 900,
115 "python -m pytest": 900,
116 "oct test": 900,
117 "oct": 900,
118}
119
120#: How long the expiry prompt waits for a key before reading silence as "no".
121#: A module constant so a test can shorten it and an operator can find it.
122BUDGET_EXTENSION_GRACE_S: float = 30.0
123
124#: Interpreter basenames normalised to ``python`` in a program key, so
125#: ``py -m pytest`` and ``python3 -m pytest`` are the same program as
126#: ``python -m pytest`` rather than three separate entries nobody would fill in.
127PYTHON_PROGRAMS: frozenset[str] = frozenset({"python", "python3", "py"})
128
129#: The mode for this process. Module state, not session state, for the same
130#: reason the plan-237 latches are: it must not survive a save/load, and every
131#: seam that drops session trust drops it.
132_MODE: str = ASK
133
134
135@dataclass(frozen=True)
137 """A standing answer for one program, for the rest of this session."""
138
139 run: bool
140 """``True`` for the ``a`` answer (always run), ``False`` for ``d`` (never)."""
141 budget_s: "int | None" = None
142 """The budget a remembered YES runs under; meaningless when *run* is False."""
143
144
145#: What this session has been told to stop asking about, keyed by program.
146_REMEMBERED: "dict[str, RememberedAnswer]" = {}
147
148
149# ── The mode ────────────────────────────────────────────────────────────────
150
151def current_mode() -> str:
152 """The mode this session is in, regardless of whether it can take effect."""
153 return _MODE
154
155
156def set_mode(mode: str) -> None:
157 """Move the session to *mode*; raises ``ValueError`` on anything else.
158
159 Refusing an unknown name rather than falling back is deliberate: a typo at
160 the ``/mode`` prompt must not leave the operator believing they switched
161 something when they did not.
162 """
163 func = "set_mode"
164 global _MODE
165 if mode not in VALID_MODES:
166 raise ValueError(
167 f"unknown confirmation mode {mode!r}; valid modes are "
168 f"{', '.join(sorted(VALID_MODES))}")
169 _MODE = mode
170 _dbg("OC_CHAT.CORE", func,
171 f"confirmation mode is now {mode!r} — "
172 f"{'the routine asks are answered without a keystroke' if mode == AUTO else 'every confirmable call reaches the human'}",
173 2, override=True)
174
175
177 """Whether Auto mode could take effect here at all (``permissive`` only)."""
178 func = "auto_mode_available"
179 from oc_chat.core.risk_profile import resolve_risk_profile
180 if resolve_risk_profile() != "permissive":
181 _dbg("OC_CHAT.CORE", func, "not permissive — Auto mode is not available", 3)
182 return False
183 return True
184
185
186def auto_mode_active() -> bool:
187 """Whether Auto mode is on AND allowed to act."""
188 return _MODE == AUTO and auto_mode_available()
189
190
191def reset_session_mode() -> None:
192 """Drop the mode and every remembered answer (the session-change seam).
193
194 The state is cleared BEFORE the diagnostic, never after: ``_dbg`` raises
195 under warnings-as-errors if diagnostics are not initialised yet, and a mode
196 that survived its own reset because a log line failed would be exactly the
197 kind of silent authority this module exists to prevent.
198 """
199 func = "reset_session_mode"
200 global _MODE
201 was, _MODE = _MODE, ASK
202 dropped = len(_REMEMBERED)
203 _REMEMBERED.clear()
204 if was != ASK or dropped:
205 _dbg("OC_CHAT.CORE", func,
206 f"confirmation mode reset to {ASK!r} (was {was!r}); "
207 f"{dropped} remembered answer(s) dropped", 2, override=True)
208
209
210# ── The key rule and the budget table ───────────────────────────────────────
211
212def _program_word(arg0: str) -> str:
213 """*arg0*'s basename, lower-cased, ``.exe`` stripped, python family folded."""
214 name = Path(arg0).name.lower()
215 if name.endswith(".exe"):
216 name = name[:-4]
217 return "python" if name in PYTHON_PROGRAMS else name
218
219
220def _module_after_dash_m(argv: "Sequence[str]") -> str:
221 """The module named by a ``-m`` in a python *argv*, or ``""``.
222
223 Deliberately narrower than CPython's own parser, which accepts the value
224 attached to the option token and inside a flag cluster. A spelling this scan
225 misses simply falls back to the bare ``python`` key and therefore to the
226 SHORTER default budget, so the miss is always the conservative direction —
227 unlike :func:`oc_chat.tools.shell._python_m_pip_at`, where a miss would have
228 let a command past a guard and the wider scan is mandatory.
229 """
230 for pos, token in enumerate(argv[1:], start=1):
231 if token == "-m":
232 return argv[pos + 1] if pos + 1 < len(argv) else ""
233 if token.startswith("-m") and len(token) > 2:
234 return token[2:]
235 if not token.startswith("-"):
236 return "" # the script path; no module follows
237 return ""
238
239
240def program_key(argv: "Sequence[str]") -> str:
241 """The identity a budget and a remembered answer are keyed by.
242
243 THE key rule, written once (P2): the classifier's ``argv[0]``, plus the
244 ``-m`` module or the ``oct`` subcommand where one is present. It is what the
245 human sees named in the prompt, in the ``[remembered]`` line and in ``/mode``
246 output, so it has to read as the thing they are answering about — ``oct
247 test`` and ``oct lint`` are different questions, and ``python -m pytest`` is
248 the same question as ``pytest`` even though the argv is not.
249 """
250 argv = list(argv)
251 if not argv:
252 return ""
253 program = _program_word(argv[0])
254 if program == "python":
255 module = _module_after_dash_m(argv)
256 return f"python -m {module}" if module else program
257 if program == "oct" and len(argv) > 1 and not argv[1].startswith("-"):
258 return f"oct {argv[1].lower()}"
259 return program
260
261
262def table_budget_for(program: str) -> "int | None":
263 """The table's budget for *program*, or ``None`` when it names none.
264
265 Falls back from a two-word key to its program word, which is what makes one
266 ``"oct": 900`` entry cover every ``oct`` subcommand without listing them.
267 """
268 if not program:
269 return None
270 if program in DEFAULT_BUDGETS:
271 return DEFAULT_BUDGETS[program]
272 return DEFAULT_BUDGETS.get(program.split(" ", 1)[0])
273
274
275def default_budget_for(program: str) -> int:
276 """What the ask offers for *program*: the table, else the shipped default."""
277 budget = table_budget_for(program)
278 return DEFAULT_BUDGET_S if budget is None else budget
279
280
281# ── The memory ──────────────────────────────────────────────────────────────
282
283def remembered_answer(program: str) -> "RememberedAnswer | None":
284 """This session's standing answer for *program*, or ``None``.
285
286 The ONE function a tracked permission profile (ORCH-03) would later feed;
287 today the process-scoped store below is its only backend.
288 """
289 return _REMEMBERED.get(program) if program else None
290
291
292def remember_answer(program: str, answer: RememberedAnswer) -> None:
293 """Record *answer* as this session's standing answer for *program*."""
294 func = "remember_answer"
295 if not program:
296 return
297 _REMEMBERED[program] = answer
298 _dbg("OC_CHAT.CORE", func,
299 f"session answer recorded for {program!r}: "
300 f"{'run' if answer.run else 'deny'}"
301 f"{f' under {answer.budget_s}s' if answer.run else ''} — "
302 f"the human is not asked about it again until /mode forget", 2, override=True)
303
304
305def remembered_programs() -> "dict[str, RememberedAnswer]":
306 """A copy of every standing answer, for ``/mode`` to print."""
307 return dict(_REMEMBERED)
308
309
310def forget_remembered() -> int:
311 """Drop every standing answer; returns how many were dropped."""
312 func = "forget_remembered"
313 dropped = len(_REMEMBERED)
314 _REMEMBERED.clear()
315 if dropped:
316 _dbg("OC_CHAT.CORE", func,
317 f"{dropped} remembered answer(s) dropped — the human is asked again", 2,
318 override=True)
319 return dropped
320
321
322def auto_answer_for(program: str) -> RememberedAnswer:
323 """What Auto mode answers for *program* (P3).
324
325 A program the table names is answered the way ``a`` would answer it — run
326 under its table budget, and remember it, so the rest of the session reads as
327 one decision rather than a stream of identical ones. Anything else runs
328 under the shipped default and is NOT remembered: the mode is a standing
329 answer to the routine, not a standing answer to whatever turns up.
330 """
331 table = table_budget_for(program)
332 if table is not None:
333 answer = RememberedAnswer(run=True, budget_s=table)
334 remember_answer(program, answer)
335 return answer
336 return RememberedAnswer(run=True, budget_s=DEFAULT_BUDGET_S)
337
338
339# ── The prompts this module owns ────────────────────────────────────────────
340
341def _read_line(prompt: str = "") -> str:
342 """Read one line from the human (``""`` on interrupt or a closed stdin).
343
344 A tiny seam so the ``t`` key is testable without faking ``sys.stdin``. The
345 ask happens before the dispatch bracket opens, so no Live display is running
346 over this and a plain ``input`` is the right primitive.
347 """
348 func = "_read_line"
349 try:
350 return input(prompt)
351 except (EOFError, KeyboardInterrupt):
352 _dbg("OC_CHAT.CORE", func, "interrupt at the budget entry -> no budget", 3)
353 return ""
354
355
356def read_typed_budget(presenter: Any) -> "int | None":
357 """The ``t`` key: read a budget in whole seconds, or ``None`` to deny.
358
359 An empty, unparseable or non-positive entry returns ``None``, which the
360 caller reads as a denial rather than as the default budget. Guessing what a
361 mistyped number meant is not consent, and re-asking in a loop is worse — the
362 human can press the key again on the next ask.
363 """
364 func = "read_typed_budget"
365 raw = _read_line(" Budget in seconds: ").strip()
366 try:
367 seconds = int(raw)
368 except ValueError:
369 presenter.show_info(
370 f" Not a whole number of seconds: {raw!r}. Denied — ask again to retry.")
371 _dbg("OC_CHAT.CORE", func, f"unusable typed budget {raw!r} -> deny", 3)
372 return None
373 if seconds <= 0:
374 presenter.show_info(
375 f" A budget must be a positive number of seconds, not {seconds}. Denied.")
376 _dbg("OC_CHAT.CORE", func, f"non-positive typed budget {seconds} -> deny", 3)
377 return None
378 return seconds
379
380
381def _read_key_within(prompt: str = "", *, valid: str = "",
382 deadline_s: float = 0.0) -> str:
383 """Read one key, giving up after *deadline_s* seconds (``""`` on silence).
384
385 :func:`oc_ui.terminal.input.read_single_key` waits forever, which is right
386 at an ask the human is already looking at and wrong at an expiry prompt that
387 arrives minutes into a run they may have walked away from. This is the same
388 read with a deadline: a poll on Windows, a ``select`` elsewhere, and an
389 immediate ``""`` wherever there is no TTY to read from.
390
391 Returns the key lower-cased, or ``""`` for Enter, a timeout, a key outside
392 *valid*, or any failure — every one of which the caller reads as "no".
393 """
394 func = "_read_key_within"
395 if not (sys.stdin and getattr(sys.stdin, "isatty", lambda: False)()):
396 return ""
397 if prompt:
398 sys.stdout.write(prompt)
399 sys.stdout.flush()
400 try:
401 return _poll_for_key(valid, time.monotonic() + max(0.0, deadline_s))
402 except (OSError, ValueError, ImportError) as exc:
403 # EXTERNAL_ERROR (L2): the terminal is the operator's, and a console we
404 # cannot read from is silence, which is already the conservative answer.
405 _dbg("OC_CHAT.CORE", func,
406 f"EXTERNAL_ERROR: timed key read unavailable " # noqa: E501
407 f"({type(exc).__name__}: {exc}); reading it as no answer", 2)
408 return ""
409 finally:
410 if prompt:
411 sys.stdout.write("\n")
412 sys.stdout.flush()
413
414
415def _poll_for_key(valid: str, end: float) -> str:
416 """Poll the TTY for a key in *valid* until *end*; ``""`` when none arrives."""
417 if os.name == "nt":
418 import msvcrt
419
420 while time.monotonic() < end:
421 if not msvcrt.kbhit():
422 time.sleep(0.05)
423 continue
424 ch = msvcrt.getwch()
425 if ch in ("\x00", "\xe0"): # function-key prefix
426 msvcrt.getwch()
427 continue
428 if ch in ("\r", "\n"):
429 return ""
430 ch = ch.lower()
431 if not valid or ch in valid:
432 return ch
433 return ""
434
435 import select
436 import termios
437 import tty
438
439 fd = sys.stdin.fileno()
440 old = termios.tcgetattr(fd)
441 try:
442 tty.setcbreak(fd)
443 while True:
444 remaining = end - time.monotonic()
445 if remaining <= 0 or not select.select([sys.stdin], [], [], remaining)[0]:
446 return ""
447 ch = sys.stdin.read(1)
448 if ch in ("\r", "\n"):
449 return ""
450 ch = ch.lower()
451 if not valid or ch in valid:
452 return ch
453 finally:
454 termios.tcsetattr(fd, termios.TCSADRAIN, old)
455
456
457def ask_budget_extension(presenter: Any, program: str, seconds: int) -> "int | None":
458 """Ask the human for one more budget-width; ``None`` declines.
459
460 The hook :func:`oc_chat.tools._proc.run_cancellable` calls when a human's
461 budget elapses. The child is still alive while this waits, so the answer is
462 genuinely "keep going" rather than "start again", and the window is bounded:
463 an operator who has walked away is not a yes, and the run they left behind
464 must not outlive their attention by more than the grace window.
465
466 The grace constant is read HERE, at call time, rather than captured — so
467 monkeypatching :data:`BUDGET_EXTENSION_GRACE_S` shortens a real window and a
468 later fix to it needs no second edit.
469 """
470 func = "ask_budget_extension"
471 answer = _read_key_within(
472 f"Budget of {seconds} s reached for {program}; extend by {seconds} s? (y/N) ",
473 valid="yn", deadline_s=BUDGET_EXTENSION_GRACE_S)
474 if answer == "y":
475 _dbg("OC_CHAT.CORE", func,
476 f"human extended {program!r} by {seconds}s at the budget prompt", 2,
477 override=True)
478 return seconds
479 presenter.show_info(
480 f" No extension for {program}; the command was stopped at "
481 f"{seconds} s.")
482 _dbg("OC_CHAT.CORE", func,
483 f"no extension for {program!r} within the "
484 f"{BUDGET_EXTENSION_GRACE_S:.0f}s window -> the child is terminated", 2,
485 override=True)
486 return None
487
488
489__all__ = [
490 "ASK",
491 "AUTO",
492 "BUDGET_EXTENSION_GRACE_S",
493 "DEFAULT_BUDGETS",
494 "DEFAULT_BUDGET_S",
495 "PYTHON_PROGRAMS",
496 "RememberedAnswer",
497 "VALID_MODES",
498 "ask_budget_extension",
499 "auto_answer_for",
500 "auto_mode_active",
501 "auto_mode_available",
502 "current_mode",
503 "default_budget_for",
504 "forget_remembered",
505 "program_key",
506 "read_typed_budget",
507 "remember_answer",
508 "remembered_answer",
509 "remembered_programs",
510 "reset_session_mode",
511 "set_mode",
512 "table_budget_for",
513]
str _read_key_within(str prompt="", *, str valid="", float deadline_s=0.0)
"int | None" ask_budget_extension(Any presenter, str program, int seconds)
None remember_answer(str program, RememberedAnswer answer)
str _poll_for_key(str valid, float end)
"int | None" table_budget_for(str program)
str _module_after_dash_m("Sequence[str]" argv)
"RememberedAnswer | None" remembered_answer(str program)
str program_key("Sequence[str]" argv)
"dict[str, RememberedAnswer]" remembered_programs()
RememberedAnswer auto_answer_for(str program)
"int | None" read_typed_budget(Any presenter)
str _read_line(str prompt="")
int default_budget_for(str program)