Skip to content

feat(bin): add read-only fleet project dashboard - #12

Open
rega10 wants to merge 23 commits into
mainfrom
fm/fleet-project-dashboard-mvp
Open

feat(bin): add read-only fleet project dashboard#12
rega10 wants to merge 23 commits into
mainfrom
fm/fleet-project-dashboard-mvp

Conversation

@rega10

@rega10 rega10 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Intent

Build the approved MVP for a permanent, on-demand fleet project dashboard in Firstmate, using Proposal 1 from the fleet-dashboard proposal report as the design basis. Show every project registered in data/projects.md through a deterministic project-grained aggregation that left-joins the canonical fm-fleet-snapshot data and never treats append-only status logs as current truth. Include safely attributable secondmate-owned project state in v1 and fail or disclose unavailable state instead of inventing a fragile cross-home join. Render a stable read-only Lavish board separate from Bearings decision answering, with uniform Firstmate-styled project cards showing project name, color-coded status, and a concise next step. Use needs-attention over active over waiting over idle-or-queued status precedence, with active, needs-attention, waiting, and idle-or-queued colors. Apply stale-risk as an overlay badge only when the latest known activity is strictly older than 8 days and the project is neither active nor needs attention. Selecting a project must expand it larger than the rest and expose relevant active work, decisions, queued next steps, recent landed work, PR links, stale age, and secondmate ownership. Keep refresh on demand only and expose a documented command or script. Keep v1 strictly read-only with no archive actions, captain-answer binding, merge buttons, dispatch controls, process-event polling, or coupling to Bearings decision flow. Add executable behavior tests for aggregation, status precedence, the 8-day stale threshold, secondmate attribution and bounded-state behavior, and selected-project payload and board behavior. Keep changes focused and do not modify any project under projects/.

What Changed

  • Added bin/fm-project-dashboard-snapshot.sh, which aggregates the canonical fm-fleet-snapshot.sh output into fm-project-dashboard.v1: one row per project registered in data/projects.md, with needs_attention > active > waiting > idle_queued precedence, a stale-risk overlay applied only when the latest known activity is strictly older than FM_PROJECT_DASHBOARD_STALE_DAYS (default 8) and the project is neither active nor needing attention, single-place lifecycle routing across decisions/waiting/queued/landed/prs, and board-wide disclosures[] for state it cannot safely attribute or a bounded read dropped.
  • Added bin/fm-project-dashboard.sh (refresh / build / path) plus assets/project-dashboard-template.html, rendering an on-demand, read-only Lavish board of uniform status-colored project cards at $FM_HOME/.lavish/project-dashboard.html; --select <project> expands one card with its active work, decisions, queued next steps, recently landed work, PR links, stale age, and secondmate owner. The board never binds answers, polls, dispatches, merges, or mutates fleet records.
  • Extended bin/fm-fleet-snapshot.sh to carry per-record projects (registry clone list unioned with parent task metadata), repo/since fields on holds, decisions, queued, active, and landed rows, and a holds bound disclosure; added tests/fm-project-dashboard.test.sh (aggregation, precedence, stale threshold, secondmate attribution and bounded state, selected-project payload and board behavior), wired the new files into bin/fm-test-run.sh's snapshot-bearings family, and documented the board in README.md, docs/scripts.md, docs/architecture.md, and docs/captain-hold-lifecycle.md.

Risk Assessment

⚠️ Medium: No defects survived verification and every intent criterion is satisfied, but the change is a ~3100-line new feature that also extends the shared canonical fm-fleet-snapshot.sh surface (new repo/projects/since fields and a new holds omitted entry), so the blast radius is wider than the fix round itself.

Testing

Ran the change's targeted behavior suite (51 tests in tests/fm-project-dashboard.test.sh) and the three existing suites that exercise the modified bin/fm-fleet-snapshot.sh - all passed. Because unit passes alone do not show the product, I built a realistic five-project fixture and drove the documented on-demand path end-to-end: fm-project-dashboard.sh refresh --select published the stable Lavish board, which I then opened in Chrome and interacted with as a reader. Screenshots show every registered project as a uniform Firstmate-styled card with the four status colors following needs-attention > active > waiting > idle-or-queued precedence, the stale-risk dashed overlay badge on the one long-idle project, and a selected project expanded full width exposing active work, decisions, recently-resolved decisions with a labelled Decision PR reference, waiting, queued next, recently landed, PR links, stale age, and secondmate ownership; both click and URL-fragment selection were exercised. A second rendered board shows the "fleet state is incomplete" banner listing each bounded-read drop with the env var to raise plus a "(state unavailable)" secondmate, demonstrating disclosure rather than an invented cross-home join. A CLI transcript records the strict 8-day boundary (8d 0s not stale, 8d 1s stale), that stale never applies to active or needs-attention projects, that appending a contradicting entry to an append-only status log does not change the card, and that the fleet state hash is identical before and after a refresh. No failures, no flakiness, and the worktree is clean with all transient fixtures removed.

Evidence: CLI transcript: help output, read-only refresh with identical state hashes, per-project status/next-step rollup, strict 8-day stale boundary, stale-as-overlay check, and append-only status log not treated as current truth

Source: CLI transcript: help output, read-only refresh with identical state hashes, per-project status/next-step rollup, strict 8-day stale boundary, stale-as-overlay check, and append-only status log not treated as current truth

$ fm-project-dashboard.sh refresh --select firstmate board: .../.lavish/project-dashboard.html served: .../.lavish/project-dashboard.html # fleet state before/after the refresh 81e227d0c3eac66b22f6c7a88f05248be0197c96 - 81e227d0c3eac66b22f6c7a88f05248be0197c96 - => unchanged: the board never mutates fleet records stale_after_days=8 selected_project=firstmate project status stale age_d next step firstmate needs_attention false 0 Choose the board refresh route: On-demand refresh only, or a background poller? herdr active false 1 Wiring the fan-out scheduler lavish waiting false 2 Blocked on the vendor export release catalog idle_queued false 3 Re-render the topology page bearings idle_queued true 78 No work queued # stale-risk overlay is strictly older than 8 days now=2026-06-18T00:00:00Z age=691200s status=idle_queued stale_risk=false now=2026-06-18T00:00:01Z age=691201s status=idle_queued stale_risk=true # an append-only status log is never read as current truth lavish -> status=waiting next_step="Blocked on the vendor export release" waiting=["lavish-vendor"]

$ fm-project-dashboard.sh --help
fm-project-dashboard.sh - build and serve the read-only fleet project dashboard.

Usage:
  fm-project-dashboard.sh [refresh] [--select <project>]
  fm-project-dashboard.sh build <data.json>
  fm-project-dashboard.sh path

refresh is the default command.
It takes a fresh fm-project-dashboard.v1 snapshot, replaces the stable board
at $FM_HOME/.lavish/project-dashboard.html, and opens or resumes its Lavish
session.
build validates and publishes a supplied snapshot before serving it.
path prints the stable home-scoped board path.

The board's recently-resolved captain decisions cover main-home work only.
The canonical secondmate home summary omits a secondmate's completed
captain-held rows, and this board does not widen that contract, so the board
states that limitation once, globally.

This board is read-only.
It never binds captain answers, registers a process-event source, polls
Lavish, merges, dispatches, archives, or mutates fleet records.
Re-run refresh whenever current data is wanted.

FM_PROJECT_DASHBOARD_TEMPLATE overrides the shipped template in tests only.

# fleet state before the on-demand refresh (read-only contract)
$ find "$FM_HOME/state" -type f | sort | xargs shasum | shasum
81e227d0c3eac66b22f6c7a88f05248be0197c96  -

$ fm-project-dashboard.sh refresh --select firstmate
board: /var/folders/sk/xl7tlrjx6q14__gt0mvk89mc0000gn/T//fm-dash-evidence.FufDfo/.lavish/project-dashboard.html
lavish-axi (stub): would open review session for /var/folders/sk/xl7tlrjx6q14__gt0mvk89mc0000gn/T//fm-dash-evidence.FufDfo/.lavish/project-dashboard.html
served: /var/folders/sk/xl7tlrjx6q14__gt0mvk89mc0000gn/T//fm-dash-evidence.FufDfo/.lavish/project-dashboard.html

# fleet state after the refresh
81e227d0c3eac66b22f6c7a88f05248be0197c96  -
=> unchanged: the board never mutates fleet records

$ fm-project-dashboard-snapshot.sh --json --select firstmate | jq (per-project rollup)
stale_after_days=8  selected_project=firstmate
project                                         status           stale  age_d  next step
firstmate                                       needs_attention  false  0      Choose the board refresh route: On-demand refresh only, or a background poller?
herdr                                           active           false  1      Wiring the fan-out scheduler
lavish                                          waiting          false  2      Blocked on the vendor export release
catalog                                         idle_queued      false  3      Re-render the topology page
bearings                                        idle_queued      true   78     No work queued

# stale-risk overlay is strictly older than 8 days (bearings, last activity 2026-06-10)
now=2026-06-18T00:00:00Z  age=691200s  status=idle_queued  stale_risk=false
now=2026-06-18T00:00:01Z  age=691201s  status=idle_queued  stale_risk=true

# stale is an overlay, never applied to active or needs-attention projects
now is 400 days later:
  firstmate             status=needs_attention  stale_risk=false
  herdr                 status=active           stale_risk=false
  lavish                status=waiting          stale_risk=true
  catalog               status=idle_queued      stale_risk=true
  bearings              status=idle_queued      stale_risk=true

# an append-only status log is never read as current truth
$ cat >> "$FM_HOME/state/lavish-vendor.status"   # append a stale, contradicting entry
2026-08-27T05:00:00Z state=working detail="Actually shipping the export pipeline now"
lavish -> status=waiting  next_step="Blocked on the vendor export release"  waiting=["lavish-vendor"]
=> the card still reads the canonical snapshot state, not the log text
Evidence: The built read-only board itself (self-contained HTML with the embedded fm-project-dashboard.v1 payload) - opens standalone in a browser

Source: The built read-only board itself (self-contained HTML with the embedded fm-project-dashboard.v1 payload) - opens standalone in a browser

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fleet projects - Firstmate</title>
<style>
@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-050: #fbece3;
  --navy-700: #1a2238; --navy-500: #2a3656; --navy-100: #d9deea;
  --gold-600: #b5791c; --gold-500: #e0a52e; --gold-300: #f0d38c; --gold-100: #f8ecc9;
  --ocean-600: #2f6688; --ocean-500: #3c7ea6; --ocean-050: #e8f1f5;
  --sea-700: #234e3a; --sea-500: #2f6b4f; --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;
  --font-display: "Chango", "Cooper Black", Rockwell, Georgia, serif;
  --font-sans: "Jost", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
  --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='.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='.045'/%3E%3C/svg%3E");
}

* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body { color: var(--ink-700); background: var(--paper-100); background-image: var(--texture-paper); font: 1rem/1.5 var(--font-sans); }
button, a { font: inherit; }
.pd-nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--cream-line); background: rgba(246,236,211,.93); backdrop-filter: blur(10px); }
.pd-nav__inner { max-width: 1240px; height: 68px; margin: auto; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; gap: 18px; min-width: 0; }
.pd-brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.pd-brand__disc { width: 36px; height: 36px; display: grid; place-items: center; flex: none; color: var(--paper-000); background: var(--rust-500); border: 2px solid var(--ink-900); border-radius: 50%; box-shadow: var(--shadow-hard-sm); font-weight: 900; }
.pd-brand__name { color: var(--ink-900); font: 22px/1 var(--font-display); white-space: nowrap; }
.pd-generated { color: var(--ink-500); font: .7rem/1.3 var(--font-mono); text-align: right; overflow-wrap: anywhere; }
.pd-main { max-width: 1240px; margin: auto; padding: 28px 28px 72px; }
.pd-hero { display: flex; align-items: end; justify-content: space-between; gap: 22px; margin-bottom: 20px; }
.pd-eyebrow { margin: 0 0 5px; color: var(--rust-500); font: 800 .76rem/1 var(--font-sans); letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 0; color: var(--ink-900); font: 2rem/1.1 var(--font-display); }
.pd-hero p { max-width: 660px; margin: 8px 0 0; color: var(--ink-500); }
.pd-readonly { flex: none; padding: 7px 10px 6px; color: var(--navy-700); background: var(--gold-100); border: 1.5px solid var(--ink-900); border-radius: 7px; box-shadow: 2px 2px 0 var(--ink-900); font: 800 .68rem/1 var(--font-sans); letter-spacing: .08em; text-transform: uppercase; }
.pd-stats { display: grid; grid-template-columns: repeat(5, minmax(0,1fr)); gap: 10px; margin-bottom: 18px; }
.pd-stat { min-width: 0; padding: 12px 14px; background: var(--paper-000); border: 1px solid var(--paper-300); border-radius: 11px; }
.pd-stat strong { display: block; color: var(--ink-900); font: 700 1.25rem/1 var(--font-mono); }
.pd-stat span { display: block; margin-top: 5px; color: var(--ink-500); font: 700 .65rem/1.2 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
.pd-legend { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin-bottom: 20px; color: var(--ink-500); font-size: .79rem; }
.pd-legend__item { display: inline-flex; align-items: center; gap: 6px; }
.pd-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--ink-900); }
.pd-dot--active { background: var(--sea-500); } .pd-dot--attention { background: var(--rust-600); }
.pd-dot--waiting { background: var(--ocean-500); } .pd-dot--idle { background: var(--gold-500); }
.pd-grid { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 18px; align-items: start; }
.pd-card { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 220px; overflow: hidden; background: var(--white); border: 2px solid var(--ink-900); border-radius: 13px; box-shadow: var(--shadow-hard); transition: transform 140ms ease, box-shadow 140ms ease; }
.pd-card:hover { transform: translateY(-2px); }
.pd-card.is-selected { grid-column: 1 / -1; min-height: 360px; transform: none; }
.pd-card.is-stale { outline: 3px dashed var(--gold-600); outline-offset: 4px; }
.pd-card__accent { height: 8px; background: var(--gold-500); }
.pd-card--active .pd-card__accent { background: var(--sea-500); }
.pd-card--needs_attention .pd-card__accent { background: var(--rust-600); }
.pd-card--waiting .pd-card__accent { background: var(--ocean-500); }
.pd-card__button { width: 100%; flex: 1; padding: 17px 18px 15px; display: flex; flex-direction: column; gap: 12px; color: inherit; text-align: left; background: transparent; border: 0; cursor: pointer; }
.pd-card__button:focus-visible { outline: 4px solid var(--gold-300); outline-offset: -4px; }
.pd-card__top { width: 100%; display: flex; align-items: start; justify-content: space-between; gap: 12px; min-width: 0; }
.pd-card__name { min-width: 0; margin: 0; color: var(--ink-900); font-size: 1.28rem; line-height: 1.1; overflow-wrap: anywhere; }
.pd-badges { display: flex; flex-wrap: wrap; justify-content: end; gap: 7px; }
.pd-badge { display: inline-flex; align-items: center; padding: 5px 8px 4px; white-space: nowrap; color: var(--paper-000); background: var(--gold-600); border: 1.5px solid var(--ink-900); border-radius: 6px; box-shadow: 2px 2px 0 var(--ink-900); font: 800 .66rem/1 var(--font-sans); letter-spacing: .06em; text-transform: uppercase; }
.pd-badge--active { background: var(--sea-500); } .pd-badge--attention { background: var(--rust-600); }
.pd-badge--waiting { background: var(--ocean-500); } .pd-badge--idle { color: var(--navy-700); background: var(--gold-500); }
.pd-badge--stale { color: var(--navy-700); background: var(--gold-100); }
.pd-card__description { min-height: 42px; margin: 0; color: var(--ink-500); font-size: .86rem; }
.pd-next { margin-top: auto; padding-top: 11px; border-top: 1px solid var(--paper-300); }
.pd-next__label { display: block; margin-bottom: 4px; color: var(--ink-300); font: 700 .63rem/1 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; }
.pd-next__text { display: block; color: var(--ink-900); font-size: .93rem; font-weight: 700; overflow-wrap: anywhere; }
.pd-expand { color: var(--rust-600); font: 700 .69rem/1 var(--font-mono); }
.pd-details { display: none; padding: 0 18px 20px; }
.pd-card.is-selected .pd-details { display: block; }
.pd-detail-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 16px; padding: 12px 14px; background: var(--paper-000); border: 1px solid var(--paper-300); border-radius: 9px; color: var(--ink-500); font: .72rem/1.4 var(--font-mono); }
.pd-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 16px; }
.pd-panel { min-width: 0; padding: 14px; background: var(--paper-000); border: 1px solid var(--cream-line); border-radius: 10px; }
.pd-panel h3 { margin: 0 0 10px; color: var(--ink-900); font-size: .75rem; letter-spacing: .11em; text-transform: uppercase; }
.pd-list { margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }
.pd-item { min-width: 0; padding-bottom: 8px; border-bottom: 1px solid var(--paper-300); overflow-wrap: anywhere; }
.pd-item:last-child { padding-bottom: 0; border-bottom: 0; }
.pd-item strong { display: bloc

... [10558 bytes truncated] ...

ats"));
  }
  const statRows = [
    [data.summary.total, "Registered"], [data.summary.active, "Active"],
    [data.summary.needs_attention, "Attention"], [data.summary.waiting, "Waiting"],
    [data.summary.stale_risk, "Stale risk"]
  ];
  const stats = document.getElementById("stats");
  statRows.forEach(([value, label]) => {
    const box = node("div", "pd-stat"); box.append(node("strong", "", value), node("span", "", label)); stats.append(box);
  });

  const appendList = (panel, title, items, options = {}) => {
    const heading = node("h3", "", title); panel.append(heading);
    if (!items.length) { panel.append(node("div", "pd-empty", options.empty || "None")); return; }
    const list = node("ul", "pd-list");
    items.forEach(item => {
      const row = node("li", "pd-item");
      if (options.links && options.linkLabel) {
        row.append(node("strong", "", itemTitle(item)));
        if (item.linkable) {
          const link = node("a", "pd-link", options.linkLabel);
          link.href = item.url; link.target = "_blank"; link.rel = "noopener noreferrer";
          row.append(link);
        } else if (item.url) {
          row.append(node("small", "pd-unlinked", `${options.linkLabel}: ${item.url} · not an https link`));
        }
      } else if (options.links && item.linkable) {
        const link = node("a", "pd-link", itemTitle(item));
        link.href = item.url; link.target = "_blank"; link.rel = "noopener noreferrer"; row.append(link);
      } else if (options.links) {
        row.append(node("strong", "", itemTitle(item)), node("small", "pd-unlinked", `${item.url} · not an https link`));
      } else row.append(node("strong", "", itemTitle(item)));
      const note = itemNote(item);
      if (note) row.append(node("small", "", `${note}${item.owner ? ` · ${item.owner}` : ""}`));
      else if (item.owner) row.append(node("small", "", item.owner));
      list.append(row);
    });
    panel.append(list);
  };

  const hashSelection = () => {
    const raw = location.hash.slice(1);
    if (!raw) return null;
    try { return decodeURIComponent(raw) || null; } catch { return raw; }
  };
  const names = new Set(data.projects.map(project => project.name));
  const fromHash = hashSelection();
  const requestedSelection = (fromHash !== null && names.has(fromHash)) ? fromHash : data.selected_project;
  let selected = null;
  const setSelected = name => {
    selected = selected === name ? null : name;
    document.querySelectorAll(".pd-card").forEach(card => {
      const open = card.dataset.project === selected;
      card.classList.toggle("is-selected", open);
      card.querySelector(".pd-card__button").setAttribute("aria-expanded", String(open));
      card.querySelector(".pd-expand").textContent = open ? "Collapse project" : "Expand project";
    });
    history.replaceState(null, "", selected ? `#${encodeURIComponent(selected)}` : location.pathname + location.search);
  };

  data.projects.forEach(project => {
    const card = node("article", `pd-card pd-card--${project.status}${project.stale_risk ? " is-stale" : ""}`);
    card.dataset.project = project.name;
    card.append(node("div", "pd-card__accent"));
    const button = node("button", "pd-card__button"); button.type = "button"; button.setAttribute("aria-expanded", "false");
    const top = node("div", "pd-card__top"); top.append(node("h2", "pd-card__name", project.name));
    const badges = node("div", "pd-badges"); badges.append(node("span", `pd-badge pd-badge--${statusClass(project.status)}`, project.status_label));
    if (project.stale_risk) badges.append(node("span", "pd-badge pd-badge--stale", "Stale risk")); top.append(badges); button.append(top);
    button.append(node("p", "pd-card__description", project.description || "Registered fleet project"));
    const next = node("div", "pd-next"); next.append(node("span", "pd-next__label", "Next step"), node("span", "pd-next__text", project.next_step)); button.append(next);
    button.append(node("span", "pd-expand", "Expand project")); button.addEventListener("click", () => setSelected(project.name)); card.append(button);

    const details = node("div", "pd-details");
    const meta = node("div", "pd-detail-meta");
    meta.append(node("span", "", `Delivery: ${project.mode}${project.yolo ? " +yolo" : ""}`));
    meta.append(node("span", "", project.last_activity ? `Last activity: ${project.last_activity.age_days}d ago` : "Last activity: unknown"));
    if (project.secondmates.length) {
      const label = project.secondmates
        .map(mate => (mate.unavailable ? `${mate.id} (state unavailable)` : mate.id))
        .join(", ");
      const mates = node("span", "", `Secondmate: ${label}`);
      if (project.secondmates.some(mate => mate.unavailable)) mates.className = "pd-meta-warn";
      meta.append(mates);
    }
    if (project.deferred_decisions.length) meta.append(node("span", "", `Deferred captain holds: ${project.deferred_decisions.length}`));
    details.append(meta);
    const detailGrid = node("div", "pd-detail-grid");
    const work = node("section", "pd-panel"); appendList(work, "Active work", project.active_work, {empty:"No active work"}); detailGrid.append(work);
    const calls = node("section", "pd-panel"); appendList(calls, "Decisions / attention", [...project.decisions, ...project.failures, ...project.unreadable], {empty:"No decisions pending"}); detailGrid.append(calls);
    if (project.finished.length) {
      const finished = node("section", "pd-panel");
      appendList(finished, "Finished work",
        project.finished.map(item => ({...item, reason: item.detail || "Crew state reports done; no further detail"})), {});
      detailGrid.append(finished);
    }
    if (project.resolved_decisions.length) {
      const resolved = node("section", "pd-panel");
      const resolvedTitle = project.counts.resolved_decisions > project.resolved_decisions.length
        ? `Decisions recently resolved (${project.resolved_decisions.length} of ${project.counts.resolved_decisions})`
        : "Decisions recently resolved";
      appendList(resolved, resolvedTitle,
        project.resolved_decisions.map(item => ({...item, reason: item.summary || item.completed || "Resolved"})),
        {links:true, linkLabel:"Decision PR"});
      detailGrid.append(resolved);
    }
    if (project.deferred_decisions.length) {
      const deferred = node("section", "pd-panel");
      appendList(deferred, "Deferred / superseded holds", project.deferred_decisions.map(item => ({...item, reason: item.summary || "Marked deferred or superseded"})), {});
      detailGrid.append(deferred);
    }
    if (project.unattributed.length) {
      const unattributed = node("section", "pd-panel");
      appendList(unattributed, "Not attributable", project.unattributed.map(item => ({...item, reason: `${item.kind} · owner covers several projects`})), {});
      detailGrid.append(unattributed);
    }
    const waits = node("section", "pd-panel"); appendList(waits, "Waiting", project.waiting, {empty:"No external waits"}); detailGrid.append(waits);
    const queue = node("section", "pd-panel"); appendList(queue, "Queued next", project.queued, {empty:"Nothing queued"}); detailGrid.append(queue);
    const landed = node("section", "pd-panel");
    const landedTitle = project.counts.landed > project.landed.length
      ? `Recently landed (${project.landed.length} of ${project.counts.landed})`
      : "Recently landed";
    appendList(landed, landedTitle, project.landed, {empty:"No recent landed work"}); detailGrid.append(landed);
    const prs = node("section", "pd-panel");
    const prsTitle = project.counts.prs > project.prs.length
      ? `Pull requests (${project.prs.length} of ${project.counts.prs})`
      : "Pull requests";
    appendList(prs, prsTitle, project.prs, {empty:"No PR links",links:true}); detailGrid.append(prs);
    details.append(detailGrid); card.append(details); grid.append(card);
  });
  if (!data.projects.length) grid.append(node("div", "pd-none", "No projects are registered in data/projects.md."));
  if (requestedSelection && names.has(requestedSelection)) setSelected(requestedSelection);
})();
</script>
</body>
</html>

Pipeline

Updates from git push no-mistakes

... (79 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

🔧 **Review** - 8 issues found → auto-fixed (20) ✅

Reproduced by running the real script and rendering assets/project-dashboard-template.html in Chrome. Registry with alpha; one backlog row a-call state "queued", captain_actionable:true, hold_kind:&#34;captain&#34;, title "Choose the alpha release route before the freeze", hold_reason "Ship behind a flag or wait for the vendor SDK?"; plus one working task. The rendered alpha card shows, verbatim and identically:
Decisions / attention -> "Choose the alpha release route before the freeze" / "Ship behind a flag or wait for the vendor SDK? - main"
Queued next -> "Choose the alpha release route before the freeze" / "Ship behind a flag or wait for the vendor SDK? - main"
The reader sees one open question presented as two distinct pieces of work.

The same path also reaches next_step: line 574 uses concise($queued[0].title; &#34;Queued work&#34;) for an idle_queued project, so a project whose only queued row is a DEFERRED captain hold (deferred rows go to deferred_decisions[] and never set the status) gets a next step naming that deferred hold rather than real queued work.

This is product behavior, not a mechanical defect - the author may intend "Queued next" to mean the literal Queued section. But the deliberate captain_actionable != true filter one branch earlier, plus the unused flag on the queued item, says the distinction was meant to be made and was left unfinished. Either exclude non-deferred captain-actionable rows from $queued the way $waiting does, or drop the dead captain_actionable field and state that Queued next mirrors the raw Queued section.

  • ℹ️ bin/fm-project-dashboard-snapshot.sh:491 - prs[] and landed[] disagree about which Done rows count as landed work, though the script header (lines 36-37) calls them "the two surfaces fed by the append-only Done section".

$landed_all filters select(.state == &#34;done&#34; and .hold_kind != &#34;captain&#34;) (line 474). That exclusion is the established fleet convention - bin/fm-fleet-snapshot.sh:698 applies the identical .hold_kind != &#34;captain&#34; filter when building a secondmate home's landed list, because a Done row still carrying (hold-kind: captain) is a captain-hold transfer record, not shipped work. $prs_seen's backlog branch (line 491) filters only select(.pr_url != null), so such a row is admitted, and with .state == &#34;done&#34; it is ranked as completed work at line 494.

Reproduced by running the real script. Registry alpha; three done rows - cap (hold_kind "captain", completion 2026-08-25, pr_url .../pull/1), old (2026-01-01, .../pull/3), tear (2020-01-01, .../pull/2):
landed -> [old, tear] counts.landed -> 2
prs -> [cap, old, tear] counts.prs -> 3
The card renders "Recently landed" without cap while "Pull requests" lists it at the top (assets/project-dashboard-template.html:246-255), offering a link for a row the board itself says is not landed work - and the PR heading's total (3) contradicts the landed total (2) for the same source section.

Low likelihood in practice, since a captain-hold question row rarely carries a pr_url, but the fix is mechanical and matches the invariant already written elsewhere in this file: add and .hold_kind != &#34;captain&#34; to the backlog branch's select at line 491.

🔧 Fix: split captain decisions from queued and PR surfaces
3 issues (2 warnings, 1 info) still open:

  • ⚠️ bin/fm-project-dashboard-snapshot.sh:488 - The captain-held exclusion added in 0b000d3 was applied only to the backlog branch of $prs_seen (line 495). The task branch at lines 487-494 has no equivalent filter, so a Done row still held for the captain still reaches prs[] whenever its task carries pr.url - which is the normal case, since pr is read from state/&lt;id&gt;.meta or the status log (bin/fm-fleet-snapshot.sh:466-471) independently of the backlog row.

This contradicts the invariant the same commit wrote into this file's header (lines 39-40: "A Done row still held for the captain is a transfer record, not shipped work, so it is absent from both landed[] and prs[]") and into README.md:194.

Reproduced by running the real script. Registry alpha; backlog rows cap (state done, hold_kind "captain", pr_url .../pull/1, completion 2026-08-25) and old (state done, hold_kind null, pr_url .../pull/3, completion 2026-01-01), plus a task cap with pr.url = .../pull/1:
landed -> ["old"] counts.landed -> 1
prs -> ["cap","old"] counts.prs -> 2
The rendered card shows "Recently landed" without cap while "Pull requests" lists it first, and the two headings report contradictory totals for the same Done section - the exact symptom the fix round claimed to remove.

test_captain_held_done_row_is_neither_landed_nor_a_pr_link (tests/fm-project-dashboard.test.sh:1344) sets .tasks = [], so it passes with this branch unfixed; it does not prove the stated invariant.

The fix belongs at the one place that already knows whether the row is a captain transfer: $done_row is computed at line 489, so add | select($done_row == null or $done_row.hold_kind != &#34;captain&#34;) after it, and extend the test fixture to include the matching task.

  • ⚠️ bin/fm-project-dashboard-snapshot.sh:495 - select(.pr_url != null and .hold_kind != &#34;captain&#34;) applies the captain exclusion to every backlog row, not just Done rows. hold_kind is set on any row carrying (hold-kind: ...) metadata (bin/fm-fleet-snapshot.sh:365), and captain_actionable is only ever true for queued rows (bin/fm-fleet-snapshot.sh:422-425), so an in-flight row held for the captain has hold_kind == &#34;captain&#34; and now loses its PR link. The sibling $landed_all filter at line 478 is correctly scoped with .state == &#34;done&#34; and; this one is not, and the header invariant it implements (lines 39-40) speaks only of Done rows.

This is a regression introduced by the fix round. Reproduced by running the real script with registry alpha, tasks: [], and backlog rows a-hold (state in_flight, current_role "held", hold_kind "captain", hold_reason "Which rollout order?", pr_url .../pull/11) and a-old (state done, pr_url .../pull/3):
at 4913b25 (pre-fix): prs -> ["a-hold","a-old"], counts.prs -> 2
at 0b000d3 (current): prs -> ["a-old"], counts.prs -> 1
The card still lists a-hold under "Waiting", so the reader sees the held work but the open PR for it has silently vanished from the "Pull requests" panel, and the panel heading reports 1 of 1 rather than disclosing the drop.

This path is explicitly a designed scenario, not a corner case: the header at lines 27-29 states that a held in-flight backlog row reaches its card from the backlog alone, without live task metadata - so the task branch cannot be relied on to restore the link.

Fix: scope the predicate the way line 478 does - select(.pr_url != null and (.state != &#34;done&#34; or .hold_kind != &#34;captain&#34;)).

  • ℹ️ bin/fm-project-dashboard-snapshot.sh:468 - $waiting and $queued are not disjoint, so a blocked or externally-held row renders twice on the same card - once under "Waiting" and once under "Queued next" (assets/project-dashboard-template.html:244-245), with both counts.waiting and counts.queued charging it.

Main home: $waiting (lines 453-457) selects queued rows with unresolved_blocker_ids or a non-captain hold_reason; $queued (lines 468-470) selects every queued row except captain-actionable ones, so that subset lands in both. Reproduced with registry alpha and a single row a-block (state queued, hold_reason "Vendor SDK is not released", hold_kind "external", unresolved_blocker_ids ["vendor"]):
waiting -> ["a-block"] queued -> ["a-block"] counts.waiting 1, counts.queued 1

Secondmate side is systematic rather than partial: $holds_all is built as a subset of $queued_all (bin/fm-fleet-snapshot.sh:748-758 filtering bin/fm-fleet-snapshot.sh:684-689), so every row in a record's holds[] is also in its queued[], and the dashboard feeds holds[] into $waiting (lines 463-466) and queued[] into $queued. Reproduced with a delta-mate record whose holds and queued both carry d-hold: waiting -> ["d-hold"], queued -> ["d-hold"].

This is not the same defect as the captain-decision duplication just fixed, and it is arguably intended - a blocked row genuinely is both queued and waiting, unlike an open question. Flagging it because the fix round edited exactly this select and chose to exclude only captain rows, leaving the remaining overlap undecided. Either exclude $waiting ids from $queued so each row appears in one panel, or state in the header that "Queued next" mirrors the whole queue including rows already shown as waiting.

🔧 Fix: give each item one lifecycle place; scope PR exclusions
2 warnings still open:

  • ⚠️ bin/fm-project-dashboard-snapshot.sh:485 - A Done backlog row with hold_kind: &#34;captain&#34; is now excluded from every surface of its project card, is counted nowhere, and is disclosed nowhere - it simply disappears.

The exclusions are at line 485 ($landed_all), line 497 ($prs_seen task branch) and line 504 ($prs_seen backlog branch). The row cannot land anywhere else: $waiting (lines 453-470) only takes queued or in_flight rows, $queued (line 473) only takes queued, and $decisions (line 410) requires captain_actionable == true, which bin/fm-fleet-snapshot.sh:422-425 only ever sets for state == &#34;queued&#34;. The board-wide disclosure at lines 341-350 does not fire either, because the row's repo IS registered - it reaches a project, it just has no place on it.

Reproduced by running the real script. Registry alpha; one backlog row cap (state done, hold_kind "captain", hold_reason "handed to the captain", pr_url .../pull/1, completion 2026-08-25); no tasks, no secondmates:
status -> "idle_queued"
next_step -> "No work queued"
landed -> [], prs -> [], waiting -> [], queued -> [], decisions -> [], deferred_decisions -> [], finished -> []
counts -> all zero
disclosures -> []
The reader is shown an idle card with nothing queued, while a completed item awaiting captain handover and its open pull request exist in the backlog.

This contradicts the invariant this same commit wrote into the file header (lines 42-44, "Every item has exactly one lifecycle place") and README.md:194 ("Every item has exactly one place on the card"), and it contradicts the board's stated general contract at lines 64-66 and README.md:193 of disclosing rather than quietly shrinking a card.

test_captain_held_done_row_is_neither_landed_nor_a_pr_link (tests/fm-project-dashboard.test.sh:1344) only asserts absence from landed and prs; it never asserts the row surfaces anywhere, so it passes on a card that shows nothing at all.

Note the secondmate side already behaves this way (bin/fm-fleet-snapshot.sh:698 predates this branch), so the choice is at least consistent - but the main-home half is new in this change, and "transfer record, not shipped work" describes what the row is NOT, never where a reader is supposed to see it. Either give it a surface (its own transfer-record panel, or keep it in landed with a distinguishing note) or disclose the drop, rather than deleting it from the board.

  • ⚠️ bin/fm-project-dashboard-snapshot.sh:453 - The canonical captain-hold shape renders the same item twice on one card: once under "Decisions / attention" and once under "Waiting".

$waiting at lines 453-456 takes every main task whose current_state.state is parked or paused, with no captain-hold exclusion. The sibling backlog branch at lines 457-461 IS excluded with .captain_actionable != true, and the fix round additionally excluded waiting ids from $queued (lines 482-483) - but a captain-actionable queued row whose task parked itself awaiting the answer enters $waiting through the task branch, while the row itself enters $decisions at lines 409-412.

This is the project's own canonical model, not a contrived state: the base test fixture (tests/fm-project-dashboard.test.sh:52,58-59) defines alpha-call as exactly this - a queued row with captain_actionable: true plus a task in state parked carrying the matching needs-decision fold.

Reproduced by running the real script on that shape (registry alpha; row alpha-call queued/captain_actionable/hold_reason "Choose release route"; task alpha-call parked with detail "Captain choice"):
decisions -> [{id:"alpha-call", summary:"Choose release route"}]
waiting -> [{id:"alpha-call", reason:"Captain choice"}]
counts.decisions -> 1, counts.waiting -> 1
The card shows one open question in two panels with two different framings, and "Waiting" reports an external wait that is really the captain decision already listed above it.

This contradicts the header at lines 36-38 ("so one open question is never shown twice on a card") and README.md:194 ("Every item has exactly one place on the card"), and it is the same class of defect the round-16 instruction targeted ("Preserve exactly one truthful lifecycle representation per item") - the fix round closed only the waiting/queued overlap and left the decisions/waiting overlap open.

The boundary that already knows the answer is $decisions_all (line 435), computed before $waiting: filter $waiting by the same owner + &#34;�&#34; + id key set the queued exclusion already builds at line 472, so an item shown as a decision is not also shown as waiting. Extend test_captain_decision_is_not_also_queued_work to assert .waiting as well, since it currently only asserts .queued.

🔧 Fix: surface resolved decisions; keep decisions out of waiting
1 error still open:

  • 🚨 bin/fm-project-dashboard-snapshot.sh:442 - The new waiting/decision de-duplication key set is built from $decisions_all, which contains deferred decisions as well as live ones, so a deferred captain hold now deletes its own task's waiting row and makes the card read idle.

$decision_keys (line 442) is [ $decisions_all[] | (.owner + &#34;�&#34; + .id) ], and line 480 filters $waiting against it. $decisions_all is the union of live and deferred decisions - $decisions (line 443) and $deferred_decisions (line 444) split it only afterwards. A deferred row therefore contributes an exclusion key even though it is never rendered under decisions.

Reproduced by running the real script (registry alpha; one backlog row x: state queued, hold_kind: &#34;captain&#34;, hold_reason: &#34;DEFERRED - superseded by new plan&#34;, captain_actionable: true, deferred_marker: true, no blockers; task x in state parked with detail "Parked awaiting the captain"):

At 7ba797c (before this commit):
status -> "waiting", waiting -> ["x"], counts.waiting -> 1, next_step -> "Parked awaiting the captain"
At e1e1dd5 (HEAD):
status -> "idle_queued", waiting -> [], counts.waiting -> 0, next_step -> "No work queued"

The reader is shown an idle card claiming nothing is queued while a task is parked awaiting a captain answer. The item survives only in the advisory deferred_decisions[] panel ("Deferred / superseded holds"), which by design never affects status or next step.

This contradicts README.md:194 ("work that is blocked or externally held appears under waiting") and the file header at lines 45-47, which claims a decision that is not actionable "appears only under waiting[] until that date arrives" - the code does the opposite for the deferred_marker variant.

The date-deferred (hold_until in the future) path is unaffected, because bin/fm-fleet-snapshot.sh:422-425 sets captain_actionable=false there, so the row never enters $decisions_all. Only the text-marker deferral (SUPERSEDED / DEFERRED / NOT REQUIRED, bin/fm-fleet-snapshot.sh:426-428) reaches this bug.

test_deferred_captain_hold_is_disclosed_not_escalated (tests/fm-project-dashboard.test.sh:397) does not catch it: its fixture leaves alpha-work in state working, so the card is active and the lost waiting row is never asserted.

Earliest supported boundary: build the exclusion key set from $decisions (the live, rendered decisions) rather than $decisions_all, so only an item actually shown under Decisions is withheld from Waiting. That still satisfies the round-17 requirement (a live captain decision stays out of waiting) and the date-deferred test. Add a behavior test asserting that a deferred captain hold on a parked task keeps status == &#34;waiting&#34; and its waiting row.

🔧 Fix: exclude only live decisions from waiting surface
3 issues (1 warning, 2 infos) still open:

  • ⚠️ bin/fm-project-dashboard-snapshot.sh:493 - The round-18 fix moved a duplicate rather than removing it: an item shown under Decisions is now also shown under Queued next.

$queued (lines 483-493) is filtered only by $waiting_keys (line 482). Until 7ba797c that was enough, because a live decision's item still had a waiting[] row, so its key was in $waiting_keys and queued[] excluded it. Commit e1e1dd5/972a93a added $decision_keys (line 445) and used it at line 481 to drop that waiting row - which removed the very key $queued was relying on. $queued never learned about $decision_keys, so the item reappears under Queued next.

Reproduced against the real script, registry alpha, comparing 7ba797c to HEAD.

Shape A - queued row plus a task carrying an open needs-decision fold (row x: state queued, no hold, no blockers; task x: state parked, detail "Parked for the region call", hints.open_decisions [{key:"k1",verb:"needs-decision",summary:"Which region?"}]):
@7ba797c -> decisions:["x"] waiting:["x"] queued:[]
@Head -> decisions:["x"] waiting:[] queued:["x"]

Shape B - a date-deferred captain hold whose task still carries the matching open fold (row x: state queued, hold_kind captain, hold_reason "Revisit after the vendor ships", hold_until 2099-01-01, captain_actionable false; task x parked with that fold):
@7ba797c -> decisions:["x"] waiting:["x"] queued:[]
@Head -> decisions:["x"] waiting:[] queued:["x"]

In both, one open question is rendered twice on the same card: once in "Decisions / attention" and again in "Queued next", with counts.decisions == 1 and counts.queued == 1. That contradicts the invariant this branch wrote into the file header (lines 49-51, "Work already shown as waiting is not repeated under queued[]: each item has exactly one lifecycle place") and README.md:194 ("Every item has exactly one place on the card"), and it contradicts the round-17 user instruction "Preserve exactly one truthful surface per item".

The secondmate half has the same shape: a status-source decision on an owner whose record.queued carries the same id (captain_actionable false) is suppressed from waiting at line 481 and then admitted at line 493.

No existing test catches it. test_captain_decision_is_not_also_queued_work uses a captain_actionable row, which line 484 excludes from $queued directly; test_deferred_hold_leaves_its_parked_task_under_waiting (added in this round) asserts .waiting, .decisions, and .deferred_decisions but never .queued.

Earliest supported boundary: build one exclusion key set at line 482 - $waiting_keys + $decision_keys - and filter $queued at line 493 against it, so anything already shown as a decision or a wait cannot also be queued work. Extend the new deferred-hold test and test_captain_decision_is_not_also_queued_work to assert .queued == [] for shape A above.

  • ℹ️ bin/fm-project-dashboard-snapshot.sh:505 - A completed captain-held row's pull-request URL now reaches no surface on the board at all.

$resolved_all (lines 505-509) projects only {id,title,summary,completed,owner} - it drops pr_url. The same row is excluded from $landed_all (line 495), from the $prs_seen task branch (line 514) and from the $prs_seen backlog branch (line 521), and from $finished (because $landed_ids at line 385 includes captain-held done rows).

Verified by running the real script. Registry alpha; one backlog row cap (state done, hold_kind "captain", hold_reason "handed to the captain", pr_url https://github.com/e/a/pull/1, completion 2026-08-25); no tasks, no secondmates:
resolved_decisions -> ["cap"], landed -> [], prs -> [], finished -> []
counts.prs -> 0
The pull request exists and is readable in the backlog, but no panel on the card links it.

This is a consequence of the round-17 policy the captain approved ("clearly separated from landed work and PR history"), so it is not a contradiction of that instruction - separating the row from the PR panel was the point. But carrying the link on the resolved-decision row itself would satisfy that separation without discarding the only local pointer to the work, and neither README.md:195 nor the file header (lines 39-43) tells a reader the link disappears. Worth a captain call on whether resolved_decisions[] should carry url/linkable and render as a link inside its own panel.

  • ℹ️ bin/fm-project-dashboard-snapshot.sh:505 - The recently-resolved decisions panel is main-home only, so the board's stated contract is not true for secondmate-owned projects.

$resolved_all (lines 505-509) draws solely from $backlog[] (main-home rows). There is no secondmate branch, and there cannot usefully be one: bin/fm-fleet-snapshot.sh:698 builds each home's landed with select(.state == &#34;done&#34; and .structured and .hold_kind != &#34;captain&#34;), so a secondmate's completed captain-held row never leaves its home summary. It is therefore in none of owner_items() (line 232: active_children, decisions_open, holds, queued, landed), which means the stranded-state disclosure at lines 335-347 cannot see it either.

Net effect for a project whose work is owned by a secondmate: a completed captain decision is absent from decisions, resolved_decisions, landed, prs, waiting, queued, unattributed, and disclosures - it is simply not on the board and nothing says so.

README.md:195 and the file header (lines 39-42) state the rule unconditionally: "A completed row still held for the captain is a resolved decision, listed in a bounded recently-resolved panel under decisions". That holds only for main-home rows.

The filter in fm-fleet-snapshot.sh:698 predates this branch (this change only added repo: to that projection), so this is a pre-existing upstream limitation that the new resolved-decisions surface exposes rather than a defect introduced here. Fixing it means either surfacing captain-held done rows in the home summary or narrowing the README/header claim to main-home work - a scope call for the author.

🔧 Fix: keep live decisions out of queued; disclose resolved scope
3 issues (1 warning, 2 infos) still open:

  • ⚠️ assets/project-dashboard-template.html:252 - The main-home-only disclosure is nested inside the resolved-decisions panel, so it is absent exactly when the omission is invisible.

assets/project-dashboard-template.html:244 gates the whole Decisions recently resolved section on project.resolved_decisions.length, and the new pd-scope-note at lines 252-255 lives inside that gate. A secondmate-owned project with zero main-home captain-held done rows therefore renders no panel and no note.

Verified against the real script and the real template. Registry alpha + delta; one main task alpha-work; one secondmate_current record delta-mate with projects:[&#34;delta&#34;], current.state:&#34;active_child_work&#34;, and one active_children row for repo delta; no captain-held done backlog rows. Snapshot -> delta: secondmates:[&#34;delta-mate&#34;], resolved_decisions:[], counts.resolved_decisions:0. Board render via the test's node driver -> delta panels are [&#34;Active work&#34;,&#34;Decisions / attention&#34;,&#34;Waiting&#34;,&#34;Queued next&#34;,&#34;Recently landed&#34;,&#34;Pull requests&#34;] and every panel's scopeNotes is [].

This contradicts README.md:196, added in this same commit: "this board does not widen that contract, and a project owned by a secondmate says so on the card." It does not say so on the card in this shape. The same sentence is repeated in the file header at bin/fm-project-dashboard-snapshot.sh:45-47.

The silent case is also the higher-risk one: with no panel at all, the card reads as "this project has no recently resolved decisions", which is a false statement when the secondmate home holds some. With the panel present, the reader at least sees a bounded list plus the caveat.

Earliest supported boundary: render the scope note for every project with project.secondmates.length, independent of resolved_decisions.length - either by hoisting it to pd-detail-meta alongside the existing Secondmate: ... span (line 225-232), or by emitting a Decisions recently resolved panel with the pd-empty state plus the note when a secondmate is present. Placement is a product call, hence ask-user. Extend test_board_renders_every_card_and_its_disclosures (tests/fm-project-dashboard.test.sh:1736-1741) to assert the note on a secondmate-owned project that has no main-home resolved decision - today that assertion only passes because the test explicitly injects the delta-call row at line 1709-1712.

  • ℹ️ bin/fm-project-dashboard.sh:18 - fm-project-dashboard.sh --help does not carry the main-home-only resolved-decisions caveat, though it is the command README.md:189 tells the captain to run.

usage() (lines 37-43) prints the header comment block, lines 2-21. That block covers the read-only contract and the refresh/build/path commands but says nothing about resolved decisions or their main-home-only scope. The caveat is present in the snapshot script's header (bin/fm-project-dashboard-snapshot.sh:45-47), which its own usage() does print - but a captain reading bin/fm-project-dashboard.sh --help never sees it.

This is a documentation-surface gap, not a behavior defect: nothing computes a wrong value. One sentence in the header block between lines 16-19 would close it, mirroring the README.md:196 wording.

  • ℹ️ tests/fm-project-dashboard.test.sh:1541 - The secondmate half of the round-19 queued-duplication fix is unexercised, so a regression there would ship silently.

test_a_live_decision_is_never_queued_work builds both shapes purely from .backlog.records and .tasks with .secondmate_current.records = [], so it only covers the main-home branch of the $placed_keys filter (bin/fm-project-dashboard-snapshot.sh:496-497 applied to the backlog-sourced rows at lines 487-489). The owner-sourced branch at lines 490-494 - $owners[] | $owner.record.queued[] filtered against a $decision_keys entry that came from record.decisions_open[source==&#34;status&#34;] - has no assertion anywhere in the file.

I verified by hand that the branch is correct today: owner delta-mate with projects:[&#34;delta&#34;], decisions_open:[{id:&#34;d1&#34;,key:&#34;k1&#34;,verb:&#34;needs-decision&#34;,summary:&#34;Which region?&#34;,repo:&#34;delta&#34;,source:&#34;status&#34;}], and queued:[{id:&#34;d1&#34;,...},{id:&#34;d2&#34;,...}] yields decisions:[&#34;d1&#34;], waiting:[], queued:[&#34;d2&#34;]. That exact fixture, asserted on the snapshot payload, is the missing test - it fails on the pre-fix code (queued would be [&#34;d1&#34;,&#34;d2&#34;]) and passes now.

🔧 Fix: disclose resolved scope once globally; cover mate dedup
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash bin/fm-test-run.sh tests/fm-project-dashboard.test.sh - 51 behavior tests covering aggregation, status precedence, 8-day stale threshold, secondmate attribution/bounded state, selected-project payload, and board rendering (exit 0)
  • bash bin/fm-test-run.sh tests/fm-fleet-snapshot-view.test.sh tests/fm-bearings-snapshot.test.sh tests/fm-captain-hold-lifecycle.test.sh - suites exercising the modified bin/fm-fleet-snapshot.sh secondmate home summary (exit 0)
  • Manual E2E: built a realistic 5-project registry fixture (firstmate/herdr/lavish/catalog/bearings) with a secondmate-owned project, captain decision, resolved captain-held row with PR link, blocked row, queued row, and a long-idle project
  • bin/fm-project-dashboard.sh --help - documented on-demand refresh command surface including the v1 main-home-only resolved-decisions caveat
  • bin/fm-project-dashboard.sh refresh --select firstmate - real user path (snapshot -> validate -> publish -> Lavish serve), with find $FM_HOME/state -type f | xargs shasum | shasum compared before and after to prove no fleet state mutation
  • bin/fm-project-dashboard-snapshot.sh --json --select firstmate - per-project rollup table showing needs_attention > active > waiting > idle_queued precedence and each project's concise next step
  • Stale threshold boundary: snapshot run at exactly 8 days (stale_risk=false) and 8 days + 1 second (stale_risk=true) of last known activity
  • Stale-as-overlay check: snapshot run 400 days later showing active and needs_attention projects keep stale_risk=false
  • Append-only status-log check: appended a contradicting state=working entry to a task status log and re-ran the snapshot; the lavish card still reads status=waiting from canonical snapshot state
  • Browser verification via chrome-devtools-axi open file://.../project-dashboard.html - clicked a card's Expand control and loaded #bearings / #herdr fragments to confirm selection expands the project larger than the rest and exposes its detail panels
  • Disclosure variant: fixture with snapshot bounds, an unreadable remote secondmate, and a row naming an unregistered project, rendered to confirm the board-wide incomplete banner and (state unavailable) secondmate attribution
⚠️ **Document** - 1 info
  • ℹ️ README.md:187 - Judgment call, already applied: the change's README section restated the fm-project-dashboard-snapshot.sh header contract in full (per-panel five-row bounds, repo-label fallback, unattributable per-kind escalation, hold/fold pairing, finished-work verbatim rule). The repo's knowledge-placement tree routes exact mechanics to the script header plus --help, and the one-owner rule forbids a second full copy, so I trimmed README to user-facing behavior and left a pointer to bin/fm-project-dashboard-snapshot.sh --help. Every disclosure the recorded round-19/20 decisions required of README is preserved: the read-only contract, the one-place-per-item invariant, and the v1 main-home-only recently-resolved caveat. If the captain deliberately wants the full aggregation contract mirrored in README despite the drift risk, that trim is the thing to revert.
🔧 **Lint** - 1 issue found → auto-fixed (2) ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: install pinned ShellCheck/actionlint; lint passes with no code changes
1 warning still open:

  • ⚠️ linter found issues (exit code 1)

🔧 Fix: install pinned ShellCheck and actionlint on executor PATH; lint passes
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

Rene Garza Jr. added 23 commits August 26, 2026 20:15
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