Skip to content

feat(pi): keep failed branch-outcome reads visible under Calm - #3106

Open
zachlandes wants to merge 9 commits into
kunchenguid:mainfrom
zachlandes:fm/calm-branch-outcomes-renderer
Open

zachlandes wants to merge 9 commits into
kunchenguid:mainfrom
zachlandes:fm/calm-branch-outcomes-renderer

Conversation

@zachlandes

@zachlandes zachlandes commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Intent

#3024 made Calm collapse the fm_branch_outcomes tool row entirely, so the raw outcome-store JSONL no longer floods the transcript.
The collapse is unconditional, which means two things that need a human also disappear under Calm: a read of the outcome store that failed, and output that is not a store record at all.
The worst case is someone asking what happened in the fleet, the store read failing, and Calm rendering that failure as an empty transcript.
This keeps exactly those two cases on screen, each as one dim line carrying the supervision branch's sailboat glyph, while a read made only of well-formed store records still collapses to nothing.

Captain-verdict outcomes are deliberately not part of this exception.
Since #3312 the supervision branch delivers every captain outcome as its own exact-once visible transcript entry, so repeating it on each Calm read of the store would duplicate what the captain already sees.

The recognizer is strict on purpose, because the safe direction is to show too much rather than too little.
A line is hidden only when it matches one of the exact record shapes bin/fm-branch-outcome.sh validates: the legacy six-key row, the row with a boolean silent, or the row that also carries the statusEndpoint/statusIdent provenance added in #3495, each with integral seq and epoch and a routine or captain verdict.
Anything else is shown byte-for-byte, so a future change to the store format degrades into visible output instead of silent suppression; if the store grows another field, extend OUTCOME_KEY_SETS in .pi/extensions/lib/fm-calm-branch-outcomes.ts alongside it.
The collapse decision still runs through Calm's existing visibility owner, Calm-off rendering keeps #3261's stock preview and expansion behaviour, and HTML export is untouched.

Verification: tests/fm-calm-branch-outcomes.test.sh pins the decision against records written by the real store script, with no harness.
The opt-in guard FM_CALM_BRANCH_OUTCOMES_LIVE_E2E=1 tests/fm-calm-branch-outcomes-live-e2e.test.sh drives the real pi 0.87.1 binary in tmux and asserts what it paints: handled records collapse, a failed read stays visible, Calm-off keeps the stock row, and /export still contains every record.
The Pi extension suites and the strict typecheck against Pi 0.87.1 also pass.
The one failing CI job (Behavior portable serial 6, tests/fm-remote-secondmate-relaunch.test.sh) is a pre-existing failure on main since #5583 added that test: https://github.com/kunchenguid/firstmate/actions/runs/36212602588/job/108322127185

Written with AI assistance.

What Changed

  • When Calm hides the fm_branch_outcomes tool result, it now checks what that result contains, using the new pure module .pi/extensions/lib/fm-calm-branch-outcomes.ts. A read made up only of valid store records, or the empty-store text, still disappears with the row. A failed read, and any line that doesn't pass the store's record key-set and type checks, now stays on screen as a dim line with the branch's sailboat glyph. Before this, both were silently hidden.
  • Added tests/fm-calm-branch-outcomes.test.sh, which tests that decision against real store records without a harness. Added tests/fm-calm-branch-outcomes-live-e2e.test.sh, a live check gated on FM_CALM_BRANCH_OUTCOMES_LIVE_E2E, which runs the real pi binary in tmux. It checks four things: the row with Calm off, the collapse with Calm on, the export, and a failed read. Both tests are registered in bin/fm-test-run.sh. The existing branch-extension and type-check fixtures now copy the new module and use valid routine records.
  • Updated docs/calm.md, docs/calm-mode-feasibility.md and docs/pi-supervision-branch.md to describe this exception and name the module that owns it. Recorded the dated live-run output against Pi 0.87.1 in docs/verification/runtime-backends.md.

Risk Assessment

⚠️ Medium: The renderer change is small, additive, and correct for its stated failure-visibility goal. However, the merged-in upstream changes (#3261 preview clipping, #3312 visible captain entries) make the branch's live guard stale and make the captain-verdict line partly redundant, so both need follow-up before relying on the refreshed validation.

Testing

Ran the portable regression and the Pi branch-extension suite (both green). Ran the shipped opt-in live guard against real Pi 0.87.1 (4/4 ok). Then ran an extended live copy with two extra adversarial cases, unrecognized output and a corrupt store (6/6 ok). Pane snapshots and the HTML export are saved as evidence. Every live scenario passed. The module-level single-line rendering check is recorded as untested live, because it was only a pure-function check.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Calm on: reading a store of routine and captain records collapses the row entirely (no duplicate captain line after #3312) ✅ pass live live-calm-on.txt: no fm_branch_outcomes shell, no records, no task-12/task-4 lines
Calm on: a failed read (outcome script missing) stays visible as one ⛵ dim line ✅ pass live live-calm-on-failure.txt
Adversarial: a corrupt real store makes the read fail, and the failure is not hidden under Calm ✅ pass live live-calm-on-corrupt.txt
Adversarial: output in an unrecognized format comes through byte-for-byte with ⛵ while the recognized record collapses ✅ pass live live-calm-on-unrecognized.txt
Calm off: the stock row shows the leading raw records inside Pi's collapsed preview (the refreshed guard assertion) ✅ pass live live-calm-off.txt: task-12 captain record shown, '... (6 more lines, ctrl+o to expand)'
/export from a Calm-on session keeps all 16 raw records ✅ pass live live-calm-on-export.html; live-guard-as-shipped.txt test 3 ok
Multiline captain/failed-read text renders as a single line; unrecognized lines stay unchanged (module level) ⏸️ untested no The earlier payload did not show a live result for this scenario. It was only a pure-function check on real store records (portable-regression.txt), not a run against real Pi. Real-Pi single-line rend…
Evidence: Live guard as shipped (real Pi 0.87.1)

Source: Live guard as shipped (real Pi 0.87.1)

ok - real Pi 0.87.1 keeps the upstream branch-outcome row and its raw records while Calm is off
ok - real Pi 0.87.1 collapses a branch-outcome read of complete store records to nothing under Calm
ok - real Pi 0.87.1 exports every branch-outcome record from a session where Calm collapsed that row on screen
ok - real Pi 0.87.1 keeps a failed branch-outcome read visible under Calm instead of collapsing it to nothing
Evidence: Extended live guard output

Source: Extended live guard output

ok - real Pi 0.87.1 keeps the upstream branch-outcome row and its raw records while Calm is off
ok - real Pi 0.87.1 collapses a branch-outcome read of complete store records to nothing under Calm
ok - real Pi 0.87.1 exports every branch-outcome record from a session where Calm collapsed that row on screen
ok - real Pi 0.87.1 keeps a failed branch-outcome read visible under Calm instead of collapsing it to nothing
ok - real Pi keeps an unrecognized outcome line byte-for-byte under Calm while collapsing the recognized record
ok - real Pi keeps a corrupt-store read failure visible under Calm
Evidence: Calm on: complete store read collapses to nothing (pane)

Source: Calm on: complete store read collapses to nothing (pane)


 pi v0.87.1
 escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
 Press ctrl+o to show full startup help and loaded resources.

 Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.

[Extensions]
  fm-branch-supervision.ts, fm-calm-outcomes-e2e.ts, fm-calm.ts


 CALM_OUTCOMES_E2E_PROMPT


 Warning: tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.

 CALM_OUTCOMES_E2E_DONE

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
/private/var/folders/4n/_bv20k5d26dgrq1t5y4l69940000gn/T/fm-calm-branch-outcomes-live.cHg7OQ/project (main)
36.5%/8.2k (auto)                                                                                                                                                      call-outcomes
Evidence: Calm on: failed read stays visible with ⛵ (pane)

Source: Calm on: failed read stays visible with ⛵ (pane)

⛵ could not read the outcome store: fm-branch-outcome.sh exited 127: ... No such file or directory


 pi v0.87.1
 escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
 Press ctrl+o to show full startup help and loaded resources.

 Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.

[Extensions]
  fm-branch-supervision.ts, fm-calm-outcomes-e2e.ts, fm-calm.ts


 CALM_OUTCOMES_E2E_PROMPT


 Warning: tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.

 ⛵ could not read the outcome store: fm-branch-outcome.sh exited 127: bash:
 /private/var/folders/4n/_bv20k5d26dgrq1t5y4l69940000gn/T/fm-calm-branch-outcomes-live.cHg7OQ/no-firstmate-scripts/bin/fm-branch-outcome.sh: No such file or directory

 CALM_OUTCOMES_E2E_DONE

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
/private/var/folders/4n/_bv20k5d26dgrq1t5y4l69940000gn/T/fm-calm-branch-outcomes-live.cHg7OQ/project (main)
28.2%/8.2k (auto)                                                                                                                                                      call-outcomes
Evidence: Calm on: corrupt store failure stays visible (pane)

Source: Calm on: corrupt store failure stays visible (pane)

⛵ could not read the outcome store: fm-branch-outcome.sh exited 1: error: refusing read because the outcome store is malformed or non-sequential


 pi v0.87.1
 escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
 Press ctrl+o to show full startup help and loaded resources.

 Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.

[Extensions]
  fm-branch-supervision.ts, fm-calm-outcomes-e2e.ts, fm-calm.ts


 Warning: tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.


 CALM_OUTCOMES_E2E_PROMPT


 ⛵ could not read the outcome store: fm-branch-outcome.sh exited 1: error: refusing read because the outcome store is malformed or non-sequential

 CALM_OUTCOMES_E2E_DONE

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
/private/var/folders/4n/_bv20k5d26dgrq1t5y4l69940000gn/T/fm-calm-branch-outcomes-live.cHg7OQ/project (main)
28.0%/8.2k (auto)                                                                                                                                                      call-outcomes
Evidence: Calm on: unrecognized line kept byte-for-byte (pane)

Source: Calm on: unrecognized line kept byte-for-byte (pane)

⛵ OUTCOME_V2|task-77|verdict=escalate|CALM_OUTCOMES_FUTURE_FORMAT keeps spacing


 pi v0.87.1
 escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
 Press ctrl+o to show full startup help and loaded resources.

 Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.

[Extensions]
  fm-branch-supervision.ts, fm-calm-outcomes-e2e.ts, fm-calm.ts


 Warning: tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.


 CALM_OUTCOMES_E2E_PROMPT


 ⛵ OUTCOME_V2|task-77|verdict=escalate|CALM_OUTCOMES_FUTURE_FORMAT   keeps  spacing

 CALM_OUTCOMES_E2E_DONE

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
/private/var/folders/4n/_bv20k5d26dgrq1t5y4l69940000gn/T/fm-calm-branch-outcomes-live.cHg7OQ/project (main)
28.2%/8.2k (auto)                                                                                                                                                      call-outcomes
Evidence: Calm off: stock row with preview clip (pane)

Source: Calm off: stock row with preview clip (pane)


 pi v0.87.1
 escape interrupt · ctrl+c/ctrl+d clear/exit · / commands · ! bash · ctrl+o more
 Press ctrl+o to show full startup help and loaded resources.

 Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.

[Extensions]
  fm-branch-supervision.ts, fm-calm-outcomes-e2e.ts, fm-calm.ts


 CALM_OUTCOMES_E2E_PROMPT


 Warning: tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux.


 $ printf 'CALM_OUTCOMES_E2E_BASH\n'

 CALM_OUTCOMES_E2E_BASH

 Took 0.0s



 fm_branch_outcomes
 {"seq":1,"epoch":1790397337,"task":"task-12","wake":"signal: task-12","verdict":"captain","summary":"PR https://example.com/pr/12 checks green, ready for
 review","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":2,"epoch":1790397337,"task":"task-r1","wake":"signal: task-r1","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_1","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":3,"epoch":1790397338,"task":"task-r2","wake":"signal: task-r2","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_2","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":4,"epoch":1790397338,"task":"task-r3","wake":"signal: task-r3","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_3","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":5,"epoch":1790397338,"task":"task-r4","wake":"signal: task-r4","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_4","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":6,"epoch":1790397338,"task":"task-r5","wake":"signal: task-r5","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_5","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":7,"epoch":1790397338,"task":"task-r6","wake":"signal: task-r6","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_6","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":8,"epoch":1790397339,"task":"task-r7","wake":"signal: task-r7","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_7","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":9,"epoch":1790397339,"task":"task-r8","wake":"signal: task-r8","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_8","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 {"seq":10,"epoch":1790397339,"task":"task-r9","wake":"signal:
 task-r9","verdict":"routine","summary":"CALM_OUTCOMES_ROUTINE_9","silent":false,"statusEndpoint":0,"statusIdent":"-"}
 ... (6 more lines, ctrl+o to expand)


 CALM_OUTCOMES_E2E_DONE

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
/private/var/folders/4n/_bv20k5d26dgrq1t5y4l69940000gn/T/fm-calm-branch-outcomes-live.cHg7OQ/project (main)
30.0%/8.2k (auto)                                                                                                                                                      call-outcomes
Evidence: Calm-on session HTML export

Source: Calm-on session HTML export

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Session Export</title>
  <style>
    :root {
      --accent: #8abeb7;
      --border: #5f87ff;
      --borderAccent: #00d7ff;
      --borderMuted: #505050;
      --success: #b5bd68;
      --error: #cc6666;
      --warning: #ffff00;
      --muted: #808080;
      --dim: #666666;
      --text: #d4d4d4;
      --thinkingText: #808080;
      --selectedBg: #3a3a4a;
      --scrollbarTrack: #505050;
      --scrollbarThumb: #d4d4d4;
      --searchMatchBg: #3a3a4a;
      --searchMatchText: #d4d4d4;
      --userMessageBg: #343541;
      --userMessageText: #d4d4d4;
      --customMessageBg: #2d2838;
      --customMessageText: #d4d4d4;
      --customMessageLabel: #9575cd;
      --toolPendingBg: #282832;
      --toolSuccessBg: #283228;
      --toolErrorBg: #3c2828;
      --toolTitle: #d4d4d4;
      --toolOutput: #808080;
      --mdHeading: #f0c674;
      --mdLink: #81a2be;
      --mdLinkUrl: #666666;
      --mdCode: #8abeb7;
      --mdCodeBlock: #b5bd68;
      --mdCodeBlockBorder: #808080;
      --mdQuote: #808080;
      --mdQuoteBorder: #808080;
      --mdHr: #808080;
      --mdListBullet: #8abeb7;
      --toolDiffAdded: #b5bd68;
      --toolDiffRemoved: #cc6666;
      --toolDiffContext: #808080;
      --syntaxComment: #6A9955;
      --syntaxKeyword: #569CD6;
      --syntaxFunction: #DCDCAA;
      --syntaxVariable: #9CDCFE;
      --syntaxString: #CE9178;
      --syntaxNumber: #B5CEA8;
      --syntaxType: #4EC9B0;
      --syntaxOperator: #D4D4D4;
      --syntaxPunctuation: #D4D4D4;
      --thinkingOff: #505050;
      --thinkingMinimal: #6e6e6e;
      --thinkingLow: #5f87af;
      --thinkingMedium: #81a2be;
      --thinkingHigh: #b294bb;
      --thinkingXhigh: #d183e8;
      --thinkingMax: #ff5fff;
      --bashMode: #b5bd68;
      --exportPageBg: #18181e;
      --exportCardBg: #1e1e24;
      --exportInfoBg: #3c3728;
      --body-bg: #18181e;
      --container-bg: #1e1e24;
      --info-bg: #3c3728;
    }

    * { margin: 0; padding: 0; box-sizing: border-box; }

    :root {
      --line-height: 18px; /* 12px font * 1.5 */
      --sidebar-width: 400px;
      --sidebar-min-width: 240px;
      --sidebar-max-width: 840px;
      --sidebar-resizer-width: 6px;
    }

    body {
      font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
      font-size: 12px;
      line-height: var(--line-height);
      color: var(--text);
      background: var(--body-bg);
    }

    body.sidebar-resizing {
      cursor: col-resize;
      user-select: none;
    }

    #app {
      display: flex;
      min-height: 100vh;
    }

    /* Sidebar */
    #sidebar {
      width: var(--sidebar-width);
      min-width: var(--sidebar-width);
      max-width: var(--sidebar-width);
      background: var(--container-bg);
      flex-shrink: 0;
      display: flex;
      flex-direction: column;
      position: sticky;
      top: 0;
      height: 100vh;
      border-right: 1px solid var(--dim);
    }

    #sidebar-resizer {
      width: var(--sidebar-resizer-width);
      flex-shrink: 0;
      position: sticky;
      top: 0;
      height: 100vh;
      cursor: col-resize;
      touch-action: none;
      background: transparent;
      border-right: 1px solid transparent;
    }

    #sidebar-resizer:hover,
    body.sidebar-resizing #sidebar-resizer {
      background: var(--selectedBg);
      border-right-color: var(--dim);
    }

    .sidebar-header {
      padding: 8px 12px;
      flex-shrink: 0;
    }

    .sidebar-controls {
      padding: 8px 8px 4px 8px;
    }

    .sidebar-search {
      width: 100%;
      box-sizing: border-box;
      padding: 4px 8px;
      font-size: 11px;
      font-family: inherit;
      background: var(--body-bg);
      color: var(--text);
      border: 1px solid var(--dim);
      border-radius: 3px;
    }

    .sidebar-filters {
      display: flex;
      padding: 4px 8px 8px 8px;
      gap: 4px;
      align-items: center;
      flex-wrap: wrap;
    }

    .sidebar-search:focus {
      outline: none;
      border-color: var(--accent);
    }

    .sidebar-search::placeholder {
      color: var(--muted);
    }

    .filter-btn {
      padding: 3px 8px;
      font-size: 10px;
      font-family: inherit;
      background: transparent;
      color: var(--muted);
      border: 1px solid var(--dim);
      border-radius: 3px;
      cursor: pointer;
    }

    .filter-btn:hover {
      color: var(--text);
      border-color: var(--text);
    }

    .filter-btn.active {
      background: var(--accent);
      color: var(--body-bg);
      border-color: var(--accent);
    }

    .sidebar-close {
      display: none;
      padding: 3px 8px;
      font-size: 12px;
      font-family: inherit;
      background: transparent;
      color: var(--muted);
      border: 1px solid var(--dim);
      border-radius: 3px;
      cursor: pointer;
      margin-left: auto;
    }

    .sidebar-close:hover {
      color: var(--text);
      border-color: var(--text);
    }

    .tree-container {
      flex: 1;
      overflow: auto;
      padding: 4px 0;
    }

    .tree-node {
      padding: 0 8px;
      cursor: pointer;
      display: flex;
      align-items: baseline;
      font-size: 11px;
      line-height: 13px;
      white-space: nowrap;
    }

    .tree-node:hover {
      background: var(--selectedBg);
    }

    .tree-node.active {
      background: var(--selectedBg);
    }

    .tree-node.active .tree-content {
      font-weight: bold;
    }

    .tree-node.in-path {
      background: color-mix(in srgb, var(--accent) 10%, transparent);
    }

    .tree-node:not(.in-path) {
      opacity: 0.5;
    }

    .tree-node:not(.in-path):hover {
      opacity: 1;
    }

    .tree-prefix {
      color: var(--muted);
      flex-shrink: 0;
      font-family: monospace;
      white-space: pre;
    }

    .tree-marker {
      color: var(--accent);
      flex-shrink: 0;
    }

    .tree-content {
      color: var(--text);
    }

    .tree-role-user {
      color: var(--accent);
    }

    .tree-role-skill {
      color: var(--customMessageLabel);
    }

    .tree-role-assistant {
      color: var(--success);
    }

    .tree-role-tool {
      color: var(--muted);
    }

    .tree-muted {
      color: var(--muted);
    }

    .tree-error {
      color: var(--error);
    }

    .tree-compaction {
      color: var(--borderAccent);
    }

    .tree-branch-summary {
      color: var(--warning);
    }

    .tree-custom-message {
      color: var(--customMessageLabel);
    }

    .tree-status {
      padding: 4px 12px;
      font-size: 10px;
      color: var(--muted);
      flex-shrink: 0;
    }

    /* Main content */
    #content {
      flex: 1;
      min-width: 0;
      overflow-y: auto;
      padding: var(--line-height) calc(var(--line-height) * 2);
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    #content > * {
      width: 100%;
      max-width: 800px;
    }

    /* Help bar */
    .help-bar {
      font-size: 11px;
      color: var(--warning);
      margin-bottom: var(--line-height);
      display: flex;
      align-items: center;
      justify-content: space-between;
      flex-wrap: wrap;
      gap: 12px;
    }

    .help-hint {
      flex: 1 1 240px;
    }

    .help-actions {
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: 8px;
    }

    .header-toggle-btn,
    .download-json-btn {
      font-size: 10px;
      padding: 2px 8px;
      background: var(--container-bg);
      border: 1px solid var(--border);
      border-radius: 3px;
      color: var(--text);
      cursor: pointer;
      font-family: inherit;
    }

    .header-toggle-btn:hover,
    .download-json-btn:hover {
      background: var(--hover);
      border-color: var(--borderAccent);
    }

    /* Header */
    .header {
      background: var(--container-bg);
      border-radius: 4px;
      padding: var(--line-height);
      margin-bottom: var(--line-height);
    }

    .header h1 {
      font-size: 12px;
      font-weight: bold;
      color: var(--borderAccent);
     

... [284302 bytes truncated] ...

ine code: escape HTML
          codespan(token) {
            return `<code>${escapeHtml(token.text)}</code>`;
          }
        }
      });

      // Simple marked parse (escaping handled in renderers)
      function safeMarkedParse(text) {
        return marked.parse(text);
      }

      // Search input
      const searchInput = document.getElementById('tree-search');
      searchInput.addEventListener('input', (e) => {
        searchQuery = e.target.value;
        forceTreeRerender();
      });

      // Filter buttons
      document.querySelectorAll('.filter-btn').forEach(btn => {
        btn.addEventListener('click', () => {
          document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
          btn.classList.add('active');
          filterMode = btn.dataset.filter;
          forceTreeRerender();
        });
      });

      // Sidebar toggle
      const sidebar = document.getElementById('sidebar');
      const overlay = document.getElementById('sidebar-overlay');
      const hamburger = document.getElementById('hamburger');
      const sidebarResizer = document.getElementById('sidebar-resizer');
      const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width';
      const MIN_CONTENT_WIDTH = 320;

      function isMobileLayout() {
        return window.matchMedia('(max-width: 900px)').matches;
      }

      function getSidebarBounds() {
        const rootStyles = getComputedStyle(document.documentElement);
        const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240;
        const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720;
        const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH;
        return {
          minWidth,
          maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth))
        };
      }

      function clampSidebarWidth(width) {
        const { minWidth, maxWidth } = getSidebarBounds();
        return Math.max(minWidth, Math.min(maxWidth, width));
      }

      function applySidebarWidth(width) {
        document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`);
      }

      function loadSidebarWidth() {
        try {
          const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
          if (raw === null) return null;
          const width = Number(raw);
          return Number.isFinite(width) ? width : null;
        } catch {
          return null;
        }
      }

      function saveSidebarWidth(width) {
        try {
          localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width))));
        } catch {
          // Ignore storage failures (e.g. private browsing restrictions)
        }
      }

      function setupSidebarResize() {
        const savedWidth = loadSidebarWidth();
        if (savedWidth !== null) {
          applySidebarWidth(savedWidth);
        }

        if (!sidebarResizer) return;

        let cleanupDrag = null;

        const stopDrag = (pointerId) => {
          if (cleanupDrag) {
            cleanupDrag(pointerId);
            cleanupDrag = null;
          }
        };

        sidebarResizer.addEventListener('pointerdown', (e) => {
          if (isMobileLayout()) return;

          e.preventDefault();
          const startX = e.clientX;
          const startWidth = sidebar.getBoundingClientRect().width;
          document.body.classList.add('sidebar-resizing');
          sidebarResizer.setPointerCapture?.(e.pointerId);

          const onPointerMove = (event) => {
            applySidebarWidth(startWidth + (event.clientX - startX));
          };

          cleanupDrag = (pointerIdToRelease) => {
            document.body.classList.remove('sidebar-resizing');
            sidebarResizer.releasePointerCapture?.(pointerIdToRelease);
            window.removeEventListener('pointermove', onPointerMove);
            window.removeEventListener('pointerup', onPointerUp);
            window.removeEventListener('pointercancel', onPointerCancel);
            saveSidebarWidth(sidebar.getBoundingClientRect().width);
          };

          const onPointerUp = (event) => stopDrag(event.pointerId);
          const onPointerCancel = (event) => stopDrag(event.pointerId);

          window.addEventListener('pointermove', onPointerMove);
          window.addEventListener('pointerup', onPointerUp);
          window.addEventListener('pointercancel', onPointerCancel);
        });

        sidebarResizer.addEventListener('dblclick', () => {
          if (isMobileLayout()) return;
          applySidebarWidth(400);
          saveSidebarWidth(400);
        });

        window.addEventListener('resize', () => {
          if (isMobileLayout()) return;
          applySidebarWidth(sidebar.getBoundingClientRect().width);
        });
      }

      setupSidebarResize();

      hamburger.addEventListener('click', () => {
        sidebar.classList.add('open');
        overlay.classList.add('open');
        hamburger.style.display = 'none';
      });

      const closeSidebar = () => {
        sidebar.classList.remove('open');
        overlay.classList.remove('open');
        hamburger.style.display = '';
      };

      overlay.addEventListener('click', closeSidebar);
      document.getElementById('sidebar-close').addEventListener('click', closeSidebar);

      // Toggle states
      let thinkingExpanded = true;
      let toolOutputsExpanded = false;

      const toggleThinking = () => {
        thinkingExpanded = !thinkingExpanded;
        document.querySelectorAll('.thinking-text').forEach(el => {
          el.style.display = thinkingExpanded ? '' : 'none';
        });
        document.querySelectorAll('.thinking-collapsed').forEach(el => {
          el.style.display = thinkingExpanded ? 'none' : 'block';
        });
      };

      const toggleToolOutputs = () => {
        toolOutputsExpanded = !toolOutputsExpanded;
        document.querySelectorAll('.tool-output.expandable').forEach(el => {
          el.classList.toggle('expanded', toolOutputsExpanded);
        });
        document.querySelectorAll('.compaction').forEach(el => {
          el.classList.toggle('expanded', toolOutputsExpanded);
        });
        document.querySelectorAll('.skill-invocation').forEach(el => {
          el.classList.toggle('expanded', toolOutputsExpanded);
        });
      };

      const attachHeaderHandlers = () => {
        document.querySelector('[data-action="toggle-thinking"]')?.addEventListener('click', toggleThinking);
        document.querySelector('[data-action="toggle-tools"]')?.addEventListener('click', toggleToolOutputs);
      };

      const isEditableTarget = (element) => {
        if (!element) return false;
        const tagName = element.tagName;
        if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || tagName === 'BUTTON') {
          return true;
        }
        return element.isContentEditable || Boolean(element.closest?.('[contenteditable="true"]'));
      };

      // Keyboard shortcuts
      document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') {
          searchInput.value = '';
          searchQuery = '';
          navigateTo(leafId, 'bottom');
        }

        if (isEditableTarget(document.activeElement)) {
          return;
        }

        const key = e.key.toLowerCase();
        if (key === 't') {
          e.preventDefault();
          toggleThinking();
        } else if (key === 'o') {
          e.preventDefault();
          toggleToolOutputs();
        }
      });

      // Initial render
      // If URL has targetId, scroll to that specific message; otherwise stay at top
      if (leafId) {
        if (urlTargetId && byId.has(urlTargetId)) {
          // Deep link: navigate to leaf and scroll to target message
          navigateTo(leafId, 'target', urlTargetId);
        } else {
          navigateTo(leafId, 'none');
        }
      } else if (entries.length > 0) {
        // Fallback: use last entry if no leafId
        navigateTo(entries[entries.length - 1].id, 'none');
      }
    })();

  </script>
</body>
</html>
Evidence: Portable regression output

Source: Portable regression output

ok - Calm collapses a branch-outcome read of complete store records to nothing and never swallows a failed read or output it does not recognize

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ⚠️ tests/fm-calm-branch-outcomes-live-e2e.test.sh:231 - The Calm-off live case asserts CALM_OUTCOMES_ROUTINE_14 (line 15 of the 16-record listing) is on screen, and the comment at lines 167-168 says the upstream renderer "shows the complete sanitized store listing". This merge brings in upstream f66be0f (fix(pi): restore Pi 0.84.4 renderer compatibility #3261, 2026-08-28), which added getStockOutcomesPreviewLines. That function clips the Calm-off fm_branch_outcomes result to Pi's stock collapsed preview on Pi >= 0.84.4. The upstream regression in tests/fm-pi-branch-extension.test.sh (the OUTCOME_TWELVE collapsed-preview check, PI_STOCK_RENDER_FLOOR=0.84.4) already asserts that a 12-line result is clipped with a "more lines ... to expand" hint. On the installed Pi 0.87.1, running FM_CALM_BRANCH_OUTCOMES_LIVE_E2E=1 tests/fm-calm-branch-outcomes-live-e2e.test.sh as the docs instruct would fail test_calm_off_keeps_the_stock_row because line 15 is outside the preview. Fix: assert a record inside the preview window, such as the first captain row task-12 raw JSON and &#34;verdict&#34;:&#34;routine&#34; (already asserted), optionally plus the expansion hint. Correct the comment. The dated record in docs/verification/runtime-backends.md:2232-2244 (Pi 0.84.3, 2026-08-24) predates this merge and fix(pi): restore Pi 0.84.4 renderer compatibility #3261, so re-run the guard and refresh it on the current Pi rather than keep that output.
  • ⚠️ .pi/extensions/lib/fm-calm-branch-outcomes.ts:99 - Intent: "making sure we arent repeating someone else's valid fix". This merge brings in upstream 5466394 (fix(pi): deliver captain outcomes as deterministic transcript entries #3312, 2026-09-01), which now delivers every captain-verdict outcome as a durable, exact-once visible transcript entry (VISIBLE_OUTCOME_ENTRY_TYPE, fm-branch-supervision.ts ~975). Routine notes already render as ⛵ task: summary (deliverRoutineOutcome, ~988). The recorded rebase decision justified keeping captain-verdict rows because upstream hid them "entirely". That rationale was written on 2026-08-26, before fix(pi): deliver captain outcomes as deterministic transcript entries #3312 existed, and no longer holds: the captain already sees each captain outcome in the transcript. With this branch, every Calm read of the store (list --recent 20) repeats each captain row in the window as a second ⛵ task: summary line, including outcomes processed long ago. The failed-read and unrecognized-output parts are still not covered upstream. Decide whether to narrow the Calm exception to failed reads and unrecognized output (drop the captain-verdict branch at lines 99-103 plus its tests and doc wording in docs/calm.md:23 and docs/calm-mode-feasibility.md:230), or to keep captain rows deliberately despite fix(pi): deliver captain outcomes as deterministic transcript entries #3312.
  • ℹ️ .pi/extensions/lib/fm-calm-branch-outcomes.ts:21 - calmBranchOutcomeAttention now returns glyph: true on every path (lines 86, 96, 101). So the glyph field on CalmBranchOutcomeLine and the renderer's glyph === false branch (.pi/extensions/fm-branch-supervision.ts:2181) are dead code. The recorded rebase decision prescribed that branch, so this is noted only; it could be dropped if that decision is revisited.

🔧 Fix applied.
2 infos still open:

  • ℹ️ .pi/extensions/lib/fm-calm-branch-outcomes.ts:21 - calmBranchOutcomeAttention now returns glyph: true on every path (lines 86, 96, 101). So the glyph field on CalmBranchOutcomeLine and the renderer's glyph === false branch (.pi/extensions/fm-branch-supervision.ts:2181) are dead code. The recorded rebase decision prescribed that branch, so this is noted only; it could be dropped if that decision is revisited.
  • ℹ️ docs/calm.md:23 - The new fm_branch_outcomes sentence was inserted between the sentence that introduces "the shared preservation rule above" (line 22) and "Pi applies that rule independently to each text block…" (line 24). As a result, "that rule" now reads as if it refers to the branch-outcome collapse instead of the working-note preservation rule. The fix is to move the new sentence below the working-note run, for example after the "…and /export artifacts." sentence at line 26, so the preservation-rule paragraph stays contiguous.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Calm on: reading a store of routine and captain records collapses the row entirely (no duplicate captain line after #3312) ✅ pass live live-calm-on.txt: no fm_branch_outcomes shell, no records, no task-12/task-4 lines
Calm on: a failed read (outcome script missing) stays visible as one ⛵ dim line ✅ pass live live-calm-on-failure.txt
Adversarial: a corrupt real store makes the read fail, and the failure is not hidden under Calm ✅ pass live live-calm-on-corrupt.txt
Adversarial: output in an unrecognized format comes through byte-for-byte with ⛵ while the recognized record collapses ✅ pass live live-calm-on-unrecognized.txt
Calm off: the stock row shows the leading raw records inside Pi's collapsed preview (the refreshed guard assertion) ✅ pass live live-calm-off.txt: task-12 captain record shown, '... (6 more lines, ctrl+o to expand)'
/export from a Calm-on session keeps all 16 raw records ✅ pass live live-calm-on-export.html; live-guard-as-shipped.txt test 3 ok
Multiline captain/failed-read text renders as a single line; unrecognized lines stay unchanged (module level) ⏸️ untested no The earlier payload did not show a live result for this scenario. It was only a pure-function check on real store records (portable-regression.txt), not a run against real Pi. Real-Pi single-line rend…
  • bash tests/fm-calm-branch-outcomes.test.sh (portable regression, real store records)
  • bash tests/fm-pi-branch-extension.test.sh (46 ok, including the fm_branch_outcomes Calm hide, Calm-off and export case)
  • FM_CALM_BRANCH_OUTCOMES_LIVE_E2E=1 bash tests/fm-calm-branch-outcomes-live-e2e.test.sh as shipped, against real pi 0.87.1
  • Extended copy of the live guard (temp dir, since removed) that saved pane snapshots and added two adversarial real-Pi cases: a future-format unrecognized line from a fixture outcome script, and a corrupt real store file
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

* Calm hid every other tool row while `fm_branch_outcomes` dumped the
  outcome store's raw JSONL into the transcript, reproduced against real
  Pi 0.84.3: with Calm on, a built-in bash row vanished in the same turn
  where the outcome read printed every record verbatim.
* Taught Calm this tool instead of adding a second hiding path. The row
  now reaches Calm's existing visibility owner the same way the watcher
  tool does, over Calm's published presentation event, so Calm stays the
  one place that decides what the transcript shows.
* Kept failure visible. An outcome the branch already handled collapses
  with the row, while a failed read, a captain-verdict outcome, and any
  output Calm does not recognize as the store's records each survive as
  one dim line under the branch's own glyph, so a fleet failure is never
  hidden. The store's own verdict field is the actionable marker; no
  keyword guessing.
* Preserved stock and export rendering. Collapsing needs self shell
  rendering, so the slots reproduce Pi's own fallback, including the
  unexpanded clip and expand hint; a Calm-off transcript came back
  byte-identical to the pre-change one at 3 and at 18 records, and the
  HTML export carries every record.
* Reviewed the other Calm surfaces (assistant layout, operational-user
  layout, working ship, visibility policy): none are touched, and their
  suite still passes.
@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge under Kun’s merge authority, with no actionable defects identified.

The changed renderer preserves stock and export paths, collapses only recognized routine outcomes, and retains failed, captain-relevant, and unrecognized output.

Reviews (1): Last reviewed commit: "no-mistakes(document): Refresh Calm bran..." | Re-trigger Greptile

* Kept main's stock preview and expansion checks in the outcomes
  renderer test and moved the Calm-on collapse check onto a routine
  store record, since unrecognized lines now stay visible by design.
* Taught the Calm recognizer the status-provenance record shape that
  bin/fm-branch-outcome.sh gained in kunchenguid#3495; without it every routine
  outcome read as unrecognized and stayed on screen under Calm.
* Folded the branch-outcome exception into main's restructured Calm,
  supervision-branch, and verification docs.
@zachlandes zachlandes changed the title fix(pi): collapse branch outcomes in Calm feat(pi): keep failed branch-outcome reads visible under Calm Sep 26, 2026
…used two of them, and both are now fixed. 1. **fm-live-gate.test.sh (shard 7), caused by this PR.** Rule it broke: every guard in the live-harness family has to start with the shared `fm_live_gate`, so that `FM_LIVE=0` switches them all off together. The new `tests/fm-calm-branch-outcomes-live-e2e.test.sh` used its own on/off check and its own pi/tmux checks instead. I replaced those with `fm_live_gate opt-in FM_CALM_BRANCH_OUTCOMES_LIVE_E2E pi tmux`, which keeps the "refuse to pass having checked nothing" behaviour when the guard is asked to run. It was the only file in this diff with its own gate. `tests/fm-live-gate.test.sh` now passes: all 40 live guards skip together under `FM_LIVE=0`. 2. **fm-pi-branch-responsiveness-live-e2e.test.sh (shard 6), caused by this PR.** Rule it broke: any test that copies `fm-branch-supervision.ts` into a fixture must also copy every `./lib` file it imports. This PR added an import of `./lib/fm-calm-branch-outcomes.ts`, but the fixture didn't copy that file, so Pi couldn't load the extension and its TUI never drew. I checked every test that copies the extension. The same gap was in `tests/fm-pi-branch-live-e2e.test.sh`, which CI didn't catch because it only runs when asked, so I fixed both. `fm-pi-codex-native` loads the extension straight from the repo, so it needed no change. The responsiveness guard now passes locally against the real Pi 0.87.1: "ok - supervision outcome delivery keeps the real Pi 0.87.1 TUI echoing keystrokes at its unloaded floor". 3. **fm-remote-secondmate-relaunch.test.sh (shard 6), not caused by this PR.** It fails with "could not arm the PR poll fixture for the relaunch-ordering test". That part of the test was added upstream in e789e52 (kunchenguid#5583). It fails the same way when run on a clean export of the base commit ea7c7f7, with none of this branch's changes, and this PR touches nothing it uses. I made no change for it. While debugging number 3, a temporary `sed` edit I made emptied `bin/fm-pr-check.sh` in the worktree. I restored it with `git checkout`, and it is not part of the changes. Also checked: `shellcheck -x` is clean on the three changed test files, and `tests/fm-calm-branch-outcomes.test.sh` still passes
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