[object Object]

← back to Answer Cockpit

TK-11793: server-side menu resolution — a posted LABEL is translated to its displayed number, free text against a live menu is refused (400); stale-client auto-reload on server bootId; ticket-backlog lane renders first, orphan memos capped at 25

60b190a46d558e50bf76d7a790ba1d2e516d6c6b · 2026-09-15 20:55:35 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Files touched

Diff

commit 60b190a46d558e50bf76d7a790ba1d2e516d6c6b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 15 20:55:35 2026 -0700

    TK-11793: server-side menu resolution — a posted LABEL is translated to its displayed number, free text against a live menu is refused (400); stale-client auto-reload on server bootId; ticket-backlog lane renders first, orphan memos capped at 25
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---
 lib/menu.js       | 34 ++++++++++++++++++++++++++++++++++
 lib/queue.js      |  6 +++++-
 public/index.html | 27 +++++++++++++--------------
 server.js         | 13 +++++++++++--
 4 files changed, 63 insertions(+), 17 deletions(-)

diff --git a/lib/menu.js b/lib/menu.js
new file mode 100644
index 0000000..a642cbe
--- /dev/null
+++ b/lib/menu.js
@@ -0,0 +1,34 @@
+'use strict';
+// menu.js — server-side answer resolution against a LIVE AskUserQuestion menu.
+//
+// Proven live 2026-09-15 (TK-11793): a real menu selects by its DISPLAYED NUMBER. Typing the
+// option LABEL (or any free text) + Enter fires the cursor default (option 1). A stale browser
+// tab, a curl script, or a peer agent can still POST a label — so the SERVER, which parsed the
+// menu, must be the last line: translate a label to its number, and refuse free text.
+//
+//   resolveMenuAnswer(text, question) → { text, translated, optionLabel, error }
+//     - question == null (no live menu)        → pass through unchanged
+//     - text is a number matching an option n   → ok
+//     - text equals an option label (ci, trim)  → text := String(n), translated:true
+//     - anything else                           → error (free text would mis-answer)
+function norm(s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); }
+
+function resolveMenuAnswer(text, question) {
+  const q = question && question.questions && question.questions[0];
+  const opts = q && Array.isArray(q.options) ? q.options : [];
+  if (!q || !opts.length) return { text, translated: false, optionLabel: null, error: null };
+  const t = String(text || '').trim();
+  const nums = opts.map((o, i) => (Number.isInteger(o.n) ? o.n : i + 1));
+  if (/^\d{1,2}$/.test(t) && nums.includes(parseInt(t, 10))) {
+    const o = opts[nums.indexOf(parseInt(t, 10))];
+    return { text: t, translated: false, optionLabel: o.label || null, error: null };
+  }
+  const idx = opts.findIndex((o) => norm(o.label) === norm(t));
+  if (idx >= 0) return { text: String(nums[idx]), translated: true, optionLabel: opts[idx].label, error: null };
+  return {
+    text, translated: false, optionLabel: null,
+    error: `a live menu is on screen — send the option number (${nums.join('/')}) or an exact label; free text + Enter would fire the cursor default (option ${nums[0]})`,
+  };
+}
+
+module.exports = { resolveMenuAnswer };
diff --git a/lib/queue.js b/lib/queue.js
index f185285..3f0039c 100644
--- a/lib/queue.js
+++ b/lib/queue.js
@@ -37,6 +37,10 @@ const TTY_RE = /^ttys\d{3}$/;
 
 let cached = null; // {ts, result}
 let inflight = null;
+// BOOT_ID changes on every server start (= every code change). The client reloads itself when it
+// sees a new one, so a browser tab left open across a fix can never keep typing with STALE JS —
+// the likeliest way to reproduce the already-fixed "label typing selects option 1" bug.
+const BOOT_ID = `${process.pid}-${Date.now().toString(36)}`;
 
 function run(cmd, args, timeout) {
   return new Promise((resolve) => {
@@ -212,7 +216,7 @@ async function build(opts = {}) {
     ticketBacklogCount = lane.length;
     ticketBacklog = opts.orphans ? lane.slice(0, 80) : [];
   } catch (e) { /* lane is best-effort; a read failure never blocks the live stream */ }
-  return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, ticketBacklog, ticketBacklogCount, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
+  return { items, remaining: items.length, orphanMemos, orphanCount: memos.filter((m) => !m.ticket || !linked.has(m.ticket)).length, ticketBacklog, ticketBacklogCount, bootId: BOOT_ID, scannedAt: res.scannedAt, cost: '$0 (local)', stale: !!res.stale, source: res.source, error: res.error, terminalApi: res.terminalApi };
 }
 
 /** one item, fresh (uncached scan) — for the post-answer confirm. */
diff --git a/public/index.html b/public/index.html
index 4b6e50d..a027a67 100644
--- a/public/index.html
+++ b/public/index.html
@@ -227,6 +227,8 @@ async function poll(){
     if(e.status===401)return;
     state.down=true;banner((e.status===503||e.status===502)?'iTerm unreachable / scan stale — actions disabled':(e.status?('backend error '+e.status+' — actions disabled'):'backend unreachable — actions disabled (retrying every 8s)'));render();return;}
   state.down=false;state.stale=!!q.stale;state.scannedAt=q.scannedAt||new Date().toISOString();
+  // stale-client guard: a new server bootId means new code — reload rather than keep running old JS
+  if(q.bootId){if(state.bootId&&q.bootId!==state.bootId){toast('cockpit updated — reloading');setTimeout(()=>location.reload(),600);return;}state.bootId=q.bootId;}
   state.orphans=q.orphanMemos||[];state.backlog=q.ticketBacklog||[];state.backlogCount=q.ticketBacklogCount||0;state.remaining=(typeof q.remaining==='number')?q.remaining:(q.items||[]).length;
   $('cost').textContent=q.cost||'$0 (local)';
   banner(state.stale?'iTerm unreachable / scan stale — actions disabled':'');
@@ -377,8 +379,17 @@ function render(){
 }
 // ?panel=1 forces the orphan/backlog panel open (bookmarkable; also lets a fresh headless browser see it)
 function renderOrphans(){const o=$('orph');const on=get('orph','0')==='1'||new URLSearchParams(location.search).get('panel')==='1';o.hidden=!on;if(!on)return;
-  if(!state.orphans.length){o.innerHTML='<h3 style="color:var(--muted);font-size:13px">orphan memos — none</h3>';return;}
-  o.innerHTML='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px">orphan memos (no live tab) — decide only</h3>'+state.orphans.map(m=>
+  // ticket backlog renders FIRST (it was buried under 100+ orphan memos); orphans are capped at 25.
+  const bl0=state.backlog||[];const kp0=k=>k==='steve-action'?'warn':k==='external-wait'?'':'bad';
+  const laneHtml='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px">ticket backlog (no open pane) — '+esc(String(state.backlogCount||bl0.length))+' waiting on you — route only</h3>'+
+    (bl0.length?bl0.slice(0,25).map(t=>'<div class="item"><b>'+esc(t.ticket)+' · '+esc(t.title)+' <span class="pill '+kp0(t.kind)+'">'+esc(t.kind)+'</span></b>'+
+      '<div class="m mono" title="'+esc(t.created||'')+'">🕓 '+esc(fmtWhen(t.created))+' · '+esc(t.owner||'unowned')+(t.project?' · '+esc(t.project):'')+' · urgency '+esc(String(t.urgency))+'</div>'+
+      '<pre style="margin-top:8px;max-height:120px">'+esc(t.ask)+'</pre>'+
+      '<div class="actions"><a class="btn" style="min-height:32px;padding:4px 10px;font-size:12px" href="http://127.0.0.1:9794" target="_blank" rel="noopener noreferrer">ticket board ↗</a></div></div>').join('')+(bl0.length>25?'<div class="m">…and '+(bl0.length-25)+' more on the ticket board</div>':'')
+    :'<div class="m">none</div>');
+  if(!state.orphans.length){o.innerHTML=laneHtml+'<h3 style="color:var(--muted);font-size:13px;margin-top:18px">orphan memos — none</h3>';return;}
+  const orphShown=state.orphans.slice(0,25);
+  o.innerHTML=laneHtml+'<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px;margin-top:18px">orphan memos (no live tab) — '+esc(String(state.orphans.length))+' — decide only'+(state.orphans.length>25?' (showing 25)':'')+'</h3>'+orphShown.map(m=>
     '<div class="item" data-file="'+esc(m.file)+'"><b>'+esc(m.title||m.file)+' <span class="pill '+esc(m.recommendation||'none')+'">'+esc(m.recommendation||'no rec')+'</span></b>'+
     '<div class="m mono" title="'+esc(m.createdAt||'')+'">'+esc(m.file)+' · 🕓 '+esc(fmtWhen(m.createdAt))+'</div>'+(m.excerpt?'<pre style="margin-top:8px;max-height:140px">'+esc(m.excerpt)+'</pre>':'')+
     '<div class="actions"><button class="oa" data-d="approve"'+dis()+'>APPROVE</button><button class="oa danger" data-d="block"'+dis()+'>BLOCK</button><button class="oa warn" data-d="revise"'+dis()+'>REVISE</button>'+
@@ -387,18 +398,6 @@ function renderOrphans(){const o=$('orph');const on=get('orph','0')==='1'||new U
     let note='';if(d!=='approve'){note=prompt(d.toUpperCase()+' note for '+file+':');if(note==null)return;}
     else if(!confirm('APPROVE '+file+' → moves to _approved/ (undo available)?'))return;
     decide(file,d,note,null);}));
-  // ---- ticket backlog lane (TK-11793 cycle 3): tickets waiting on Steve with NO open pane.
-  // Display/route only — there is nothing to type into. Sorted by urgency (steve_action >
-  // past-due external_wait > blocked), then oldest first. The ticket board owns the actions.
-  const bl=state.backlog||[];
-  const kindPill=k=>k==='steve-action'?'warn':k==='external-wait'?'':'bad';
-  o.innerHTML+='<h3 style="color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.6px;margin-top:18px">ticket backlog (no open pane) — '+esc(String(state.backlogCount||bl.length))+' waiting on you — route only</h3>'+
-    (bl.length?bl.map(t=>
-      '<div class="item"><b>'+esc(t.ticket)+' · '+esc(t.title)+' <span class="pill '+kindPill(t.kind)+'">'+esc(t.kind)+'</span></b>'+
-      '<div class="m mono" title="'+esc(t.created||'')+'">🕓 '+esc(fmtWhen(t.created))+' · '+esc(t.owner||'unowned')+(t.project?' · '+esc(t.project):'')+' · urgency '+esc(String(t.urgency))+'</div>'+
-      '<pre style="margin-top:8px;max-height:120px">'+esc(t.ask)+'</pre>'+
-      '<div class="actions"><a class="btn" style="min-height:32px;padding:4px 10px;font-size:12px" href="http://127.0.0.1:9794" target="_blank" rel="noopener noreferrer">ticket board ↗</a></div></div>').join('')
-    :'<div class="m">none</div>');
 }
 
 function bind(it){
diff --git a/server.js b/server.js
index 27f3427..1eb7b40 100644
--- a/server.js
+++ b/server.js
@@ -103,7 +103,7 @@ async function guardTarget(body, { needsSteveOnly = true, requireKey = true } =
   const force = body.force === true;
   if (needsSteveOnly && !queue.NEEDS_STEVE.has(row.color) && !liveMenu && !force) return { code: 403, body: { error: `tty is ${row.color} — not waiting on you (pass force:true to override)`, color: row.color } };
   if (row.runtime === 'codex' && !force) return { code: 403, body: { error: 'tty hosts a codex REPL — answer disabled unless force', runtime: 'codex' } };
-  return { row };
+  return { row, sess, liveMenu };
 }
 
 async function doType(row, text, body, action, extra = {}) {
@@ -167,7 +167,16 @@ const server = http.createServer(async (req, res) => {
       if (bad) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: bad, refused: true }); return json(res, 400, { error: bad }); }
       const g = await guardTarget(body);
       if (g.code) { audit.audit({ action: 'answer', tty: body.tty, text, ok: false, err: g.body.error, refused: true }); return json(res, g.code, g.body); }
-      const r = await doType(g.row, text, body, 'answer', { optionLabel: typeof body.optionLabel === 'string' ? body.optionLabel.slice(0, 200) : null });
+      // Server-side menu resolution: translate a label to its displayed number, refuse free text
+      // against a live menu (a stale client or a curl script must not be able to mis-answer).
+      let typed = text, optionLabel = typeof body.optionLabel === 'string' ? body.optionLabel.slice(0, 200) : null, translated = false;
+      if (g.liveMenu && !(body.force === true && body.rawText === true)) {
+        const m = require('./lib/menu').resolveMenuAnswer(text, g.sess.detail.question);
+        if (m.error) { audit.audit({ action: 'answer', tty: g.row.tty, ticket: g.row.ticket, color: g.row.color, text, ok: false, err: m.error, refused: true }); return json(res, 400, { error: m.error, liveMenu: true }); }
+        typed = m.text; translated = m.translated; if (m.optionLabel) optionLabel = m.optionLabel;
+      }
+      const r = await doType(g.row, typed, body, 'answer', { optionLabel, translated: translated || undefined });
+      if (r.body && translated) r.body.translated = { from: text, to: typed, label: optionLabel };
       return json(res, r.code, r.body);
     }
     if (p === '/api/continue') {

← 1e893e7 TK-11793: ?panel=1 forces the orphan/backlog panel open (boo  ·  back to Answer Cockpit  ·  TK-11793: pane is authoritative for the on-screen menu on ev 2f37523 →