feat(bin): add read-only fleet project dashboard - #12
Open
rega10 wants to merge 23 commits into
Open
Conversation
added 23 commits
August 26, 2026 20:15
…bounded-read disclosure
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
bin/fm-project-dashboard-snapshot.sh, which aggregates the canonicalfm-fleet-snapshot.shoutput intofm-project-dashboard.v1: one row per project registered indata/projects.md, withneeds_attention>active>waiting>idle_queuedprecedence, a stale-risk overlay applied only when the latest known activity is strictly older thanFM_PROJECT_DASHBOARD_STALE_DAYS(default 8) and the project is neither active nor needing attention, single-place lifecycle routing acrossdecisions/waiting/queued/landed/prs, and board-widedisclosures[]for state it cannot safely attribute or a bounded read dropped.bin/fm-project-dashboard.sh(refresh/build/path) plusassets/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.bin/fm-fleet-snapshot.shto carry per-recordprojects(registry clone list unioned with parent task metadata),repo/sincefields on holds, decisions, queued, active, and landed rows, and aholdsbound disclosure; addedtests/fm-project-dashboard.test.sh(aggregation, precedence, stale threshold, secondmate attribution and bounded state, selected-project payload and board behavior), wired the new files intobin/fm-test-run.sh'ssnapshot-bearingsfamily, and documented the board inREADME.md,docs/scripts.md,docs/architecture.md, anddocs/captain-hold-lifecycle.md.Risk Assessment
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"]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
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-callstate "queued",captain_actionable:true,hold_kind:"captain", 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 usesconcise($queued[0].title; "Queued work")for an idle_queued project, so a project whose only queued row is a DEFERRED captain hold (deferred rows go todeferred_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 != truefilter 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$queuedthe way$waitingdoes, or drop the deadcaptain_actionablefield and state that Queued next mirrors the raw Queued section.bin/fm-project-dashboard-snapshot.sh:491-prs[]andlanded[]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_allfiltersselect(.state == "done" and .hold_kind != "captain")(line 474). That exclusion is the established fleet convention - bin/fm-fleet-snapshot.sh:698 applies the identical.hold_kind != "captain"filter when building a secondmate home'slandedlist, 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 onlyselect(.pr_url != null), so such a row is admitted, and with.state == "done"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
capwhile "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 != "captain"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 reachesprs[]whenever its task carriespr.url- which is the normal case, sincepris read fromstate/<id>.metaor 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) andold(state done, hold_kind null, pr_url .../pull/3, completion 2026-01-01), plus a taskcapwithpr.url= .../pull/1:landed -> ["old"] counts.landed -> 1
prs -> ["cap","old"] counts.prs -> 2
The rendered card shows "Recently landed" without
capwhile "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_rowis computed at line 489, so add| select($done_row == null or $done_row.hold_kind != "captain")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 != "captain")applies the captain exclusion to every backlog row, not just Done rows.hold_kindis set on any row carrying(hold-kind: ...)metadata (bin/fm-fleet-snapshot.sh:365), andcaptain_actionableis only ever true for queued rows (bin/fm-fleet-snapshot.sh:422-425), so an in-flight row held for the captain hashold_kind == "captain"and now loses its PR link. The sibling$landed_allfilter at line 478 is correctly scoped with.state == "done" 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 rowsa-hold(state in_flight, current_role "held", hold_kind "captain", hold_reason "Which rollout order?", pr_url .../pull/11) anda-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-holdunder "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 != "done" or .hold_kind != "captain")).bin/fm-project-dashboard-snapshot.sh:468-$waitingand$queuedare 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 bothcounts.waitingandcounts.queuedcharging it.Main home:
$waiting(lines 453-457) selects queued rows withunresolved_blocker_idsor a non-captainhold_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 rowa-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_allis 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'sholds[]is also in itsqueued[], and the dashboard feedsholds[]into$waiting(lines 463-466) andqueued[]into$queued. Reproduced with adelta-materecord whoseholdsandqueuedboth carryd-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
$waitingids from$queuedso 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 withhold_kind: "captain"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_seentask branch) and line 504 ($prs_seenbacklog branch). The row cannot land anywhere else:$waiting(lines 453-470) only takesqueuedorin_flightrows,$queued(line 473) only takesqueued, and$decisions(line 410) requirescaptain_actionable == true, which bin/fm-fleet-snapshot.sh:422-425 only ever sets forstate == "queued". 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 rowcap(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 fromlandedandprs; 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".$waitingat lines 453-456 takes every main task whosecurrent_state.stateisparkedorpaused, 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$waitingthrough the task branch, while the row itself enters$decisionsat 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-callas exactly this - a queued row withcaptain_actionable: trueplus a task in stateparkedcarrying the matchingneeds-decisionfold.Reproduced by running the real script on that shape (registry alpha; row
alpha-callqueued/captain_actionable/hold_reason "Choose release route"; taskalpha-callparked 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$waitingby the sameowner + "�" + idkey set the queued exclusion already builds at line 472, so an item shown as a decision is not also shown as waiting. Extendtest_captain_decision_is_not_also_queued_workto assert.waitingas 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 + "�" + .id) ], and line 480 filters$waitingagainst it.$decisions_allis 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 underdecisions.Reproduced by running the real script (registry
alpha; one backlog rowx: statequeued,hold_kind: "captain",hold_reason: "DEFERRED - superseded by new plan",captain_actionable: true,deferred_marker: true, no blockers; taskxin stateparkedwith 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_markervariant.The date-deferred (
hold_untilin the future) path is unaffected, because bin/fm-fleet-snapshot.sh:422-425 setscaptain_actionable=falsethere, 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 leavesalpha-workin stateworking, so the card isactiveand the lostwaitingrow 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 keepsstatus == "waiting"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 awaiting[]row, so its key was in$waiting_keysandqueued[]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$queuedwas relying on.$queuednever 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; taskx: 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; taskxparked 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.queuedcarries 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_workuses a captain_actionable row, which line 484 excludes from$queueddirectly;test_deferred_hold_leaves_its_parked_task_under_waiting(added in this round) asserts.waiting,.decisions, and.deferred_decisionsbut never.queued.Earliest supported boundary: build one exclusion key set at line 482 -
$waiting_keys + $decision_keys- and filter$queuedat 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 andtest_captain_decision_is_not_also_queued_workto 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 dropspr_url. The same row is excluded from$landed_all(line 495), from the$prs_seentask branch (line 514) and from the$prs_seenbacklog branch (line 521), and from$finished(because$landed_idsat line 385 includes captain-held done rows).Verified by running the real script. Registry
alpha; one backlog rowcap(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 carryurl/linkableand 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'slandedwithselect(.state == "done" and .structured and .hold_kind != "captain"), so a secondmate's completed captain-held row never leaves its home summary. It is therefore in none ofowner_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:244gates the wholeDecisions recently resolvedsection onproject.resolved_decisions.length, and the newpd-scope-noteat 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 taskalpha-work; onesecondmate_currentrecorddelta-matewithprojects:["delta"],current.state:"active_child_work", and oneactive_childrenrow for repodelta; no captain-held done backlog rows. Snapshot ->delta:secondmates:["delta-mate"],resolved_decisions:[],counts.resolved_decisions:0. Board render via the test's node driver ->deltapanels are["Active work","Decisions / attention","Waiting","Queued next","Recently landed","Pull requests"]and every panel'sscopeNotesis[].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 ofresolved_decisions.length- either by hoisting it topd-detail-metaalongside the existingSecondmate: ...span (line 225-232), or by emitting aDecisions recently resolvedpanel with thepd-emptystate plus the note when a secondmate is present. Placement is a product call, hence ask-user. Extendtest_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 thedelta-callrow at line 1709-1712.bin/fm-project-dashboard.sh:18-fm-project-dashboard.sh --helpdoes 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 ownusage()does print - but a captain readingbin/fm-project-dashboard.sh --helpnever 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_workbuilds both shapes purely from.backlog.recordsand.taskswith.secondmate_current.records = [], so it only covers the main-home branch of the$placed_keysfilter (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_keysentry that came fromrecord.decisions_open[source=="status"]- has no assertion anywhere in the file.I verified by hand that the branch is correct today: owner
delta-matewithprojects:["delta"],decisions_open:[{id:"d1",key:"k1",verb:"needs-decision",summary:"Which region?",repo:"delta",source:"status"}], andqueued:[{id:"d1",...},{id:"d2",...}]yieldsdecisions:["d1"], waiting:[], queued:["d2"]. That exact fixture, asserted on the snapshot payload, is the missing test - it fails on the pre-fix code (queuedwould be["d1","d2"]) 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 modifiedbin/fm-fleet-snapshot.shsecondmate 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 projectbin/fm-project-dashboard.sh --help- documented on-demand refresh command surface including the v1 main-home-only resolved-decisions caveatbin/fm-project-dashboard.sh refresh --select firstmate- real user path (snapshot -> validate -> publish -> Lavish serve), withfind $FM_HOME/state -type f | xargs shasum | shasumcompared before and after to prove no fleet state mutationbin/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 stepStale threshold boundary: snapshot run at exactly 8 days (stale_risk=false) and 8 days + 1 second (stale_risk=true) of last known activityStale-as-overlay check: snapshot run 400 days later showing active and needs_attention projects keepstale_risk=falseAppend-only status-log check: appended a contradictingstate=workingentry to a task status log and re-ran the snapshot; the lavish card still readsstatus=waitingfrom canonical snapshot stateBrowser verification viachrome-devtools-axi open file://.../project-dashboard.html- clicked a card's Expand control and loaded#bearings/#herdrfragments to confirm selection expands the project larger than the rest and exposes its detail panelsDisclosure 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 attributionREADME.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 tobin/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) ✅
🔧 Fix: install pinned ShellCheck/actionlint; lint passes with no code changes
1 warning still open:
🔧 Fix: install pinned ShellCheck and actionlint on executor PATH; lint passes
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.