SLOPSHOPPER

fleet-lamp

A lamp above the prompt: red when the firstmate fleet needs you, green when a PR is ready.

newbandpromptprocesstimer
A shopper browsing a rack in a slop shop
README

firstmate-mods

<img width="2306" height="1937" alt="image" src="https://github.com/user-attachments/assets/b7c507c9-6256-40af-9023-78929b30ab83" />

Claude Code mods for firstmate fleets, published as a Claude Code plugin marketplace.

ModWhat it does
fleet-lampA lamp above the prompt: red when the fleet needs you, green when a PR is ready.
pr-weatherThe CI weather of the fleet's open PRs above the prompt: one glyph per PR.

Install

claude plugin marketplace add jayjongcheolpark/firstmate-mods
claude plugin install fleet-lamp@firstmate-mods
claude plugin install pr-weather@firstmate-mods

To try a local checkout without installing it, start a session with the plugin folder loaded:

claude --plugin-dir /path/to/firstmate-mods/plugins/fleet-lamp
claude --plugin-dir /path/to/firstmate-mods/plugins/pr-weather

fleet-lamp

A band above the prompt that tells you when the fleet needs a human. You don't need a lamp.

A task needs a decision (red), with one more open red behind it:

The fleet-lamp band in red: a red dot, then "fix-login needs-decision: keep session cookie (A) or move to JWT (B)?  +1 more"

A PR is ready for review (green):

The fleet-lamp band in green: a green dot, then "PR ready <a href=https://github.com/acme/webapp/pull/7 fix-login"" loading="lazy">

Both images come from a live session at 90 columns: the terminal screen the session drew, rendered with a dark palette. The band draws its dot in the theme's error color for red and its success color for green, and draws the task (or PR ready) in bold.

When nothing needs you, the band shows nothing.

What it shows

  • 🔴 Red (strongest): a task is waiting on you. The band shows the task, its state and its reason. The reason is the status text without the status line's own lead (the state word, any [key=...], [at=...] or [corr=...] tags and the colon), so needs-decision [key=pick]: A or B? shows as needs-decision: A or B?. The newest open red comes first, and +N more counts the others.
  • 🟢 Green: a PR is ready for review. The band shows the PR URL and its task.
  • Nothing: the fleet doesn't need you.

The lamp is always one line. When it does not fit the band, the reason (or, for green, the task after the URL) is cut first, with …, down to 16 cells; then the task (or the URL), down to 12 cells; then the reason goes. The dot and +N more are never cut. The mod measures in terminal cells, so Korean and other wide text counts double, and in the terminal it leaves the last four cells for the band's [-] control.

The rules

The band reads only task.status and task.pr_ready records from firstmate's fleet activity ledger, state/fleet-ledger.jsonl (see docs/fleet-ledger.md in the firstmate repository), in the session's own firstmate home only.

  • Red: a task.status record whose state is needs-decision, blocked or failed. Two kinds of record are left out:
  • worker validation findings that firstmate decides itself (text contains ask-user findings=);
  • a held decision recorded again with the captain's own answer (Captain 2026-09-30 verbatim ...).
  • A keyed red (a record with a [key=...] decision key) turns off by itself when a later resolved record carries the same task and key.
  • A keyless red stays until your next prompt.
  • Green (latched): a task.pr_ready record, or a done record that reports a ready PR (PR https://..., child X done: PR https://..., PR ready: https://...). A done record that says the PR already landed or merged does not count. Green stays until your next prompt.
  • Your next prompt clears the band, red and green alike. Only a prompt you send counts, typed at the terminal or sent through Remote Control. Task notifications, messages from other sessions and scheduled prompts leave the band as it is.

These are the same rules as the lamp script that inspired this mod.

Turning on the ledger

The ledger is opt-in. In your firstmate home, create the flag:

touch config/fleet-ledger

Delete the flag to turn the ledger off. While the ledger is off, the band shows nothing. The first time a session finds the home's ledger off, the mod adds one dim line to the transcript that tells you how to turn it on.

How it finds your fleet

The mod looks for the firstmate home at or above the session's working directory: the nearest directory whose AGENTS.md has a # Firstmate heading line and that has a state/ folder. Start your firstmate session in its home, as usual, and the mod finds it. A claude -p run or an SDK session, where no one is at the prompt, follows no ledger.

To follow a home from somewhere else, set the plugin's home option in /config, or from a shell:

echo '{"home": "~/firstmate"}' | claude plugin configure fleet-lamp@firstmate-mods --values-stdin

Second mates

The mod does not follow the ledgers of second mate homes. You do not see a second mate's decisions, so a red in a second mate's ledger is not yours yet. When the second mate passes a decision up, the main firstmate records it in the main ledger, and the band turns red then.

The mod reads only the config/fleet-ledger flag and state/fleet-ledger.jsonl in the home. It reads no other state file, makes no network calls and controls no hardware.

How it reads the ledger

  • The mod checks the ledger every 2 seconds and reads only the lines added since the last check, from a saved byte offset. A partial last line waits for the next check.
  • On the first look at the ledger in a session, the mod starts at its end, so old history does not turn the band red.
  • The offset and the signals are kept in the session's state, so a reload of the mod does not read old lines again.
  • If the ledger is truncated, the mod reads it again from the top.
  • The ledger never rotates, and $.fs.read reads whole files of at most 4 MiB. So the mod reads the new bytes with tail -c +<offset>.

Developing

claude plugin validate .                    # the marketplace
claude plugin validate plugins/fleet-lamp   # the plugin and its hooks module
claude plugin test plugins/fleet-lamp       # the rule, line fitting and band tests
npx -p typescript tsc -p plugins/fleet-lamp  # type-check, once a session has loaded the mod

A session that loads the mod from a folder you own (--plugin-dir) writes the API types to plugins/fleet-lamp/.claude-plugin/types/, which tsconfig.json extends.

The rules are pure functions in plugins/fleet-lamp/hooks/rules.ts, and the one-line fitting is in plugins/fleet-lamp/hooks/line.ts. The hooks module that reads the ledger and draws the band is plugins/fleet-lamp/hooks/register.tsx.

pr-weather

A band above the prompt with the CI weather of the PRs your firstmate fleet is working on, one glyph per PR:

PRs ☂ #7 ↯ #9 ⚔ #11 ✎ #10 ☀ #12 +2 more updated 2m ago [ ↻ ] [ auto ]

What it shows

GlyphMeaning
↯ (magenta)A workflow run on the PR's head commit is held for approval: a human has to approve it.
☂ (red)A check failed, was cancelled or timed out.
⚔ (red)The PR has merge conflicts with its base branch. While GitHub is still computing mergeability, the PR shows its CI glyph.
☁ (yellow)Checks are pending, queued or in progress.
✎ (gray)The PR is a draft.
☀ (green)Every check passed.
- (gray)The PR has no checks.

When more than one applies, the first in the table wins: a draft with a failed check shows ☂.

After the PRs come:

  • +N more when the PRs do not fit on one line;
  • updated 2m ago, the time of the last successful refresh;
  • (stale) when the last refresh failed, so the glyphs are from an earlier one. When the lookup of one PR fails, that PR alone keeps its last glyph, or stays out of the band until a lookup succeeds;
  • low quota, every 12m while auto mode backs off, or rate-limited, resets in 17m while GitHub's rate limit holds every refresh;
  • the refresh button [ ↻ ] ([ … ] while a refresh runs) and the mode button [ auto ] or [ manual ].

Opening a PR

Press a PR's #N to open it, in any terminal:

  • Fullscreen layout: click #N.
  • Default layout: focus the band with ctrl+x tab, move to #N with tab, and press Enter.
  • Desktop: click #N.

What a press does depends on where the session runs:

  • A local Mac runs open <url>, which opens the PR in your default browser, and shows Opened PR #N.
  • A local Linux desktop (with DISPLAY or WAYLAND_DISPLAY set) runs xdg-open <url> and shows Opened PR #N.
  • Anywhere else, the mod copies the PR URL to your clipboard and shows Copied PR #N URL. That covers an SSH session (SSH_CONNECTION, SSH_TTY or SSH_CLIENT set) and a Linux server without a desktop, such as one you reach through a terminal multiplexer. In the terminal the copy goes through the terminal's clipboard (OSC 52, as /copy does), so it lands on the machine you are sitting at. Paste the URL into your browser.
  • If the opener fails, the mod copies the URL instead, and the toast says why first: open exited 1; copied PR #N URL, or open failed: <error>; copied PR #N URL. If the copy fails too, the toast shows the reason and the URL itself.

Each press also writes lines to the Claude Code debug log, which Claude Code writes only when you start it with claude --debug or --debug-file <path>. Find them with grep 'pr-weather press'. The first line shows that the press arrived. The second line shows the opener argv and its result:

pr-weather press #7 https://github.com/acme/webapp/pull/7 opener=none pressed
pr-weather press #7 https://github.com/acme/webapp/pull/7 opener=["open","https://github.com/acme/webapp/pull/7"] exit=0 stderr=""

In the second line, exit=<code> stderr="<text>" means the opener ran. error="<message>" means it could not run, for example a timeout. opener=none copy=no-local-browser means the session has no local browser to open.

The mod opens only https://github.com/<owner>/<repo>/pull/<n> URLs, runs the opener without a shell, and gives it 10 seconds.

The weather glyph before each #N is also a terminal hyperlink to the PR (cmd-click, handled by the terminal itself) where Claude Code draws terminal hyperlinks: Ghostty, iTerm2, WezTerm, kitty, Alacritty, Warp, Hyper and the VS Code terminal. If your terminal supports OSC 8 hyperlinks but is not on that list (Kaku, for example), set FORCE_HYPERLINK=1 in Claude Code's environment. On the desktop the glyph is always a link.

When there are no open PRs, the band shows PRs none and the buttons.

Refreshing

  • auto (default): the mod refreshes on the interval, every 3 minutes unless you change it.
  • manual: the mod loads once when the session starts and then refreshes only when you ask.

To refresh now, press [ ↻ ] or run /pr-weather refresh. That works in both modes, at most once every 30 seconds.

To switch modes, press the mode button or run /pr-weather mode auto or /pr-weather mode manual. The choice is kept across sessions until you change the mode setting itself.

To press a band button from the keyboard, focus the band with ctrl+x tab and press r (refresh) or m (mode), or move to a button with tab and press Enter. In the fullscreen layout you can also click them.

Rate limits

Each refresh first reads your remaining GitHub quota with gh api rate_limit, which does not count against the quota.

  • Below 10% remaining (of the REST or GraphQL quota, whichever is lower), auto mode doubles its interval each refresh, up to 30 minutes, and the band says so. When the quota recovers, the interval returns to normal.
  • When the quota is used up, or GitHub answers a call with a rate-limit error, the band is marked stale and no refresh runs, by hand or on the interval, until the quota resets.

Which PRs

With the default fleet source, the PRs come from firstmate's fleet activity ledger, state/fleet-ledger.jsonl (see docs/fleet-ledger.md in the firstmate repository):

  • a task.pr_ready record adds its PR, and a later one for the same task replaces it;
  • a done status record that reports a ready PR does the same, by fleet-lamp's green rule (PR https://..., PR ready: https://..., child X done: PR https://...);
  • a task.merged or task.cleaned_up record for that task removes it, and so does a done record that says the PR landed or merged;
  • a PR that GitHub reports as merged or closed is left out.

The ledger is opt-in: create the flag config/fleet-ledger in your firstmate home. Until the ledger exists, the band shows nothing, and the mod adds one dim line to the transcript that tells you how to turn it on.

The mod also reads the ledgers of the second mate homes registered in the home's data/secondmates.md: every entry with (home: <absolute path>; ...) whose directory exists on this machine. Remote entries (host: ...; root: ...; home: ...) are skipped, so the mod makes no SSH calls. Unlike fleet-lamp, which follows the main home alone, pr-weather shows second mate PRs. The band shows the PRs of every home, each PR once. A second mate home without a ledger is left out, with one dim line in the transcript that names it. While the main home has no ledger but a second mate does, the band shows the second mate's PRs and the mod adds the turn-it-on line once. Each refresh reads data/secondmates.md again, so a new second mate shows on the next refresh. Turn off includeSecondMates to read the main home alone.

The band shows only in a firstmate session: one whose working directory is at or under a firstmate home (a directory whose AGENTS.md has a # Firstmate heading line and that has a state/ folder). In any other session the mod draws nothing and makes no calls. With either source, a claude -p run or an SDK session, where no one is at the prompt, also draws nothing and makes no calls.

With the mine source, the band shows your own open PRs in the session's repository (gh pr list --author @me), in any session inside a git repository.

Settings

Set these in /config, or from a shell:

echo '{"mode": "manual", "refreshMinutes": 5}' | claude plugin configure pr-weather@firstmate-mods --values-stdin
SettingDefaultWhat it does
sourcefleetfleet: the fleet ledger's PRs, in firstmate sessions. mine: your open PRs in the session's repository.
modeautoauto refreshes on the interval; manual refreshes only when you ask.
refreshMinutes3How often auto mode refreshes, in minutes (at least 1).
homeemptyThe firstmate home to read (~ allowed). Empty: the nearest one at or above the session's working directory. When set, the band shows in every session.
includeSecondMatestrueAlso read the ledgers of the home's local second mate homes (fleet source).

Requirements

The GitHub CLI (gh), logged in (gh auth login). The mod reads GitHub only through gh with JSON output. If gh is missing or not logged in, the band shows nothing and the mod adds one dim line to the transcript that says what to do.

Each refresh makes one gh api rate_limit call, then two calls per PR: gh pr view --json for the checks and draft state, and gh api .../actions/runs?head_sha=... for runs held for approval.

Developing

claude plugin validate plugins/pr-weather   # the plugin and its hooks module
claude plugin test plugins/pr-weather       # the weather, ledger, second mate, layout, mode, opening and band tests
npx -p typescript tsc -p plugins/pr-weather  # type-check, once a session has loaded the mod

The pure parts (glyphs, check classification, the ledger, the band layout, quota and backoff) are in plugins/pr-weather/hooks/weather.ts, the URL check and opener choice are in plugins/pr-weather/hooks/open.ts, and the data/secondmates.md parser is plugins/pr-weather/hooks/secondmates.ts. The hooks module that calls gh, schedules refreshes and draws the band is plugins/pr-weather/hooks/register.tsx.

Source 4 files
hooks/register.tsx 181 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { fitLine } from './line'
5import type { LampLine } from './line'
6import { applyLines, EMPTY, lampOf } from './rules'
7
8const latch = atom({ plugin: 'fleet-lamp', key: 'latch' } as const, EMPTY)
9const offsets = atom({ plugin: 'fleet-lamp', key: 'offsets' } as const, {})
10const followed = atom({ plugin: 'fleet-lamp', key: 'followed' } as const, null as string | null)
11
12const POLL_MS = 2000
13// The captain's own prompt, typed or sent from a phone; never a notification, peer, schedule or plugin.
14const CAPTAIN_ORIGINS = new Set(['composer', 'bridge'])
15
16export const register: Register = (on, options) => {
17  let isPolling = false
18
19  on('session.start', async ($, e, next) => {
20    const result = await next(e)
21    // No one is at the prompt to see the lamp: follow nothing.
22    if (!e.isInteractive) {
23      return result
24    }
25    const home = await findHome($, String(options.home ?? ''))
26    if (home === null) {
27      await update($, followed, () => null)
28      $.ui.log('no firstmate home above this session; set the plugin\'s "home" option to follow one', { to: 'debug' })
29      return result
30    }
31
32    // Whether this session has logged the ledger-off hint: once per session.
33    let isHinted = false
34    const tick = async () => {
35      if (isPolling) {
36        return
37      }
38      isPolling = true
39      try {
40        const isOn = await $.fs.exists(`${home}/config/fleet-ledger`)
41        if (!isOn && !isHinted) {
42          isHinted = true
43          $.ui.log(`the fleet ledger is off in ${home}; turn it on with: touch ${home}/config/fleet-ledger`)
44        }
45        const live = isOn ? home : null
46        if (live !== (await read($, followed))) {
47          await update($, followed, () => live)
48        }
49        if (isOn) {
50          await poll($, home)
51        }
52      } catch (error) {
53        $.ui.log(`ledger read skipped: ${String(error)}`, { to: 'debug' })
54      } finally {
55        isPolling = false
56      }
57    }
58    await tick()
59    $.clock.every(POLL_MS, () => void tick())
60    return result
61  })
62
63  // The lamp's clear-hook: the captain has seen it.
64  on('prompt.submit', async ($, e, next) => {
65    if (CAPTAIN_ORIGINS.has(e.origin.kind)) {
66      await update($, latch, () => EMPTY)
67    }
68    return next(e)
69  })
70
71  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72    const lamp = lampOf(await read($, latch))
73    if (e.props.hasSurvey || (await read($, followed)) === null || lamp === null) {
74      return next(e)
75    }
76
77    const { Box, Text } = $.ui.resolve(e)
78    // What the plugins beneath draw stays, above the lamp: the band is shared.
79    const below = await next(e)
80    const more = lamp.count > 1 ? `  +${lamp.count - 1} more` : ''
81    const isRed = lamp.color === 'red'
82    const head = isRed ? '' : 'PR ready '
83    const parts: LampLine = isRed
84      ? { lead: '● ', main: lamp.signal.task, tail: ` ${lamp.signal.state}${lamp.signal.reason === '' ? '' : `: ${lamp.signal.reason}`}`, more }
85      : {
86          lead: `● ${head}`,
87          main: lamp.signal.pr ?? lamp.signal.task,
88          tail: lamp.signal.pr === null ? '' : `  ${lamp.signal.task}`,
89          more,
90        }
91    // The terminal draws its [-] collapse control in the row's last three cells; keep one more of air.
92    const columns = e.props.bodyColumns - (e.surface === 'terminal' ? 4 : 0)
93    const { main, tail } = fitLine(parts, columns)
94
95    // One Text, fitted beforehand: the line never wraps, and no part's spacing is squeezed away.
96    return (
97      <Box flexDirection="column">
98        {below}
99        <Box key="lamp" flexDirection="row">
100          <Text wrap="truncate-end">
101            <Text color={isRed ? 'error' : 'success'}>●</Text>{' '}
102            {/* A red reads `task state: reason`; a green `PR ready <url>  task`, its task dim. */}
103            <Text bold>{isRed ? main : head}</Text>
104            {isRed ? tail : main}
105            <Text dimColor>{isRed ? '' : tail}</Text>
106            <Text dimColor>{more}</Text>
107          </Text>
108        </Box>
109      </Box>
110    )
111  })
112}
113
114/**
115 * The firstmate home: the configured one, else the nearest directory at or above the session's
116 * working directory whose AGENTS.md has a `# Firstmate` heading line and which holds state/.
117 */
118async function findHome($: EngineInterface, configured: string): Promise<string | null> {
119  if (configured.trim() !== '') {
120    return trimSlash(await expandTilde($, configured.trim()))
121  }
122  const found = await $.fs.ancestors({ names: ['AGENTS.md'] })
123  for (const { dir, content } of [...found].reverse()) {
124    if (/^# Firstmate\s*$/m.test(content) && (await isDir($, `${dir}/state`))) {
125      return trimSlash(dir)
126    }
127  }
128  return null
129}
130
131/** Reads the lines appended to the home's ledger since its saved offset and folds them into the latch. */
132async function poll($: EngineInterface, home: string): Promise<void> {
133  const ledger = `${home}/state/fleet-ledger.jsonl`
134  const stat = await $.fs.stat(ledger).catch(() => undefined)
135  const size = stat?.kind === 'file' ? stat.size : 0
136  const saved = (await read($, offsets))[home]
137  const save = (to: number) => update($, offsets, current => ({ ...current, [home]: to }))
138  // First look: start at the end, so old history does not flash red.
139  if (saved === undefined) {
140    await save(size)
141    return
142  }
143  // The ledger was truncated (docs/fleet-ledger.md): later records start from the top.
144  const start = size < saved ? 0 : saved
145  if (size === start) {
146    if (start !== saved) {
147      await save(start)
148    }
149    return
150  }
151
152  // $.fs.read takes whole files of at most 4 MiB, and the ledger never rotates: tail reads the
153  // appended bytes alone.
154  const { exitCode, stdout } = await $.process.run(['tail', '-c', `+${start + 1}`, ledger])
155  if (exitCode !== 0) {
156    return
157  }
158  // A partial last record waits for the next write.
159  const complete = stdout.slice(0, stdout.lastIndexOf('\n') + 1)
160  if (complete === '') {
161    return
162  }
163  await update($, latch, current => applyLines(current, complete))
164  await save(start + new TextEncoder().encode(complete).length)
165}
166
167async function isDir($: EngineInterface, path: string): Promise<boolean> {
168  const stat = await $.fs.stat(path).catch(() => undefined)
169  return stat?.kind === 'dir'
170}
171
172async function expandTilde($: EngineInterface, path: string): Promise<string> {
173  if (path !== '~' && !path.startsWith('~/')) {
174    return path
175  }
176  const home = (await $.env.get('HOME')) ?? ''
177  return home + path.slice(1)
178}
179
180const trimSlash = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path)
181
hooks/line.ts 80 lines
1// The lamp's one line, fitted to the band in terminal cells. Pure: the tests hold it without a surface.
2
3// Code points a terminal draws two cells wide: Hangul, CJK, fullwidth forms, wide emoji.
4const WIDE =
5  /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏ꥠ-꥿가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA70}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
6// Code points that take no cell of their own: combining marks, joiners, variation selectors.
7const ZERO = /[\p{Mn}\p{Me}​-‏⁠︀-️]/u
8
9const ELLIPSIS = '…'
10
11function cellsOf(char: string): number {
12  if (ZERO.test(char)) return 0
13  return WIDE.test(char) ? 2 : 1
14}
15
16/** How many terminal cells `text` takes. */
17export function cellWidth(text: string): number {
18  let width = 0
19  for (const char of text) width += cellsOf(char)
20  return width
21}
22
23/** `text` cut to at most `max` cells, an ellipsis marking the cut; empty when `max` is below one. */
24export function truncateCells(text: string, max: number): string {
25  if (cellWidth(text) <= max) return text
26  if (max < 1) return ''
27  let kept = ''
28  let width = 0
29  for (const char of text) {
30    const cells = cellsOf(char)
31    if (width + cells > max - 1) break
32    kept += char
33    width += cells
34  }
35  return kept + ELLIPSIS
36}
37
38/**
39 * The lamp's line in parts, drawn in this order:
40 * `lead` (the dot, and for a green `PR ready`, kept), `main` (the task or the PR), `tail` (the state and
41 * reason, or the task beside a PR), then `more` (the `+N more` count, kept whole).
42 */
43export type LampLine = { lead: string; main: string; tail: string; more: string }
44
45// The cells the tail keeps before the main part starts to give way.
46export const MIN_TAIL = 16
47// The cells the main part keeps before the tail gives way entirely.
48export const MIN_MAIN = 12
49// The fewest cells of tail worth drawing once it has given way.
50const MIN_STUB = 4
51
52/**
53 * The line fitted to `columns` cells: the tail shrinks first, down to MIN_TAIL; then the main
54 * part, down to MIN_MAIN; then the tail to a stub or nothing, then the main part to nothing. The lead
55 * and the count are never cut here; a band too narrow even for them is the surface's to cut.
56 */
57export function fitLine(line: LampLine, columns: number): LampLine {
58  const fixed = cellWidth(line.lead) + cellWidth(line.more)
59  let room = columns - fixed
60  const mainWidth = cellWidth(line.main)
61  const tailWidth = cellWidth(line.tail)
62  if (mainWidth + tailWidth <= room) return line
63
64  // The tail gives way first, keeping MIN_TAIL cells while the main part fits beside it.
65  const tailKept = Math.min(tailWidth, MIN_TAIL)
66  if (mainWidth + tailKept <= room) return { ...line, tail: truncateCells(line.tail, room - mainWidth) }
67
68  // Then the main part, keeping MIN_MAIN cells beside the shortened tail.
69  const mainKept = Math.min(mainWidth, MIN_MAIN)
70  if (mainKept + tailKept <= room) {
71    return { ...line, main: truncateCells(line.main, room - tailKept), tail: truncateCells(line.tail, tailKept) }
72  }
73
74  // Then the tail goes, but for a stub of it where a few cells are left, and the main part takes the rest.
75  room = Math.max(0, room)
76  const main = truncateCells(line.main, room)
77  const left = room - cellWidth(main)
78  return { ...line, main, tail: left >= MIN_STUB ? truncateCells(line.tail, left) : '' }
79}
80
hooks/rules.ts 110 lines
1// The captain's lamp rules over firstmate's fleet activity ledger (docs/fleet-ledger.md).
2// Pure: no `$`, so the tests hold them without a file system.
3
4import type { GreenSignal, Latch, RedSignal } from '../types'
5
6export const EMPTY: Latch = { reds: [], greens: [] }
7
8const RED_STATES = new Set(['needs-decision', 'blocked', 'failed'])
9// a held decision re-recorded with the captain's own answer is not a new question
10const CAPTAIN_ANSWER = /\bCaptain(?: answer)? \d{4}-\d{2}-\d{2},? verbatim/
11const GREEN_PATTERNS = [/PR ready: https:\/\//, /^\s*PR https:\/\//, /child \S+ done: PR https:\/\//]
12const LANDED = /\b(landed|merged)\b/
13const URL = /https:\/\/\S+/
14
15type LedgerRecord = Readonly<Record<string, unknown>>
16
17const text = (value: unknown): string => (typeof value === 'string' ? value : '')
18
19// A status line's own lead, `<state> [key=...] [at=...] [corr=...]:`, which the band already says.
20const STATUS_TAGS = /^(?:\s*\[[^\]]*\])*\s*:\s*/
21
22/** The one-line reason a red shows: the status text without its status-line lead, whitespace folded. */
23function reasonOf(value: string, state: string): string {
24  const folded = value.replace(/\s+/g, ' ').trim()
25  if (!folded.startsWith(state)) {
26    return folded
27  }
28  const tags = STATUS_TAGS.exec(folded.slice(state.length))
29  return tags === null ? folded : folded.slice(state.length + tags[0].length).trim()
30}
31
32/** Folds one ledger record into the latch; records it does not recognize leave it as it was. */
33export function apply(latch: Latch, record: LedgerRecord): Latch {
34  const task = text(record.task)
35  const ts = typeof record.ts === 'number' ? record.ts : 0
36
37  if (record.event === 'task.pr_ready') {
38    return addGreen(latch, { task, pr: text(record.pr) || null, ts })
39  }
40  if (record.event !== 'task.status') {
41    return latch
42  }
43
44  const state = text(record.state)
45  const key = text(record.key) || null
46  const body = text(record.text)
47
48  if (state === 'resolved') {
49    return key === null
50      ? latch
51      : { ...latch, reds: latch.reds.filter(red => !(red.task === task && red.key === key)) }
52  }
53  if (RED_STATES.has(state) && !body.includes('ask-user findings=') && !CAPTAIN_ANSWER.test(body)) {
54    return addRed(latch, { task, key, state, reason: reasonOf(body, state), ts })
55  }
56  if (state === 'done' && GREEN_PATTERNS.some(pattern => pattern.test(body)) && !LANDED.test(body)) {
57    return addGreen(latch, { task, pr: body.match(URL)?.[0] ?? null, ts })
58  }
59  return latch
60}
61
62/** Parses text appended to the ledger, complete lines only, and folds each record in order. */
63export function applyLines(latch: Latch, lines: string): Latch {
64  return lines.split('\n').reduce((next, line) => {
65    const record = parse(line)
66    return record === null ? next : apply(next, record)
67  }, latch)
68}
69
70function parse(line: string): LedgerRecord | null {
71  if (line.trim() === '') {
72    return null
73  }
74  try {
75    const value: unknown = JSON.parse(line)
76    return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as LedgerRecord) : null
77  } catch {
78    return null
79  }
80}
81
82// The ledger delivers at least once, so a repeated record replaces its twin rather than stacking.
83function addRed(latch: Latch, red: RedSignal): Latch {
84  const isSame = (other: RedSignal) =>
85    other.task === red.task &&
86    (red.key === null ? other.key === null && other.reason === red.reason : other.key === red.key)
87  return { ...latch, reds: [...latch.reds.filter(other => !isSame(other)), red] }
88}
89
90function addGreen(latch: Latch, green: GreenSignal): Latch {
91  const isSame = (other: GreenSignal) =>
92    green.pr === null ? other.pr === null && other.task === green.task : other.pr === green.pr
93  return { ...latch, greens: [...latch.greens.filter(other => !isSame(other)), green] }
94}
95
96/** What the band shows: the newest open red, else the newest ready PR, else nothing. */
97export type Lamp =
98  | { color: 'red'; signal: RedSignal; count: number }
99  | { color: 'green'; signal: GreenSignal; count: number }
100  | null
101
102export function lampOf(latch: Latch): Lamp {
103  const red = latch.reds.at(-1)
104  if (red) {
105    return { color: 'red', signal: red, count: latch.reds.length }
106  }
107  const green = latch.greens.at(-1)
108  return green ? { color: 'green', signal: green, count: latch.greens.length } : null
109}
110
types/index.d.ts 32 lines
1/** A captain-facing question or failure, newest last in `Latch.reds`. */
2export type RedSignal = {
3  task: string
4  /** The record's `[key=...]` decision key; a keyed red closes on its `resolved` record. */
5  key: string | null
6  state: string
7  reason: string
8  ts: number
9}
10
11/** A ready PR, newest last in `Latch.greens`. */
12export type GreenSignal = {
13  task: string
14  pr: string | null
15  ts: number
16}
17
18/** Everything the band shows: red outranks green, and both clear on the captain's next prompt. */
19export type Latch = { reds: RedSignal[]; greens: GreenSignal[] }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'fleet-lamp': {
24      latch: Latch
25      /** Byte offset into the home's state/fleet-ledger.jsonl read so far, by home; absent until the first look. */
26      offsets: Readonly<Record<string, number>>
27      /** The firstmate home whose ledger is followed right now; the band is off while it is null. */
28      followed: string | null
29    }
30  }
31}
32