Skip to content

feat(bin): add captain inbox board - #1004

Closed
ItsFlow wants to merge 6 commits into
kunchenguid:mainfrom
ItsFlow:fm/inbox-board-v2
Closed

ItsFlow wants to merge 6 commits into
kunchenguid:mainfrom
ItsFlow:fm/inbox-board-v2

Conversation

@ItsFlow

@ItsFlow ItsFlow commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Intent

Ship the captain's decision-and-review board (firstmate shared tracked material). The board is one Lavish HTML surface that collects everything waiting on the captain (decisions to make, PRs to approve, stopped work) so it arrives in one place instead of scattered chat interruptions. Deliverables: bin/fm-inbox-view.sh (a pure READ-ONLY generator projecting bin/fm-fleet-snapshot.sh --json plus 'tasks-axi show --full'; must mutate nothing under state/ or data/, proven by a test), bin/fm-inbox-render.py (HTML renderer), bin/fm-inbox-arm.sh and bin/fm-inbox-serve.sh (serve + arm the answer relay), tests, and docs/inbox-board.md.

The captain approved the board shape yesterday and, from live use, stressed five HARD requirements folded into this change: (1) FREE-FORM ANSWER IS FIRST-CLASS - every decision card must accept a standalone free-text answer with NO forced option selection; predefined options are optional quick-picks with a clear-selection reset. (2) A per-card discuss/clarify path that sends a question back (DISCUSS : ...) instead of an answer (DECISION : ...). (3) RELIABLE DELIVERY - his prior answers never reached firstmate because nothing polled the board; fm-inbox-serve.sh now serves AND arms a registered watcher check (state/inbox.check.sh via fm-check-register.sh, same pattern as the Workflowy @go channel) that relays answers on the normal supervision cadence with no manual polling, and the submit sends immediately with visible confirmation. The relay fails closed: silent when the board is unserved. (4) Fix horizontal overflow so long titles wrap (Lavish's layout audit flagged it error-severity). (5) Clearer names for no-project items, derived from origin/id.

Deliberate design decisions a reviewer should know: The generator selects open captain decisions on hold_kind==captain ALONE, intentionally wider than the snapshot's captain_actionable flag (which also requires kind==captain and thus hides the captain's oldest holds). Full decision text is read via tasks-axi show --full because the snapshot truncates hold reasons at the first comma. Recorded PRs render as UNVERIFIED unless --verify-prs live-checks them, because local metadata cannot know a PR was closed. Plain-English card copy lives in an optional captain-private cards file (data/inbox-cards.md), not in tracked code. fm-inbox-view stays a pure read-only generator; the stateful arming lives in the separate fm-inbox-serve/fm-inbox-arm so the no-mutation guarantee and its test stay clean. This touches firstmate shared tracked material, so firstmate-coding-guidelines was followed (one sentence per line in Markdown, plain dashes, no agent co-author, shellcheck-clean scripts, colocated tests, evidence-backed doc). A pre-existing unrelated failure in tests/fm-bearings-snapshot.test.sh (an SSHHIP-child warning) exists on the base commit and is out of scope.

What Changed

  • Adds a read-only Lavish inbox board generator and renderer that collects captain decisions, recorded PRs, and stopped work into one HTML surface, with optional plain-English card copy and opt-in live PR verification.
  • Adds serve and arm commands that publish the board, register a bounded state/inbox.check.sh relay, and deliver submitted answers through the normal supervision cadence while staying silent when unserved.
  • Updates docs and focused tests for read-only generation, free-form answers, discuss messages, quick-pick clearing, long-title wrapping, relay safety, and changed-test selection.

Risk Assessment

✅ Low: The follow-up is narrow, source-verifiable, and addresses the prior relay-spawn and wake-count defects without introducing a material remaining risk I can substantiate.

Testing

After fixing an initial changed-test selector gap, I reran the runner and inbox behavior tests, generated and served a fixture board, verified read-only manifests and relay registration, captured desktop/mobile/interaction screenshots, and confirmed free-text answers, discuss prompts, immediate send confirmation, and no horizontal overflow all worked.

  • Evidence: Rendered inbox board desktop (local file: /var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/inbox-board-rendered.png)
  • Evidence: Mobile wrapping / no-overflow view (local file: /var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/inbox-board-mobile-wrap.png)
  • Evidence: Free-text answer and discuss confirmation (local file: /var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/inbox-board-interaction.png)
Evidence: Generated inbox board HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="generator" content="fm-inbox-board.v1">
<title>Decisions and reviews firstmate needs from you</title>
<style>
*,*::before,*::after{box-sizing:border-box}
:root{
  color-scheme:light dark;
  --bg:#fbfaf8; --panel:#ffffff; --ink:#1d1f21; --muted:#5f6672; --faint:#8b929c;
  --line:#e4e2dd; --line-soft:#efeee9; --accent:#8a5a2b; --accent-soft:#f5efe6;
  --flagbg:#fdf6e6; --flagline:#e3d3ac; --ok:#2f6b48; --warn:#8a5a2b;
  --radius:10px;
}
@media (prefers-color-scheme:dark){
  :root{
    --bg:#16181b; --panel:#1d2024; --ink:#e8e6e2; --muted:#a3a9b2; --faint:#7d848d;
    --line:#2e3238; --line-soft:#25282d; --accent:#d9a86a; --accent-soft:#2a2620;
    --flagbg:#2a2620; --flagline:#4a4030; --ok:#7fb894; --warn:#d9a86a;
  }
}
:root[data-theme="dark"]{
  --bg:#16181b; --panel:#1d2024; --ink:#e8e6e2; --muted:#a3a9b2; --faint:#7d848d;
  --line:#2e3238; --line-soft:#25282d; --accent:#d9a86a; --accent-soft:#2a2620;
  --flagbg:#2a2620; --flagline:#4a4030; --ok:#7fb894; --warn:#d9a86a;
}
:root[data-theme="light"]{
  --bg:#fbfaf8; --panel:#ffffff; --ink:#1d1f21; --muted:#5f6672; --faint:#8b929c;
  --line:#e4e2dd; --line-soft:#efeee9; --accent:#8a5a2b; --accent-soft:#f5efe6;
  --flagbg:#fdf6e6; --flagline:#e3d3ac; --ok:#2f6b48; --warn:#8a5a2b;
}
body{
  margin:0; background:var(--bg); color:var(--ink);
  font:16px/1.6 ui-sans-serif,-apple-system,"Segoe UI",Inter,Helvetica,Arial,sans-serif;
  -webkit-font-smoothing:antialiased;
}
html,body{max-width:100%; overflow-x:hidden}
.wrap{max-width:56rem; margin:0 auto; padding:2.5rem 1.25rem 5rem}
/* A long title, id, or URL must wrap rather than widen the page at any nesting
   level. Lavish's layout audit flags horizontal overflow as an error, so every
   text container wraps unbreakable strings and every flex/grid child gets a
   min-width:0 track so it can actually shrink. Genuinely wide blocks scroll
   inside their own container instead of stretching the page. */
*{min-width:0}
a{color:var(--accent); overflow-wrap:anywhere}
.card,.field,.field p,.flag,form.answer,form.discuss,
.card h3,.tag,li.row,li.row .what,li.row .what .t,li.row .what .sub{
  overflow-wrap:anywhere; word-break:break-word;
}
h1{font-size:1.55rem; line-height:1.25; margin:0 0 .4rem; letter-spacing:-.01em}
.lede{margin:0 0 .35rem; color:var(--muted); max-width:44rem}
.meta{margin:0; color:var(--faint); font-size:.82rem}
header.board{border-bottom:1px solid var(--line); padding-bottom:1.5rem; margin-bottom:1.5rem}

.controls{
  display:flex; flex-wrap:wrap; gap:.75rem 1.25rem; align-items:flex-end;
  margin-bottom:2.25rem;
}
.control{display:flex; flex-direction:column; gap:.35rem; min-width:0}
.control > span{font-size:.72rem; letter-spacing:.08em; text-transform:uppercase; color:var(--faint)}
.segmented{display:flex; border:1px solid var(--line); border-radius:var(--radius); overflow:hidden}
.segmented button{
  appearance:none; border:0; background:var(--panel); color:var(--muted);
  font:inherit; font-size:.88rem; padding:.42rem .85rem; cursor:pointer;
  border-right:1px solid var(--line);
}
.segmented button:last-child{border-right:0}
.segmented button[aria-pressed="true"]{background:var(--accent-soft); color:var(--ink); font-weight:600}
select{
  font:inherit; font-size:.88rem; padding:.42rem .6rem; border-radius:var(--radius);
  border:1px solid var(--line); background:var(--panel); color:var(--ink); max-width:100%;
}
.tally{margin-left:auto; color:var(--faint); font-size:.82rem; align-self:flex-end}

section.block{margin:0 0 2.75rem}
section.block > h2{
  font-size:.78rem; letter-spacing:.11em; text-transform:uppercase; color:var(--faint);
  margin:0 0 .25rem; font-weight:600;
}
section.block > .blurb{margin:0 0 1rem; color:var(--muted); font-size:.88rem}
.empty{color:var(--faint); font-size:.9rem; font-style:italic; margin:0}

.card{
  background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
  padding:1.4rem 1.5rem; margin-bottom:1.1rem;
}
.card > h3{font-size:1.12rem; line-height:1.35; margin:0 0 .6rem; letter-spacing:-.005em}
.tags{display:flex; flex-wrap:wrap; gap:.4rem; margin:0 0 1rem}
.tag{
  font-size:.72rem; letter-spacing:.03em; color:var(--muted); background:var(--line-soft);
  border-radius:99px; padding:.16rem .55rem; white-space:nowrap;
}
.tag.warn{background:var(--flagbg); color:var(--warn); border:1px solid var(--flagline)}

.field{margin:0 0 .95rem}
.field > .label{
  font-size:.74rem; letter-spacing:.06em; text-transform:uppercase; color:var(--faint);
  margin:0 0 .18rem; font-weight:600;
}
.field p{margin:0 0 .5rem}
.field p:last-child{margin-bottom:0}
.field.take p{color:var(--ink)}

.flag{
  background:var(--flagbg); border:1px solid var(--flagline); border-radius:var(--radius);
  padding:.8rem 1rem; margin:0 0 1rem;
}
.flag .label{color:var(--warn)}

details{border-top:1px solid var(--line-soft); margin-top:1.1rem; padding-top:.85rem}
details summary{
  cursor:pointer; font-size:.85rem; color:var(--muted); list-style:none;
  display:inline-flex; align-items:center; gap:.4rem;
}
details summary::-webkit-details-marker{display:none}
details summary::before{content:"+"; color:var(--faint); font-weight:600}
details[open] summary::before{content:"\2212"}
details .inner{padding-top:.85rem; font-size:.92rem; color:var(--muted); overflow-x:auto}
details .inner code, details .inner .src{
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.84rem;
}

form.answer{border-top:1px solid var(--line-soft); margin-top:1.1rem; padding-top:1rem}
form.answer .label{
  font-size:.74rem; letter-spacing:.06em; text-transform:uppercase; color:var(--faint);
  margin:0 0 .5rem; font-weight:600;
}
form.answer label.opt{
  display:flex; gap:.6rem; align-items:flex-start; padding:.38rem 0; cursor:pointer;
  font-size:.95rem; overflow-wrap:anywhere;
}
form.answer input[type=radio]{margin-top:.35rem; flex:none}
form.answer .clear{
  appearance:none; background:none; border:0; padding:.2rem 0; margin-top:.15rem;
  font:inherit; font-size:.8rem; color:var(--muted); cursor:pointer; text-decoration:underline;
}
form.answer textarea,form.discuss textarea{
  width:100%; margin-top:.7rem; min-height:3.6rem; font:inherit; font-size:.92rem;
  padding:.55rem .7rem; border-radius:var(--radius); border:1px solid var(--line);
  background:var(--bg); color:var(--ink); resize:vertical; max-width:100%;
}
form.answer .send,form.discuss .send{
  display:flex; align-items:center; gap:.75rem; margin-top:.75rem; flex-wrap:wrap;
}
form.answer button[type=submit],form.discuss button[type=submit]{
  appearance:none; font:inherit; font-size:.9rem; font-weight:600; cursor:pointer;
  padding:.45rem 1rem; border-radius:var(--radius); border:1px solid var(--accent);
  background:var(--accent); color:var(--panel);
}
form.answer .status,form.discuss .status{font-size:.84rem; color:var(--muted); overflow-wrap:anywhere}
form.answer .status.done,form.discuss .status.done{color:var(--ok); font-weight:600}
form.discuss{margin-top:.4rem}
form.discuss summary{
  cursor:pointer; font-size:.85rem; color:var(--muted); list-style:none;
  display:inline-flex; align-items:center; gap:.4rem;
}
form.discuss summary::-webkit-details-marker{display:none}
form.discuss summary::before{content:"?"; color:var(--faint); font-weight:700}

ul.rows{list-style:none; margin:0; padding:0}
li.row{
  display:flex; gap:.75rem; align-items:baseline; padding:.6rem 0;
  border-bottom:1px solid var(--line-soft); min-width:0;
}
li.row:last-child{border-bottom:0}
li.row .who{flex:none; width:8.5rem; color:var(--faint); font-size:.78rem; overflow-wrap:anywhere}
li.row .what{flex:1 1 auto; min-width:0}
li.row .what .t{display:block; overflow-wrap:anywhere}
li.row .what .sub{display:block; color:var(--faint); font-size:.82rem; overflow-wrap:anywhere}
li.row .what .sub a{overflow-wrap:anywhere}
@media (max-width:34rem){
  .wrap{padding:1.75rem 1rem 4rem}
  .card{padding:1.15rem 1.1rem}
  li.row{flex-direction:column; gap:.15rem}
  li.row .who{width:auto}
  .tally{margin-left:0; width:100%}
}
</style>
</head>
<body>
<div class="

... [7589 bytes truncated] ...

; tracked as workflowy-partner-idea.</p></div></details><form class="answer" data-lavish-question="workflowy-partner-idea" data-question="Full title for workflowy-partner-idea" onsubmit="event.preventDefault();fmInboxAnswer(event.currentTarget);"><p class="label">Your answer</p><textarea name="note" placeholder="Write your answer here."></textarea><div class="send"><button type="submit">Send answer to firstmate</button><span class="status" role="status"></span></div></form><form class="discuss" data-lavish-question="workflowy-partner-idea-discuss" data-question="Full title for workflowy-partner-idea" onsubmit="event.preventDefault();fmInboxDiscuss(event.currentTarget);"><details><summary>Not ready to answer? Ask a question instead</summary><textarea name="question" placeholder="What is unclear? firstmate will reply here, it will not treat this as your decision."></textarea><div class="send"><button type="submit">Send question to firstmate</button><span class="status" role="status"></span></div></details></form></article><p class="empty empty-filtered" hidden>Nothing here for this filter.</p></section>
<section class="block" data-block="review"><h2>Review and merge</h2><p class="blurb">Finished work waiting for your yes. These links were read from local records, so a closed or already merged pull request can still appear here.</p><ul class="rows"><li class="row" data-project="alpha"><span class="who">alpha</span><span class="what"><span class="t">PR Task</span><span class="sub"><a href="https://github.com/kunchenguid/firstmate/pull/920" rel="noreferrer">https://github.com/kunchenguid/firstmate/pull/920</a> &middot; not checked, it may already be closed or merged</span></span></li></ul><p class="empty empty-filtered" hidden>Nothing here for this filter.</p></section>
<section class="block" data-block="underway"><h2>Underway</h2><p class="blurb">Being worked on right now. Nothing for you to do.</p><ul class="rows"><li class="row" data-project="alpha"><span class="who">alpha</span><span class="what"><span class="t">Running Task</span><span class="sub">in progress, started 2026-07-07</span></span></li></ul><p class="empty empty-filtered" hidden>Nothing here for this filter.</p></section>
<section class="block" data-block="queued"><h2>Queued</h2><p class="blurb">Lined up behind something else, or parked on purpose.</p><ul class="rows"><li class="row" data-project="alpha"><span class="who">alpha</span><span class="what"><span class="t">Blocked Decision</span><span class="sub">waiting for Running Task</span></span></li><li class="row" data-project="alpha"><span class="who">alpha</span><span class="what"><span class="t">PR Task</span><span class="sub">queued since 2026-07-05</span></span></li></ul><p class="empty empty-filtered" hidden>Nothing here for this filter.</p></section>
<section class="block" data-block="shipped"><h2>Recently built and shipped</h2><p class="blurb">Landed work, most recent first.</p><ul class="rows"><li class="row" data-project="alpha"><span class="who">alpha</span><span class="what"><span class="t">Shipped Task</span><span class="sub">merged 2026-07-06 &middot; <a href="https://github.com/kunchenguid/firstmate/pull/7" rel="noreferrer">https://github.com/kunchenguid/firstmate/pull/7</a></span></span></li></ul><p class="empty empty-filtered" hidden>Nothing here for this filter.</p></section>
<section class="block" data-block="stuck"><h2>Stuck</h2><p class="blurb">Stopped and not going to move on its own.</p><p class="empty">Nothing is stuck.</p><p class="empty empty-filtered" hidden>Nothing here for this filter.</p></section>
</div>
<script>
// Clear the quick-pick radios so a selection is never a trap.
function fmInboxClear(button){
  var form=button.closest('form');
  if(!form){return;}
  form.querySelectorAll('input[name=answer]').forEach(function(r){r.checked=false;});
}
// One shared send path. It queues exactly one prompt and sends it immediately
// so an answer never sits waiting on a separate Send button the captain has to
// find - the failure mode where the board looked like it silently ate answers.
function fmInboxSend(form, text, data, confirm){
  var mark=form.querySelector('.status');
  if(window.lavish&&window.lavish.queuePrompt){
    window.lavish.queuePrompt(text,{
      tag:data.tag, queueKey:data.queueKey, element:form, text:data.summary, data:data
    });
    if(window.lavish.sendQueuedPrompts){window.lavish.sendQueuedPrompts();}
    if(mark){mark.textContent=confirm; mark.classList.add('done');}
  }else if(mark){
    mark.textContent='Open this board through Lavish to send.';
  }
}
// The free-text answer stands alone: a picked option is a convenience, never
// required. Submit works with just the note, just an option, or both.
function fmInboxAnswer(form){
  var data=new FormData(form);
  var choice=(data.get('answer')||'').trim();
  var note=(data.get('note')||'').trim();
  var mark=form.querySelector('.status');
  if(!choice&&!note){
    if(mark){mark.textContent='Type your answer, or pick one of the options.'; mark.classList.remove('done');}
    return;
  }
  var id=form.dataset.lavishQuestion;
  var question=form.dataset.question||id;
  var body=note?(choice?(choice+' -- '+note):note):choice;
  fmInboxSend(form,'DECISION '+id+': '+body,{
    tag:'decision', queueKey:id, summary:question+' -> '+body,
    hold:id, question:question, answer:choice, note:note
  },'Sent to firstmate.');
}
// The discuss path asks a question back instead of deciding. firstmate replies;
// it does not treat this as the answer.
function fmInboxDiscuss(form){
  var data=new FormData(form);
  var text=(data.get('question')||'').trim();
  var mark=form.querySelector('.status');
  if(!text){
    if(mark){mark.textContent='Type your question first.'; mark.classList.remove('done');}
    return;
  }
  var id=form.dataset.lavishQuestion;
  var hold=id.replace(/-discuss$/,'');
  var question=form.dataset.question||hold;
  fmInboxSend(form,'DISCUSS '+hold+': '+text,{
    tag:'discuss', queueKey:id, summary:'Question on '+question,
    hold:hold, question:question, message:text
  },'Question sent to firstmate.');
}
</script>
<script>
(function(){
  var root=document.documentElement;
  var typeButtons=[].slice.call(document.querySelectorAll('[data-type-filter]'));
  var projectSelect=document.getElementById('project-filter');
  var tally=document.getElementById('tally');
  var state={type:'all',project:'all'};

  function visibleFor(section){
    if(state.type==='all') return true;
    if(state.type==='decisions') return section.dataset.block==='decide';
    if(state.type==='shipped') return section.dataset.block==='shipped';
    return true;
  }

  function apply(){
    var shown=0;
    [].forEach.call(document.querySelectorAll('section.block'),function(section){
      var allowed=visibleFor(section);
      var live=0;
      [].forEach.call(section.querySelectorAll('[data-project]'),function(item){
        var ok=allowed&&(state.project==='all'||item.dataset.project===state.project);
        item.hidden=!ok;
        if(ok){live++;}
      });
      var none=section.querySelector('.empty-filtered');
      if(none){none.hidden=!(allowed&&live===0);}
      section.hidden=!allowed;
      shown+=live;
      if(section.dataset.block==='decide'){
        var c=section.querySelector('.count');
        if(c){c.textContent=live===1?'1 decision':(live+' decisions');}
      }
    });
    if(tally){tally.textContent=shown+(shown===1?' item shown':' items shown');}
  }

  typeButtons.forEach(function(btn){
    btn.addEventListener('click',function(){
      state.type=btn.dataset.typeFilter;
      typeButtons.forEach(function(b){
        b.setAttribute('aria-pressed',String(b===btn));
      });
      apply();
    });
  });
  if(projectSelect){
    projectSelect.addEventListener('change',function(){
      state.project=projectSelect.value;
      apply();
    });
  }
  var toggle=document.getElementById('theme-toggle');
  if(toggle){
    toggle.addEventListener('click',function(){
      var dark=root.getAttribute('data-theme')==='dark';
      root.setAttribute('data-theme',dark?'light':'dark');
    });
  }
  apply();
})();
</script>
</body>
</html>
Evidence: Browser interaction payload and layout check
{
  "prompts": [
    {
      "text": "DECISION hidden-decision: Please ship it from free text only.",
      "tag": "decision",
      "queueKey": "hidden-decision",
      "data": {
        "tag": "decision",
        "queueKey": "hidden-decision",
        "summary": "Should we approve the wrapped-title launch decision? -> Please ship it from free text only.",
        "hold": "hidden-decision",
        "question": "Should we approve the wrapped-title launch decision?",
        "answer": "",
        "note": "Please ship it from free text only."
      }
    },
    {
      "text": "DISCUSS hidden-decision: What risk remains before rollout?",
      "tag": "discuss",
      "queueKey": "hidden-decision-discuss",
      "data": {
        "tag": "discuss",
        "queueKey": "hidden-decision-discuss",
        "summary": "Question on Should we approve the wrapped-title launch decision?",
        "hold": "hidden-decision",
        "question": "Should we approve the wrapped-title launch decision?",
        "message": "What risk remains before rollout?"
      }
    }
  ],
  "sentCalls": 2,
  "answerStatus": "Sent to firstmate.",
  "discussStatus": "Question sent to firstmate.",
  "desktopLayout": {
    "clientWidth": 1280,
    "scrollWidth": 1280
  },
  "mobileLayout": {
    "clientWidth": 390,
    "scrollWidth": 390
  }
}
Evidence: Serve and arm output

armed: state/inbox.check.sh relays answers for /var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/inbox-board.html board: /var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/inbox-board.html link: http://127.0.0.1:4891/session/31e87e96a7fed7be&#10;reachable: yes (HTTP 200 over Tailscale)

armed: state/inbox.check.sh relays answers for /var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/inbox-board.html
board: /var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/inbox-board.html
link:  http://127.0.0.1:4891/session/31e87e96a7fed7be
reachable: yes (HTTP 200 over Tailscale)
Evidence: Relay registration check

registered=yes mode=700 relay=/var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/fixture-home/state/inbox.check.sh

registered=yes
mode=700
relay=/var/folders/tc/llfmckm54ls5tzxm08q758lm0000gn/T/no-mistakes-evidence/01KYB60J8J5SM9F7Z527TA80M4/fixture-home/state/inbox.check.sh
Evidence: Read-only manifest check

state/ and data/ manifests are byte-identical after fm-inbox-view.sh

state/ and data/ manifests are byte-identical after fm-inbox-view.sh

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed (2) ✅
  • ⚠️ bin/fm-inbox-render.py:321 - When gh pr view fails, fm-inbox-view.sh records the state as unknown, but this branch treats every verified non-OPEN state as stale. With --verify-prs, a transient auth/network failure can move an open PR out of “Review and merge” and label it “already unknown”; keep unknown in the review list as unchecked instead.
  • ⚠️ bin/fm-inbox-arm.sh:68 - The registered relay runs bare lavish-axi poll, so a board served with a non-default LAVISH_AXI_PORT is verified by fm-inbox-serve.sh but later polled on the watcher's default port unless that environment happens to match. Capture/export the served port in the generated check so non-default-port boards do not silently miss answers.
  • ⚠️ bin/fm-inbox-arm.sh:58 - cat &gt; &#34;$CHECK&#34; opens the predictable state/inbox.check.sh path before destination validation, so an existing symlink or hard link can be overwritten outside the intended state file before fm-check-register.sh rejects it. Publish through a private temp file in state/ and a validated rename, matching the existing check-publication pattern.
  • ⚠️ bin/fm-inbox-arm.sh:65 - The generated check embeds BOARD and ANSWERS inside single quotes without shell escaping. A board/home path containing a single quote or newline can register a syntactically broken or injectable check, making the relay fail silently; generate shell-escaped assignments instead.

🔧 Fix: Fix inbox relay safety and PR verification
2 warnings still open:

  • ⚠️ bin/fm-inbox-arm.sh:106 - The generated check calls lavish-axi poll even when the board is unserved. Current Lavish poll calls ensureServer() first, so an idle watcher can start a Lavish server itself; because the shim does not set LAVISH_AXI_HOST, that server may be localhost-bound and later fm-inbox-serve.sh can reuse it instead of serving on Tailscale. Gate polling on an already-running served session before invoking poll.
  • ⚠️ bin/fm-inbox-arm.sh:111 - The answer-count grep does not match Lavish's prompt rows, which are serialized as rows like decision,&#34;DECISION ...&#34;, not lines beginning with two spaces and a quote. With zero matches, grep -c prints 0 and exits 1, so the fallback appends another 0; real answer wakes can say 0\n0 captain answer(s) even after writing an answer file. Count prompt rows correctly or emit a fixed single-line wake message.

🔧 Fix: Gate inbox relay before polling
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh --list --changed --base 2b7cd66ad09722fec4cf11acdbee4fd8046f06e7 initially exposed the unmapped renderer; after the fix it succeeded and wrote changed-selection-after-fix.txt.
  • bin/fm-test-run.sh tests/fm-test-run.test.sh
  • bin/fm-test-run.sh tests/fm-inbox-view.test.sh tests/fm-inbox-arm.test.sh
  • Generated a fixture board with PATH=&#34;$FAKEBIN:$PATH&#34; FM_HOME=&#34;$EVIDENCE/fixture-home&#34; bin/fm-inbox-view.sh &#34;$EVIDENCE/inbox-board.html&#34; and compared state/ + data/ manifests before/after.
  • Served and armed the fixture board with LAVISH_AXI_PORT=4891 FM_HOME=&#34;$EVIDENCE/fixture-home&#34; bin/fm-inbox-serve.sh --no-generate --link-host 127.0.0.1 &#34;$EVIDENCE/inbox-board.html&#34;.
  • Captured rendered UI with playwright screenshot at desktop and 390px mobile widths.
  • Ran node &#34;$EVIDENCE/inbox-board-interaction.cjs&#34; to clear a quick-pick, submit a free-text-only DECISION, submit a DISCUSS question, verify immediate send calls, and assert no horizontal overflow.
  • Checked relay trust with fm_custom_check_registered &#34;$STATE&#34; inbox and verified state/inbox.check.sh mode 700.
  • Cleaned up the temporary Lavish server with LAVISH_AXI_PORT=4891 lavish-axi stop and confirmed the port no longer answered.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

ItsFlow added 6 commits July 25, 2026 01:05
bin/fm-inbox-view.sh writes one self-contained HTML board from live fleet
state, so every open captain decision, review-ready pull request and stopped
task is answerable from a single surface instead of arriving one chat
interrupt at a time.

It is a read-only projection in the same shape as fm-fleet-view.sh: it shells
out to fm-fleet-snapshot.sh --json and `tasks-axi show --full`, and writes
nothing but the board file. A test proves state/ and data/ stay byte-identical
across a generation.

Three selection defects the existing aggregated surface has are fixed here.
Decisions are selected on the captain hold alone, because the snapshot's own
captain_actionable flag also requires kind == "captain" and therefore hides
every thread gated with `tasks-axi hold --kind captain`, which is where the
oldest waiting decisions live.
Decision text is read through tasks-axi, because the snapshot's metadata
capture stops at the first comma and silently drops the options from any hold
reason that lists them.
A recorded pull request renders as unverified unless --verify-prs live-checks
it, because locally recorded metadata cannot know a pull request was closed
and a closed one must never read as ready to merge.

Decision cards lead with plain English. An optional captain-private cards file
supplies the plain question, what the captain is actually choosing, where it
came from, firstmate's recommendation, a link to research a product or
service, and a collapsed technical section. An item that is firstmate's own
assumption rather than a choice the captain made is flagged as one. A decision
with no card still renders and says so rather than looking complete.

Answers use the Lavish input contract: native radios plus a free-text note,
with exactly one queued prompt per question on submit. This change delivers
the surface only; regenerating on a cadence, relaying answers back, and any
escalation-routing change remain separate captain decisions.
…flow fix

Fold in the captain's hard requirements from live use of the board.

Free-form answers are now first-class. A card's predefined options are
optional quick-picks with a clear-selection reset; the answer submits with
just free text and no option selected. Previously the submit required picking
an offered option, so a genuine free-form answer could not be sent at all.

Each card gains a discuss path that sends a question back (DISCUSS <id>: ...)
instead of an answer (DECISION <id>: ...), so a decision can be clarified
rather than forced.

Delivery is made reliable end to end. bin/fm-inbox-serve.sh is one operator
command that regenerates the board, arms the answer relay, serves it on the
Tailscale address, and verifies the link. bin/fm-inbox-arm.sh writes and
registers state/inbox.check.sh, a bounded lavish poll bound by
fm-check-register, so firstmate's supervision cycle relays answers on its
normal check cadence without anyone remembering to poll. The relay fails
closed: when the board is not served the poll errors fast and it stays silent.
Submitting also sends immediately with a visible confirmation, closing the
window where an answer looked silently eaten.

Long titles and ids now wrap instead of overflowing horizontally, which
Lavish's own layout audit flags as an error. No-project items get readable
area names derived from their origin or id instead of a generic badge.

Behavior is covered by tests/fm-inbox-view.test.sh (generator, read-only,
free-form, discuss, immediate send, area names, wrapping) and
tests/fm-inbox-arm.test.sh (the relay is a valid registered fail-closed
check, silent while unserved, rejected after tampering). docs/inbox-board.md
records the design and verification evidence.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant