Mark loop director plan implemented

ober

b86340ff6e78692e4d27fa904c091bfb6c829b39

diff --git a/stop-loop.md b/stop-loop.md
new file mode 100644
index 0000000..4012a4d
--- /dev/null
+++ b/stop-loop.md
@@ -0,0 +1,1055 @@
+# stop-loop — turn the no-progress breaker into a loop director
+
+**Audience:** a local LLM (or human) implementing changes in this repository
+(`jerboa-code`, the `jcode` coding agent).
+**Written:** 2026-08-02.
+**Status:** implemented in commit `8d9b7b9`.
+
+This document is the complete brief for reworking jcode's "no-progress loop
+breaker" into a **loop director**: when the model repeats tool calls that
+return no new information, the harness should (1) detect it even when the
+model varies arguments to evade exact-match counting, (2) tell the model
+*exactly* which calls looped and what to do instead, (3) escalate from
+nudge → tool restriction → forced terminal summary, and (4) remember looped
+calls across user turns so typing "continue" does not re-arm the same loop.
+
+It contains: the problem evidence, the current mechanisms with code anchors,
+the target design, exact code to add/change (work packages WP0–WP5), the
+verification protocol, and a pitfalls catalog. **Do not claim the work is
+done until the full test suite passes** (§8 — `make test`).
+
+---
+
+## 1. Ground rules for the implementer (MANDATORY)
+
+These come from `AGENTS.md` and are non-negotiable. Several exist because
+previous sessions lost hours to mistakes they prevent in seconds.
+
+1. **NEVER edit `*.ss` or `*.sls` with raw `edit`/`write`/`sed`/`python`.**
+   Use the jerboa-mcp tools:
+   - Add a top-level form → `jerboa_balanced_insert` (anchor = one unique
+     complete form already in the file, e.g. the `def` above the insertion
+     point).
+   - Replace exact text → `jerboa_balanced_replace` (**dry-run by default**;
+     pass `dry_run: false` to actually write).
+   - File already unbalanced → STOP. `git checkout -- <file>` and redo, or
+     `jerboa_repair_balance` (dry-run first).
+2. **After EVERY `.ss` change, run `jerboa_check_balance`** (the balanced
+   tools do this automatically), then `jerboa_verify` on the changed file
+   before building.
+3. **Keep closer-runs ≤ ~4.** Flatten deep nesting with helper `def`s,
+   `let*`, or `cond` clauses. The code in this document already follows
+   that — do not "simplify" it into longer `))))` runs.
+4. **No `(def ...)` after an expression in a body.** Internal defines come
+   first in a body, or use `let`/`let*`. All code below is top-level `def`s
+   and `let`-bound bodies — keep it that way.
+5. **Jerboa ≠ Gerbil/Racket.** Before using any function you are not 100%
+   sure of, check it with `jerboa_function_signature` or
+   `jerboa_module_exports`. Every function used in the code below already
+   appears in the files you are editing, or is listed in §9 ("verified
+   vocabulary") — stay within that vocabulary.
+6. **`stop-loop.md` (this file) is plain markdown.** Raw `write`/`edit` is
+   fine for this file only, never for `.ss`.
+
+---
+
+## 2. The problem (evidence)
+
+The chat loop has a per-turn "no-progress breaker"
+(`src/jcode/core/agent.ss`, `forge-no-progress?` at line 248, wired into the
+agent loop at lines 1548–1562 and 1679–1695). When the model repeats the
+same tool call 3× in one turn it injects one generic nudge; on the next
+repeat it ends the turn with the assistant message
+`[stopped: repeated the same tool call(s) with no progress]`.
+
+Session transcripts in `~/.jcode/sessions/` show it failing as a redirector:
+
+- **313462F8** (cwd `/Users/user/sfb`, 254 messages, breaker fired 3×):
+  the model ran `sed -n 'NNNN,MMMMp' continue` / `grep ... continue` /
+  `tail continue` variants ~100 times across four user turns. Each variant
+  (different line ranges, added `| head -N`, different `timeout` values) is
+  a *different* exact-match signature, so the breaker only tripped after
+  dozens of wasted rounds. After each dead stop the user typed "continue",
+  the per-turn breaker state reset, and the same loop re-ran.
+- **867CE393** (cwd `/Users/user/mine/jerboa-gitsite`, 388 messages,
+  breaker fired 4×): the model ran `cat Makefile`, `cat src/gitsite.ss`,
+  `ls -la vendor/jerboa-git/` dozens of times via `bash`, with trivial
+  variations (`| head -20`, `| cat`, `-l` vs `-la`). Same pattern: nudge →
+  1–2 superficially different calls → re-trip → dead stop → "continue" →
+  repeat.
+
+Root causes, in order of importance:
+
+1. **Detection is exact-match only.** Call signatures are
+   `name|raw-args-json`. Any arg variation (line range, pipe suffix,
+   `timeout`, `cd` prefix) produces a new signature, so the model
+   random-walks around the counter. Output *content* is never compared —
+   and the outputs of `cat Makefile`, `cat Makefile | head`, and
+   `sed -n '1,50p' Makefile` are (near-)identical.
+2. **The nudge carries no information.** It names no calls, no counts, and
+   gives no concrete next action. Small local models cannot infer what to
+   change. Observed effect: nudge → immediate re-trip.
+3. **The dead stop teaches the wrong loop.** The break message is stored as
+   an *assistant* message with no summary of findings; the user types
+   "continue"; `agent-run-once` creates a fresh `forge-breaker-state`; the
+   loop re-arms with a full budget.
+
+---
+
+## 3. Current state — the THREE existing mechanisms
+
+Read these before writing anything. Do not merge or remove any of them in
+this work package; you are adding alongside them.
+
+1. **Registry-level exact-call tracker** — `src/jcode/tool/registry.ss`
+   lines 49–65 (`record-tool-call!`), called from `tool-execute` at line
+   ~177. Per-turn, keyed by `(name . canonical-json-args)`: at 3 identical
+   calls the tool result is replaced by a "⚠ Loop warning" string; at 5+
+   by a "Tool blocked" string (the tool does not execute). Reset per turn
+   by `reset-turn-tool-calls!` — **but only `agent-chat` calls that
+   (agent.ss line 1817); `agent-run-once` (the session/TUI path,
+   agent.ss line 1432) does not** (see WP0).
+2. **Agent-level no-progress breaker (ATLAS)** — `src/jcode/core/agent.ss`:
+   - Parameters/state: lines 131–166 (`forge-max-repeated-calls` = 3,
+     `forge-max-similar-search-calls` = 2, `forge-breaker-state`,
+     `make-forge-breaker-state`, nudge-used? flag).
+   - Signatures/counting: lines 168–266 (`chat-call-signature`,
+     `forge-count-bump!`, `forge-no-progress?`).
+   - Wiring: `agent-run-once` line 1451 creates a fresh
+     `forge-breaker-state` per user turn; the check fires in
+     `agent-guardrails-step` (lines 1548–1562) and
+     `agent-guardrails-step-stream` (lines 1679–1695): first trip →
+     `forge-no-progress-nudge-message` (lines 144–149) as a user message;
+     second trip → `forge-no-progress-message` (lines 142–143) as a final
+     assistant message.
+   - Existing tests: `test/run.ss` lines 2378–2450. These MUST keep
+     passing unmodified (WP3 keeps the three nudge-flag functions'
+     semantics for exactly this reason).
+3. **Hard round cap** — `*max-tool-rounds*` = 100 (agent.ss line 387);
+   the cap branch (lines 1529–1537 non-stream, 1653–1665 stream) executes
+   the final calls and then runs one last inference with `tools='()` to
+   force a terminal text answer. **This is the pattern WP3's tier-3
+   "terminal summary" copies.**
+
+Also relevant:
+
+- `current-tool-allowlist` parameter (registry.ss line 66) gates both the
+  schema list (`get-tool-schemas`, registry.ss lines 212+) and execution
+  (`tool-disabled?`, registry.ss lines 82–87 → `tool-execute` returns
+  `disabled-tool-message` without running). WP3 tier 2 uses this.
+- `execute-tool-calls` (agent.ss lines 1705–1730) returns tool-result
+  messages **in the same order as the input calls** (it maps
+  `thread-join!` over the spawned threads), so zipping calls ↔ results is
+  safe.
+- `truncate-tool-output` (agent.ss lines 448–476) appends a trailer
+  containing a **random** file path
+  (`...~a truncated...\n\nFull output saved to: <random path>...`), so
+  identical oversized outputs do NOT produce identical tool-result
+  strings. WP1's output key strips this trailer before hashing.
+- `chat-call-arg` (agent.ss lines 178–186) parses a tool call's JSON
+  arguments and returns one key — use it, do not re-parse JSON.
+
+---
+
+## 4. Target design — the loop director
+
+Escalation tiers per user turn (state lives in the extended
+`forge-breaker-state`):
+
+```
+tool batch proposed
+      │
+      ▼
+forge-no-progress?            ← exact-match fast path (unchanged)
+      │                        + NEW: bash-normalized signatures
+      │                        + NEW: identical-output counts (fed by
+      │                          forge-record-outputs! after each execution)
+      ▼ trip
+forge-note-loop! session-id   ← remember worst offenders (WP4)
+tier = forge-escalate!        ← 1 → 2 → 3 (WP3)
+      │
+      ├─ tier 1: inject forge-nudge-text  (names the actual repeated
+      │          calls + counts + 3 concrete next actions), continue turn
+      │
+      ├─ tier 2: inject forge-loop-restrict-directive, restrict
+      │          current-tool-allowlist to write/todo tools for the rest
+      │          of the turn ("discovery closed"), continue turn
+      │
+      └─ tier 3: forge-terminal-summary — one final inference with
+                 tools='() (copies the max-rounds pattern) so the turn
+                 ends with a findings summary, not a bare dead stop
+```
+
+Detection additions (WP1), both evasion-resistant:
+
+- **Bash signature normalization** — `chat-call-signature` special-cases
+  `bash`: strip leading `cd DIR &&` segments, drop everything after the
+  first `|`, collapse whitespace, ignore every arg except `command`.
+  `cd /x && cat Makefile | head -20` (timeout 5000) and `cat Makefile`
+  (timeout 15000) become the SAME signature.
+- **Identical-output counting** — after each `execute-tool-calls`, hash
+  the outputs of read-family calls (explicit tool allowlist + `bash` only
+  when its normalized command starts with a read prefix like
+  `cat `/`sed `/`grep `/…). Build/test commands (`make …`) are NEVER
+  output-hashed: identical build failures must not trip this path (they
+  remain on exact-match counting). Files being edited change output
+  between reads, so legitimate read-edit-read cycles self-clear.
+
+Cross-turn memory (WP4): on every tier trip, the worst offenders (top 5
+signatures + top 5 output keys) are stored in a module-level table keyed
+by session-id (cap 10 each). The next turn seeds the fresh breaker state
+with those counts at `limit - 1`, so the FIRST rerun of a remembered call
+trips immediately instead of re-burning the repeat budget.
+
+---
+
+## 5. Work packages
+
+All `.ss` changes are in `src/jcode/core/agent.ss` unless stated otherwise.
+Use `jerboa_balanced_insert` for new top-level forms and
+`jerboa_balanced_replace` (`dry_run: false`) for replacements. After every
+single change: `jerboa_verify` on the file, then `make build` before moving
+to the next work package.
+
+### WP0 — fix the missing per-turn reset (one line)
+
+`agent-chat` (agent.ss line 1817) calls `(reset-turn-tool-calls!)` at the
+start of every user turn; `agent-run-once` (the TUI/session path) never
+does, so the registry-level exact-call tracker can accumulate across turns.
+Add the reset as the first body form of `agent-run-once` (agent.ss line
+1432, directly under the existing comment block):
+
+```scheme
+(def (agent-run-once session-id user-input)
+  (reset-turn-tool-calls!)
+  ;; Defensively repair the session before the new turn — ... (unchanged)
+  (try (session-repair-orphan-tool-calls! session-id) (catch (_) (void)))
+  ...)
+```
+
+`reset-turn-tool-calls!` is already exported by `:jcode/tool/registry`,
+which agent.ss already imports. Idempotent; harmless if the turn already
+ran on a fresh thread.
+
+### WP1 — detection: bash normalization + identical-output counting
+
+**WP1a. Extend the breaker state vector from 6 slots to 7.**
+
+Replace `make-forge-breaker-state` (lines 151–154):
+
+```scheme
+(def (make-forge-breaker-state)
+  ;; #(last-batch-sig consecutive-count seen-batches seen-individual-calls
+  ;;   seen-similar-search-paths escalation-tier seen-outputs)
+  (vector #f 0 '() '() '() 0 '()))
+```
+
+Slot 5 changes meaning: boolean `nudge-used?` → integer `escalation-tier`
+(0–3). Slot 6 `seen-outputs` is an alist of
+`output-key . (count . desc-string)`. WP3 updates the three nudge-flag
+functions to match; do WP1 and WP3 together before building.
+
+**WP1b. Add the new parameter and detection helpers.**
+
+Insert the following block after `forge-no-progress-nudge-message`
+(anchor: the complete `def` of `forge-no-progress-nudge-message`,
+lines 144–149):
+
+```scheme
+;; Max times read-family calls may return byte-identical output in one
+;; turn before it counts as a no-progress loop. #f disables. Output
+;; identity is evasion-proof: the model can rephrase arguments, but a
+;; loop returns the same bytes.
+(def forge-max-identical-outputs (make-parameter 3))
+
+;; Tools whose outputs are always safe to hash for loop detection.
+;; Build/test/execution tools (notably bare "bash") are excluded so an
+;; identical `make build` failure never trips this path.
+(def *forge-output-hash-tools*
+  '("read" "ls" "glob" "grep" "git_status" "git_diff"
+    "git_log" "git_show" "fetch" "websearch"))
+
+;; Normalized bash commands starting with one of these prefixes count as
+;; read-family. "python3"/"python " cover the inline-script probes local
+;; models loop on (heredocs, -c). Anything else ("make ...", "cargo ...")
+;; stays on exact-match counting only.
+(def *forge-bash-read-prefixes*
+  '("cat " "sed " "grep " "tail " "head " "ls " "find "
+    "stat " "wc " "file " "readlink " "awk " "python3" "python "))
+
+(def (forge-collapse-ws s)
+  (string-join
+    (filter (lambda (p) (not (string=? p "")))
+            (string-split s #\space))
+    " "))
+
+(def (forge-strip-cd-prefixes cmd)
+  ;; Drop leading `cd DIR && ` segments (loops are usually the same
+  ;; command re-issued after a redundant cd).
+  (if (and (string-prefix? "cd " cmd)
+           (string-contains cmd "&&"))
+    (let ((rest (substring cmd (+ (string-contains cmd "&&") 2)
+                           (string-length cmd))))
+      (forge-strip-cd-prefixes (string-trim rest)))
+    cmd))
+
+(def (forge-normalize-bash-command cmd)
+  ;; Canonical form for LOOP DETECTION ONLY (never executed):
+  ;; strip cd-prefixes, drop everything after the first pipe,
+  ;; collapse whitespace.
+  (let* ((no-cd (forge-strip-cd-prefixes (string-trim cmd)))
+         (first (car (string-split no-cd #\|))))
+    (string-trim (forge-collapse-ws first))))
+
+(def (forge-starts-with-any? s prefixes)
+  (cond
+    ((null? prefixes) #f)
+    ((string-prefix? (car prefixes) s) #t)
+    (else (forge-starts-with-any? s (cdr prefixes)))))
+
+(def (forge-bash-command tc)
+  ;; The "command" arg of a bash tool call, or #f.
+  (and (equal? (tool-call-name tc) "bash")
+       (chat-call-arg tc "command")))
+
+(def (forge-bash-read-command? cmd)
+  (forge-starts-with-any? (forge-normalize-bash-command cmd)
+                          *forge-bash-read-prefixes*))
+
+(def (forge-output-hashable-call? tc)
+  (let ((name (tool-call-name tc)))
+    (cond
+      ((member name *forge-output-hash-tools*) #t)
+      ((equal? name "bash")
+       (let ((cmd (forge-bash-command tc)))
+         (and cmd (forge-bash-read-command? cmd))))
+      (else #f))))
+
+(def (forge-strip-truncation-trailer text)
+  ;; truncate-tool-output appends a trailer containing a RANDOM path —
+  ;; strip it so identical truncated outputs hash identically.
+  (let ((marker (string-contains text "\n\n...")))
+    (if (and marker (string-contains text "Full output saved to:"))
+      (substring text 0 marker)
+      text)))
+
+(def (forge-output-key text)
+  ;; Length + first/last 1KB: cheap, collision-safe enough for one turn.
+  (let* ((core (forge-strip-truncation-trailer text))
+         (len  (string-length core))
+         (head (substring core 0 (min 1024 len)))
+         (tail (substring core (max 0 (- len 1024)) len)))
+    (string-append (number->string len) "\x1;" head "\x1;" tail)))
+
+(def (forge-call-desc tc)
+  ;; Human-readable one-liner for nudge reports, e.g.
+  ;; bash({"command":"sed -n '4935,4938p' continue"})
+  (let* ((name (tool-call-name tc))
+         (args (tool-call-arguments tc))
+         (raw  (if (string? args) args (format "~a" args)))
+         (flat (forge-collapse-ws raw)))
+    (if (> (string-length flat) 140)
+      (string-append name "(" (substring flat 0 137) "...)")
+      (string-append name "(" flat ")"))))
+
+;; seen-outputs entry accessors: entry = (key . (count . desc))
+(def (forge-out-entry-count e) (car (cdr e)))
+(def (forge-out-entry-desc e)  (cdr (cdr e)))
+
+(def (forge-bump-output! st key desc)
+  (let ((hit (assoc key (vector-ref st 6))))
+    (if hit
+      (set-cdr! hit (cons (+ (forge-out-entry-count (cdr hit)) 1)
+                          desc))
+      (vector-set! st 6
+        (cons (cons key (cons 1 desc)) (vector-ref st 6))))))
+
+(def (forge-max-output-count st)
+  (let loop ((rest (vector-ref st 6)) (mx 0))
+    (if (null? rest)
+      mx
+      (loop (cdr rest)
+            (max mx (forge-out-entry-count (cdr (car rest))))))))
+
+(def (forge-record-outputs! calls results)
+  ;; Zip executed calls with their tool-result messages (same order —
+  ;; see execute-tool-calls) and count identical outputs from
+  ;; read-family calls. Call AFTER execute-tool-calls, before recursing.
+  (let ((st (forge-breaker-state)))
+    (when st
+      (for-each
+        (lambda (tc r)
+          (when (and (forge-output-hashable-call? tc)
+                     (message-content r))
+            (forge-bump-output! st
+              (forge-output-key (message-content r))
+              (forge-call-desc tc))))
+        calls results))))
+```
+
+**WP1c. Normalize bash in `chat-call-signature`.**
+
+Replace `chat-call-signature` (lines 168–171):
+
+```scheme
+(def (chat-call-signature/raw tc)
+  (let ((a (tool-call-arguments tc)))
+    (string-append (tool-call-name tc) "|"
+                   (if (string? a) a (format "~a" a)))))
+
+(def (chat-call-signature tc)
+  ;; bash calls sign on the NORMALIZED command only — line ranges, pipe
+  ;; suffixes, cd prefixes and timeout values no longer evade counting.
+  (if (equal? (tool-call-name tc) "bash")
+    (let ((cmd (forge-bash-command tc)))
+      (if cmd
+        (string-append "bash|" (forge-normalize-bash-command cmd))
+        (chat-call-signature/raw tc)))
+    (chat-call-signature/raw tc)))
+```
+
+**WP1d. Consult output counts in `forge-no-progress?`.**
+
+Replace `forge-no-progress?` (lines 248–266):
+
+```scheme
+;; Update the per-turn breaker state with CALLS; report whether executing
+;; them now crosses the configured repeat limit. Also trips when earlier
+;; read-family executions already produced >= forge-max-identical-outputs
+;; identical results, even if THIS batch is novel (a model that recovered
+;; answers in text and never reaches this check).
+(def (forge-no-progress? calls)
+  (let ((limit (forge-max-repeated-calls))
+        (similar-limit (forge-max-similar-search-calls))
+        (out-limit (forge-max-identical-outputs))
+        (st    (forge-breaker-state)))
+    (and st (pair? calls) (or limit similar-limit out-limit)
+         (let* ((sig (chat-calls-signature calls))
+                (batch-seen (if limit (forge-count-bump! st 2 sig) 0))
+                (call-seen  (if limit (forge-max-call-count! st calls) 0))
+                (similar-seen
+                  (if similar-limit
+                    (forge-max-similar-search-count! st calls)
+                    0)))
+           (if (equal? sig (vector-ref st 0))
+             (vector-set! st 1 (+ (vector-ref st 1) 1))
+             (begin (vector-set! st 0 sig) (vector-set! st 1 1)))
+           (or (and limit (>= (vector-ref st 1) limit))
+               (and limit (>= batch-seen limit))
+               (and limit (>= call-seen limit))
+               (and similar-limit (>= similar-seen similar-limit))
+               (and out-limit
+                    (>= (forge-max-output-count st) out-limit)))))))
+```
+
+**WP1e. Record outputs after execution — two call sites.**
+
+In `agent-guardrails-step` (line 1568) and `agent-guardrails-step-stream`
+(line 1699), the `else` branch contains:
+
+```scheme
+(let ((results (execute-tool-calls calls)))
+  (for-each (lambda (r) (session-add-message session-id r)) results)
+```
+
+Insert `(forge-record-outputs! calls results)` as the new first form
+inside that `let`, immediately after the binding (both sites, identical
+edit).
+
+### WP2 — the nudge names names
+
+**WP2a. Report helpers.** Insert after `forge-record-outputs!` (anchor:
+the complete `def` of `forge-record-outputs!`):
+
+```scheme
+(def (forge-take-up-to lst n)
+  (if (or (null? lst) (<= n 0))
+    '()
+    (cons (car lst) (forge-take-up-to (cdr lst) (- n 1)))))
+
+(def (forge-sig->desc sig)
+  (let ((flat (forge-collapse-ws sig)))
+    (if (> (string-length flat) 140)
+      (string-append (substring flat 0 137) "...")
+      flat)))
+
+(def (forge-top-repeats n)
+  ;; Merge individual-call counts (slot 3) and identical-output counts
+  ;; (slot 6) into a (desc . count) alist, highest count first.
+  (let ((st (forge-breaker-state)))
+    (if (not st)
+      '()
+      (let* ((from-calls
+               (map (lambda (e) (cons (forge-sig->desc (car e)) (cdr e)))
+                    (vector-ref st 3)))
+             (from-outs
+               (map (lambda (e)
+                      (cons (string-append (forge-out-entry-desc (cdr e))
+                                           "  [identical output]")
+                            (forge-out-entry-count (cdr e))))
+                    (vector-ref st 6))))
+        (forge-take-up-to
+          (list-sort (lambda (a b) (> (cdr a) (cdr b)))
+                     (append from-calls from-outs))
+          n)))))
+
+(def (forge-nudge-text)
+  ;; Tier-1 corrective: WHICH calls looped, how often, and exactly what
+  ;; to do instead. Small local models cannot infer this from a generic
+  ;; "stop repeating yourself".
+  (let ((repeats (forge-top-repeats 5)))
+    (string-append
+      "[jcode: loop detected] You re-ran calls that returned no new "
+      "information this turn:\n"
+      (if (null? repeats)
+        "  (calls identical to earlier ones in this conversation)\n"
+        (string-join
+          (map (lambda (e)
+                 (string-append "  - " (car e)
+                                "  (x" (number->string (cdr e)) ")"))
+               repeats)
+          "\n"))
+      "\nTheir results are ALREADY in this conversation. Do NOT rerun "
+      "them or trivial variants (same file with slightly different "
+      "flags, ranges, or pipes).\n"
+      "Pick ONE next action:\n"
+      " 1. Found what you needed? Quote it and do the next task step now.\n"
+      " 2. Not found? Say so in one line, then use a DIFFERENT file, "
+      "pattern, or tool.\n"
+      " 3. Stuck? Write three lines — goal / what you learned / next "
+      "concrete step — then do that step.")))
+```
+
+The old constant `forge-no-progress-nudge-message` (lines 144–149) stays
+in place (harmless; WP3 stops referencing it).
+
+**WP2b. System prompt line.** In `system-prompt` (the default branch,
+around line 304), immediately after the existing line
+`- Do NOT call the same tool repeatedly with the same or similar arguments. Use grep/glob to locate code instead of guessing paths.`
+add one new line:
+
+```
+- If a '[jcode: loop detected]' message appears, obey it at once — the flagged results are already in the conversation; re-running those calls wastes your remaining budget.
+```
+
+### WP3 — tiered escalation: nudge → restrict → terminal summary
+
+**WP3a. Update the nudge-flag functions to the tier counter** (slot 5 is
+now an integer; keep these three functions' external semantics so the
+existing tests at `test/run.ss` lines 2447–2450 pass unmodified).
+Replace lines 156–166 (`forge-no-progress-nudge-used?`,
+`forge-mark-no-progress-nudged!`, `forge-no-progress-nudge-available?`):
+
+```scheme
+(def (forge-no-progress-nudge-used?)
+  (let ((st (forge-breaker-state)))
+    (and st (>= (vector-ref st 5) 1))))
+
+(def (forge-mark-no-progress-nudged!)
+  (let ((st (forge-breaker-state)))
+    (when st (vector-set! st 5 (max 1 (vector-ref st 5))))))
+
+(def (forge-no-progress-nudge-available?)
+  (and (forge-breaker-state)
+       (not (forge-no-progress-nudge-used?))))
+
+(def (forge-escalate!)
+  ;; Advance the escalation tier (slot 5); returns the NEW tier (1..3).
+  ;; 1 = specific nudge, 2 = tool restriction, 3 = terminal summary.
+  (let ((st (forge-breaker-state)))
+    (if st
+      (let ((tier (min 3 (+ (vector-ref st 5) 1))))
+        (vector-set! st 5 tier)
+        tier)
+      3)))
+```
+
+**WP3b. Restriction allowlist + directive.** Insert after
+`forge-escalate!`:
+
+```scheme
+(def *forge-loop-restricted-tools*
+  ;; Allowlist after escalation tier 2 ("discovery closed"): write,
+  ;; todo, and pre-commit status tools only. Discovery tools (bash,
+  ;; read, grep, glob, ls, fetch, git_log, git_show) are withheld from
+  ;; the schema AND rejected at execute time (tool-disabled? →
+  ;; disabled-tool-message), which itself tells the model why.
+  '("todowrite" "write" "edit" "edit_block" "multi-edit" "patch"
+    "apply_patch" "git_status" "git_diff" "git_commit"
+    "mcp_jerboa_jerboa" "respond"))
+
+(def (forge-restricted-allowlist)
+  ;; Intersect with any allowlist already in force (compact/small-context
+  ;; providers set one) so tier 2 can only ever NARROW the tool set.
+  (let ((current (or (current-tool-allowlist) (list-tools))))
+    (filter (lambda (t) (member t *forge-loop-restricted-tools*))
+            current)))
+
+(def forge-loop-restrict-directive
+  (string-append
+    "[jcode: loop detector — discovery closed] You repeated discovery "
+    "calls again after being warned. Discovery tools (bash, read, grep, "
+    "glob, ls, fetch, git_log, git_show) are now DISABLED for the rest "
+    "of this turn; calling them returns an error. Everything you need "
+    "is already in this conversation. Do ONE of:\n"
+    " 1. Make the edit / write the file that completes the task.\n"
+    " 2. Update the todo list and give the final answer.\n"
+    " 3. If truly blocked: reply with the single specific question for "
+    "the user."))
+```
+
+**WP3c. Terminal summary.** Insert after `forge-loop-restrict-directive`:
+
+```scheme
+(def forge-terminal-summary-directive
+  (string-append
+    "[jcode: loop detector] Tool use has been stopped for this turn: "
+    "repeated calls produced identical results. Tools are OFF now. "
+    "Using ONLY what is already in this conversation, write the final "
+    "reply with three short sections: (1) what the task needs, "
+    "(2) what you found so far, (3) the single most useful next action "
+    "for the user. Do NOT attempt tool calls."))
+
+(def (forge-terminal-summary session-id provider round)
+  ;; Tier 3: end the turn with a forced findings summary (same pattern
+  ;; as the *max-tool-rounds* branch), never a bare dead stop.
+  (log-warn logger "no-progress-break" `((round . ,round)))
+  (session-add-message session-id
+    (make-user-message forge-terminal-summary-directive))
+  (let* ((msgs (refresh-system-prompt (session-get-messages session-id)))
+         (resp (chat-with-expert provider msgs '()))
+         (txt  (message-content resp))
+         (final (if (and txt (not (string=? txt "")))
+                  (make-assistant-message txt #f)
+                  (make-assistant-message forge-no-progress-message #f))))
+    (session-add-message session-id final)
+    final))
+
+(def (forge-terminal-summary-stream session-id provider raw-cb round)
+  ;; Streaming twin of forge-terminal-summary.
+  (log-warn logger "no-progress-break" `((round . ,round)))
+  (session-add-message session-id
+    (make-user-message forge-terminal-summary-directive))
+  (let-values (((fc _tc _u)
+                (stream-chat-with-expert provider
+                  (refresh-system-prompt (session-get-messages session-id))
+                  '()
+                  (and raw-cb (make-tool-call-stream-filter raw-cb)))))
+    (let* ((use-fallback (or (not fc) (string=? fc "")))
+           (msg (if use-fallback forge-no-progress-message fc)))
+      (when (and use-fallback raw-cb) (raw-cb msg))
+      (let ((final (make-assistant-message msg #f)))
+        (session-add-message session-id final)
+        final))))
+```
+
+**WP3d. Rewire the breaker branch in `agent-guardrails-step`.**
+
+Replace lines 1548–1562 (the `((forge-no-progress? calls) ...)` clause):
+
+```scheme
+           ;; Loop director: repeated no-progress calls escalate through
+           ;; specific nudge → tool restriction → terminal summary.
+           ((forge-no-progress? calls)
+            (forge-note-loop! session-id)
+            (let ((tier (forge-escalate!)))
+              (cond
+                ((= tier 1)
+                 (log-warn logger "no-progress-nudge" `((round . ,round)))
+                 (session-add-message session-id
+                   (make-user-message (forge-nudge-text)))
+                 (agent-loop session-id (session-get-messages session-id)
+                             (+ round 1) gr))
+                ((= tier 2)
+                 (log-warn logger "no-progress-restrict" `((round . ,round)))
+                 (session-add-message session-id
+                   (make-user-message forge-loop-restrict-directive))
+                 (parameterize ((current-tool-allowlist
+                                 (forge-restricted-allowlist)))
+                   (agent-loop session-id (session-get-messages session-id)
+                               (+ round 1) gr)))
+                (else
+                 (forge-terminal-summary session-id provider round)))))
+```
+
+**WP3e. Rewire the streaming twin** in `agent-guardrails-step-stream`
+(replace lines 1679–1695):
+
+```scheme
+           ;; Loop director: repeated no-progress calls escalate through
+           ;; specific nudge → tool restriction → terminal summary.
+           ((forge-no-progress? calls)
+            (forge-note-loop! session-id)
+            (let ((tier (forge-escalate!)))
+              (cond
+                ((= tier 1)
+                 (log-warn logger "no-progress-nudge" `((round . ,round)))
+                 (session-add-message session-id
+                   (make-user-message (forge-nudge-text)))
+                 (agent-loop-stream session-id
+                   (session-get-messages session-id) (+ round 1) gr))
+                ((= tier 2)
+                 (log-warn logger "no-progress-restrict" `((round . ,round)))
+                 (session-add-message session-id
+                   (make-user-message forge-loop-restrict-directive))
+                 (parameterize ((current-tool-allowlist
+                                 (forge-restricted-allowlist)))
+                   (agent-loop-stream session-id
+                     (session-get-messages session-id) (+ round 1) gr)))
+                (else
+                 (forge-terminal-summary-stream
+                   session-id provider raw-cb round)))))
+```
+
+**WP3f. Check `cli.ss` references.** Run
+`grep -n "forge-no-progress\|forge-breaker\|nudge" src/jcode/ui/cli.ss`.
+Keep every exported name it uses working unchanged
+(`forge-no-progress-message` stays exported with its current text — it is
+now only the fallback when the summary inference returns empty). If
+cli.ss pattern-matches the stop text for display styling, leave that
+alone.
+
+### WP4 — cross-turn loop memory
+
+Kills the `break → "continue" → same loop` cycle seen in both evidence
+sessions. Insert after `forge-terminal-summary-stream`:
+
+```scheme
+;; Per-session memory of looped calls, so a later user turn that reruns
+;; them trips at once instead of re-burning the repeat budget.
+;; session-id -> (sig-list outkey-list), each capped.
+(def *session-loop-memory* (make-hash-table))
+(def *session-loop-memory-cap* 10)
+
+(def (forge-note-loop! session-id)
+  ;; Remember this turn's worst offenders. Called on every tier trip.
+  (when (and session-id (forge-breaker-state))
+    (let* ((st (forge-breaker-state))
+           (sigs (map car
+                      (forge-take-up-to
+                        (list-sort (lambda (a b) (> (cdr a) (cdr b)))
+                                   (vector-ref st 3))
+                        5)))
+           (outs (map car
+                      (forge-take-up-to
+                        (list-sort
+                          (lambda (a b)
+                            (> (forge-out-entry-count (cdr a))
+                               (forge-out-entry-count (cdr b))))
+                          (vector-ref st 6))
+                        5)))
+           (old (or (hash-get *session-loop-memory* session-id)
+                    '(() ())))
+           (new-sigs (forge-take-up-to (append sigs (car old))
+                                       *session-loop-memory-cap*))
+           (new-outs (forge-take-up-to (append outs (cadr old))
+                                       *session-loop-memory-cap*)))
+      (hash-put! *session-loop-memory* session-id
+                 (list new-sigs new-outs)))))
+
+(def (forge-seed-from-memory! session-id)
+  ;; Pre-load counts at limit-1 so the FIRST rerun of a remembered call
+  ;; (or identical output) trips immediately this turn.
+  (when session-id
+    (let ((mem (hash-get *session-loop-memory* session-id))
+          (st  (forge-breaker-state))
+          (limit     (forge-max-repeated-calls))
+          (out-limit (forge-max-identical-outputs)))
+      (when (and st mem)
+        (when (and limit (>= limit 2))
+          (for-each
+            (lambda (sig)
+              (vector-set! st 3
+                (cons (cons sig (- limit 1)) (vector-ref st 3))))
+            (car mem)))
+        (when (and out-limit (>= out-limit 2))
+          (for-each
+            (lambda (key)
+              (vector-set! st 6
+                (cons (cons key (cons (- out-limit 1)
+                                      "repeated in an earlier turn"))
+                      (vector-ref st 6))))
+            (cadr mem)))))))
+```
+
+Wire the seed into `agent-run-once`: inside the existing
+`(parameterize ((forge-breaker-state (make-forge-breaker-state))) ...)`
+(line 1451), add `(forge-seed-from-memory! session-id)` as the first form
+of the parameterize body (before the `if`). Do the same in `agent-chat`
+(line 1826) — there is no session-id there, so call
+`(forge-seed-from-memory! #f)`… **no**: `#f` is a no-op by design, so in
+`agent-chat` simply add nothing. Session memory is a session-path feature;
+the one-shot CLI path does not need it.
+
+### WP5 — exports and tests
+
+**WP5a. Exports.** Add these to the `export` form at the top of agent.ss
+(keep everything already there):
+
+```scheme
+        forge-max-identical-outputs
+        forge-record-outputs!
+        forge-escalate!
+        forge-nudge-text
+        forge-loop-restrict-directive
+        forge-restricted-allowlist
+        forge-note-loop!
+        forge-seed-from-memory!
+        forge-normalize-bash-command
+```
+
+**WP5b. Tests.** Append a new section to `test/run.ss` immediately after
+the existing `=== agent chat no-progress breaker ===` section (anchor:
+the block ending at line 2450, just before
+`(section "=== expert escalation ===")`). Use `jerboa_balanced_insert`
+with that `section` form as anchor, position `before`. House style:
+`(check! desc result expected)` / `(check-pred! desc result pred)`;
+tool calls via `(make-tool-call "name" "{\"k\":\"v\"}")`; tool results via
+`(make-tool-result "id" "content")`.
+
+```scheme
+(section "=== loop director: bash signature normalization ===")
+;; Same semantic command, different cd/pipe/timeout → same signature →
+;; counts as a repeat (the exact-match evasion from the field sessions).
+(let ([mk (lambda (cmd timeout)
+            (list (make-tool-call "bash"
+                    (string-append "{\"command\":\"" cmd
+                                   "\",\"timeout\":" (number->string timeout)
+                                   "}"))))])
+  (parameterize ([forge-max-repeated-calls 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (check! "bash norm: first variant ok"
+      (forge-no-progress? (mk "cd /repo && cat Makefile | head -20" 5000)) #f)
+    (check! "bash norm: second variant ok"
+      (forge-no-progress? (mk "cat Makefile" 15000)) #f)
+    (check! "bash norm: third variant trips"
+      (forge-no-progress? (mk "cd /repo && cat Makefile|cat" 5000)) #t)))
+
+(section "=== loop director: identical-output detection ===")
+;; The killer feature: same file re-read with DIFFERENT args producing
+;; byte-identical output trips even when no two calls are identical.
+(let ([sed-a (list (make-tool-call "bash" "{\"command\":\"sed -n '1,50p' x.ss\"}"))]
+      [sed-b (list (make-tool-call "bash" "{\"command\":\"sed -n '1,49p' x.ss | head\"}"))]
+      [cat-c (list (make-tool-call "bash" "{\"command\":\"cd /r && cat x.ss | tail -50\"}"))]
+      [out   "line1\nline2\nline3\n...same bytes...")]
+  (parameterize ([forge-max-repeated-calls 3]
+                 [forge-max-identical-outputs 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (forge-record-outputs! sed-a (list (make-tool-result "t1" out)))
+    (check! "outputs: distinct batch after 1st identical ok"
+      (forge-no-progress? sed-b) #f)
+    (forge-record-outputs! sed-b (list (make-tool-result "t2" out)))
+    (check! "outputs: distinct batch after 2nd identical ok"
+      (forge-no-progress? cat-c) #f)
+    (forge-record-outputs! cat-c (list (make-tool-result "t3" out)))
+    (check! "outputs: 3 identical outputs trip on next batch"
+      (forge-no-progress? sed-a) #t)))
+;; Changed output (file edited between reads) does not accumulate.
+(let ([rd (list (make-tool-call "read" "{\"path\":\"x.ss\"}"))])
+  (parameterize ([forge-max-identical-outputs 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (forge-record-outputs! rd (list (make-tool-result "a" "v1")))
+    (forge-record-outputs! rd (list (make-tool-result "b" "v2-after-edit")))
+    (forge-record-outputs! rd (list (make-tool-result "c" "v3-after-edit")))
+    (check! "outputs: distinct contents never trip"
+      (forge-no-progress? rd) #f)))
+;; Build/test commands are never output-hashed.
+(let ([mk-build (list (make-tool-call "bash" "{\"command\":\"make build 2>&1\"}"))]
+      [fail     "ld: 1 error")]
+  (parameterize ([forge-max-identical-outputs 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (forge-record-outputs! mk-build (list (make-tool-result "a" fail)))
+    (forge-record-outputs! mk-build (list (make-tool-result "b" fail)))
+    (forge-record-outputs! mk-build (list (make-tool-result "c" fail)))
+    (check! "outputs: identical build failures do not trip output path"
+      (forge-max-output-count (forge-breaker-state)) 0)))
+;; Truncation trailers (random paths) are stripped before hashing.
+(let ([rd (list (make-tool-call "read" "{\"path\":\"big.ss\"}"))]
+      [core "aaaa-bbbb-core-bytes"]
+      [trailer "\n\n...90000 bytes truncated...\n\nFull output saved to: /tmp/tool-1-123.txt\nUse grep")]
+  (parameterize ([forge-max-identical-outputs 2]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (forge-record-outputs! rd
+      (list (make-tool-result "a" (string-append core trailer "1"))))
+    (forge-record-outputs! rd
+      (list (make-tool-result "b" (string-append core trailer "2"))))
+    (check! "outputs: trailers with different paths count identical"
+      (forge-no-progress? rd) #t)))
+
+(section "=== loop director: escalation tiers ===")
+(parameterize ([forge-breaker-state (make-forge-breaker-state)])
+  (check! "tier starts unused" (forge-no-progress-nudge-used?) #f)
+  (check! "first escalate → 1" (forge-escalate!) 1)
+  (check! "nudge-used? true at tier 1" (forge-no-progress-nudge-used?) #t)
+  (check! "second escalate → 2" (forge-escalate!) 2)
+  (check! "third escalate → 3" (forge-escalate!) 3)
+  (check! "escalate clamps at 3" (forge-escalate!) 3))
+;; Nudge text names the offending call and its count.
+(let ([cat-x (list (make-tool-call "bash" "{\"command\":\"cat x.ss\"}"))])
+  (parameterize ([forge-max-repeated-calls 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (forge-no-progress? cat-x)
+    (forge-no-progress? cat-x)
+    (check! "trip on third" (forge-no-progress? cat-x) #t)
+    (let ([txt (forge-nudge-text)])
+      (check-pred! "nudge names the call"
+        txt (lambda (s) (string-contains s "cat x.ss")))
+      (check-pred! "nudge shows the count"
+        txt (lambda (s) (string-contains s "(x3)")))
+      (check-pred! "nudge has next actions"
+        txt (lambda (s) (string-contains s "Pick ONE next action"))))))
+
+(section "=== loop director: session memory ===")
+;; A loop recorded in one "turn" makes the next turn trip on the first
+;; rerun (this is the continue→fumble→break→continue killer).
+(let ([sid "test-session-loop-mem"]
+      [cat-x (list (make-tool-call "bash" "{\"command\":\"cat x.ss\"}"))])
+  (parameterize ([forge-max-repeated-calls 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (forge-no-progress? cat-x)
+    (forge-no-progress? cat-x)
+    (forge-no-progress? cat-x)
+    (forge-note-loop! sid))
+  (parameterize ([forge-max-repeated-calls 3]
+                 [forge-breaker-state (make-forge-breaker-state)])
+    (forge-seed-from-memory! sid)
+    (check! "seeded turn: first rerun of remembered call trips"
+      (forge-no-progress? cat-x) #t))
+  (hash-remove! *session-loop-memory* sid))
+
+(section "=== loop director: restriction allowlist ===")
+(let ([allow (forge-restricted-allowlist)])
+  (check-pred! "bash withheld" allow
+    (lambda (a) (not (member "bash" a))))
+  (check-pred! "read withheld" allow
+    (lambda (a) (not (member "read" a))))
+  (check-pred! "edit kept" allow
+    (lambda (a) (and (member "edit" a) #t)))
+  (check-pred! "todowrite kept" allow
+    (lambda (a) (and (member "todowrite" a) #t))))
+(parameterize ([current-tool-allowlist '("read" "edit" "bash")])
+  (let ([allow (forge-restricted-allowlist)])
+    (check! "tier 2 only narrows an existing allowlist" allow '("edit"))))
+```
+
+Note: `forge-no-progress-nudge-used?` returns `(and st ...)` → `#f` on a
+fresh state, and `check!` compares with `equal?` — the existing tests at
+2447–2450 already rely on exactly this, so `#f`/values line up.
+
+`forge-max-output-count` is used in one test; it is internal (not in the
+export list). Tests import `(jcode core agent)` and only see exports —
+so EITHER export it too (add it to WP5a's list) or replace that one check
+with a `forge-no-progress?`-based assertion. Pick exporting: add
+`forge-max-output-count` to the exports.
+
+---
+
+## 6. Verified vocabulary
+
+Every procedure used in this document's code either already appears in
+`src/jcode/core/agent.ss` today or is defined by this document. Before
+using ANYTHING else, check it with `jerboa_function_signature`. The ones
+worth double-checking because they are easy to get wrong:
+
+- `(string-contains str sub)` → **index or #f**, never a boolean.
+- `(string-prefix? prefix str)` — prefix FIRST.
+- `(string-split str char)` — takes a CHAR (`#\space`, `#\|`).
+- `(substring s start end)` — both bounds required.
+- `(list-sort pred lst)` — this codebase's order (prelude shadowing);
+  used at agent.ss line 366.
+- `(take lst n)` — **do not use**, errors when n > length; this document
+  defines `forge-take-up-to` instead.
+- `(hash-get ht key)` → value or `#f`; `(hash-remove! ht key)` exists.
+- `(make-tool-call name args-json-string)` / `(make-tool-result id content)`
+  — message.ss lines 42/58.
+- `(member x lst)` → sublist or `#f`; wrap in `(and ... #t)` when a
+  boolean is needed.
+- Parameters: `(make-parameter v)`, mutate by calling `(p new-v)`,
+  rebind with `(parameterize ((p v)) body ...)`. Parameter bindings are
+  thread-local and do NOT cross `spawn` — never touch
+  `forge-breaker-state` from inside a spawned thread.
+
+## 7. Pitfalls (all observed in prior sessions)
+
+1. **Editing `.ss` with raw tools.** Instant paren damage. Balanced tools
+   only (§1). `test/run.ss` is also `.ss` — same rule.
+2. **`forge-no-progress?` has side effects.** It bumps counts. Tests that
+   call it to "check state" are changing state. Write each test with a
+   fresh `(make-forge-breaker-state)`.
+3. **Do not renumber slots inconsistently.** Slot 5 is the escalation
+   tier everywhere; slot 6 is `seen-outputs`. `forge-no-progress-nudge-used?`
+   and `forge-mark-no-progress-nudged!` MUST keep their old semantics
+   (tests at `test/run.ss` 2447–2450 lock them).
+4. **Do not hash outputs of `make build` / test runners.** Identical
+   build failures are legitimate iteration. That is why `bash` is only
+   hashed when its normalized command matches `*forge-bash-read-prefixes*`.
+5. **Do not hash inside `execute-single-tool` or spawned threads.**
+   Parameter state is thread-local; you would silently update a copy.
+   Recording happens in the parent thread AFTER `execute-tool-calls`
+   returns (WP1e).
+6. **Truncation trailer.** Never hash a tool result without passing
+   through `forge-output-key` (the random truncated-output path would
+   defeat identity).
+7. **Tier 2 must only narrow.** Always compute the allowlist via
+   `forge-restricted-allowlist` (intersection), never by assigning
+   `*forge-loop-restricted-tools*` directly — compact providers already
+   run a reduced tool set.
+8. **Do not touch the workflow/`verified` no-progress machinery.**