Private
Public Access
0
0
Files
manual_slop/docs/guide_state_lifecycle.md
T
conductor-tier2 161ebb0da6 docs(fix): correct nav link case + relative-path level
Gitea (and any case-sensitive filesystem) was rendering the [Top]
nav links in /docs as broken because of two bugs:

1. Case-sensitivity: 22 links used '../README.md' (all-uppercase)
   but the actual file is 'docs/Readme.md' (capital R, lowercase
   rest). 21 guide_*.md nav bars were affected, plus 1 internal
   cross-link in Readme.md itself. Works on Windows (case-
   insensitive) but broken on Linux/Gitea.

   Fix: 22 occurrences across 22 files changed
   '../README.md' -> '../Readme.md'

2. Wrong relative-path level: 16 links used '../../conductor/...'
   from 'docs/guide_*.md' to reach 'conductor/'. This goes up 2
   levels to 'projects/', which doesn't exist. The correct path
   from 'docs/guide_*.md' to 'conductor/' is 1 level up
   ('../conductor/...'). 12 unique patterns across 10 files
   affected.

   Fix: 16 occurrences across 10 files changed
   '../../conductor/' -> '../conductor/'

3. Bonus: 1 planned-guide link in guide_context_curation.md
   referenced a never-written 'guide_context_presets.md'. The
   ContextPreset schema is now fully covered in the new
   'guide_context_aggregation.md' (per the 2026-06-08 docs
   refresh). Fix: link target updated.

No content was changed, only link paths. 24 files, 37 link
replacements, 37 deletions.

Verification:
- All .md links in docs/ now resolve to existing files
  (validated by path-resolution check from each file's directory)
- The 3 new guides from the previous docs refresh commit
  (guide_discussions.md, guide_state_lifecycle.md,
  guide_context_aggregation.md) had the case bug inherited from
  guide_architecture.md's existing nav pattern; their top-of-file
  nav bars are now correct
- The 21 pre-existing guide nav bars that had the same bug
  (all 21 of them, except the 3 that used the correct case:
  guide_mma.md, guide_simulations.md, guide_tools.md) are now
  also fixed
- Inter-guide links (e.g. [Discussions](guide_discussions.md))
  were not affected; they were always correct because both the
  link text and the actual filename are lowercase

This is a docs-only fix. No code modified.
2026-06-08 19:51:55 -04:00

376 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# State Lifecycle: Undo/Redo, Reset, and State Delegation
[Top](../Readme.md) | [App Controller](guide_app_controller.md) | [Discussions](guide_discussions.md) | [GUI Main](guide_gui_2.md)
---
## Overview
Manual Slop's state lifecycle has three load-bearing concerns:
1. **Undo/Redo** via `HistoryManager` + `UISnapshot` — the non-provider history system
2. **Reset** via `_handle_reset_session` — the "throw away everything" flow
3. **State delegation** via `App.__getattr__`/`__setattr__` — the App is a thin proxy over the Controller
This guide covers the *implementation* of all three. The design philosophy is documented in `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1` (state visibility) and `§9` (edit-the-input, not the output).
> **Domain classification.** All three concerns are **Application**-domain. The Hook API (which exposes state) is also Application, but crosses into Meta-Tooling via the bridge scripts. See `guide_meta_boundary.md`.
---
## 1. Undo/Redo: `HistoryManager` + `UISnapshot`
### Why a Non-Provider History?
Manual Slop's history is **not** the same as the provider-side conversation history. The provider (`ai_client._anthropic_history`, `_deepseek_history`, etc.) tracks the *exact bytes* sent to and received from the LLM. The Manual Slop history (`app.history` + `UISnapshot`) tracks the *user's UI state* — text inputs, sliders, file lists, discussions.
This separation is intentional. It means:
- The user can `Ctrl+Z` to undo a typo in the AI input box, even if the previous LLM call's history is preserved on the provider side.
- The provider's history is the *authoritative* transcript for the LLM; the Manual Slop history is the *user's working state*.
- A reset of the Manual Slop state does not clear the provider's history (and vice versa) — see §"Reset" below.
This is exactly the kind of "edit the input, not the output" pattern nagent uses; see nagent takeaways §9.
### `UISnapshot` — The Serializable State
`src/history.py:8 UISnapshot` is a frozen-shaped dataclass capturing the 13 user-mutable fields:
| Field | Type | Source |
|---|---|---|
| `ai_input` | `str` | `self.ui_ai_input` |
| `project_system_prompt` | `str` | `self.ui_project_system_prompt` |
| `global_system_prompt` | `str` | `self.ui_global_system_prompt` |
| `base_system_prompt` | `str` | `self.ui_base_system_prompt` |
| `use_default_base_prompt` | `bool` | `self.ui_use_default_base_prompt` |
| `temperature` | `float` | `self.temperature` |
| `top_p` | `float` | `self.top_p` |
| `max_tokens` | `int` | `self.max_tokens` |
| `auto_add_history` | `bool` | `self.ui_auto_add_history` |
| `disc_entries` | `list[dict]` | `copy.deepcopy(self.disc_entries)` |
| `files` | `list[dict]` | `[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.files]` |
| `context_files` | `list[dict]` | `[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.context_files]` |
| `screenshots` | `list[str]` | `list(self.screenshots)` |
`to_dict()` / `from_dict()` are explicit serializers, used by the Hook API for the `/api/session` endpoint. The dataclass is **not** auto-serialized; explicit `to_dict` is required so the schema is documented.
### `HistoryManager` — The Undo/Redo Stack
`src/history.py:71 HistoryManager` is a 100-snapshot capacity stack:
```python
class HistoryManager:
def __init__(self, max_capacity: int = 100): ...
def push(self, state: typing.Any, description: str) -> None: ...
def undo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
def redo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
def jump_to_undo(self, index: int, current_state: typing.Any, current_description: str = "Before Jump") -> typing.Optional[HistoryEntry]: ...
@property
def can_undo(self) -> bool: ...
@property
def can_redo(self) -> bool: ...
def get_history(self) -> typing.List[typing.Dict[str, typing.Any]]: ...
```
- `push` appends a new entry; clears the redo stack; pops the oldest if capacity exceeded.
- `undo` moves the current state to the redo stack and returns the top of the undo stack.
- `redo` is the inverse.
- `jump_to_undo` allows time-traveling to any past snapshot, moving subsequent states to the redo stack.
- `get_history` returns `[{description, timestamp}, ...]` for the History List view in the GUI.
The `max_capacity=100` is the default and is sufficient for a 5-second window of rapid typing or a longer session of infrequent edits.
### The Push Trigger — `gui_2.py:1140-1170`
The undo stack is *not* pushed on every keystroke. It's pushed via debounced change-detection at the start of every render frame:
```python
current = self._take_snapshot()
if self._last_ui_snapshot is None:
self._last_ui_snapshot = current
return
changed = (
current.ai_input != self._last_ui_snapshot.ai_input or
current.project_system_prompt != self._last_ui_snapshot.project_system_prompt or
# ... 10 more field comparisons ...
len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) or
len(current.files) != len(self._last_ui_snapshot.files) or
len(current.context_files) != len(self._last_ui_snapshot.context_files) or
len(current.screenshots) != len(self._last_ui_snapshot.screenshots)
)
if not changed and len(current.disc_entries) > 0:
if current.disc_entries[-1].get('content') != self._last_ui_snapshot.disc_entries[-1].get('content'):
changed = True
if changed:
self.history.push(current, description="<auto>")
self._last_ui_snapshot = current
```
The change detector compares:
- 7 scalar fields directly (`!=`)
- 2 float fields with epsilon (`abs(...) > 1e-5`)
- 4 list fields by length
- The `disc_entries[-1]["content"]` separately (because streaming AI responses can change the last entry's content without changing the length)
**Performance:** The check is at the start of every render frame. `copy.deepcopy(self.disc_entries)` (line 748) is the most expensive part — O(N) where N is the entry count. For a 100-entry discussion, this is microseconds. The full snapshot push only happens when a change is detected.
### The Apply Trigger — `gui_2.py:819 _apply_undo` / `_apply_redo` / `_apply_jump`
Three wrapper methods invoke `_apply_snapshot(entry.state)` with the right description:
- `_apply_undo(...)` — pops from undo, pushes current to redo, applies popped
- `_apply_redo(...)` — pops from redo, pushes current to undo, applies popped
- `_apply_jump(index, ...)` — invokes `jump_to_undo(index, current_state)`, applies the result
`_apply_snapshot` is the *single* restore function (`gui_2.py:754-789`):
```python
def _apply_snapshot(self, snapshot: history.UISnapshot) -> None:
self._is_applying_snapshot = True
try:
self.ui_ai_input = snapshot.ai_input
self.ui_project_system_prompt = snapshot.project_system_prompt
# ... 10 more assignments ...
self.disc_entries = snapshot.disc_entries
# Restore files as FileItem objects
from src import models
self.files = []
for f in snapshot.files:
if isinstance(f, dict):
self.files.append(models.FileItem.from_dict(f))
else:
self.files.append(models.FileItem(path=str(f)))
# ... similar for context_files, screenshots ...
finally:
self._is_applying_snapshot = False
```
The `_is_applying_snapshot` flag is set during the restore to prevent re-pushing a new snapshot from the changes made *by* the restore itself. (Without this, pressing Ctrl+Z would push a new snapshot identical to the restored one, which would *clear the redo stack* — making Ctrl+Y a no-op.)
---
## 2. State Delegation: `App.__getattr__` / `__setattr__`
### The Pattern
`App` (`src/gui_2.py:264+`) is a thin wrapper around `AppController` (`src/app_controller.py:772+`). The wrapper exists because:
- The Controller is the *headless* orchestrator (no ImGui dependencies).
- The App is the *render* side (ImGui calls).
- Render functions take `app: App` and read state via `app.<field>`.
The simplest design would be: copy every Controller field to the App. But that's brittle (every new Controller field requires an App copy). The actual pattern uses Python's `__getattr__`/`__setattr__` for transparent delegation.
### `App.__getattr__` — Read Fallthrough
`gui_2.py:666-669`:
```python
def __getattr__(self, name: str) -> Any:
if name == 'controller':
raise AttributeError(name)
return getattr(self.controller, name)
```
When `app.foo` is accessed and `foo` is not an instance attribute of `App`, Python falls through to `__getattr__`, which reads from `self.controller`. This means `app.disc_entries`, `app.history`, `app.temperature` all read from the Controller transparently.
The `'controller'` exception prevents infinite recursion if the Controller is not yet set (during early `__init__`).
### `App.__setattr__` — Write Fallthrough
`gui_2.py:671-675`:
```python
def __setattr__(self, name: str, value: Any) -> None:
if name != 'controller' and hasattr(self, 'controller') and hasattr(self.controller, name):
setattr(self.controller, name, value)
else:
object.__setattr__(self, name, value)
```
When `app.foo = bar` is assigned and the Controller has a `foo` attribute, the write goes *through* to the Controller. Otherwise it stores on the App instance.
### Why This Matters
- **No data duplication.** There is one source of truth (the Controller). The App never has its own copy of `disc_entries`, `temperature`, etc.
- **No boilerplate.** New Controller fields are automatically available via the App without code changes.
- **Backward-compatible.** Existing App fields (e.g. `app._is_applying_snapshot`) work because `object.__setattr__` is the fallback when the Controller doesn't have the field.
### The 4 Edge Cases (per the `guide_gui_2.md` Known Issues)
1. **`app.controller` is itself an App attribute** — it must not be delegated, hence the explicit `if name == 'controller'` guard.
2. **App instance attributes shadow Controller attributes** — the `object.__setattr__` fallback stores on the App; the next `__getattr__` call returns the App's value (because Python's normal attribute lookup finds the instance attribute first). This is sometimes intentional (e.g. `app._is_applying_snapshot` is App-local).
3. **Fields in `app._app` (not on the Controller)** are stored on the App. The `_app` field is the Controller's back-reference to the App (set at `gui_2.py:264`).
4. **Underscore-prefixed App-specific fields** like `app._mma_approval_open`, `app._pending_ask_dialog` are stored on the App because the Controller doesn't have them. They appear in the Controller's `__getattr__` mirror via `hasattr` check.
### Known Fragility
`guide_gui_2.md` notes: `ui_separate_context_preview`, `ui_separate_message_panel`, `ui_separate_response_panel`, `ui_separate_tool_calls_panel`, `ui_separate_external_tools`, `ui_discussion_split_h` are NOT in the Controller's `_settable_fields`, so `__setattr__` falls through to `object.__setattr__` and stores them on the App. This is intentional for window-separator flags (they're render-only and shouldn't pollute the Controller), but it does mean they don't survive a hot-reload of `App`.
---
## 3. Reset: `_handle_reset_session`
`src/app_controller.py:3286-3356 _handle_reset_session` is the **nuclear** reset, called from the "Reset Session" button in the message panel.
### What It Clears
| Group | What | Why |
|---|---|---|
| AI client | `ai_client.reset_session()` + `clear_comms_log()` | Clears provider-side history. |
| Tool stats | `_tool_log.clear()`, `_tool_stats.clear()`, `_comms_log.clear()` | Clears the in-memory activity logs. |
| Discussion | `self.disc_entries.clear()`; for each discussion in project: `discussions[d_name]["history"] = []` | Empties the *current* take and all takes across all discussions. |
| Files | `self.files.clear()`, `self.context_files.clear()` | Drops the FileItem lists. |
| Tracks | `self.tracks.clear()` | Drops the loaded tracks. |
| **Project dict (full replacement)** | `self.project = project_manager.default_project(...)` | The project is *replaced* with a fresh default, not mutated. |
| Project paths | `self.project_paths = []` | Clears the recent-projects list. |
| Project switch state | `_project_switch_in_progress = False` etc. | Resets the in-flight switch state machine. |
| AI status | `ai_status = "session reset"`, `ai_response = ""` | Status bar update. |
| UI inputs | `ui_ai_input = ""`, `ui_manual_approve = False`, `ui_auto_add_history = False` | Empties the message box. |
| MMA | `active_track = None`, `active_tier = None`, `mma_status = "idle"`, `proposed_tracks = []`, `active_tickets = []`, `engines.clear()`, `mma_streams.clear()`, `_worker_status.clear()` | Drops all MMA state. |
| Provider/model | `_current_provider = "gemini"`, `_current_model = "gemini-2.5-flash-lite"`, `ai_client.set_provider(...)` | Resets to defaults. |
| Locks + queues | `_pending_history_adds.clear()`, `_api_event_queue.clear()`, `_pending_gui_tasks.clear()` | Drains all queues under their locks. |
| Prompts | `ui_use_default_base_prompt = True`, all 3 system prompts = `''` | Resets to default base prompt. |
| Persona/tool settings | `ui_active_persona = ''`, `ui_active_tool_preset = None`, `ui_active_bias_profile = None` | Drops active persona. |
| Generation params | `temperature = 0.0`, `top_p = 1.0`, `max_tokens = 8192` | Defaults. |
### What It Does NOT Touch
| Field | Why preserved |
|---|---|
| `self.active_project_path` | `_do_project_switch` writes to this path; clearing it would cause OSError on next switch and an infinite re-switch loop. The 2026-06-08 regression test `test_context_sim_live` documents this. |
| `self.history` (the `HistoryManager`) | The undo stack survives a reset. Ctrl+Z after a reset can restore the pre-reset state. This may be a bug or a feature. |
| The on-disk `manual_slop.toml` | The saved project TOML is not deleted or rewritten. Switching projects after reset reloads from disk. |
| `self.discussion_sent_markdown` / `discussion_sent_system_prompt` | These *are* cleared (set to `""`). They're not in the preserve list. |
### The `_is_applying_snapshot` Guard During Reset
`_handle_reset_session` does *not* set `self._is_applying_snapshot`. This means the change-detection logic *will* push a new snapshot to the undo stack after the reset. The snapshot will contain the *post-reset* state. To get the pre-reset state, the user must Ctrl+Z *twice* (once to push the post-reset snapshot, once to restore the pre-reset snapshot).
This is a known papercut. The fix is to set `self._is_applying_snapshot = True` during the reset and clear it at the end. See `tests/test_session_logger_reset.py:test_reset_session` for the current behavior.
### The 2026-06-08 Regression
`app_controller.py:3307-3312` documents a regression that was caught by `test_context_sim_live`:
> The test's `client.click("btn_reset")` resets the AI session but does not reset the project (see `_handle_reset_session` at line 3244 — it clears files, context_files, disc_entries, etc. but not self.project or self.active_project_path).
The fix was to *not* clear `self.active_project_path`. The project dict *is* replaced (`self.project = project_manager.default_project(...)`), but the path is preserved. This is the right behavior: the user is *resetting the session*, not *abandoning the project*.
---
## 4. State Synchronization Across Threads
The state lifecycle has 4 distinct threads of access:
1. **Render thread** (60 FPS) — reads `app.<field>` to render ImGui widgets
2. **AI response callback thread** (background) — appends to `app.disc_entries` under `_disc_entries_lock`
3. **Hook API thread** (HTTP server on `127.0.0.1:8999`) — reads via `_gettable_fields` and writes via `_predefined_callbacks`
4. **MMA worker thread(s)** — write to `_api_event_queue`, `_pending_gui_tasks`, `_pending_history_adds`
The synchronization primitives:
- `_disc_entries_lock: threading.Lock` — protects `disc_entries` (read by render, written by AI callback and Truncate)
- `_pending_history_adds_lock` — protects the queue of pending discussion entries
- `_api_event_queue_lock` — protects the Hook API event stream
- `_pending_gui_tasks_lock` — protects the queue of GUI tasks scheduled from background threads
- `_project_switch_lock` — protects the project-switch state machine
- `ai_client._send_lock: threading.Lock` — serializes all `ai_client.send()` calls (the global lock per `guide_ai_client.md`)
- `ai_client._<provider>_history_lock` — per-provider history lock (one per provider)
**Invariant:** the render thread *never* blocks. It reads lock-free. All locks are acquired by the writer (AI callback, Truncate button, Hook API), held for the minimum critical section, and released before the writer returns.
See `docs/reports/MUTATION_MATRIX_PHASE5.md` for the full matrix of state mutations × lock × thread.
---
## 5. State Persistence
Three layers of state persistence:
1. **In-memory only** (lost on crash): `ai_client._<provider>_history`, `app.disc_entries`, `app.history` (undo stack)
2. **Project TOML** (persisted on Save / switch discussion): `project.discussion.discussions[*].history`, `project.discussion.discussions[*].context_snapshot`, `project.discussion.discussions[*].sent_markdown`, `project.discussion.discussions[*].sent_system_prompt`
3. **Config TOML** (persisted on `save_config()`): `config.disc_roles`, `config.use_default_base_system_prompt`, `config.ui_global_system_prompt`, etc.
The auto-save flow is:
- `_flush_to_project()` → writes project TOML
- `_flush_to_config()` → writes in-memory config to `AppController.config`
- `save_config()` → calls `models.save_config(config)` to write to disk
These three calls are *manual* (not automatic). The user must click Save, or trigger a state change that flushes (switch discussion, branch, etc.). The reset path explicitly does *not* save (per the "What reset does NOT touch" section above).
**The comms log** is its own persistence layer. `ai_client._comms_log` and `app._comms_log` are in-memory, but every entry is also written to `logs/sessions/<session_id>/comms.log` (JSON-L) via the `_on_comms_entry` callback. The reset clears the in-memory log; the on-disk log survives. This is intentional — the comms log is an *audit trail*, not a working state.
---
## 6. Hot Reload Integration
`src/gui_2.py` is hot-reloadable. The `HotReloader` (covered in `guide_hot_reload.md`) swaps module references at runtime. The state lifecycle interacts with hot-reload in 3 ways:
1. **`HistoryManager` survives hot-reload** because it lives on the Controller, not the App. The `gui_2.py` module's functions can be reloaded without losing the undo stack.
2. **`UISnapshot` schema is the contract** — if a hot-reload changes the fields captured by `UISnapshot`, the old snapshots in the undo stack may have different shapes. The `from_dict` method handles missing fields via `.get(..., default)`, so old snapshots degrade gracefully.
3. **`_app` back-reference** is set in `App.__init__` (line 264). After a hot-reload of `gui_2.py`, the Controller's `self._app` still points to the (now reloaded) App instance. Render functions that captured `app` in closures may still hold the old App — but the App is a thin wrapper, so the new App is functionally equivalent.
See `guide_hot_reload.md §"What can/cannot be safely reloaded"` for the full list.
---
## 7. Hook API Surface
The state lifecycle is exposed to the Hook API via two registries on the Controller (`src/app_controller.py:296-326` in `App.__init__`):
- **`_predefined_callbacks: dict[str, Callable]`** — name → function. The Hook API exposes each as a `custom_callback` action. Includes:
- `save_context_preset`, `load_context_preset`, `delete_context_preset`
- `set_ui_file_paths`, `set_ui_screenshot_paths`
- `set_context_files_for_test`, `set_screenshots_for_test`
- `_toggle_command_palette`
- `get_app_debug_info`, `save_context_preset_force`
- `set_ui_attr(k, v)`, `set_context_files`
- `simulate_save_preset`
- **`_gettable_fields: dict[str, str]`** — public name → internal field name. The Hook API exposes each as a readable state field. Includes `show_command_palette`, `app_debug_info`, and 50+ other UI/state fields.
The Hook API does *not* directly expose `disc_entries` mutation; it goes through `/api/session` POST which replaces the full list.
`/api/session` is the most state-relevant endpoint:
- `GET /api/session``{"session": {"entries": [...]}}`
- `POST /api/session` with `{"session": {"entries": [...]}}``{"status": "updated"}`
See `guide_tools.md §"Hook API"` and `guide_api_hooks.md` for the full surface.
---
## 8. Tests
- `tests/test_history.py``test_undo_redo`, `test_jump_to_undo`, `test_max_capacity`, `test_redo_cleared_on_push`, `test_push_state`, `test_initial_state`
- `tests/test_history_manager.py``TestHistoryManager` class with: `test_snapshot_roundtrip`, `test_push_and_undo`, `test_push_clears_redo_stack`, `test_undo_and_redo`, `test_undo_no_history_returns_none`, `test_redo_no_history_returns_none`, `test_get_history_returns_descriptions`, `test_jump_to_undo`
- `tests/test_session_logger_reset.py``test_reset_session`
- `tests/test_state_inventory.py` — validates the state inventory is up-to-date
- `tests/test_state_delegation.py` — validates `App.__getattr__`/`__setattr__` behavior
- `tests/test_live_gui_state_sync.py` — validates that Hook API state reads are consistent with the live GUI state
- `tests/test_gui_fast_render.py``test_render_discussion_panel_fast` (the change-detection path)
- `tests/conftest.py:app_instance`, `tests/conftest.py:mock_app` — the App fixtures used by these tests
---
## 9. Known Limitations
1. **Per-edit save is not debounced to disk.** See `guide_discussions.md §"Known Limitations"` for the related issue. The fix is to hook the change-detection in `gui_2.py:1140-1170` to also call `_flush_disc_entries_to_project` after a debounce.
2. **`_handle_reset_session` pushes a new snapshot to the undo stack.** The pre-reset state is two Ctrl+Z presses away, not one. Fix: set `self._is_applying_snapshot = True` during the reset.
3. **Provider-side history and Manual Slop state can diverge.** When the user edits an entry's `content` via the discussion UI, the provider's `ai_client._<provider>_history` still has the original. This is Pitfall #4 in `conductor/tracks/nagent_review_20260608/report.md` and Decision candidate #3.
4. **Undo stack capacity is 100.** For long sessions with infrequent edits, this is plenty. For a 5-second window of rapid typing, you can fill it. Capacity is set in `app.history = HistoryManager(max_capacity=100)` in `App.__init__` and is not configurable.
5. **Hook API state writes are not undoable.** A `POST /api/session` that replaces the discussion list is a *single change*; if the change-detection logic pushes a snapshot, it's pushed as one entry. The user can Ctrl+Z to revert it, but the API caller has no way to know the change is undoable.
---
## Cross-References
- **Undo/redo core:** `src/history.py:8 UISnapshot`, `src/history.py:71 HistoryManager`
- **App-side wiring:** `src/gui_2.py:735 _take_snapshot`, `src/gui_2.py:754 _apply_snapshot`, `src/gui_2.py:819 _apply_undo`, `src/gui_2.py:825 _apply_redo`, `src/gui_2.py:832 _apply_jump`, `src/gui_2.py:1140-1170 change_detection`, `src/gui_2.py:666 __getattr__`, `src/gui_2.py:671 __setattr__`
- **Controller-side reset:** `src/app_controller.py:3286 _handle_reset_session`, `src/app_controller.py:3357 _handle_compress_discussion`
- **Hook API registries:** `src/gui_2.py:296-326` (the `_predefined_callbacks` / `_gettable_fields` assignments in `App.__init__`)
- **State inventory:** `docs/reports/STATE_INVENTORY_PHASE5.md`, `docs/reports/MUTATION_MATRIX_PHASE5.md`
- **Discussion integration:** `guide_discussions.md`
- **Actionable patterns:** `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1` (state visibility), §9 (edit-the-input)
- **Future-track candidate for stateless LLMClient:** `conductor/tracks/nagent_review_20260608/decisions.md` candidate #3