6The confirmation mode, the budget table, and what this session remembers (SHELL-04).
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:
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.
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.
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.
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.
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.
70- oc_diagnostics (structured debug logging via _dbg)
71- oc_chat.core.risk_profile (lazy; the permissive-only rule)
77 L1 — never (a mode change is an operator decision, not a fault)
78 L2 — mode / memory lifecycle events
79 L3 — per-decision detail
82from __future__
import annotations
87from dataclasses
import dataclass
88from pathlib
import Path
89from typing
import Any, Sequence
91from oc_diagnostics
import _dbg
93_dbg(
"OC_CHAT.CORE",
"module",
"loaded", 4)
102VALID_MODES: frozenset[str] = frozenset({ASK, AUTO})
107DEFAULT_BUDGET_S: int = 300
113DEFAULT_BUDGETS: dict[str, int] = {
115 "python -m pytest": 900,
122BUDGET_EXTENSION_GRACE_S: float = 30.0
127PYTHON_PROGRAMS: frozenset[str] = frozenset({
"python",
"python3",
"py"})
135@dataclass(frozen=True)
137 """A standing answer for one program, for the rest of this session."""
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."""
146_REMEMBERED:
"dict[str, RememberedAnswer]" = {}
152 """The mode this session is in, regardless of whether it can take effect."""
157 """Move the session to *mode*; raises ``ValueError`` on anything else.
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.
165 if mode
not in VALID_MODES:
167 f
"unknown confirmation mode {mode!r}; valid modes are "
168 f
"{', '.join(sorted(VALID_MODES))}")
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'}",
177 """Whether Auto mode could take effect here at all (``permissive`` only)."""
178 func =
"auto_mode_available"
180 if resolve_risk_profile() !=
"permissive":
181 _dbg(
"OC_CHAT.CORE", func,
"not permissive — Auto mode is not available", 3)
187 """Whether Auto mode is on AND allowed to act."""
192 """Drop the mode and every remembered answer (the session-change seam).
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.
199 func =
"reset_session_mode"
201 was, _MODE = _MODE, ASK
202 dropped = len(_REMEMBERED)
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)
213 """*arg0*'s basename, lower-cased, ``.exe`` stripped, python family folded."""
214 name = Path(arg0).name.lower()
215 if name.endswith(
".exe"):
217 return "python" if name
in PYTHON_PROGRAMS
else name
221 """The module named by a ``-m`` in a python *argv*, or ``""``.
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.
230 for pos, token
in enumerate(argv[1:], start=1):
232 return argv[pos + 1]
if pos + 1 < len(argv)
else ""
233 if token.startswith(
"-m")
and len(token) > 2:
235 if not token.startswith(
"-"):
241 """The identity a budget and a remembered answer are keyed by.
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.
254 if program ==
"python":
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()}"
263 """The table's budget for *program*, or ``None`` when it names none.
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.
270 if program
in DEFAULT_BUDGETS:
271 return DEFAULT_BUDGETS[program]
272 return DEFAULT_BUDGETS.get(program.split(
" ", 1)[0])
276 """What the ask offers for *program*: the table, else the shipped default."""
278 return DEFAULT_BUDGET_S
if budget
is None else budget
284 """This session's standing answer for *program*, or ``None``.
286 The ONE function a tracked permission profile (ORCH-03) would later feed;
287 today the process-scoped store below is its only backend.
289 return _REMEMBERED.get(program)
if program
else None
293 """Record *answer* as this session's standing answer for *program*."""
294 func =
"remember_answer"
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)
306 """A copy of every standing answer, for ``/mode`` to print."""
307 return dict(_REMEMBERED)
311 """Drop every standing answer; returns how many were dropped."""
312 func =
"forget_remembered"
313 dropped = len(_REMEMBERED)
316 _dbg(
"OC_CHAT.CORE", func,
317 f
"{dropped} remembered answer(s) dropped — the human is asked again", 2,
323 """What Auto mode answers for *program* (P3).
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.
332 if table
is not None:
342 """Read one line from the human (``""`` on interrupt or a closed stdin).
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.
351 except (EOFError, KeyboardInterrupt):
352 _dbg(
"OC_CHAT.CORE", func,
"interrupt at the budget entry -> no budget", 3)
357 """The ``t`` key: read a budget in whole seconds, or ``None`` to deny.
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.
364 func =
"read_typed_budget"
365 raw =
_read_line(
" Budget in seconds: ").strip()
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)
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)
382 deadline_s: float = 0.0) -> str:
383 """Read one key, giving up after *deadline_s* seconds (``""`` on silence).
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.
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".
394 func =
"_read_key_within"
395 if not (sys.stdin
and getattr(sys.stdin,
"isatty",
lambda:
False)()):
398 sys.stdout.write(prompt)
401 return _poll_for_key(valid, time.monotonic() + max(0.0, deadline_s))
402 except (OSError, ValueError, ImportError)
as exc:
405 _dbg(
"OC_CHAT.CORE", func,
406 f
"EXTERNAL_ERROR: timed key read unavailable "
407 f
"({type(exc).__name__}: {exc}); reading it as no answer", 2)
411 sys.stdout.write(
"\n")
416 """Poll the TTY for a key in *valid* until *end*; ``""`` when none arrives."""
420 while time.monotonic() < end:
421 if not msvcrt.kbhit():
425 if ch
in (
"\x00",
"\xe0"):
428 if ch
in (
"\r",
"\n"):
431 if not valid
or ch
in valid:
439 fd = sys.stdin.fileno()
440 old = termios.tcgetattr(fd)
444 remaining = end - time.monotonic()
445 if remaining <= 0
or not select.select([sys.stdin], [], [], remaining)[0]:
447 ch = sys.stdin.read(1)
448 if ch
in (
"\r",
"\n"):
451 if not valid
or ch
in valid:
454 termios.tcsetattr(fd, termios.TCSADRAIN, old)
458 """Ask the human for one more budget-width; ``None`` declines.
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.
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.
470 func =
"ask_budget_extension"
472 f
"Budget of {seconds} s reached for {program}; extend by {seconds} s? (y/N) ",
473 valid=
"yn", deadline_s=BUDGET_EXTENSION_GRACE_S)
475 _dbg(
"OC_CHAT.CORE", func,
476 f
"human extended {program!r} by {seconds}s at the budget prompt", 2,
480 f
" No extension for {program}; the command was stopped at "
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,
492 "BUDGET_EXTENSION_GRACE_S",
498 "ask_budget_extension",
501 "auto_mode_available",
503 "default_budget_for",
509 "remembered_programs",
510 "reset_session_mode",
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)
None reset_session_mode()
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 _program_word(str arg0)
bool auto_mode_available()
str _read_line(str prompt="")
int default_budget_for(str program)