Skip to content

fix(snapshot): read captain-actionability off the hold kind, not the record kind - #83

Merged
Freudator86 merged 4 commits into
mainfrom
fm/firstmate-captain-hold-invisible-to-decision-surface-r2
Aug 9, 2026
Merged

fix(snapshot): read captain-actionability off the hold kind, not the record kind#83
Freudator86 merged 4 commits into
mainfrom
fm/firstmate-captain-hold-invisible-to-decision-surface-r2

Conversation

@Freudator86

Copy link
Copy Markdown
Owner

Intent

A captain-gated decision must reach the captain even when it is filed on a record whose own kind is not captain.

THE DEFECT. AGENTS.md section 10 tells firstmate to file a captain-gated thread with tasks-axi hold <id> --reason "<reason>" --kind captain and says nothing about the record's own kind. But the captain-actionable predicate in bin/fm-fleet-snapshot.sh also demanded .kind == "captain", so a hold filed exactly as the instructions describe was invisible to /decisionboard, to bin/fm-decision-inventory.sh, and to every bearings read.

MEASURED, NOT ARGUED. On the live home 2026-08-09: 35 records carried hold-kind: captain and the surface returned 32. The three missing were a production deploy filed as kind ship, a thread carrying no record kind at all, and one record blocked by an unresolved blocker. After the change the surface returns 34.

THE DECISION AND WHY IT WENT THIS WAY. The captain's standing preference, recorded 2026-07-31, is to widen the mechanism so the instruction becomes true rather than narrow the instruction to match the mechanism. It was a genuine choice, not an order: the two fields do mean different things, but not in a way that justifies narrowing. kind says what the work IS (ship, scout, fog); hold_kind says WHO is being asked. Captain-actionability is a property of the hold, so the predicate now reads hold_kind alone. tasks-axi hold --kind is a closed vocabulary (captain, external, load, parked, future) enforced by tasks-axi, so widening admits only records someone deliberately held for the captain - it is not an open door.

DELIBERATELY OUT OF SCOPE, AND PINNED SO IT CANNOT MOVE. A record blocked by another record also leaves decisions_open entirely with no footnote. That is a separate, already-filed gap and the brief explicitly forbade folding it in. The change does not touch it, and the new test asserts the blocked record STILL stays out of decisions_open and STILL lands in gates - so this change cannot quietly alter that behaviour later.

ONE REAL CONSEQUENCE, WRITTEN DOWN RATHER THAN LEFT IMPLICIT. bin/fm-chart-kinds-lib.sh claimed fog and out-of-course were safe by construction BECAUSE the predicate required kind: captain. That guarantee now rests on the hold kind instead: both are filed with hold --kind future, so both still read captain_actionable false. But a fog record misfiled with a captain hold would now surface as a captain decision. That is the recoverable direction on purpose - a misfiled question the captain can see is recoverable, one he cannot is not - and the header now says so.

CROSS-REFERENCES CORRECTED (the repo's one-owner rule requires it). Six places claimed the record kind was what kept a record off the captain's surface and are now corrected to name the hold kind: AGENTS.md section 10, bin/fm-chart-kinds-lib.sh, bin/fm-sea-chart.sh (three comment blocks), bin/fm-to-backlog.sh, .agents/skills/sea-chart/SKILL.md, and docs/to-backlog-provenance.md. bin/fm-to-backlog.sh never calls tasks-axi hold at all, so its kind refusal was never what protected that surface; its header said otherwise and now says the truth. bin/fm-sea-chart.sh's own reconciliation baseline stays kind-based on purpose and is now documented as deliberately narrower than the predicate - it is not an oversight. A stale comment in tests/fm-sea-chart.test.sh is corrected for the same reason.

COVERAGE. The brief required a test that asserts the SURFACE REPORTS the hold, not that a jq expression parses, and explicitly said a test that would have passed before the fix is not coverage. test_captain_hold_on_a_non_captain_kind_record_surfaces in tests/fm-bearings-snapshot.test.sh drives bearings end-to-end and asserts decisions_open. It was verified failing against the old predicate and passing against the new one by stashing the fix and re-running.

VERIFIED ON THIS EXACT BASE. bin/fm-lint.sh green. Eight affected suites green: fm-bearings-snapshot, fm-fleet-snapshot-view, fm-sea-chart, fm-backlog-lint, fm-instruction-owners, fm-to-backlog, fm-decision-inventory, fm-captain-translation-contract.

BRANCH SHAPE AND NAME, BOTH DELIBERATE. Work was done in a disposable worktree of the admiralty fleet repository, where bin/fm-fleet-snapshot.sh and AGENTS.md are vendored paths that cannot change there. An earlier run correctly flagged that the branch was based on admiralty main, 113 commits ahead of the fork main this pull request targets, which would have bundled 52 unrelated files into the pull request. The captain authorised rebasing onto fork/main, which was done: the branch is now exactly one commit on top of fork/main touching nine files, all byte-identical between the two bases, so the replay dropped nothing. The branch then carries an -r2 suffix only because the local gate still held the pre-rebase tip and rejected the rewritten history; a fresh name was taken rather than force-replacing anything. No pin bump is proposed or performed - that is a separate act by whoever curates the fleet repository.

What Changed

  • bin/fm-fleet-snapshot.sh now derives captain_actionable from hold_kind == "captain" alone instead of also requiring kind == "captain", so a thread held exactly as AGENTS.md section 10 instructs (tasks-axi hold <id> --reason "…" --kind captain) reaches /decisionboard, bin/fm-decision-inventory.sh, and every bearings read regardless of the record's own kind. Records with unresolved blockers stay withheld — unchanged and deliberately out of scope.
  • tests/fm-bearings-snapshot.test.sh gains test_captain_hold_on_a_non_captain_kind_record_surfaces, driving bearings end-to-end to assert the hold appears in decisions_open while a blocked record still stays out of it and lands in gates; the Test phase bisect-proved it fails against the base predicate.
  • Corrected the comments and docs that credited the record kind for keeping work off the captain's surface — AGENTS.md, bin/fm-chart-kinds-lib.sh (fog/out-of-course safety now rests on hold --kind future), bin/fm-sea-chart.sh (its reconciliation baseline stays kind-based and is documented as deliberately narrower), bin/fm-to-backlog.sh, docs/to-backlog-provenance.md, the decisionboard/sea-chart/to-backlog skills, and a stale comment in tests/fm-sea-chart.test.sh.

Risk Assessment

✅ Low: The follow-up commit changes only comments and one Markdown line - zero executable lines in bin/ - leaves the deliberately narrow reconciliation baseline at bin/fm-sea-chart.sh:514 untouched as the brief requires, and both reworded claims were verified against the code they describe.

Testing

I ran the bearings snapshot suite plus the seven other suites the change touches, all green, and proved the new test is real coverage by swapping in the base-commit fm-fleet-snapshot.sh and watching it fail, then restoring a clean worktree. For product-level evidence I built a demo home whose backlog files captain holds exactly as AGENTS.md section 10 describes and ran the actual fm-bearings-snapshot.sh and fm-decision-inventory.sh CLIs on both predicates: before the fix the surface returned one decision and pushed the ship-kind production deploy and the kindless fleet notice into gates, after the fix it returns all three while the blocked record still stays out of decisions_open and still lands in gates, and the fog record held --kind future stays non-actionable in both. I also rendered the captain-facing decision board through the repo's own bin/fm-board.sh and screenshotted the before/after side by side. No failures, no flakiness, and no transient files left in the working tree.

  • Evidence: Decision board screenshot — before/after the predicate change (local file: /tmp/no-mistakes-evidence/01KZJGA6X57S5QEHX409R91M5S/decision-surface-before-after.png)
Evidence: Rendered board HTML (built with bin/fm-board.sh, self-contained, no network)
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Captain-gated holds reaching the decision surface</title>
<style>
/* layout.css - the single owner of Firstmate board styling.
 *
 * bin/fm-board.sh inlines this file into every board it builds, so a board
 * carries its own styling and makes ZERO network requests. Nothing here may
 * reference a remote font, image, or stylesheet: @import and url(http...) are
 * refused by the builder, which is what keeps the no-network guarantee true
 * rather than merely intended.
 *
 * Class names are prefixed fm- so a board body can add its own classes without
 * colliding with the layout contract. docs/board-layout.md lists the components
 * and the markup each one expects.
 */

:root {
  --fm-bg: #f6f7f9;
  --fm-panel: #ffffff;
  --fm-panel-2: #f0f2f5;
  --fm-ink: #12151a;
  --fm-dim: #5b6470;
  --fm-line: #dde1e7;
  --fm-accent: #1f6feb;
  --fm-accent-soft: #e8f0fe;
  --fm-hot: #b23c08;
  --fm-hot-soft: #fff1e7;
  --fm-calm: #0f766e;
  --fm-calm-soft: #e6f4f1;
  --fm-gate: #6d28d9;
  --fm-gate-soft: #f1eaff;
  --fm-shadow: 0 1px 2px rgba(16, 20, 26, .06), 0 4px 14px rgba(16, 20, 26, .05);
  --fm-r: 12px;
  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  :root {
    --fm-bg: #0e1116;
    --fm-panel: #161b22;
    --fm-panel-2: #1c232c;
    --fm-ink: #e6edf3;
    --fm-dim: #9aa4b2;
    --fm-line: #2a313b;
    --fm-accent: #58a6ff;
    --fm-accent-soft: #122238;
    --fm-hot: #ff9057;
    --fm-hot-soft: #2c1810;
    --fm-calm: #3fd0bd;
    --fm-calm-soft: #0e2724;
    --fm-gate: #b48cff;
    --fm-gate-soft: #221a35;
    --fm-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 4px 14px rgba(0, 0, 0, .3);
  }
}

/* min-width:0 on every element is what stops a long unbreakable token from
 * pushing a grid or flex child wider than its track. Nested grids inherit it,
 * which is the failure the Lavish guidance calls out specifically. */
* {
  box-sizing: border-box;
  min-width: 0;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--fm-bg);
  color: var(--fm-ink);
  font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  padding: clamp(14px, 2.5vw, 32px);
  /* Body-level horizontal scrolling is prevented by SIZING - min-width:0
   * everywhere, minmax(0,1fr) tracks, and .fm-scroll around anything genuinely
   * wide - deliberately NOT by overflow-x:hidden here. Hiding it would clip
   * whatever still overflowed, turning a visible layout bug into content the
   * reader cannot reach at all. A stray scrollbar is a nuisance that gets
   * reported; silently cut-off content is not. */
}

code, kbd, samp, pre, .fm-mono {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}

code {
  background: var(--fm-panel-2);
  border-radius: 5px;
  padding: 1px 5px;
  font-size: .92em;
  /* A long identifier must wrap rather than widen its column. */
  overflow-wrap: anywhere;
}

.fm-wrap {
  max-width: 1400px;
  margin: 0 auto;
}

h1 {
  font-size: clamp(20px, 2.6vw, 30px);
  margin: 0 0 4px;
  letter-spacing: -.02em;
  line-height: 1.2;
}

h2 {
  font-size: 15px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--fm-dim);
  margin: 34px 0 12px;
  font-weight: 600;
}

h3 {
  font-size: 15px;
  margin: 0;
  letter-spacing: -.01em;
}

p {
  margin: 0;
}

.fm-sub {
  color: var(--fm-dim);
  margin: 0 0 22px;
  font-size: 14px;
}

.fm-note {
  color: var(--fm-dim);
  font-size: 13px;
}

/* ---- scroll container -------------------------------------------------
 * The only sanctioned way to carry content wider than the viewport: a table,
 * a diagram, a code block. Put it around the wide element, never on body. */
.fm-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  max-width: 100%;
}

/* ---- stat strip ---- */
.fm-stats {
  display: grid;
  gap: 10px;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  margin-bottom: 8px;
}

.fm-stat {
  background: var(--fm-panel);
  border: 1px solid var(--fm-line);
  border-radius: var(--fm-r);
  padding: 12px 14px;
  box-shadow: var(--fm-shadow);
}

.fm-stat b {
  display: block;
  font-size: 26px;
  line-height: 1.1;
  letter-spacing: -.02em;
}

.fm-stat span {
  display: block;
  color: var(--fm-dim);
  font-size: 12.5px;
  margin-top: 3px;
}

.fm-stat.is-hot b { color: var(--fm-hot); }
.fm-stat.is-gate b { color: var(--fm-gate); }
.fm-stat.is-calm b { color: var(--fm-calm); }

/* ---- graphic: gate / dependency map ----------------------------------
 * An inline <svg> inside .fm-map. The svg keeps a min-width so the shapes stay
 * legible on a phone and scroll horizontally inside the panel instead of
 * squashing; width:100% lets it use a wide screen fully. */
.fm-map {
  background: var(--fm-panel);
  border: 1px solid var(--fm-line);
  border-radius: var(--fm-r);
  padding: 14px;
  box-shadow: var(--fm-shadow);
  overflow-x: auto;
}

.fm-map svg {
  display: block;
  min-width: 640px;
  width: 100%;
  height: auto;
}

.fm-map text {
  font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  fill: var(--fm-ink);
}

.fm-map .fm-map-dim { fill: var(--fm-dim); }
.fm-map .fm-map-node { fill: var(--fm-panel-2); stroke: var(--fm-line); }
.fm-map .fm-map-node-gate { fill: var(--fm-gate-soft); stroke: var(--fm-gate); }
.fm-map .fm-map-node-open { fill: var(--fm-accent-soft); stroke: var(--fm-accent); }
.fm-map .fm-map-edge { stroke: var(--fm-line); fill: none; }
.fm-map .fm-map-edge-gate { stroke: var(--fm-gate); fill: none; }
.fm-map .fm-map-edge-soft { stroke: var(--fm-dim); fill: none; stroke-dasharray: 4 3; opacity: .65; }

.fm-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  margin-top: 10px;
  font-size: 12.5px;
  color: var(--fm-dim);
}

.fm-legend i {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  margin-right: 5px;
  vertical-align: -1px;
}

/* ---- graphic: age bar ----
 * How long something has waited. Width is set inline by the generator. */
.fm-age {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
  color: var(--fm-dim);
}

.fm-agebar {
  flex: 1 1 auto;
  height: 6px;
  background: var(--fm-panel-2);
  border-radius: 999px;
  overflow: hidden;
  min-width: 40px;
}

.fm-agebar i {
  display: block;
  height: 100%;
  background: var(--fm-accent);
  border-radius: 999px;
}

.fm-age.is-hot .fm-agebar i { background: var(--fm-hot); }

/* ---- graphic: distribution bar ----
 * One horizontal bar split into labelled proportional segments. */
.fm-dist {
  display: flex;
  width: 100%;
  height: 26px;
  border-radius: 7px;
  overflow: hidden;
  border: 1px solid var(--fm-line);
  background: var(--fm-panel-2);
}

.fm-dist span {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11.5px;
  font-weight: 600;
  color: #fff;
  overflow: hidden;
  white-space: nowrap;
}

.fm-dist-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-top: 8px;
  font-size: 12.5px;
  color: var(--fm-dim);
}

.fm-dist-legend i {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  margin-right: 5px;
  vertical-align: -1px;
}

/* ---- graphic: status line ----
 * A left-to-right run of steps with the reached ones filled. */
.fm-statusline {
  display: flex;
  align-items: center;
  gap: 0;
  flex-wrap: wrap;
  row-gap: 8px;
}

.fm-step {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12.5px;
  color: var(--fm-dim);
}

.fm-step b {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  border-radius: 999px;
  border: 1.5px solid var(--fm-line);
  background: var(--fm-panel);
  font-size: 11px;
  font-weight: 700;
  flex: 0 0 auto;
}

.fm-step.is-done b {
  background: var(--fm-calm);
  border-color: var(--fm-calm);
  color: #fff;
}

.fm-step.is-done { color: var(--fm-ink); }

.fm-step.is-now b {
  background: var(--fm-accent);
  border-color: var(--fm-accent);
  color: #fff;
}

.fm-

... [9040 bytes truncated] ...

 to
 * declare markup and never repeats a submit handler:
 *
 *   <form data-fm-question="upstream-strategy" data-fm-label="Upstream-Strategie">
 *     <div class="fm-opts">
 *       <label class="fm-opt"><input type="radio" name="upstream-strategy" value="selektiv"> ...</label>
 *     </div>
 *     <textarea class="fm-free" data-fm-note placeholder="..."></textarea>
 *     <button type="submit" class="fm-submit">Antwort vormerken</button>
 *     <div class="fm-queued">Vorgemerkt.</div>
 *   </form>
 *
 * Playbook obligations this file discharges:
 *   - radio changes only update LOCAL state; they never queue a prompt,
 *   - the explicit submit queues EXACTLY ONE prompt for the final answer,
 *   - queueKey is the question key, so re-answering replaces the earlier
 *     unsent answer for that question instead of appending a second one,
 *   - queued state is displayed separately from selected state.
 *
 * A board is also opened directly from disk, with no Lavish server and thus no
 * window.lavish. That is a supported way to read a board, so this file must not
 * throw there: it reveals the .fm-offline notice instead of failing on submit.
 * The notice is advisory and reversible - a Lavish runtime that lands late hides
 * it again, because a board that keeps saying answers cannot be sent back while
 * it is sending them is worse than one that says nothing.
 *
 * No submit is silent. An answer that carries neither a choice nor a note says
 * so in the form's own .fm-queued box rather than doing nothing at all, and a
 * form built without that box is named on the console at startup, so the one
 * position where nothing could be shown is still not a quiet one.
 */
(function () {
  'use strict';

  function bridge() {
    var l = window.lavish;
    return l && typeof l.queuePrompt === 'function' ? l : null;
  }

  function answerOf(form) {
    var data = new FormData(form);
    var key = form.getAttribute('data-fm-question');
    var choice = data.get(key);
    if (choice === null || choice === undefined || choice === '') {
      // The radio name is meant to equal data-fm-question, but nothing enforces
      // that at build time. Read the checked control out of the form itself so a
      // mismatched name costs a console warning and never the captain's answer.
      var checked = form.querySelector('input[type="radio"]:checked');
      choice = checked ? checked.value : '';
    }
    var note = form.querySelector('[data-fm-note]');
    var text = note && note.value ? note.value.trim() : '';
    if (!choice && !text) {
      return null;
    }
    return { choice: choice ? String(choice) : '', note: text };
  }

  function promptFor(label, answer) {
    var parts = [];
    parts.push('Entscheidung "' + label + '": ');
    parts.push(answer.choice ? answer.choice : '(keine Option gewählt)');
    if (answer.note) {
      parts.push(' - Anmerkung des Captains: ' + answer.note);
    }
    return parts.join('');
  }

  function say(form, text, warn) {
    var box = form.querySelector('.fm-queued');
    if (!box) {
      return;
    }
    box.textContent = text;
    if (warn) {
      box.classList.add('is-warn');
    } else {
      box.classList.remove('is-warn');
    }
    box.classList.add('is-shown');
  }

  function markQueued(form, answer) {
    say(form, answer.choice
      ? 'Vorgemerkt: ' + answer.choice
      : 'Vorgemerkt: freie Anmerkung', false);
  }

  function markUnanswered(form) {
    say(form, 'Nichts vorgemerkt: bitte eine Option wählen oder eine Anmerkung schreiben.', true);
  }

  function onSubmit(event) {
    var form = event.target;
    if (!form || !form.matches || !form.matches('form[data-fm-question]')) {
      return;
    }
    event.preventDefault();

    var answer = answerOf(form);
    if (!answer) {
      markUnanswered(form);
      return;
    }

    var key = form.getAttribute('data-fm-question');
    var label = form.getAttribute('data-fm-label') || key;
    var lavish = bridge();
    if (!lavish) {
      // Opened without a running Lavish server: there is nowhere to queue to.
      // Say so rather than pretending the answer was recorded.
      revealOffline();
      return;
    }

    lavish.queuePrompt(promptFor(label, answer), {
      tag: 'decision',
      text: label + ': ' + (answer.choice || 'freie Anmerkung'),
      // queueKey is what makes a changed answer REPLACE the previous unsent one.
      queueKey: key,
      element: form,
      data: { question: key, answer: answer.choice, note: answer.note }
    });
    // The queue just worked, so any offline notice on this board is stale.
    showOffline(false);
    markQueued(form, answer);
  }

  function showOffline(show) {
    var notes = document.querySelectorAll('.fm-offline');
    for (var i = 0; i < notes.length; i++) {
      if (show) {
        notes[i].classList.add('is-shown');
      } else {
        notes[i].classList.remove('is-shown');
      }
    }
  }

  function revealOffline() {
    showOffline(true);
  }

  // Lavish injects its own runtime, and this inlined script may run before that
  // injection lands. A single check at startup would therefore report a served
  // board as offline. Poll briefly instead, and keep checking at a slower
  // cadence after the notice appears, so a runtime that lands late takes the
  // notice back down rather than leaving the captain reading that his answers
  // cannot be sent back while they are in fact being sent.
  //
  // The poll gives up after about 122 seconds - 6 tries at 400ms, then 60 at
  // 2000ms. A runtime that lands after that leaves the notice standing until the
  // captain's first successful submit clears it; the submit path is the backstop
  // here, not the poll.
  function watchForBridge(quietLeft, slowLeft) {
    if (bridge()) {
      showOffline(false);
      return;
    }
    if (quietLeft > 0) {
      window.setTimeout(function () {
        watchForBridge(quietLeft - 1, slowLeft);
      }, 400);
      return;
    }
    revealOffline();
    if (slowLeft <= 0) {
      return;
    }
    window.setTimeout(function () {
      watchForBridge(0, slowLeft - 1);
    }, 2000);
  }

  // docs/board-layout.md states two rules a board body has to follow: the radio
  // name equals data-fm-question, and every question form carries a .fm-queued
  // box. Neither can be enforced from prose, and a board that breaks either one
  // builds and renders, so both are named on the console at startup. Breaking
  // the first costs nothing, because answerOf falls back to the form's own
  // checked control; breaking the second is what would make a submit silent.
  function reportFormDefects(form) {
    if (!window.console || !window.console.warn) {
      return;
    }
    var key = form.getAttribute('data-fm-question');
    if (form.querySelector && !form.querySelector('.fm-queued')) {
      window.console.warn('fm-board: the form for "' + key +
        '" has no .fm-queued box, so nothing on it can report a queued or an empty answer.');
    }
    if (!form.querySelectorAll) {
      return;
    }
    var radios = form.querySelectorAll('input[type="radio"]');
    for (var i = 0; i < radios.length; i++) {
      if (radios[i].name && radios[i].name !== key) {
        window.console.warn('fm-board: radio name "' + radios[i].name +
          '" does not match data-fm-question "' + key +
          '"; the answer is read from the form instead.');
        return;
      }
    }
  }

  function init() {
    // The playbook wants data-lavish-question on the question wrapper. Derive it
    // from data-fm-question so a board body declares the key exactly once.
    var forms = document.querySelectorAll('form[data-fm-question]');
    for (var i = 0; i < forms.length; i++) {
      if (!forms[i].hasAttribute('data-lavish-question')) {
        forms[i].setAttribute('data-lavish-question', forms[i].getAttribute('data-fm-question'));
      }
      reportFormDefects(forms[i]);
    }
    watchForBridge(6, 60);
  }

  document.addEventListener('submit', onSubmit);

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
}());
</script>
</body>
</html>
Evidence: CLI transcript — bearings and decision-inventory on the same home, both predicates

### Demo home backlog (holds filed exactly as AGENTS.md s10 instructs) - [ ] deploy-prod - Deploy the stack to production (kind: ship) (hold: production action needs his live word) (hold-kind: captain) - [ ] allships-notice - Tell the fleet an update exists (hold: captain chose to hold it until close-out) (hold-kind: captain) - [ ] pick-approach - Which retry shape do we take (kind: captain) (hold: two viable shapes) (hold-kind: captain) - [ ] blocked-deploy - Clear the residue rows blocked-by: ship-task (kind: ship) (hold: production data change) (hold-kind: captain) - [ ] fog-thing - Unclear area (kind: fog) (hold: parked until later) (hold-kind: future) ### BEFORE - bin/fm-bearings-snapshot.sh at base e6762c5 decisions_open[1]{id,key,verb,summary,owner}: pick-approach,pick-approach,captain-hold,"Which retry shape do we take: two viable shapes",(main) gates[4]{id,title,blocked_by,reason,owner}: (main-inventory),in-flight backlog item has no child metadata,"-",main inventory,(main) deploy-prod,Deploy the stack to production,"-",production action needs his live word,(main) allships-notice,Tell the fleet an update exists,"-",captain chose to hold it until close-out,(main) blocked-deploy,Clear the residue rows,ship-task,production data change,(main) ### AFTER - bin/fm-bearings-snapshot.sh at bfb4c4b decisions_open[3]{id,key,verb,summary,owner}: deploy-prod,deploy-prod,captain-hold,"Deploy the stack to production: production action needs his live word",(main) allships-notice,allships-notice,captain-hold,"Tell the fleet an update exists: captain chose to hold it until close-out",(main) pick-approach,pick-approach,captain-hold,"Which retry shape do we take: two viable shapes",(main) gates[2]{id,title,blocked_by,reason,owner}: (main-inventory),in-flight backlog item has no child metadata,"-",main inventory,(main) blocked-deploy,Clear the residue rows,ship-task,production data change,(main) ### /decisionboard feed - bin/fm-decision-inventory.sh BEFORE: {"records":1,"decisions":1,"ids":["pick-approach"]} AFTER : {"records":3,"decisions":3,"ids":["allships-notice","deploy-prod","pick-approach"]} ### fog record held with --kind future stays non-actionable in both "sea-chart items (fog, out-of-course) kept out of gates: 1","--all-queued"

### Demo home backlog (all four captain holds filed exactly as AGENTS.md s10 instructs)
## Queued
- [ ] deploy-prod - Deploy the stack to production (repo: firstmate) (kind: ship) (hold: production action needs his live word) (hold-kind: captain)
- [ ] allships-notice - Tell the fleet an update exists (repo: firstmate) (hold: captain chose to hold it until close-out) (hold-kind: captain)
- [ ] pick-approach - Which retry shape do we take (repo: firstmate) (kind: captain) (hold: two viable shapes, needs a call) (hold-kind: captain)
- [ ] blocked-deploy - Clear the residue rows blocked-by: ship-task (repo: firstmate) (kind: ship) (hold: production data change, captain-gated) (hold-kind: captain)
- [ ] fog-thing - Unclear area (repo: firstmate) (kind: fog) (hold: parked until later) (hold-kind: future)

## In flight
- [ ] ship-task - Ship the thing (repo: firstmate) (kind: ship) (since 2026-07-11)

### BEFORE - bin/fm-bearings-snapshot.sh at base e6762c5
decisions_open[1]{id,key,verb,summary,owner}:
  pick-approach,pick-approach,captain-hold,"Which retry shape do we take: two viable shapes",(main)
landed: []
gates[4]{id,title,blocked_by,reason,owner}:
  (main-inventory),in-flight backlog item has no child metadata,"-",main inventory,(main)
  deploy-prod,Deploy the stack to production,"-",production action needs his live word,(main)
  allships-notice,Tell the fleet an update exists,"-",captain chose to hold it until close-out,(main)
  blocked-deploy,Clear the residue rows,ship-task,production data change,(main)
integrity: []

### AFTER - bin/fm-bearings-snapshot.sh at bfb4c4b
decisions_open[3]{id,key,verb,summary,owner}:
  deploy-prod,deploy-prod,captain-hold,"Deploy the stack to production: production action needs his live word",(main)
  allships-notice,allships-notice,captain-hold,"Tell the fleet an update exists: captain chose to hold it until close-out",(main)
  pick-approach,pick-approach,captain-hold,"Which retry shape do we take: two viable shapes",(main)
landed: []
gates[2]{id,title,blocked_by,reason,owner}:
  (main-inventory),in-flight backlog item has no child metadata,"-",main inventory,(main)
  blocked-deploy,Clear the residue rows,ship-task,production data change,(main)
integrity: []

### /decisionboard feed - bin/fm-decision-inventory.sh
BEFORE: {"records":1,"decisions":1,"ids":["pick-approach"]}
AFTER : {"records":3,"decisions":3,"ids":["allships-notice","deploy-prod","pick-approach"]}

### fog record held with --kind future stays non-actionable in both (the pinned consequence)
  "sea-chart items (fog, out-of-course) kept out of gates: 1","--all-queued"
Evidence: New test fails against the base-commit predicate (bisect proof)
$ git show e6762c5:bin/fm-fleet-snapshot.sh > bin/fm-fleet-snapshot.sh
$ bash tests/fm-bearings-snapshot.test.sh | grep -iE 'whatever the record kind|not ok'
not ok - a captain hold filed on a record whose own kind is not captain must reach the captain: {
$ git checkout -- bin/fm-fleet-snapshot.sh # worktree clean

$ bash tests/fm-bearings-snapshot.test.sh # at bfb4c4b
ok - an authoritative captain hold surfaces end-to-end
ok - a captain hold reaches the captain whatever the record kind, and a blocked one still does not
Evidence: Demo home fixture used for the end-to-end run
## Queued
- [ ] deploy-prod - Deploy the stack to production (repo: firstmate) (kind: ship) (hold: production action needs his live word) (hold-kind: captain)
- [ ] allships-notice - Tell the fleet an update exists (repo: firstmate) (hold: captain chose to hold it until close-out) (hold-kind: captain)
- [ ] pick-approach - Which retry shape do we take (repo: firstmate) (kind: captain) (hold: two viable shapes, needs a call) (hold-kind: captain)
- [ ] blocked-deploy - Clear the residue rows blocked-by: ship-task (repo: firstmate) (kind: ship) (hold: production data change, captain-gated) (hold-kind: captain)
- [ ] fog-thing - Unclear area (repo: firstmate) (kind: fog) (hold: parked until later) (hold-kind: future)

## In flight
- [ ] ship-task - Ship the thing (repo: firstmate) (kind: ship) (since 2026-07-11)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ⚠️ bin/fm-sea-chart.sh:84 - The new header claims that after this change "Only a BLOCKED one is still lost, and that is the blocked-decision gap this header opens with, not a second flank" (repeated at line 502: "it loses to the blocked-decision gap in the header, not to this line"). But the gap the header opens with (lines 59-72) is the one this chart RECOVERS into withheld[], and that recovery is built from $own_decision_records at line 504, which filters .kind == &#34;captain&#34;. A blocked record of another kind carrying a captain hold is therefore in neither decisions_open (predicate fails on the blocker) nor withheld[] (baseline fails on the kind); it falls through to unplaced[], where unplaced_reason emits cause "blocked" with why "kind ship is none this chart places as fog, as a boundary, or as a decision, and it is held back by X, so it is not takeable either" - a sentence that never says the captain is being asked. So this class is not covered by the recovery the header points it at. This is not a regression (it behaved identically before) and the narrower baseline is deliberate per the intent, but the two new comment blocks describe a safety net that provably excludes this case. Resolve either by rewording both to say this class is recovered nowhere as a decision, or by widening the baseline to .kind == &#34;captain&#34; or .hold_kind == &#34;captain&#34; - the latter is a behavior change and the author explicitly documented the narrow baseline as deliberate, hence ask-user.
  • ⚠️ .agents/skills/to-backlog/SKILL.md:114 - Missed cross-reference of exactly the class this change corrects elsewhere. The line still reads "this skill refuses to file a captain-actionable record so it cannot become a second owner by accident" - true only while captain-actionability required kind: captain, the kind this skill refuses. The intent enumerates six corrected places, and the author already rewrote the identical claim in bin/fm-to-backlog.sh:36-40 ("This script never calls tasks-axi hold, and a hold is what bin/fm-fleet-snapshot.sh reads for captain-actionability, so the refusal is about ownership rather than the surface") and in docs/to-backlog-provenance.md:142-143. This SKILL.md is the surface firstmate actually reads, so it is the one most likely to mislead a future filer into believing the kind refusal protects the captain surface. Same correction as the other two: the refusal guards ownership, not actionability.
  • ℹ️ AGENTS.md:488 - AGENTS.md and .agents/skills/sea-chart/SKILL.md:157 keep the absolute "can never be mistaken for a captain decision" / "Neither can ever be mistaken", while bin/fm-chart-kinds-lib.sh:35-38 - edited in the same commit - was deliberately made honest that the guarantee is now filing discipline rather than structure ("a fog record filed with a captain hold would now surface as a captain decision"). The three files edited together now hedge differently on the same claim. The AGENTS.md sentence does state its own condition ("and both are held as future"), so a careful reader gets it; noting the divergence rather than asking for a change, since the intent explicitly chose the recoverable direction and recorded it in the lib header.

🔧 Fix: correct sea-chart and to-backlog comments on blocked captain holds
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-bearings-snapshot.test.sh — full suite green, including the new test_captain_hold_on_a_non_captain_kind_record_surfaces
  • Bisect proof: replaced bin/fm-fleet-snapshot.sh with the base-commit (e6762c5) version, re-ran tests/fm-bearings-snapshot.test.sh, confirmed the new test emits not ok, then git checkout -- bin/fm-fleet-snapshot.sh (worktree verified clean)
  • bash tests/fm-instruction-owners.test.sh
  • bash tests/fm-decision-inventory.test.sh
  • bash tests/fm-sea-chart.test.sh
  • bash tests/fm-to-backlog.test.sh
  • bash tests/fm-fleet-snapshot-view.test.sh
  • bash tests/fm-backlog-lint.test.sh
  • bash tests/fm-captain-translation-contract.test.sh
  • Manual end-to-end: built a demo FM_HOME with holds filed per AGENTS.md section 10 (kind ship, no kind, kind captain, a blocked one, plus a fog record held --kind future) and ran FM_HOME=... bin/fm-bearings-snapshot.sh against both the base and target predicates
  • Manual end-to-end: FM_HOME=... bin/fm-decision-inventory.sh | jq &#39;{records,decisions,ids:[.groups[].decisions[].id]}&#39; against both predicates
  • Rendered the captain-facing board with bin/fm-board.sh --title ... --body ... --out ... from the two bearings JSON outputs and captured a full-page screenshot via chrome-devtools-axi
⚠️ **Document** - 1 info
  • ℹ️ bin/fm-sea-chart.sh:82 - Follow-up, deliberately not done here. The new fm-sea-chart.sh header now states a real captain-facing consequence: a BLOCKED captain hold carried on a record whose own kind is not captain reaches neither surface — the predicate fails it on the blocker, the chart's kind-based baseline fails it on the kind, so it is recovered nowhere as a decision and lands in unplaced[] under cause blocked without ever saying the captain is being asked. That fact currently lives only in a code header; no captain-facing surface discloses it (the assembler's limits[] does not carry it, and the sea-chart skill's withheld-cause guidance does not cover it). The behaviour is unchanged by this change and folding it in was explicitly forbidden by the brief, so I left it alone rather than adding a limit string (which would be executable behaviour) or duplicating the header's prose into the skill. Worth attaching to the already-filed blocked-record gap: when that gap is closed, decide whether this class earns an entry in limits[].
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…record kind

AGENTS.md section 10 files a captain-gated thread with
`tasks-axi hold <id> --reason "<reason>" --kind captain` and says nothing about
the record kind, but the captain-actionable predicate also demanded
`kind == "captain"`. A hold filed exactly as instructed was therefore invisible
to /decisionboard, to bin/fm-decision-inventory.sh, and to every bearings read.

Measured on the live home 2026-08-09: 35 records carried `hold-kind: captain`
and the surface returned 32. The three missing were a production deploy filed
as kind ship, a thread carrying no record kind at all, and one blocked record.
Widening the predicate to the hold kind returns the first two; `hold --kind` is
a closed vocabulary, so it admits only records someone deliberately held for the
captain.

The blocked one is untouched on purpose: a captain hold behind an unresolved
blocker still leaves decisions_open and lands in gates carrying no kind. That is
a separate, separately filed gap, and the new test pins it so this change cannot
quietly move it.

Cross-references that claimed the record kind was what kept a record off the
captain's surface are corrected to name the hold kind: AGENTS.md section 10,
bin/fm-chart-kinds-lib.sh, bin/fm-sea-chart.sh, bin/fm-to-backlog.sh,
.agents/skills/sea-chart, and docs/to-backlog-provenance.md. fog and
out-of-course stay safe by construction because both are held as `future`.
The sea chart's own reconciliation baseline stays kind-based and is documented
as deliberately narrower.

Regression coverage in tests/fm-bearings-snapshot.test.sh asserts the reported
surface, not the predicate: verified failing before the change and passing after.
@Freudator86
Freudator86 merged commit b2a5db4 into main Aug 9, 2026
10 checks passed
@Freudator86
Freudator86 deleted the fm/firstmate-captain-hold-invisible-to-decision-surface-r2 branch August 9, 2026 14:10
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.

2 participants