Skip to content

fix(bin): wrap long meta-row badges in the generated kanban board - #1412

Closed
Lcxiv wants to merge 22 commits into
kunchenguid:mainfrom
Lcxiv:fm/fm-board-badge-clip
Closed

Lcxiv wants to merge 22 commits into
kunchenguid:mainfrom
Lcxiv:fm/fm-board-badge-clip

Conversation

@Lcxiv

@Lcxiv Lcxiv commented Jul 31, 2026

Copy link
Copy Markdown

Intent

Fix vertically clipped badge text in the kanban board generated by bin/fm-board.sh. The board renders card metadata as DaisyUI badges inside .meta-row; DaisyUI's .badge has a fixed height and does not wrap, so a badge whose text runs longer than one line has its text clipped. A real browser layout audit of a generated board reported two severe clipped-text failures (vertical, 6.5px overflow, viewport 1152): the 'fog:' and 'out of scope:' badges on an effort-map card in the top band. Their text is long by nature - the generator already truncates it with an ellipsis and even truncated it exceeds one badge line. The captain asked to fix the CLASS of defect (any long .meta-row badge), not just those two spans.

Deliberate decisions, so a reviewer reading only the diff has the context:

  • This is a PRESENTATION-LAYER fix only. Explicitly out of scope by the captain's instruction: do NOT change the truncation logic and do NOT change the badge markup shape. The ellipsis truncation staying in place is intentional, not an oversight - the fix makes the already-truncated text wrap and be fully readable rather than clipped.
  • The fix is deliberately scoped to '.meta-row .badge' and must NOT restyle badges elsewhere; the header count badges are short and already render correctly, so widening the selector would be a regression.
  • The exact rule (height:auto; min-height:1.1rem; white-space:normal; text-align:left; line-height:1.3; padding-top/bottom:0.1rem) is proven-working evidence: it was applied by hand to a generated artifact and confirmed by a clean re-audit in a real browser before being mirrored into the generator. It is placed directly beside the existing .meta-row rule to match the surrounding style block's conventions.
  • The board must remain a single self-contained HTML file with no new external assets, so the fix is an inline stylesheet rule rather than any added asset.

Test coverage: tests/fm-board.test.sh gains an assertion inside the existing render test that extracts the emitted .meta-row .badge rule from the GENERATED HTML (the executable interface, per this repo's rule that tests must never assert implementation-source bytes) and checks height:auto and white-space:normal, so the defect cannot silently return. It matches the file's existing assert_contains conventions rather than adding a new runner.

Verification already performed: the full tests/fm-board.test.sh suite passes (4/4). A board generated from live fleet data was opened in a real browser and all 60 .meta-row badges measured - zero clipped; the previously-clipped fog badge now renders 31px tall against 29px of content, wrapping to two lines. docs/board.md was checked and documents no styling behavior, so per the task it was correctly left untouched.

Two established environment facts: (1) local lint cannot run on this machine - the pinned shellcheck is linux-x86_64 only and this is Apple Silicon macOS; bin/fm-lint.sh correctly refuses. (2) origin is the captain's own fork Lcxiv/firstmate with push access, and that fork has no CI configured, so no GitHub Actions run will ever appear for this PR.

What Changed

  • Added a .meta-row .badge rule to the inline stylesheet emitted by bin/fm-board.sh (height:auto; min-height:1.1rem; white-space:normal; text-align:left; line-height:1.3; plus small vertical padding), so card metadata longer than one line wraps instead of being vertically clipped by DaisyUI's fixed-height, no-wrap .badge. The selector is deliberately scoped to .meta-row so header count badges keep their current rendering, and the rule is inline so the board stays a single self-contained HTML file.
  • Left the existing ellipsis truncation and badge markup untouched — even truncated, fog: and out of scope: text on effort-map cards exceeds one badge line, so this is a presentation-layer fix on top of truncation, not a replacement for it.
  • Extended the existing render test in tests/fm-board.test.sh to extract the emitted .meta-row .badge rule from the generated HTML and assert height:auto and white-space:normal, and documented the invariant in the bin/fm-board.sh header comment beside the existing clipping-bug note. The full board suite passes 4/4, and a browser layout audit of a generated board measured zero clipped .meta-row badges after the change.

Risk Assessment

✅ Low: A single presentation-only CSS rule tightly scoped to .meta-row .badge, with no logic, markup, or data-path changes, plus a generated-HTML regression assertion that fails correctly if the rule is removed.

Testing

Ran the targeted suite tests/fm-board.test.sh (4/4 pass) and verified its new assertion actually catches the defect by removing the CSS rule and watching it fail. For end-user evidence I generated a real board with long effort-map metadata, produced a pre-fix baseline of the identical HTML minus the one new rule, and opened both in Chrome: before the fix 1-2 .meta-row badges clip with 8px of vertical overflow depending on viewport, after the fix zero of 12 clip at 1600, 1152, and 500px, with the fog badge growing to 31px and wrapping to two fully readable lines. The 1600px pair also shows a ticket card's stage: badge — not one of the two originally reported spans — going from clipped to wrapped, which is the direct evidence that the class of defect is fixed rather than the two instances. Header count badges measured an unchanged 24px fixed height throughout, and the rendered output still shows the ellipsis truncation, so the scoping and out-of-scope constraints both hold. Screenshots and the rendered board are in the evidence directory; the worktree is clean and temp fixtures were removed.

  • Evidence: Before fix @1152 — fog and out-of-scope badge text spills outside the badge box and collides with the neighbouring badge (local file: /var/folders/gp/phywrkfd3kj2jp2f764xmldw0000gn/T/no-mistakes-evidence/01KYWFCVGYZXGXP8SP4JH2HFAJ/01-before-clipped-badges.png)
  • Evidence: After fix @1152 — both long badges grow to two lines, fully readable inside their own badge boxes; header count badges unchanged (local file: /var/folders/gp/phywrkfd3kj2jp2f764xmldw0000gn/T/no-mistakes-evidence/01KYWFCVGYZXGXP8SP4JH2HFAJ/02-after-wrapped-badges.png)
  • Evidence: Before fix @1600 — a ticket card's long 'stage: unknown - harness state unavailable' badge clips (class of defect, not one of the two reported spans) (local file: /var/folders/gp/phywrkfd3kj2jp2f764xmldw0000gn/T/no-mistakes-evidence/01KYWFCVGYZXGXP8SP4JH2HFAJ/03-before-1600-ticket-badge-clipped.png)
  • Evidence: After fix @1600 — the same ticket-card badge wraps cleanly to two lines (local file: /var/folders/gp/phywrkfd3kj2jp2f764xmldw0000gn/T/no-mistakes-evidence/01KYWFCVGYZXGXP8SP4JH2HFAJ/04-after-1600-ticket-badge-wrapped.png)
Evidence: Generated board HTML used for the post-fix measurements (self-contained, no added assets)
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fleet Board — 2026-07-31</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css">
<style>
  .col { min-width: 0; }
  .card-compact .card-body { padding: 0.85rem; }
  .ticket { border-left: 4px solid transparent; overflow: visible; }
  .card, .card-body, .todos, .todos li, details, summary { overflow: visible !important; max-height: none !important; height: auto !important; }
  .t-captain { border-left-color: oklch(75% 0.18 60); }
  .t-flight  { border-left-color: oklch(70% 0.15 250); }
  .t-queued  { border-left-color: oklch(70% 0.12 180); }
  .t-upstream{ border-left-color: oklch(72% 0.14 300); }
  .t-done    { border-left-color: oklch(72% 0.17 145); }
  .todos { font-size: 0.72rem; line-height: 1.35; }
  .todos li { list-style: none; }
  .meta-row { display:flex; flex-wrap:wrap; gap:0.3rem; margin:0.25rem 0; }
  .meta-row .badge { height:auto; min-height:1.1rem; white-space:normal; text-align:left; line-height:1.3; padding-top:0.1rem; padding-bottom:0.1rem; }
  .approval { background: oklch(30% 0.06 60 / 0.4); border: 1px solid oklch(75% 0.18 60 / 0.7); border-radius: 0.4rem; padding: 0.25rem 0.45rem; margin: 0.25rem 0; }
  .approval summary { font-weight: 700; color: oklch(85% 0.16 70); cursor: pointer; font-size: 0.7rem; }
  .approval textarea { width:100%; font-size:0.7rem; margin-top:0.2rem; }
  .approval label { display:block; font-size:0.7rem; padding:0.1rem 0; cursor:pointer; }
  .approval .queued-note { display:none; font-size:0.7rem; color: oklch(80% 0.15 145); font-weight:600; }
  .dragging { opacity: 0.4; }
  .col.dropover { outline: 2px dashed oklch(70% 0.15 250); outline-offset: 4px; border-radius: 0.5rem; }
</style>
</head>
<body class="bg-base-200 min-h-screen p-4 md:p-6">
<div class="max-w-[1700px] mx-auto">
  <header class="mb-5 flex flex-wrap items-end justify-between gap-2">
    <div>
      <h1 class="text-2xl font-bold">Fleet Board</h1>
      <p class="text-sm opacity-70">snapshot 2026-07-31T18:42:54Z · regenerate with bin/fm-board.sh; say “refresh the board” anytime</p>
    </div>
    <div class="flex gap-2 text-xs"><span class="badge badge-warning badge-outline">Your call: 0</span><span class="badge badge-accent badge-outline">Queued: 1</span><span class="badge badge-info badge-outline">Building: 1</span><span class="badge badge-secondary badge-outline">Review: 0</span><span class="badge badge-success badge-outline">Landed: 0</span></div>
  </header>
  <div class="mb-4 grid grid-cols-1 md:grid-cols-2 gap-3"><div class="card card-compact bg-base-100 shadow border-l-4" style="border-left-color: oklch(78% 0.14 200);"><div class="card-body"><h3 class="font-semibold text-sm">🗺️ Effort: alpha refit</h3><p class="text-xs opacity-80"><b>Destination:</b> Alpha sails clean: refit landed and every mast decision settled.</p><div class="meta-row"><span class="badge badge-xs badge-success">2 decided</span><span class="badge badge-xs badge-warning">1 open</span><span class="badge badge-xs badge-ghost">fog: rigging vendor selection and the lead time it implies; whether the spare halyard set ships with the refit or follows la…</span><span class="badge badge-xs badge-ghost">out of scope: new hull fabrication and the yard time it would require; migrating the legacy navigation stack off its current chartplo…</span></div></div></div></div>
  <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
<section class="col" ondragover="event.preventDefault(); this.classList.add('dropover')" ondragleave="this.classList.remove('dropover')" ondrop="fbDrop(event, this)"><h2 class="font-semibold mb-2 text-warning">1 · ⚓ Decide</h2><div class="flex flex-col gap-3"><div class="text-xs opacity-50 italic p-2">nothing here</div></div></section><section class="col" ondragover="event.preventDefault(); this.classList.add('dropover')" ondragleave="this.classList.remove('dropover')" ondrop="fbDrop(event, this)"><h2 class="font-semibold mb-2 text-accent">2 · 🧭 Queued</h2><div class="flex flex-col gap-3"><div draggable="true" ondragstart="fbDrag(event, this)" ondragend="this.classList.remove('dragging')" class="card card-compact bg-base-100 ticket t-queued shadow" data-board-id="queued-task"><div class="card-body"><h3 class="font-semibold text-sm">Queued Chore</h3><div class="meta-row"><span class="badge badge-xs badge-ghost">alpha</span><span class="badge badge-xs badge-ghost">ship</span><span class="badge badge-xs badge-ghost">stage: blocked by build-task</span><span class="badge badge-xs badge-warning badge-outline">blocked-by: build-task</span></div></div></div></div></section><section class="col" ondragover="event.preventDefault(); this.classList.add('dropover')" ondragleave="this.classList.remove('dropover')" ondrop="fbDrop(event, this)"><h2 class="font-semibold mb-2 text-info">3 · ⛵ Building</h2><div class="flex flex-col gap-3"><div draggable="true" ondragstart="fbDrag(event, this)" ondragend="this.classList.remove('dragging')" class="card card-compact bg-base-100 ticket t-flight shadow" data-board-id="build-task"><div class="card-body"><h3 class="font-semibold text-sm">Build The Widget</h3><div class="meta-row"><span class="badge badge-xs badge-ghost">alpha</span><span class="badge badge-xs badge-ghost">ship</span><span class="badge badge-xs badge-info badge-outline">crew: codex · opus · high effort</span><span class="badge badge-xs badge-ghost">stage: unknown - harness state unavailable (unknown codex-unverified)</span></div><ul class="todos opacity-80"><li>Widget assembly in progress.</li></ul></div></div></div></section><section class="col" ondragover="event.preventDefault(); this.classList.add('dropover')" ondragleave="this.classList.remove('dropover')" ondrop="fbDrop(event, this)"><h2 class="font-semibold mb-2 text-secondary">4 · 🌊 Review</h2><div class="flex flex-col gap-3"><div class="text-xs opacity-50 italic p-2">nothing here</div></div></section><section class="col" ondragover="event.preventDefault(); this.classList.add('dropover')" ondragleave="this.classList.remove('dropover')" ondrop="fbDrop(event, this)"><h2 class="font-semibold mb-2 text-success">5 · 🏁 Landed</h2><div class="flex flex-col gap-3"><div class="text-xs opacity-50 italic p-2">nothing here</div></div></section>
  </div>
  <footer class="mt-6 text-xs opacity-60">Static snapshot — answer Decide cards or drag tickets, then press Send to Agent; firstmate dispatches from this board.</footer>
</div>
<script>
let fbDragged=null;
function fbDrag(ev, el){ fbDragged=el; el.classList.add('dragging'); ev.dataTransfer.effectAllowed='move'; }
function fbDrop(ev, colEl){
  ev.preventDefault(); colEl.classList.remove('dropover');
  if(!fbDragged) return;
  const ticket=(fbDragged.querySelector('h3')||{}).innerText||'ticket';
  const stage=(colEl.querySelector('h2')||{}).innerText||'column';
  colEl.querySelector('.flex.flex-col').appendChild(fbDragged);
  if(window.lavish && window.lavish.queuePrompt){
    window.lavish.queuePrompt('Move ticket "'+ticket.trim()+'" to stage "'+stage.trim()+'" - advance it accordingly', { tag:'move', text:ticket.trim()+' -> '+stage.trim(), element: fbDragged, queueKey:'move-'+ticket.trim() });
  }
  fbDragged=null;
}
function fbDecide(ev){
  ev.preventDefault();
  const form=ev.currentTarget;
  const key=form.dataset.question;
  const fd=new FormData(form);
  const c=((fd.get('freetext')||'').trim()) || fd.get(key);
  if(c && window.lavish && window.lavish.queuePrompt){
    window.lavish.queuePrompt('Decision '+key+': '+c, { tag:'choice', text:key+': '+c, element: form, queueKey:key, data:{question:key, answer:c} });
    form.querySelector('.queued-note').style.display='block';
  }
  return false;
}
</script>
</body>
</html>
Evidence: Browser clipping measurements across three viewports (scrollHeight - clientHeight over every .meta-row .badge)
before @1600 -> {"total":12,"clippedCount":1,"clipped":["stage: unknown - harness state unavailab [+8px]"],"headerBadgeHeights":[24,24,24,24,24]}
before @1152 -> {"total":12,"clippedCount":2,"clipped":["fog: rigging vendor selection and the le [+8px]","out of scope: new hull fabrication and t [+8px]"],"headerBadgeHeights":[24,24,24,24,24]}
before @500 -> {"total":12,"clippedCount":2,"clipped":["fog: … [+8px]","out of scope: … [+8px]"],"headerBadgeHeights":[24,24,24,24,24]}
after @1600 -> {"total":12,"clippedCount":0,"clipped":[],"headerBadgeHeights":[24,24,24,24,24]}
after @1152 -> {"total":12,"clippedCount":0,"clipped":[],"headerBadgeHeights":[24,24,24,24,24]}
after @500 -> {"total":12,"clippedCount":0,"clipped":[],"headerBadgeHeights":[24,24,24,24,24]}

fog badge after fix: height 31px, scrollHeight 29, clientHeight 29, overflow 0
Evidence: New assertion fails when the fix is removed (non-vacuity proof)
$ sed -i '' '/^ \.meta-row \.badge {{/d' bin/fm-board.sh && bash tests/fm-board.test.sh
not ok - meta badges must grow past one line (missing: 'height:auto')

$ git checkout bin/fm-board.sh && bash tests/fm-board.test.sh
ok - board renders all five columns, cards, decision panels, and effort maps
ok - captain holds route to Decide and PR-recorded work routes to Review
ok - approval panels are siblings of the todo list, never nested in it
ok - default output path works and an empty fleet renders explicit placeholders

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 1 issue found → auto-fixed ✅
  • ⚠️ .agents/skills/afk/SKILL.md - branch carries 20 commit(s) that exist on your local main branch but were never pushed to origin/main; rebasing would bundle this unrelated work (59 file(s)) into the PR:
  • 2aeff5e Kanban dispatch board: bin/fm-board.sh + docs + tests (fix(bin): coalesce watcher signals into one wake #4)
  • 15091bc Merge fork main (effort maps, lock fix, hermes adapter); conflicts resolved to the fork's reworked versions
  • c4454fb Merge pull request docs: align tmux and harness guidance #3 from Lcxiv/fm/fm-hermes-adapter
  • 30b2d4e Merge pull request docs: polish README banner and repo housekeeping #2 from Lcxiv/fm/fm-lock-survives-session-fork
  • f74208e Merge pull request chore: initialize no-mistakes gate #1 from Lcxiv/fm/fm-wayfinder-maps
  • 77b6115 no-mistakes(ci): port Hermes watcher busy expectations to the semantic busy-state contract
  • e135055 no-mistakes(document): document Hermes turn-end token in AGENTS.md state inventory
  • 29115c2 no-mistakes(test): scrub inherited agent env markers in Hermes detection test
  • 7f47f83 no-mistakes(review): fix raw-launch Hermes token registry creation; add regression test
  • 51f719e feat(harness): add Hermes worker adapter
  • cfa5660 feat(skills): add effort-maps orientation convention for multi-session efforts
  • f6c815b fix(session-lock): keep an identical sidecar in place across a same-session re-key
  • cb22cfb fix(session-lock): cover the id-minting fork end-to-end and state re-key scope honestly
  • c76f9f4 fix(session-lock): let a fork or relaunch successor own its home, loudly
  • 810129e no-mistakes(document): correct Claude session-identity docs for versioned exec and fail-closed inertness
  • fe154b5 no-mistakes(review): anchor Claude subcommand extraction on the executable token
  • 2e9602a fix(session-lock): never use Claude's shared daemon as session identity
  • acae49e no-mistakes(document): correct Claude session-identity docs for versioned exec and fail-closed inertness
  • 15bc673 no-mistakes(review): anchor Claude subcommand extraction on the executable token
  • 538b44b fix(session-lock): never use Claude's shared daemon as session identity

Push main to origin, or rebase your branch onto origin/main, before gating.

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Review** - 1 info
  • ℹ️ bin/fm-board.sh:428 - bin/fm-board.sh:428 — white-space:normal only breaks at whitespace, so a .meta-row badge whose text is a single unbreakable token wider than the card (e.g. a URL landing in an effort-map fog/out-of-scope item) would overflow horizontally rather than wrap. No current badge source produces such a token (fog/out-of-scope are joined with "; ", stage: is prose, repo/kind/crew/blocked-by are short), so this is not a reachable defect today — adding overflow-wrap:anywhere to the same rule would close the residual case if it ever appears. Flagged as an observation only; it does not affect the reported clipping class.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-board.test.sh — full board suite, 4/4 pass
  • Non-vacuity check: deleted the .meta-row .badge line from bin/fm-board.sh, re-ran bash tests/fm-board.test.sh → not ok - meta badges must grow past one line (missing: &#39;height:auto&#39;), then git checkout bin/fm-board.sh
  • Generated a board via FM_HOME=&lt;fixture&gt; bin/fm-board.sh --out after.html from a fixture whose effort map has long Not yet specified / Out of scope entries, so fog: and out of scope: truncate to more than one badge line
  • Built the pre-fix baseline by stripping only the .meta-row .badge rule from the same generated HTML (grep -v → before.html; diff confirms a 1-line delta)
  • Opened both files in real Chrome via chrome-devtools-axi open file://… and measured every .meta-row .badge with scrollHeight - clientHeight at viewports 1600, 1152, and 500 — before: 1/2/2 clipped at +8px; after: 0/0/0
  • Measured header .badge computed height in every before/after pair (24px in all cases) to confirm the fix does not restyle badges outside .meta-row
  • Captured chrome-devtools-axi screenshot before/after pairs at 1152 (effort-map fog and out-of-scope badges) and 1600 (a ticket card's long stage: badge)
✅ **Document** - passed

✅ No issues found.

⏭️ **Lint** - skipped
  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

Louis Condevaux and others added 22 commits July 29, 2026 20:28
FM_HARNESS_RE matched the bare command name "claude", but Claude Code
names its shared background daemon and the daemon's pooled workers
"claude" too. The daemon is parented to init, is shared by every session
of the user, and outlives all of them, so when a hook or tool call was
hosted by a pooled spare the ancestry walk resolved the daemon's pid as
that session's identity.

That broke identity in both directions. A session-start fm-lock.sh call
hosted by a pooled spare published the daemon's pid into state/.lock, so
two different sessions each verified ownership of a lock the other wrote,
and because the daemon never dies no later session could see that lock
go stale: fm-lock.sh refused acquisition permanently and the home stayed
read-only. With a daemon pid in the lock, fm-claude-stop-autoarm.sh found
ownership false but the recorded owner alive, so it exited 0 on every
Stop for the whole session and nothing routinely armed the watcher.

The walk now classifies process shapes through one shared owner,
fm_harness_session_match, used by both the ancestry walk and the
holder-liveness check so they cannot disagree about the same pid. The
daemon (matched by its "daemon" subcommand, not by one --origin value),
bg-pty-host, and bg-spare are rejected as non-session shapes. Claude
Code's versioned executable is now recognized as a session, so resumed,
forked, and app-hosted sessions resolve their own per-session pid instead
of the daemon-owned pty host above them.

Infrastructure met before any session match is skipped and the walk
continues, which preserves the nested bg-spare chain fixed in kunchenguid#1206;
infrastructure met after a match ends the contiguous run. When every
candidate is infrastructure the walk resolves nothing and every caller
fails closed, because a pooled worker's ancestry and inherited
environment both describe the daemon and carry no evidence of the session
that claimed it. A lock recorded against infrastructure is now a
reclaimable owner, so an already-wedged home recovers through the
existing stale-owner path.

Every caller of the shared predicates was reviewed: all six call sites in
fm-claude-stop-autoarm.sh and fm-lock.sh were affected and are fixed by
this change. fm-harness.sh answers harness kind rather than session
identity and is unaffected; fm-sessionstart-nudge.sh classifies no shape
and was exposed only through lock content.
FM_HARNESS_RE matched the bare command name "claude", but Claude Code
names its shared background daemon and the daemon's pooled workers
"claude" too. The daemon is parented to init, is shared by every session
of the user, and outlives all of them, so when a hook or tool call was
hosted by a pooled spare the ancestry walk resolved the daemon's pid as
that session's identity.

That broke identity in both directions. A session-start fm-lock.sh call
hosted by a pooled spare published the daemon's pid into state/.lock, so
two different sessions each verified ownership of a lock the other wrote,
and because the daemon never dies no later session could see that lock
go stale: fm-lock.sh refused acquisition permanently and the home stayed
read-only. With a daemon pid in the lock, fm-claude-stop-autoarm.sh found
ownership false but the recorded owner alive, so it exited 0 on every
Stop for the whole session and nothing routinely armed the watcher.

The walk now classifies process shapes through one shared owner,
fm_harness_session_match, used by both the ancestry walk and the
holder-liveness check so they cannot disagree about the same pid. The
daemon (matched by its "daemon" subcommand, not by one --origin value),
bg-pty-host, and bg-spare are rejected as non-session shapes. Claude
Code's versioned executable is now recognized as a session, so resumed,
forked, and app-hosted sessions resolve their own per-session pid instead
of the daemon-owned pty host above them.

Infrastructure met before any session match is skipped and the walk
continues, which preserves the nested bg-spare chain fixed in kunchenguid#1206;
infrastructure met after a match ends the contiguous run. When every
candidate is infrastructure the walk resolves nothing and every caller
fails closed, because a pooled worker's ancestry and inherited
environment both describe the daemon and carry no evidence of the session
that claimed it. A lock recorded against infrastructure is now a
reclaimable owner, so an already-wedged home recovers through the
existing stale-owner path.

Every caller of the shared predicates was reviewed: all six call sites in
fm-claude-stop-autoarm.sh and fm-lock.sh were affected and are fixed by
this change. fm-harness.sh answers harness kind rather than session
identity and is unaffected; fm-sessionstart-nudge.sh classifies no shape
and was exposed only through lock content.
A Claude Code mid-conversation fork (observed live 2026-07-29 16:51,
Claude Code 2.1.220) replaces the session process: a new pid carrying a
new --session-id continues the working conversation while the pre-fork
pid stays alive as an idle interactive process. The lock still recorded
the pre-fork pid, so the working session failed
fm_session_lock_owned_by_self forever, the Stop auto-arm stayed
correctly-but-silently inert, and nothing noticed that supervision was
down: the working session neither owned its home nor demoted.

Identity now has two layers. state/.lock keeps the owning pid; a new
state/.lock-session sidecar records the owning harness SESSION id when a
trusted source proves it - the Stop-payload session_id hint exported by
the auto-arm, or the CLAUDE_CODE_SESSION_ID/CLAUDE_PID env pair Claude
Code plants in every tool shell, accepted only when CLAUDE_PID names
exactly the ancestry-resolved pid so a daemon-inherited or outer-session
environment can never leak in. Command-line text is never an identity
source because prompts are argv-visible.

fm_session_lock_relation classifies a non-owned holder as self,
same-session, live-other, or stale. A same-session holder is the same
logical session in a replaced process, so fm-lock.sh re-keys the pid and
the auto-arm recovers through the existing fm-lock.sh delegation, after
the unchanged AFK and need gates. A live-other holder is never armed
over, reclaimed, inherited, or forced - but the auto-arm no longer goes
silent: once per distinct holder it wakes the model (exit 2, deduped via
state/.claude-autoarm-foreign-lock) with the real diagnosis, and the
fm-lock.sh refusal now names the holder's pid, session, start time, and
terminal, plus the exact recovery action when this process's own argv
shows it was forked from the recorded session. Dead-holder reclaim,
daemon-shape rejection, lock-refused read-only, and the no-force rule
are unchanged; non-Claude harnesses resolve no session id and keep the
exact pid-only contract.

Also fixes a latent test-hermeticity bug in the cherry-picked
shared-daemon reclaim test: a single-command fake-claude body was
tail-exec-collapsed by bash, so the ancestry walk escaped to whatever
real session hosted the test run (it only passed locally because a real
Claude session sat above the runner; CI has none).
…key scope honestly

Independent review confirmed against the verification data that Claude
Code's --fork-session mints the successor a NEW session id (cfaf5775 ->
42ed4142 in docs/verification/supervision.md), so same-session
re-keying covers only successions that KEEP their session id; the fork
successor's path is the loud foreign-owner notice while the pre-fork
process lives, then the ordinary stale reclaim the moment it exits.
That split is deliberate - fork lineage cannot distinguish an abandoned
live holder from an idle working one, so automatic takeover from any
live different-session holder would break the never-inherit boundary -
but the lib, hook, and doc wording previously blurred the two legs.

State the coverage split explicitly in fm-session-lock-lib.sh,
fm-lock.sh, fm-claude-stop-autoarm.sh, docs/watcher-continuity.md, and
the verification record, and add the missing end-to-end fork test:
sidecar holds the pre-fork id, the Stop payload proves a different id,
the hook emits exactly the foreign notice naming the holder and its
session while mutating nothing, and the firing after the pre-fork
process exits reclaims, re-keys to the successor's own id, and arms.

Also make the two foreign-owner tests hermetic: their single-command
fake-claude bodies were tail-exec-collapsed by bash, so the hook's
ancestry escaped to whatever real session hosted the run; in CI, where
no real session exists, classification would have failed closed and the
tests would have failed.
…ession re-key

Third-review TOCTOU findings, dispositioned by evidence. A same-session
re-key rewrites the sidecar with a byte-identical value, so skip the
clear-then-rewrite entirely in that case: the recorded identity is then
never even transiently absent, which removes both the crash window that
degraded a re-keyed lock to pid-only and the false foreign-owner notice
a concurrent auto-arm firing could emit while the sidecar was cleared.
Every other acquisition keeps the clear-first order deliberately: the
reviewer-proposed alternative (retain the old sidecar until the new one
lands) would let a crash leave the previous owner's identity beside the
new owner's pid, and a relaunch successor of that PREVIOUS session
could then prove same-session against a live foreign holder and steal
the lock - clear-first makes every crash degrade toward pid-only, the
safe direction.

A sidecar write failure now also warns on stderr with the concrete
consequence (a replacement process for this session cannot take over
until this process exits) instead of degrading silently, and the lib
header notes that alternating re-keys between two live incarnations of
one session stay inside that session and settle once the superseded
incarnation idles.
…n efforts

Add an agent-only effort-maps skill owning a wayfinder-style orientation
map convention at data/maps/<effort-slug>.md: Destination, Notes, a
Decisions-so-far index over authoritative decision records, fog-of-war
Not-yet-specified, and Out-of-scope, adapted from the MIT-licensed
wayfinder skill in mattpocock/skills with attribution.

Wire the convention in with pointer-sized edits only: a decision-hold-lifecycle
operating-sequence step to append newly resolved decisions to an active map,
a bearings line surfacing each active map's Destination and newest decisions,
one AGENTS.md section 13 trigger line, and the docs audience inventory entry.
…c busy-state contract

Rebasing onto kunchenguid#1327 (semantic lifecycle state) removed fm-watch's rendered
busy-regex selection; the only rendered fallback left is Grok-scoped inside
bin/fm-busy-lib.sh. Standalone Hermes has no trusted semantic busy source, so
a Hermes task classifies unknown and its ASCII cancel row stays a
delivery-only signature in the shared tmux matcher, mirroring Kimi's
demotion. The watcher test now asserts that truth instead of the pre-kunchenguid#1327
rendered-row recognition, and the adapter doc records the contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Effort maps: wayfinder-style orientation maps
Session-lock fork-handover fix (panel-hardened)
Hermes worker adapter (verified)
* fix: preserve Calm boat continuity across working periods (kunchenguid#1356)

* fix(calm): resume working boat from frozen column across runs

Keep one extension-owned boat animation for the Pi session so settling
freezes column and direction, the next working period resumes there
without hidden-time jumps, and only a fresh session resets to the left edge.

* no-mistakes(review): Freeze Calm boat from last rendered state

* no-mistakes(document): Document Calm boat continuity contract

* feat(board): captain-facing kanban board generated from live fleet state

Add bin/fm-board.sh, a renderer over the canonical fm-fleet-snapshot.sh
JSON contract plus the hand-maintained effort maps under data/maps/. It
writes a self-contained Lavish-reviewable HTML board with the ordered
pipeline columns Decide / Queued / Building / Review / Landed: captain
holds and open keyed decisions land in Decide with approval panels
(radio options plus a free-text override, one queued Lavish prompt per
submit), queued rows surface blockers, building cards show the assigned
harness/model/effort, review cards link the recorded PR, and Done rows
land in Landed. Cards are draggable; a drop queues a move order. All
interactions queue prompts - nothing on the board mutates fleet state.

Approval panels render as siblings after each card's todo list, never
inside it, avoiding the details-in-ul clipping bug the hand-built
prototype hit; a test enforces that structurally on the output.

Expose the recorded model and effort meta fields on fleet-snapshot task
rows (additive to schema fm-fleet-snapshot.v1) so the board never
re-parses state files. Document the board in docs/board.md (classified
operator-current) with a toolbelt row in docs/scripts.md.

* no-mistakes(document): docs: count fm-board.sh among fleet-snapshot renderers

---------

Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
Co-authored-by: Louis Condevaux <louiscondevaux@Louiss-MacBook-Pro.local>
DaisyUI's .badge has a fixed height and does not wrap, so a card's
metadata badge whose text runs past one line has that text clipped.
The effort-map card's fog: and out of scope: badges hit this today,
but any long .meta-row badge can.

Add a stylesheet override in the generated board that lets .meta-row
badges grow and wrap. Presentation only: truncation logic and badge
markup are unchanged, and badges outside .meta-row keep their default
fixed height.
@Lcxiv

Lcxiv commented Jul 31, 2026

Copy link
Copy Markdown
Author

Opened here by mistake - this change targets our own fork and was never intended as an upstream contribution. Closing; apologies for the noise.

@Lcxiv Lcxiv closed this Jul 31, 2026
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