repl: Norris driver + \C-n + :norris/:safety meta (Phase 3 commit #5)

Phase 3 commit #5 per docs/PHASE3.md §12. Wires safety.norris_step
(commit #4) into the REPL with the user-facing surface.

ffi/readline.lua extensions (A1 + R-C4):
  - rl_insert_text + rl_redisplay added to ffi.cdef block; M.insert_text
    and M.redisplay wrappers exposed.
  - M.bind: removed `:free()` on previous callback. Now keeps every
    bound callback pinned for process lifetime in `_pinned` list
    (alongside `_bound[seq]` for current lookup). Avoids the
    use-after-free window between unbind and rebind that R-C4 flagged.
    Memory cost is bounded — one closure per key sequence binding.

context.lua Norris suffix (R-C3 / §8):
  - to_messages() composes a dynamic NORRIS MODE block onto the
    system prompt when ctx.norris_active is set. The block carries
    ctx.norris_goal so eviction of the user's "[norris] goal:" turn
    doesn't lose the anchor. Returns to plain system prompt when
    Norris exits.

repl.lua Norris driver:
  - prompt() now shows  marker when ctx.norris_active per PHASE0.md §9.
  - \C-n bound to a real handler — inserts ":norris " at the cursor
    (replaces Phase 1 status placeholder).
  - run_norris(goal) function: sets norris_active + norris_goal,
    appends a "[norris] <goal>" user turn, renders the banner, then
    loops calling safety.norris_step with an injected helpers table
    until a terminal status returns. Renders the closing banner.
  - norris_halt(): the [N] proceed/skip/abort prompt called by
    safety.norris_step via helpers.halt. Empty input → abort (safe).
  - dispatch_tool(): factored from the Phase 2 ask_ai code so
    safety.norris_step can call it.
  - norris_exec(): factored exec path for autonomous mode (skips
    the interactive run_shell cd-status renderer).
  - :norris <goal>  meta — launches autonomous mode
  - :norris off     meta — drops Norris flag (rare; usually 'abort')
  - :safety patterns meta — lists active is_destructive rules
  - :safety check <cmd> meta — probes a hypothetical command

End-to-end mock-driven test:
  Submitted ":norris find files in /tmp" → banner → step 1 emits
  tool_call (auto_approved per policy) → dispatched → frame rendered
  → step 2 emits "GOAL: complete" → sub-loop exits → DONE banner.
  2 broker invocations, no stalls.

config.lua safety example block lands in commit #6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 23:42:14 +00:00
parent 11b1f566b3
commit a404b2a152
3 changed files with 232 additions and 14 deletions
+37 -9
View File
@@ -1,7 +1,10 @@
-- ffi/readline.lua — GNU readline binding.
-- Phase 0: readline + add_history + EOF handling.
-- Phase 1: custom key bindings via rl_bind_keyseq.
-- See docs/PHASE0.md §9 and docs/PHASE1.md §7.
-- Phase 3: rl_insert_text + rl_redisplay so bound key handlers can
-- stuff text into the in-progress line buffer (used by \C-n
-- to insert ":norris " in repl.lua).
-- See docs/PHASE0.md §9 and docs/PHASE1.md §7 and docs/PHASE3.md §3.
local ffi = require("ffi")
@@ -12,6 +15,8 @@ void free(void *ptr);
typedef int (*rl_command_func_t)(int, int);
int rl_bind_keyseq(const char *keyseq, rl_command_func_t function);
int rl_insert_text(const char *text);
int rl_redisplay(void);
]]
-- libreadline-dev (which ships the unversioned `libreadline.so` symlink) is
@@ -53,15 +58,21 @@ end
-- Bind `seq` (e.g. "\\C-n") to a Lua function that runs when the user types
-- that key sequence at the readline prompt. The Lua fn takes no arguments
-- (readline passes count + key, but Phase 1 consumers don't need them).
-- Callback trampolines are pinned in module-local state so they outlive the
-- M.bind call — readline retains the function pointer indefinitely.
local _bound = {}
-- (readline passes count + key, but consumers don't need them).
-- Callback trampolines are pinned in module-local state for process
-- lifetime. We do NOT free the previous binding on rebind: readline
-- retains the function pointer in its keymap, and the window between
-- :free() and the new rl_bind_keyseq is a potential use-after-free.
-- Memory cost is bounded — one closure per bound key sequence.
-- (Phase 3 R-C4 fold-in.)
-- `_pinned` keeps every callback ever cast alive for process lifetime
-- (so readline's keymap pointers never dangle even after a re-bind).
-- `_bound` indexes by seq for "what's currently bound here" lookup but
-- both old and new closures stay reachable via _pinned.
local _bound = {}
local _pinned = {}
function M.bind(seq, fn)
if _bound[seq] then
_bound[seq]:free()
end
local cb = ffi.cast("rl_command_func_t", function(_count, _key)
local ok, err = pcall(fn)
if not ok then
@@ -69,8 +80,25 @@ function M.bind(seq, fn)
end
return 0
end)
_pinned[#_pinned + 1] = cb -- never freed; bounded by N rebinds
local rc = rl.rl_bind_keyseq(seq, cb)
_bound[seq] = cb
return rl.rl_bind_keyseq(seq, cb) == 0
return rc == 0
end
-- Insert `text` at the cursor in the in-progress readline buffer.
-- Used by bound key handlers to stuff e.g. ":norris " into the line.
-- Caller typically follows with M.redisplay() to refresh the display.
function M.insert_text(text)
if text and text ~= "" then
rl.rl_insert_text(text)
end
end
-- Force readline to redraw the current line. Call after insert_text or
-- any other buffer mutation from inside a bound handler.
function M.redisplay()
rl.rl_redisplay()
end
return M