Skip to content

feat(bin): add captain-facing Lavish ticket board - #3515

Open
TomatoIceberg wants to merge 6 commits into
kunchenguid:mainfrom
TomatoIceberg:fm-ticket-board-recover
Open

feat(bin): add captain-facing Lavish ticket board#3515
TomatoIceberg wants to merge 6 commits into
kunchenguid:mainfrom
TomatoIceberg:fm-ticket-board-recover

Conversation

@TomatoIceberg

Copy link
Copy Markdown

Intent

Add a captain-facing Lavish ticket board (bin/fm-ticket-board.sh) that lets the captain create/manage tickets via a Lavish board and a receiving script (bin/fm-ticket-board-consume.sh) that reads captain-typed board content and turns it into tickets. Apply tech-lead review feedback: (1) the receiving script must reuse the existing owner script/library for parsing captured Lavish board results instead of re-implementing that parser - reuse bin/fm-procevent-lavish.sh's cmd_messages/parsing rather than duplicating it. (2) the receiving script must distinguish 'the captain typed nothing' (genuinely empty) from 'a read/parse failure' (a genuine error) - use the repo's existing error-vs-empty distinguishing machinery so a ticket the captain actually typed can never silently vanish with no error surfaced anywhere; ticket replays must also be deduped so a replayed event never duplicates a ticket. (3) verify whether rebuilding the board (reopening its Lavish session) is refused when the captain explicitly ended the session from the browser (e.g. via Send & End) - if so, handle recovery (recover ended board sessions on rebuild) so the captain's natural type-then-close-tab usage does not silently break ticket pickup; if this is a pre-existing limitation shared with the bearings board rather than unique to this PR, document that rather than treating it as this PR's bug to fully solve. Keep the shared skill-doc registration (docs/documentation-audiences.json, .agents/skills/ticket-board) accurate.

What Changed

  • Added bin/fm-ticket-board.sh, a captain-facing Lavish ticket board for creating/managing tickets, and bin/fm-ticket-board-consume.sh, which reads captain-typed board content and turns it into tickets by reusing bin/fm-procevent-lavish.sh's cmd_messages/parsing rather than duplicating a parser.
  • Extended bin/fm-procevent-lavish.sh to support the consume flow, distinguishing a genuinely empty captain message from a read/parse failure (surfacing the latter as an error) and decoding/encoding UTF-8 so title/body truncation stays character-safe.
  • Added replay dedup for tickets, recovery of ended board Lavish sessions on rebuild so a captain's type-then-close-tab flow doesn't silently break ticket pickup, and fixed ticket-id entropy generation.
  • Registered the ticket board skill (.agents/skills/ticket-board/SKILL.md and assets/board-template.html), updated .agents/skills/process-event-sources/SKILL.md and AGENTS.md, and added the skill to docs/documentation-audiences.json.
  • Added test coverage: tests/fm-ticket-board.test.sh, tests/fm-ticket-board-consume.test.sh, and a UTF-8 truncation regression test.

Risk Assessment

✅ Low: This fix round is a small, well-verified pair of changes: a one-line entropy fix confirmed correct via direct bash precedence testing, and a genuine behavioral regression test (not a source-content grep) that exercises the real parsing pipeline end-to-end and correctly proves the prior UTF-8 truncation fix.

Testing

The two targeted test files (fm-ticket-board.test.sh, fm-ticket-board-consume.test.sh) pass in full, and a manual end-to-end CLI run against a stubbed lavish-axi — plus a browser screenshot of the actual rendered board — confirms all three tech-lead-review fixes work as intended: captain-typed tickets persist correctly with UTF-8-safe truncation, a replayed capture is deduped rather than creating a duplicate ticket, and build recovers from a captain-ended Lavish session via an automatic --reopen retry rather than silently failing or falsely claiming success. No issues found.

Evidence: Targeted test suite output
FM_TEST_SUMMARY total=2 failed=0 skipped_gate=0 duration_ms=8259 — all 20 assertions passed across fm-ticket-board.test.sh and fm-ticket-board-consume.test.sh, including the UTF-8 character-boundary truncation test, the replay-dedup test, and the ended-session-recovery test.
Evidence: Manual end-to-end CLI transcript
init/build create and arm the board; consuming a captured 'message' row creates ticket tkt-20260902T094822-0be2 titled 'Fix the header spacing on the pricing page'; replaying the identical result prints 'skipped: 1 already-recorded row(s) (replayed result)' and the store stays at exactly 1 ticket; after the captain ends the session (stubbed lavish-axi returning status: user-ended), build retries once with --reopen (confirmed via call log showing the plain call then the '--reopen' call) and reports 'served:' successfully.
Evidence: Rendered ticket board HTML (real template + injected store from the manual run)

Source: Rendered ticket board HTML (real template + injected store from the manual run)

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tickets - firstmate</title>
<style>
/* ============================================================
   Ticket board - Firstmate Design System, inlined for portability.
   Tokens and shared components (.fm-badge, .fm-btn, .fm-card, .fm-sign) copied
   verbatim from .agents/skills/bearings/assets/board-template.html so the two
   boards read as one system.
   ============================================================ */
@import url("https://fonts.googleapis.com/css2?family=Chango&family=Jost:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap");

:root {
  --rust-700: #8f2f17; --rust-600: #a93a1f; --rust-500: #c0452a;
  --rust-400: #d35f3f; --rust-300: #e08365; --rust-100: #f4d8c9; --rust-050: #fbece3;
  --navy-700: #1a2238; --navy-600: #222c49; --navy-500: #2a3656;
  --navy-300: #6c7796; --navy-100: #d9deea;
  --gold-600: #b5791c; --gold-500: #e0a52e; --gold-300: #f0d38c; --gold-100: #f8ecc9;
  --ocean-600: #2f6688; --ocean-500: #3c7ea6; --ocean-200: #b6d4e2; --ocean-050: #e8f1f5;
  --sea-700: #234e3a; --sea-500: #2f6b4f; --sea-200: #b9d4c5; --sea-050: #e9f2ec;
  --paper-000: #fbf4e2; --paper-100: #f6ecd3; --paper-200: #f0e3c4; --paper-300: #e7d6ae;
  --cream-line: #ddc89c;
  --ink-900: #241c14; --ink-700: #3f3224; --ink-500: #6f5e46; --ink-300: #9c8a6c;
  --white: #fffdf7;
  --bg-page: var(--paper-100);
  --surface-card: var(--white);
  --surface-card-warm: var(--paper-000);
  --text-strong: var(--ink-900); --text-body: var(--ink-700);
  --text-muted: var(--ink-500); --text-faint: var(--ink-300);
  --border-default: var(--cream-line); --border-soft: var(--paper-300);
  --status-online: var(--sea-500); --status-online-soft: var(--sea-050);
  --status-warn: var(--gold-600); --status-warn-soft: var(--gold-100);
  --status-danger: var(--rust-600); --status-danger-soft: var(--rust-050);
  --status-info: var(--ocean-500); --status-info-soft: var(--ocean-050);
  --font-display: "Chango", "Cooper Black", Rockwell, Georgia, serif;
  --font-sans: "Jost", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --fs-h3: 1.4rem; --fs-h4: 1.15rem; --fs-base: 1rem; --fs-sm: 0.9375rem;
  --fs-xs: 0.8125rem; --fs-2xs: 0.6875rem;
  --ls-caps: 0.16em;
  --radius-xs: 6px; --radius-sm: 9px; --radius-md: 12px; --radius-banner: 7px;
  --radius-lg: 18px; --radius-pill: 999px;
  --shadow-sm: 0 1px 2px rgba(36, 28, 20, 0.06), 0 4px 10px rgba(36, 28, 20, 0.06);
  --shadow-hard: 4px 4px 0 var(--ink-900);
  --shadow-hard-sm: 3px 3px 0 var(--ink-900);
  --texture-paper: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E");
  --focus-ring: 0 0 0 3px var(--gold-300);
  --container-app: 1180px;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  font-family: var(--font-sans);
  color: var(--text-body);
  background: var(--bg-page);
  background-image: var(--texture-paper);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }

/* ---- fm-badge (enamel sign-pin), verbatim contract ---- */
.fm-badge {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--fs-2xs); font-weight: 800; line-height: 1;
  padding: 5px 9px 4px; text-transform: uppercase; letter-spacing: 0.07em;
  border-radius: var(--radius-xs); border: 1.5px solid var(--ink-900);
  box-shadow: 2px 2px 0 var(--ink-900); white-space: nowrap;
}
.fm-badge--online  { background: var(--sea-500);  color: var(--paper-000); }
.fm-badge--warn    { background: var(--gold-500); color: var(--navy-700); }
.fm-badge--danger  { background: var(--rust-600); color: var(--paper-000); }
.fm-badge--info    { background: var(--ocean-500); color: var(--paper-000); }
.fm-badge--neutral { background: var(--paper-000); color: var(--ink-900); }
.fm-badge--solid   { background: var(--rust-500); color: var(--paper-000); }

/* ---- fm-card ---- */
.fm-card {
  background: var(--surface-card); border: 1px solid var(--border-default);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow: hidden;
}
.fm-card--poster { border: 2px solid var(--ink-900); box-shadow: var(--shadow-hard); border-radius: var(--radius-md); }
.fm-card--warm { background: var(--surface-card-warm); }

/* ---- fm-sign (eyebrow section label) ---- */
.fm-sign {
  display: inline-flex; align-items: center; gap: 8px; font-weight: 800;
  text-transform: uppercase; letter-spacing: var(--ls-caps); font-size: var(--fs-xs);
  line-height: 1; white-space: nowrap;
}
.fm-sign--eyebrow { color: var(--rust-500); }
.fm-sign--muted { color: var(--ink-500); }
.fm-sign svg { width: 14px; height: 14px; }
.fm-ico { width: 1.15em; height: 1.15em; flex: none; vertical-align: -0.18em; }

/* ============================================================
   Board chrome (tb-*) - built on the same tokens as the bearings board
   ============================================================ */
.tb-nav {
  position: sticky; top: 0; z-index: 20;
  background: color-mix(in srgb, var(--paper-100) 86%, transparent);
  backdrop-filter: saturate(150%) blur(10px);
  border-bottom: 1px solid var(--border-default);
}
.tb-nav__inner {
  max-width: var(--container-app); margin: 0 auto; padding: 0 28px; height: 64px;
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
}
.tb-brand { display: inline-flex; align-items: center; gap: 12px; }
.tb-brand__disc {
  display: grid; place-items: center; width: 34px; height: 34px; flex: none;
  border-radius: 999px; background: var(--rust-500); color: var(--paper-000);
  border: 2px solid var(--ink-900); box-shadow: var(--shadow-hard-sm);
}
.tb-brand__disc svg { width: 60%; height: 60%; }
.tb-brand__wm { font-family: var(--font-display); font-size: 23px; color: var(--text-strong); line-height: 1; }
.tb-meta-mono { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--text-faint); white-space: nowrap; }

.tb-main {
  max-width: var(--container-app); margin: 0 auto;
  padding: 26px 28px 72px; display: flex; flex-direction: column; gap: 22px;
}

/* how-to banner: the board itself carries no creation form, so this is the
   one place that explains the mechanism */
.tb-howto {
  display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px;
  background: var(--ocean-050); border: 1.5px solid var(--ocean-200);
  border-radius: var(--radius-md);
}
.tb-howto svg { width: 20px; height: 20px; flex: none; color: var(--ocean-600); margin-top: 2px; }
.tb-howto__body { min-width: 0; font-size: var(--fs-sm); color: var(--text-body); line-height: 1.5; }
.tb-howto__body strong { color: var(--text-strong); }

/* stat strip */
.tb-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
.tb-stat {
  display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; min-width: 0;
  background: var(--surface-card-warm); border: 1px solid var(--border-soft); border-radius: var(--radius-md);
}
.tb-stat__num { font-family: var(--font-mono); font-size: var(--fs-h3); font-weight: 600; line-height: 1; color: var(--text-strong); }
.tb-stat__label { font-family: var(--font-mono); font-size: var(--fs-2xs); letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-muted); }

/* kanban columns */
.tb-cols { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; align-items: start; }
.tb-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.tb-col__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.tb-col__cards { display: flex; flex-direction: column; gap: 10px; min-height: 64px; }

.tb-ticket { padding: 13px 15px; display: flex; flex-direction: column; gap: 6px; }
.tb-ticket__title { font-size: var(--fs-sm); font-weight: 700; color: var(--text-strong); line-height: 1.35; }
.tb-ticket__body {
  font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.45;
  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
.tb-ticket__foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 2px; }
.tb-ticket__id { font-family: var(--font-mono); font-size: var(--fs-2xs); color: var(--text-faint); }
.tb-ticket__date { font-family: var(--font-mono); font-size: var(--fs-2xs); color: var(--text-faint); }

.tb-empty { padding: 18px; font-size: var(--fs-sm); color: var(--text-muted); }

.tb-foot { display: flex; justify-content: flex-end; border-top: 1px solid var(--border-default); padding-top: 14px; }

@media (max-width: 900px) {
  .tb-cols { grid-template-columns: 1fr; }
}
</style>
</head>
<body>

<header class="tb-nav">
  <div class="tb-nav__inner">
    <span class="tb-brand">
      <span class="tb-brand__disc">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
        </svg>
      </span>
      <span class="tb-brand__wm">tickets</span>
    </span>
  </div>
</header>

<main class="tb-main">

  <div class="tb-howto">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
    <span class="tb-howto__body">
      <strong>To add a ticket:</strong> type the description in the conversation panel on the right and send it to the agent.
      Firstmate turns it into a new card in Backlog the next time it checks in - no form to fill out here.
    </span>
  </div>

  <div class="tb-stats" id="tb-stats"></div>

  <div class="tb-cols" id="tb-cols">
    <section class="tb-col" data-status="backlog">
      <div class="tb-col__head">
        <span class="fm-sign fm-sign--eyebrow">Backlog</span>
        <span class="fm-sign fm-sign--muted" data-col-count></span>
      </div>
      <div class="tb-col__cards" data-col-cards></div>
    </section>
    <section class="tb-col" data-status="in_progress">
      <div class="tb-col__head">
        <span class="fm-sign fm-sign--eyebrow">In Progress</span>
        <span class="fm-sign fm-sign--muted" data-col-count></span>
      </div>
      <div class="tb-col__cards" data-col-cards></div>
    </section>
    <section class="tb-col" data-status="done">
      <div class="tb-col__head">
        <span class="fm-sign fm-sign--eyebrow">Done</span>
        <span class="fm-sign fm-sign--muted" data-col-count></span>
      </div>
      <div class="tb-col__cards" data-col-cards></div>
    </section>
  </div>

  <footer class="tb-foot">
    <span class="tb-meta-mono" id="tb-provenance">-</span>
  </footer>

</main>

<!-- ONE SLOT. bin/fm-ticket-board.sh build injects the fresh
     fm-ticket-board.v1 payload in place of the placeholder below; everything
     above and below is the shipped template and is never regenerated per run. -->
<script id="ticket-board-data" type="application/json">
{"schema":"fm-ticket-board.v1","generated":"2026-09-02T09:48:22Z","tickets":[{"id":"tkt-20260902T094822-0be2","title":"Fix the header spacing on the pricing page","status":"backlog","created":"2026-09-02T09:48:22Z","body":"Fix the header spacing on the pricing page","source":"/private/tmp/e2e-ticket-board/result1.txt#0"}]}
</script>

<script>
(function () {
  "use strict";

  /* Fail closed: a board with unreadable or wrong-schema data says so plainly
     instead of rendering an empty page that looks like an empty board. */
  function renderBoardError(msg) {
    var main = document.querySelector(".tb-main");
    main.innerHTML = "";
    var card = document.createElement("div");
    card.className = "fm-card fm-card--poster tb-ticket";
    var h = document.createElement("div");
    h.className = "tb-ticket__title";
    h.textContent = "This board could not load";
    var d = document.createElement("div");
    d.className = "tb-ticket__body";
    d.textContent = msg + " - ask firstmate to rebuild the board.";
    card.appendChild(h);
    card.appendChild(d);
    main.appendChild(card);
  }

  var data;
  try {
    data = JSON.parse(document.getElementById("ticket-board-data").textContent);
  } catch (e) {
    renderBoardError("The board data is not valid JSON");
    return;
  }
  if (!data || data.schema !== "fm-ticket-board.v1" || !Array.isArray(data.tickets)) {
    renderBoardError("Unsupported or malformed board data schema: " + (data && data.schema ? data.schema : "(none)"));
    return;
  }
  var ticketsValid = data.tickets.every(function (t) {
    return t && typeof t === "object" &&
      typeof t.id === "string" && t.id.length > 0 &&
      typeof t.title === "string" && t.title.length > 0 &&
      (t.status === "backlog" || t.status === "in_progress" || t.status === "done") &&
      typeof t.created === "string" && t.created.length > 0;
  });
  if (!ticketsValid) {
    renderBoardError("The board data does not have the required ticket structure");
    return;
  }

  try {
  function el(tag, cls, text) {
    var n = document.createElement(tag);
    if (cls) n.className = cls;
    if (text != null) n.textContent = text;
    return n;
  }

  document.getElementById("tb-provenance").textContent = data.schema + " · " + data.generated;

  var byStatus = { backlog: [], in_progress: [], done: [] };
  data.tickets.forEach(function (t) { byStatus[t.status].push(t); });

  var strip = document.getElementById("tb-stats");
  [
    { n: byStatus.backlog.length, l: "backlog" },
    { n: byStatus.in_progress.length, l: "in progress" },
    { n: byStatus.done.length, l: "done" },
    { n: data.tickets.length, l: "total tickets" }
  ].forEach(function (s) {
    var t = el("div", "tb-stat");
    t.appendChild(el("span", "tb-stat__num", String(s.n)));
    t.appendChild(el("span", "tb-stat__label", s.l));
    strip.appendChild(t);
  });

  function formatDate(iso) {
    var d = new Date(iso);
    if (isNaN(d.getTime())) return iso;
    return d.toISOString().slice(0, 10);
  }

  document.querySelectorAll(".tb-col").forEach(function (col) {
    var status = col.getAttribute("data-status");
    var rows = byStatus[status] || [];
    col.querySelector("[data-col-count]").textContent =
      rows.length + " ticket" + (rows.length === 1 ? "" : "s");
    var cards = col.querySelector("[data-col-cards]");
    if (!rows.length) {
      cards.appendChild(el("div", "tb-empty", "Nothing here."));
      return;
    }
    rows.forEach(function (t) {
      var card = el("div", "fm-card tb-ticket");
      card.appendChild(el("div", "tb-ticket__title", t.title));
      if (t.body && t.body !== t.title) card.appendChild(el("div", "tb-ticket__body", t.body));
      var foot = el("div", "tb-ticket__foot");
      foot.appendChild(el("span", "tb-ticket__id", t.id));
      foot.appendChild(el("span", "tb-ticket__date", formatDate(t.created)));
      card.appendChild(foot);
      cards.appendChild(card);
    });
  });
  } catch (e) {
    renderBoardError("The board data could not be rendered");
  }
})();
</script>
</body>
</html>

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 1 issue found → auto-fixed ✅
  • ⚠️ bin/fm-procevent-lavish.sh - merge conflict rebasing onto origin/main

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

🔧 **Review** - 2 issues found → auto-fixed ✅
  • ⚠️ bin/fm-procevent-lavish.sh:656 - The final commit (5b8fae4) correctly fixes the byte-vs-character truncation bug in cmd_messages by decoding the result file as UTF-8 and re-encoding STDOUT as UTF-8, so substr() now operates on characters and can no longer split a multi-byte sequence - verified by inspection: open my $fh, &#34;&lt;:encoding(UTF-8)&#34;, $path decodes on read and binmode STDOUT, &#34;:encoding(UTF-8)&#34; re-encodes on print, so title/body caps land on character boundaries. However, no test was added exercising non-ASCII text near the 200/4000 cap (e.g. repeated multi-byte characters straddling the cutoff), which is exactly the gap the original finding called out ("no test ... exercises non-ASCII or near-cap-length text"). The fix itself is correct but remains unverified by an automated regression test.
  • ℹ️ bin/fm-ticket-board-consume.sh:60 - new_id() builds the ticket id suffix with $((RANDOM % 65536)) formatted as %04x, but bash's $RANDOM only ever yields 0-32767, so the modulo is a no-op and the suffix can never fall in 0x8000-0xffff - the format string implies a full 16-bit space that is actually halved. Not a functional bug since a generated collision is refused loudly (jq check followed by fail &#34;generated a colliding ticket id&#34;), just weaker-than-implied entropy.

🔧 Fix: Add UTF-8 truncation regression test; fix ticket-id RANDOM entropy
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • ./bin/fm-test-run.sh tests/fm-ticket-board-consume.test.sh tests/fm-ticket-board.test.sh — 20/20 assertions pass, including the multi-byte UTF-8 character-boundary truncation test, the replayed-result dedup test, and the ended-session-recovery-on-rebuild test
  • Manual CLI walkthrough: fm-ticket-board.sh init -> build -> wrote a captured Lavish result with a freeform message row -> fm-ticket-board-consume.sh &lt;result&gt; created ticket tkt-20260902T094822-0be2 and rebuilt the board
  • Manual CLI walkthrough: replayed the identical captured result through fm-ticket-board-consume.sh and confirmed skipped: 1 already-recorded row(s) (replayed result) with the store staying at exactly 1 ticket (no duplicate)
  • Manual CLI walkthrough: set a stub lavish-axi to report status: user-ended on the plain call, ran fm-ticket-board.sh build, and confirmed via a call log that the script retried once with --reopen and then reported served: successfully instead of falsely claiming success or failing loudly
  • Opened the actual rendered board HTML (template + injected store from the manual run) in Chrome and took a screenshot confirming the created ticket renders correctly in the Backlog column
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: No lint fixes needed; installed actionlint for sandbox PATH only
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

Sebastien Hardy added 5 commits September 2, 2026 17:38
The captain creates tickets by typing plain text into a stable Lavish
board; bin/fm-ticket-board-consume.sh turns a captured poll result into
a Backlog ticket and rebuilds the board, following the same
serve-then-bind-then-arm architecture as fm-bearings-board.sh. Wires
the board into the process-event-sources wake-routing contract and the
new ticket-board skill so firstmate recognizes and consumes a board
wake instead of leaving it generically acknowledged.
…content

The ticket-board consume script duplicated fm-procevent-lavish.sh's
prompts[N] parsing contract instead of reusing it, and treated a zero-row
parse the same whether the captain typed nothing or the parse silently
failed. Add messages/has-content subcommands to fm-procevent-lavish.sh
(the wire format's one owner) and have consume delegate to them, failing
loudly when content is present but nothing was captured instead of
reporting captured: 0.

Also verified live against a real Send & End: lavish-axi exits 0 without
reopening a session the captain ended from the browser, so the board's
pickup loop silently stops accepting new tickets until an agent reopens
it by hand. This is a pre-existing limitation shared verbatim with
fm-bearings-board.sh, so it is documented rather than fixed here.
…er skill docs

Consume now dedupes tickets by result-file+row so a replayed captured
result never mints a duplicate, and publishes the store before rebuilding
the board so a rebuild failure never drops a captain-typed ticket.
Build verifies lavish-axi actually reports an opened session and
reopens it when the captain ended it from the browser, instead of
trusting exit code alone. Registers the ticket-board skill in
docs/documentation-audiences.json to satisfy the repo's documentation
audience check.
@greptile-apps

greptile-apps Bot commented Sep 2, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (2): Last reviewed commit: "no-mistakes: apply CI fixes" | Re-trigger Greptile

Comment thread bin/fm-procevent-lavish.sh
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