SLOPSHOPPER

final-commit

A sci-fi creature-collection game built from your real dev work.

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-09coopers98/final-commit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · final-commit
│ ┃ Calibrate ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Press Space each time the light shows. │ final-commit │ │ ┃ [ ] ⏺ Read(src/auth.ts) │ Captain's log, stardate 25282.3, recorded. │ │ ┃ 0 of 8 presses ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ Space: ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /epic │ ⎿ final-commit: Usage: /epic <KEY> (the title is asked for in a pa │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Calibrate
Press Space each time the light shows. [ ] 0 of 8 presses Space:
Pane · Specimen Bay
Cells: Reinforced 0 · Stasis 0 · Singularity 0 Flora · Common: 0 Flora ◆ Rare: 0 Flora ★ Legendary: 0 No specimens yet. Finish a mission to find one. /bay companion N sets your companion. /craft makes cells. Esc closes.
Pane · Dossier
No puzzles answered yet. They come before containment. Esc closes.
Pane · Set up work sources
Which work sources to read, comma-separated: plans, github, jira. Leave empty for none. Sources: plans Enter: next Esc: cancel, nothing saved
Pane · Bridge
No system charted: /epic <KEY> Mission none Hull no test runs yet Shields no lint run yet Fuel [#####-----] 51% Crew 1 Engineering idle 2 Science idle 3 Tactical idle Captain's log 25282.3 OK e: run tests l: lint s: ask Science t: security review 1-3: last report Esc: close Key:
Pane · fc-report
Nothing to report.
Pane · fc-lattice
Nothing to contain.
Pane · fc-scan
Esc closes.
Pane · fc-confirm
Nothing to confirm.
Pane · fc-epic
No epic is being charted.
README

The Final Commit

Space: the final commit.

A mod for Claude Code that turns your real development work into a sci-fi exploration game. Epics become star systems, issues become missions, and finishing work turns up Difflings: procedurally generated alien flora and fauna you contain, collect, and raise as companions. Optional puzzles built from your own diffs improve your odds and double as interview practice.

It runs entirely in the terminal: ASCII art, keyboard only, 40 columns minimum, playable over SSH and tmux, including from a phone.

A companion Diffling, Gnaw Rat, shown above the Claude Code prompt with its tier, mood and mission

Your companion above the prompt, from a real game: name, tier, mood and current mission beside its sprite, with the game's status line below.

Status: pre-alpha. The first playable slice works: chart an epic, run missions, meet and contain Difflings, keep a companion. Plan documents can drive it, as can GitHub Issues and Jira Cloud. Puzzles are starting: a question on your own changed code before containment. How it plays is in the Player's Guide; the design is in docs/SPEC.md.

Install

There is no release yet. To play from a clone, see Running the mod while developing.

How to play

The commands are below. The Player's Guide explains the rest: when encounters happen, your containment odds, cells, flora and crafting, the crew, and what each part of the screen means.

CommandWhat it does
/epic NOVA-1Opens a form for the epic's title and description, then charts it as a star system in the background.
/mission NOVA-12Starts a mission. Checking out a branch like feature/NOVA-12-x (in the epic's project) starts it too.
/mission completeFinishes the mission. Tests passing during it earn a Reinforced Cell, a clean Tactical review a Stasis Cell, and it harvests 1 to 3 flora samples; an encounter may follow. A plain npm test counts by its exit status; a piped, guarded or chained run (`npm test \tail, npm test; echo done`) counts by the runner's summary line, so keep that line in the output.
/mission reopen NOVA-12Undoes a mission completed by mistake: its latest completion leaves the log (so /mission can start it again), and the completed count, cells and flora it gave are taken back.
/setupWalks through the work sources the game reads (plan documents, GitHub, Jira) in a pane, and saves your answers as its settings.
`/contain [reinforced\stasis\singularity]`Opens Seal the Lattice: press Space as the needle crosses the zone, Enter to throw the cell, Esc to pause. A Singularity Cell needs a Legendary flora sample, which it spends.
`/craft [reinforced\stasis]`Crafts a cell from flora: 3 Common samples make a Reinforced Cell, 2 Rare a Stasis Cell. Alone, lists the recipes and what you hold.
/calibrateMeasures your key latency on this device, so timing is fair over SSH or from a phone.
/bay, /bay companion NShows the cells and flora you hold, lists your specimens, and picks the companion shown above the prompt.
/dossierOpens your puzzle accuracy by category, grouped by puzzle type, weakest first, with a trend and the weakest category marked. It does not change which puzzles you get.
/epic completeSurveys the epic: a Singularity Cell and a guaranteed encounter, never a Common.
/bridgeThe bridge: the active system and mission, hull (this session's test pass rate), shields (the last lint or type-check), fuel (context window left), crew status and the latest captain's log, with your companion at the foot. Keys send the crew: e run tests, l lint, s ask a question, t security review; the report opens when the run finishes. 1, 2, 3 reopen Engineering's, Science's or Tactical's last report (saved locally, including runs Claude sent).
CrewThree read-only subagents Claude (or you) can dispatch: final-commit:engineering runs and explains tests, final-commit:science answers questions about the code, final-commit:tactical reviews your branch for security issues before a push. A clean Tactical review after your last commit raises the mission's quality (better odds of rarer Difflings).
/captains-logWrites a short summary of the session (standup notes) and shows it in a pane. The last 20 are kept.

A failed test run, or a failed tool call other than a shell command, during a mission raises a red alert: the status line flashes, and a toast appears at most once every five minutes.

Plan documents

Instead of typing /epic and /mission, you can keep a markdown plan in your project and let the game follow it. Run /setup, or add plans to workSources in /config (comma-separated, like every list setting). The game then reads every .md file in docs/plans/ (the plansFolders setting) at session start and every 12 minutes:

# NOVA-7 Billing export

Export invoices as files for the finance team.

- [x] NOVA-8 Write the exporter
- [~] NOVA-9 Schedule it
- [ ] NOVA-10 Document it

The first heading's key is the epic, and the text before the first task is its description. Each task whose text starts with a key (or has one in brackets) is a mission: [~] starts it, [x] finishes it, and all tasks done surveys the epic. Tasks and plans without a key are skipped. The first read only records where things stand, so work already done is not awarded. Checking out a task's branch or typing /mission starts it too. Mark a task [~] when you begin it: only work done while its mission is active counts, so a task that goes straight to [x] completes without rewards.

GitHub Issues

Add github to workSources, and list each repository with the prefix its keys get in githubRepos: example/nova=NOVA makes issue #12 NOVA-12. The game reads through the gh CLI and its own login, so gh auth login first; the mod never handles a token. Every issue assigned to you is a mission: being assigned starts it, and closing it finishes it (closing one that is not your active mission only records it as closed, without rewards). Its star system is its parent issue if it has one, else its milestone (NOVA-M3), else the repo's backlog (NOVA-BACKLOG), so loose tickets count too. Closing a parent or a milestone surveys its system. Name a branch with the key (feature/NOVA-12-export) and checking it out starts the mission too.

Jira Cloud

Add jira to workSources, and set jiraSite (your https://<name>.atlassian.net), jiraEmail and jiraToken (an API token from id.atlassian.com, under Security; Claude Code keeps it in secure storage, never in the repo or the game's save). Only an atlassian.net site is accepted, and the token goes only to the site you set, so check its name. Every issue assigned to you is a mission: moving it to In Progress starts it, and Done finishes it. Its star system is its epic, else its project's backlog (NOVA-BACKLOG). An epic moving to Done surveys its system. Check your employer's policy before pointing this at a work Jira.

What data the mod sends, and where

The game is built from your own work, so some of it is sent to a model.

What is sent to the model: the epic title and description you type into the /epic form (or a plan document's first heading and the prose before its first task, or a GitHub parent issue's title and body, or a milestone's title and description, or a Jira epic's summary and description; a backlog sends only the word "Backlog"), after the privacy filter, in one request per epic (plus up to two follow-up requests that redraw creature art; those carry generated names and descriptions, not your text). The issue key is never sent. The default model is Opus; you can pick Sonnet or Haiku in /config. Puzzles can send code. When a mission rolls an encounter, the functions it changed are read for a puzzle. With puzzles set to on (the default) and privacyMode set to standard or off, one of them is sent to the puzzle model (Sonnet by default, the puzzleModel setting) in two requests: one writes a question about it, one checks the answer. The code is filtered first: only lines of plain code are kept (any line with a string, template, regular expression, comment or escape is blanked to …, and after anything the filter cannot be sure of, the rest of the file is), the value shapes below (emails, keys, IDs, names after a title, and so on) are replaced. Only TypeScript files are read (.ts, .mts, .cts): JavaScript files may hold JSX, whose text the filter cannot tell from code. Identifiers and type names are not changed. That is why the default strict mode never sends code: under it, puzzles are made on this machine only. Set puzzles to local for the same with any filter, or off for none. Trace runs your code locally. For a Trace puzzle the game runs one of the changed functions, the filtered version the puzzle shows, with node on this machine: in the system's temporary folder, under node's permission model (no file reads or writes, no child processes), started with an empty environment (none of your session's variables or tokens), in a vm sandbox that holds only a few built-ins (Math, JSON, Array and the like) and cannot load modules, with a 1 s limit per call and 10 s in all. Node's permission model does not itself block the network; the sandbox holds nothing that can reach it. Only a small, self-contained function is ever run; anything that could reach outside is refused before running. Its inputs are made up by the game. Nothing is sent to a model for Trace. /captains-log asks your session's own model to summarize the session it already holds; the mod adds only a fixed instruction, and the summary is stored locally. The crew subagents read your code the way any Claude Code subagent does, through your session's model and only when dispatched; the mod supplies only their instructions.

Where it goes: only through your own Claude Code session and account. Apart from the work sources you turn on (GitHub, through gh; your Jira Cloud site, with your API token), the mod makes no other network calls.

What stays local: the mod watches the Bash commands Claude runs in your session (to notice branch switches, commits and test runs), runs git to read the current branch, and reads TERM, TMUX and SSH_CONNECTION to tell your devices apart for calibration (stored only as a salted hash). With the plans source on, it reads the markdown files in your plan folders and keeps each task's last status. With the github source on, it runs gh api graphql to read the listed repositories' issues: that request goes to GitHub, through gh, as any gh command does. Saved game data, including your issue keys, stays in Claude Code's plugin store on this machine.

What the model sees in the transcript: slash commands and their output are part of the session the model reads. That is why /epic takes only the key and asks for the title in a form, and why command output is short and factual (Mission NOVA-12 complete.). Issue keys you type in commands are visible to the model.

No telemetry. No analytics. No data leaves your machine except as described above.

Privacy filter, on by default. Before epic text or code reaches a prompt, the filter replaces quoted text, issue keys, URLs, emails, IPs and hostnames, phone numbers, SSN-, date- and ID-shaped values, ages, ZIP codes, and names after a title (Dr.) or a role word (patient). The setting is privacyMode in /config:

  • strict (the default) also replaces every capitalized word that is not a common title word or technical acronym, which catches most names.
  • standard keeps capitalized words, for better system theming.
  • off sends the text as typed.

Known limit: a name written in lowercase in plain prose ("ask jane") cannot be told from an ordinary word, in any mode. Keep names out of epic titles.

Your responsibility: check whether your employer permits sending work content through Claude Code before pointing this mod at work projects.

Contributing

This repository is public, and history is permanent. Never commit secrets, real work content (employer or client names, real issue keys, ticket text), hostnames, IPs, usernames, or local paths. Tests, fixtures, and examples use invented projects with the issue key prefix NOVA-. See SPEC section 15.

Local guardrail hooks

Two git hooks in scripts/hooks/ block a commit before it happens:

  • pre-commit scans staged changes with gitleaks and checks the staged files against your denylist
  • commit-msg checks the commit message and branch name against your denylist

CI runs the same checks, so the hooks only catch problems earlier.

  1. Install gitleaks 8.30 or later and make sure gitleaks is on your PATH. The pre-commit hook refuses to commit without it.
  2. Create denylist.txt at the repository root. It is gitignored. Put one term per line: names of employers and clients, real project keys, hostnames, anything that must never appear here. Matching is case-insensitive and substring-based; lines starting with # are comments.
   # denylist.txt
   examplecorp
   ACME-
   my-laptop
  1. Point git at the hooks:
   git config core.hooksPath scripts/hooks

To check everything by hand:

scripts/denylist-check.sh files      # tracked files
scripts/denylist-check.sh history    # every commit message
gitleaks git --redact --config .gitleaks.toml .

Running the mod while developing

npm install
npm run types       # copy your Claude Code API declarations (never committed)
npm run typecheck
npm test
claude --plugin-dir .

claude --plugin-dir . starts a session with the mod loaded from this folder; edits reload when the folder goes quiet. Set devMode in /config to get /encounter, which forces an encounter for playtesting. A numeric FINAL_COMMIT_SEED environment variable makes every roll reproducible (the tests use it).

Machine-specific notes go in CLAUDE.local.md (gitignored). Start from CLAUDE.local.md.example.

Reporting security issues

See SECURITY.md. Do not open a public issue.

License

MIT

Not affiliated

The Final Commit is an independent project. It is not affiliated with, endorsed by, or sponsored by Anthropic or Paramount. Claude Code is a product of Anthropic.

Source 61 files
hooks/register.tsx 741 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { BandView, CalibrationView, ChartFailures, ChartingEntry, ConfirmView, EpicFormView, LatticeView, QueuedMission, ReportView } from '../types'
4import { NO_ALERT, redAlert } from '../src/bridge/alert'
5import { NO_GAUGES } from '../src/bridge/bridge'
6import { wireBridge } from '../src/bridge/bridge-pane'
7import { wireCrew } from '../src/crew/crew-wire'
8import { CREW_SPECS, ROLES } from '../src/crew/roster'
9import { wireBand } from '../src/bridge/band'
10import { wireBay } from '../src/bridge/bay-pane'
11import { wireDossier } from '../src/bridge/dossier-pane'
12import { wireScan } from '../src/bridge/scan-pane'
13import { wireSetup } from '../src/setup/setup-pane'
14import { wrap } from '../src/bridge/text'
15import { appendLog, LOG_PROMPT, logLines, stardate } from '../src/bridge/log'
16import { CHARTING, COMPANION, GENERATION, GIT, GITHUB, PUZZLE, RED_ALERT, SYNC, type GenerationModel } from '../src/config'
17import { missionUnits } from '../src/puzzle/material'
18import { buildPuzzle } from '../src/puzzle/puzzle'
19import { runnerPath, runCalls, type Run } from '../src/puzzle/runners'
20import { buildTrace, type TraceCapability } from '../src/puzzle/trace'
21import { wireCalibration } from '../src/contain/calibrate'
22import { wireLattice } from '../src/contain/lattice'
23import { classifyBash, lintVerdict, testRunFailed, testRunPassed } from '../src/detect/git'
24import { NO_CHART_FAILURES, chartFailed, chartSucceeded, isChartHeld } from '../src/detect/chart-retry'
25import { createGithubSources, parseRepos } from '../src/detect/github'
26import { createJiraRest, createJiraSource, parseJiraSettings } from '../src/detect/jira'
27import { createPlansSource, plansId } from '../src/detect/plans'
28import { createSyncGate, resolveSources, syncSources, type Backend } from '../src/detect/sources'
29import type { WorkSource } from '../src/detect/work-source'
30import { completeEpic, completeMission, craftCell, forceEncounter, onBranch, openItems, parseCraftCell, parseEpicKey, queueTarget, recordBash, reopenMission, startEpic, startMission, type Outcome } from '../src/game'
31import type { Rng } from '../src/rng'
32import { NO_MOOD, localDay, orphanedCharts, readSettings, rngFor, sendsCode, snapshot, type Settings } from '../src/runtime'
33import { migrate } from '../src/store/migrate'
34import { createRepo, type Repo, type StoreLike } from '../src/store/repo'
35import type { Complete } from '../src/world/generate'
36
37// Wiring only: events in, src/ modules do the work. Command `text` is read by
38// the model, so it stays factual (CLAUDE.md rule 1); flavor goes in toasts,
39// panes, the band and the status line.
40
41export const EPIC_PANE = 'fc-epic'
42/** `/mission complete` asks here before completing a mission with open items. */
43export const CONFIRM_PANE = 'fc-confirm'
44/** The report pane is drawn and dismissed in src/contain/lattice.tsx, which can open containment from it. */
45export const REPORT_PANE = 'fc-report'
46const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
47const form = atom({ plugin: 'final-commit', key: 'epicForm' } as const, null as EpicFormView | null)
48const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
49const confirm = atom({ plugin: 'final-commit', key: 'confirm' } as const, null as ConfirmView | null)
50const queued = atom({ plugin: 'final-commit', key: 'queuedMission' } as const, null as QueuedMission | null)
51const mood = atom({ plugin: 'final-commit', key: 'mood' } as const, NO_MOOD)
52const charting = atom({ plugin: 'final-commit', key: 'charting' } as const, [] as ChartingEntry[])
53const chartFailures = atom({ plugin: 'final-commit', key: 'chartFailures' } as const, NO_CHART_FAILURES as ChartFailures)
54const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
55const alert = atom({ plugin: 'final-commit', key: 'alert' } as const, NO_ALERT)
56const gauges = atom({ plugin: 'final-commit', key: 'gauges' } as const, NO_GAUGES)
57const lattice = atom({ plugin: 'final-commit', key: 'lattice' } as const, null as LatticeView | null)
58const calibration = atom({ plugin: 'final-commit', key: 'calibration' } as const, null as CalibrationView | null)
59
60const NOT_READY = { text: 'The Final Commit could not load its save; see the debug log (claude --debug).' }
61
62/**
63 * Epics this module instance is charting. The `charting` state shows them;
64 * this set says which are really running, since a hot reload ends the
65 * generation but keeps the state.
66 */
67const live = new Set<string>()
68let actions = 0
69/** True while a /captains-log summary is being written. */
70let logging = false
71/** The configured work sources, built once per session start (a backend may keep what it last read). */
72let sources: WorkSource[] = []
73/** One sync at a time (SPEC 4.2); made by the first `startSync` of this module instance and kept, so a later start cannot overlap a sync still running. */
74let syncGate: ReturnType<typeof createSyncGate> | undefined
75/** Work sources whose failure has been reported (SPEC 4.4 rule 5: once per outage). */
76let failing = new Set<string>()
77let settings: Settings = readSettings({})
78
79// The engine lets `$` reach only top-level functions of the same file: these
80// adapters are this file's own (each wiring file has its own copy).
81function storeOf($: EngineInterface): StoreLike {
82  return { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() }
83}
84
85function repoOf($: EngineInterface): Repo {
86  return createRepo(storeOf($), line => $.ui.log(line, { to: 'debug' }))
87}
88
89async function rngOf($: EngineInterface): Promise<Rng> {
90  actions += 1
91  return rngFor(await $.env.get('FINAL_COMMIT_SEED'), actions)
92}
93
94/** The model call, never rejecting: a refused request becomes a failed result, so generation falls back. */
95function completeVia($: EngineInterface, model: GenerationModel, limits: { maxTokens: number; timeoutMs: number } = GENERATION): Complete {
96  return async ({ system, prompt }) => {
97    try {
98      const r = await $.model.complete({ model, system, prompt, maxTokens: limits.maxTokens, timeoutMs: limits.timeoutMs })
99      return r.isAnswered ? { ok: true, text: r.text } : { ok: false, reason: r.reason }
100    } catch (err) {
101      return { ok: false, reason: err instanceof Error ? err.message : 'refused' }
102    }
103  }
104}
105
106async function refresh($: EngineInterface) {
107  const now = await $.clock.now()
108  const s = await snapshot(repoOf($), await read($, mood), now, await read($, charting), now < (await read($, alert)).until)
109  $.ui.status(s.status)
110  await update($, band, prev => (s.band && prev ? { ...s.band, isBlinking: prev.isBlinking } : s.band))
111}
112
113async function deps($: EngineInterface) {
114  const now = await $.clock.now()
115  return { repo: repoOf($), now, rng: await rngOf($), day: localDay(now) }
116}
117
118/** `activate: false` for a chart a work source starts: an issue's start makes its epic active (SPEC 4.2 rule 6). */
119async function chart($: EngineInterface, key: string, title: string, description: string, activate = true) {
120  live.add(key)
121  const startedAt = await $.clock.now()
122  await update($, charting, list => [...list.filter(c => c.key !== key), { key, startedAt }])
123  // The status line's spinner and seconds (SPEC 9): redrawn while the model works.
124  const spinner = $.clock.every(CHARTING.spinnerMs, () => {
125    void refresh($)
126  })
127  await refresh($)
128  try {
129    const out = await startEpic({ ...(await deps($)), epic: { key, title, description }, activate, complete: completeVia($, settings.generationModel), privacy: settings.privacyMode })
130    $.ui.toast(out.toast ?? out.text)
131    await update($, chartFailures, f => chartSucceeded(f, key))
132    await startQueued($, key, true)
133    await refresh($)
134    // A chart a source started holds back that source's mission: sync now rather than at the next poll.
135    if (!activate) syncGate?.request({ again: true })
136  } catch (err) {
137    const reason = err instanceof Error ? err.message : String(err)
138    if (activate) {
139      $.ui.toast(`Charting ${key} failed: ${reason}`)
140    } else {
141      // A source's chart is tried again after a wait, not at every poll; only the first failure in a row is told.
142      const failed = chartFailed(await read($, chartFailures), key, await $.clock.now())
143      await update($, chartFailures, () => failed.failures)
144      if (failed.isFirst) $.ui.toast(`Charting ${key} failed: ${reason}. It is tried again later.`)
145      else $.ui.log(`final-commit: charting ${key} failed again (${failed.failures[key]?.count} in a row): ${reason}`, { to: 'debug' })
146    }
147    await startQueued($, key, false)
148  } finally {
149    spinner.cancel()
150    live.delete(key)
151    await update($, charting, list => list.filter(c => c.key !== key))
152    await refresh($)
153  }
154}
155
156/** A charting finished: start the mission that was waiting for that epic, timed from when it was asked for. */
157async function startQueued($: EngineInterface, epicKey: string, isCharted: boolean) {
158  const q = await read($, queued)
159  if (!q || q.epicKey !== epicKey) return
160  await update($, queued, () => null)
161  if (!isCharted) {
162    $.ui.toast(`Mission ${q.issueKey} was not started: charting ${epicKey} failed. Run /mission ${q.issueKey} again.`)
163    return
164  }
165  const out = await startMission({ ...(await deps($)), issueKey: q.issueKey, startedAt: q.at, epicKey: q.epicKey })
166  $.ui.toast(out.text)
167}
168
169/** The form's Enter: the title first, then the description; then charting starts after the pane closes. */
170async function submitForm($: EngineInterface, value: string) {
171  const f = await read($, form)
172  if (!f) return
173  if (f.step === 'title') {
174    if (value.trim() === '') return
175    await update($, form, () => ({ ...f, step: 'description', title: value.trim() }))
176    return
177  }
178  await update($, form, () => null)
179  await $.ui.close({ id: EPIC_PANE })
180  // Generation can take minutes, so it runs after this handler returns
181  // (verified on 2.1.291: the model call is not aborted).
182  void chart($, f.key, f.title, value.trim())
183}
184
185/**
186 * Shows an outcome: its report in a pane that stays until dismissed (a toast
187 * vanishes before a long name is read), or its toast where no pane can open.
188 */
189async function announce($: EngineInterface, out: Outcome) {
190  // A level up or evolution is told by toast too: the report may be dismissed unread.
191  if (out.companionToast) $.ui.toast(out.companionToast)
192  if (out.report) {
193    const r = out.report
194    await update($, report, () => r)
195    const placed = await $.ui.open({ id: REPORT_PANE, title: r.title, focus: true, closeOnEscape: true })
196    if (placed.isPlaced) return
197    await update($, report, () => null)
198  }
199  if (out.toast) $.ui.toast(out.toast)
200}
201
202/**
203 * SPEC 4.4: the backends this build has, by the name `workSources` lists.
204 * Each closes over `$` here, as `$` reaches only this file's functions.
205 */
206function backendsOf($: EngineInterface): Record<string, Backend> {
207  const repo = repoOf($)
208  return {
209    // SPEC 4.4 rule 3: files only. Its state and sync record are per project folder, so another project's plans never read as changes.
210    plans: () => createPlansSource({
211      folders: settings.plansFolders,
212      project: async () => plansId(await $.session.cwd()),
213      exists: path => $.fs.exists(path),
214      list: dir => $.fs.list(dir),
215      read: path => $.fs.read(path),
216      load: project => repo.plans(project),
217      save: (project, s) => repo.savePlans(project, s),
218      now: () => $.clock.now(),
219      log: line => $.ui.log(line, { to: 'debug' }),
220    }),
221    // SPEC 4.4 rule 6: one source per repo, through gh and its own login; no token passes through the mod. Incomplete without a valid repo entry.
222    github: () => {
223      const { repos, invalid } = parseRepos(settings.githubRepos)
224      for (const entry of invalid) $.ui.log(`final-commit: github: ignored "${entry}" (expected owner/repo=PREFIX, each repo and prefix once)`, { to: 'debug' })
225      if (repos.length === 0) return undefined
226      return createGithubSources({ repos, run: argv => $.process.run(argv, { timeoutMs: GITHUB.timeoutMs }), log: line => $.ui.log(line, { to: 'debug' }) })
227    },
228    // SPEC 4.4 rule 7: Jira Cloud over REST with the user's API token (a secret setting). Incomplete until site, email and token are set.
229    jira: () => {
230      const { settings: jira, problem } = parseJiraSettings(settings.jira.site, settings.jira.email, settings.jira.token)
231      if (!jira) {
232        $.ui.log(`final-commit: jira: ${problem}`, { to: 'debug' })
233        return undefined
234      }
235      return createJiraSource({
236        transport: createJiraRest(jira, (url, init) => $.http.fetch(url, init)),
237        now: () => $.clock.now(),
238        log: line => $.ui.log(line, { to: 'debug' }),
239      })
240    },
241  }
242}
243
244/** One sync of every configured work source (SPEC 4.2): at session start, then each poll. */
245async function syncTracked($: EngineInterface) {
246  try {
247    if (sources.length === 0 || !(await read($, ready))) return
248    const d = await deps($)
249    const held = await read($, chartFailures)
250    const r = await syncSources({
251      ...d, sources, failing, charting: new Set(live),
252      // An epic whose last chart failed waits out its retry; its issues stay deferred meanwhile.
253      chart: e => {
254        if (isChartHeld(held, e.key, d.now)) return false
255        void chart($, e.key, e.title, e.description, false)
256        return true
257      },
258    })
259    failing = r.failing
260    for (const t of r.toasts) $.ui.toast(t)
261    await refresh($)
262    // One report pane at a time: an outcome after the first report is told by toast.
263    let isReporting = false
264    for (const out of r.outcomes) {
265      if (out.report && !isReporting) {
266        isReporting = true
267        await announce($, out)
268      } else {
269        $.ui.toast(out.toast ?? out.text)
270        if (out.companionToast) $.ui.toast(out.companionToast)
271      }
272    }
273    void preparePuzzle($)
274  } catch (err) {
275    $.ui.log(`final-commit: work sync: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
276  }
277}
278
279/** SPEC 4.2: the catch-up sync now, then a poll on the clock. Names no backend answers to are said once. */
280async function startSync($: EngineInterface) {
281  const resolved = resolveSources(settings.workSources, backendsOf($))
282  sources = resolved.sources
283  const gate = (syncGate ??= createSyncGate(() => syncTracked($)))
284  const { unknown, incomplete } = resolved
285  for (const name of unknown) $.ui.toast(`Work source ${name} is not available in this build; it is ignored.`)
286  for (const name of incomplete) $.ui.toast(`Work source ${name} is missing settings; it is ignored until they are set.`)
287  if (sources.length === 0) return
288  $.clock.every(SYNC.pollMs, () => gate.request())
289  // In the background: a slow tracker never holds up the session's start.
290  gate.request()
291}
292
293async function epic($: EngineInterface, args: string): Promise<{ text: string }> {
294  if (!(await read($, ready))) return NOT_READY
295  if (args.trim().toLowerCase() === 'complete') {
296    const out = await completeEpic(await deps($))
297    await refresh($)
298    await announce($, out)
299    return { text: out.text }
300  }
301  const key = parseEpicKey(args)
302  if (!key) return { text: 'Usage: /epic <KEY> (the title is asked for in a pane), or /epic complete.' }
303  if ((await read($, charting)).some(c => c.key === key)) return { text: `Epic ${key} is already being charted.` }
304  await update($, form, () => ({ key, step: 'title', title: '' }))
305  await $.ui.open({ id: EPIC_PANE, title: `Chart ${key}`, focus: true, closeOnEscape: true })
306  return { text: `Opened the charting form for ${key}.` }
307}
308
309async function mission($: EngineInterface, args: string): Promise<{ text: string }> {
310  if (!(await read($, ready))) return NOT_READY
311  const arg = args.trim()
312  const verb = arg.toLowerCase().split(/\s+/).join(' ')
313  if (verb === 'reopen' || verb.startsWith('reopen ')) {
314    const out = await reopenMission({ ...(await deps($)), issueKey: arg.slice('reopen'.length) })
315    await refresh($)
316    return { text: out.text }
317  }
318  if (verb !== 'complete' && verb !== 'complete anyway') {
319    const key = parseEpicKey(arg)
320    // An epic still being charted becomes active when it finishes: the mission waits for it.
321    const target = key && !(await repoOf($).activeMission()) ? queueTarget(key, (await read($, charting)).filter(c => live.has(c.key))) : undefined
322    if (key && target) {
323      const before = await read($, queued)
324      const at = await $.clock.now()
325      await update($, queued, () => ({ issueKey: key, epicKey: target, at }))
326      return { text: `Mission ${key} queued: it starts on epic ${target} when charting finishes.${before && before.issueKey !== key ? ` It replaces the queued ${before.issueKey}.` : ''}` }
327    }
328    const out = await startMission({ ...(await deps($)), issueKey: arg })
329    await refresh($)
330    await announce($, out)
331    return { text: out.text }
332  }
333  const active = await repoOf($).activeMission()
334  const waiting = await read($, queued)
335  if (!active && waiting) return { text: `No active mission: ${waiting.issueKey} is queued until epic ${waiting.epicKey} is charted.` }
336  const items = active ? openItems(active) : []
337  if (!active || items.length === 0 || verb === 'complete anyway') return { text: await finishMission($) }
338  const open = `Mission ${active.issueKey} has open items: ${items.join('; ')}.`
339  await update($, confirm, () => ({ issueKey: active.issueKey, items }))
340  const placed = await $.ui.open({ id: CONFIRM_PANE, title: `Complete ${active.issueKey}?`, focus: true, closeOnEscape: true })
341  if (placed.isPlaced) return { text: `${open} Asked for confirmation in a pane; not completed yet.` }
342  await update($, confirm, () => null)
343  return { text: `${open} Not completed. /mission complete anyway completes it regardless.` }
344}
345
346async function finishMission($: EngineInterface): Promise<string> {
347  const out = await completeMission(await deps($))
348  await refresh($)
349  await announce($, out)
350  void preparePuzzle($)
351  return out.text
352}
353
354/** The encounter a puzzle is being prepared for: one at a time, and once. */
355let preparing: string | undefined
356
357/**
358 * SPEC 8: prepares the waiting encounter's puzzle in the background, from the
359 * code its mission changed, so /contain need not wait. Once per encounter;
360 * none for a survey's. With puzzles `local`, nothing is sent to a model.
361 */
362async function preparePuzzle($: EngineInterface) {
363  try {
364    if (settings.puzzles === 'off' || !(await read($, ready))) return
365    const repo = repoOf($)
366    const pending = await repo.pending()
367    if (!pending?.missionKey || pending.puzzle !== undefined || preparing === pending.id) return
368    preparing = pending.id
369    const mission = (await repo.missionLog()).findLast(m => m.issueKey === pending.missionKey)
370    const io = {
371      git: (args: readonly string[]) => $.process.run(['git', ...args], { timeoutMs: PUZZLE.gitTimeoutMs }),
372      read: async (path: string) => {
373        try {
374          return await $.fs.read(path)
375        } catch {
376          return undefined
377        }
378      },
379    }
380    const units = mission ? await missionUnits(io, mission.startedAt, settings.privacyMode) : []
381    const trace = units.length > 0 ? await traceVia($) : undefined
382    const puzzle = await buildPuzzle({
383      units, tier: pending.tier, rng: await rngOf($),
384      ...(sendsCode(settings) ? { complete: completeVia($, settings.puzzleModel, PUZZLE) } : {}),
385      ...(trace ? { trace } : {}),
386    })
387    // Kept only if the same creature is still waiting.
388    const now = await repo.pending()
389    if (now?.id === pending.id) await repo.savePending({ ...now, puzzle: puzzle ?? null })
390  } catch (err) {
391    $.ui.log(`final-commit: puzzle: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
392  } finally {
393    preparing = undefined
394  }
395}
396
397/** SPEC 8.3 rule 3: node's absolute path if it answers, checked once per session (once per module load). */
398let nodeRunner: Promise<string | undefined> | undefined
399
400/**
401 * SPEC 8, Trace: runs a unit with node in the system's temporary folder
402 * (never the project), or undefined when node is not available. Nothing is
403 * sent to a model, so it is built with puzzles `local` and under strict too.
404 */
405async function traceVia($: EngineInterface): Promise<TraceCapability | undefined> {
406  const tmp = await $.env.get('TMPDIR')
407  const cwd = tmp !== undefined && tmp.startsWith('/') ? tmp : '/tmp'
408  const run: Run = (argv, init) => $.process.run(argv, init)
409  nodeRunner ??= runnerPath('node', run, cwd)
410  const path = await nodeRunner
411  if (!path) return undefined
412  return (units, rng) => buildTrace(units, rng, (unit, inputs) => runCalls({ name: 'node', path, run, cwd, code: unit.code, fn: unit.name, inputs }))
413}
414
415/** Enter on the confirmation: complete the mission it asked about, if that one is still active. */
416async function confirmComplete($: EngineInterface) {
417  const c = await read($, confirm)
418  // A plugin's own $.ui.close does not reach its own ui.close hook, so the view is cleared here.
419  await update($, confirm, () => null)
420  await $.ui.close({ id: CONFIRM_PANE })
421  if (!c) return
422  if ((await repoOf($).activeMission())?.issueKey !== c.issueKey) {
423    $.ui.toast(`Mission ${c.issueKey} is no longer active.`)
424    return
425  }
426  await finishMission($)
427}
428
429async function encounter($: EngineInterface): Promise<{ text: string }> {
430  if (!settings.devMode) return { text: 'Developer mode is off.' }
431  if (!(await read($, ready))) return NOT_READY
432  const out = await forceEncounter(await deps($))
433  if (out.toast) $.ui.toast(out.toast)
434  await refresh($)
435  return { text: out.text }
436}
437
438/** SPEC 7.2: `/craft <cell>`; an unknown cell lists the recipes. */
439async function craft($: EngineInterface, args: string): Promise<{ text: string }> {
440  if (!(await read($, ready))) return NOT_READY
441  const out = await craftCell({ ...(await deps($)), cell: parseCraftCell(args) })
442  return { text: out.text }
443}
444
445/**
446 * The captain's log (SPEC 9.2): a summary of this session from a fork of its
447 * own transcript, saved and shown in the report pane. Runs after the command
448 * returns, as a fork takes a while.
449 */
450async function writeLog($: EngineInterface) {
451  logging = true
452  try {
453    const reply = await $.model.fork({ prompt: LOG_PROMPT })
454    if (!reply.isAnswered) {
455      $.ui.toast(reply.reason === 'nothing-to-fork' ? "Nothing to log yet: the session has no replies." : `The captain's log could not be written (${reply.reason}).`)
456      return
457    }
458    const now = await $.clock.now()
459    const entry = { at: now, stardate: stardate(now), lines: logLines(reply.text) }
460    const repo = repoOf($)
461    await repo.saveCaptainsLog(appendLog(await repo.captainsLog(), entry))
462    // Finished in the background: never take over a pane in use (a report with an encounter, a timing game, the form).
463    const isBusy = (await read($, report)) !== null || (await read($, confirm)) !== null || (await read($, lattice)) !== null || (await read($, calibration)) !== null || (await read($, form)) !== null
464    if (isBusy) {
465      $.ui.toast(`Captain's log, stardate ${entry.stardate}, recorded.`)
466      return
467    }
468    await announce($, {
469      text: '',
470      toast: `Captain's log, stardate ${entry.stardate}, recorded.`,
471      report: { title: `Captain's log, stardate ${entry.stardate}`, lines: entry.lines, encounter: null },
472    })
473  } catch (err) {
474    $.ui.log(`final-commit: captain's log: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
475  } finally {
476    logging = false
477  }
478}
479
480async function captainsLog($: EngineInterface): Promise<{ text: string }> {
481  if (!(await read($, ready))) return NOT_READY
482  if (logging) return { text: 'A session summary is already being written.' }
483  void writeLog($)
484  return { text: 'Writing a session summary to the captain\'s log.' }
485}
486
487/**
488 * An errored call that was interrupted (Esc) rather than failed. The engine
489 * gives no flag for an errored call, only the error text the model reads, so
490 * this goes by that text.
491 */
492function wasInterrupted(ran: { result?: unknown; text?: string }): boolean {
493  const said = [ran.text, typeof ran.result === 'string' ? ran.result : undefined].join(' ')
494  return /interrupt/i.test(said) || (ran.result as { interrupted?: unknown } | null)?.interrupted === true
495}
496
497/** SPEC 9.2: a failed test run or tool call during a mission flashes the status line, and toasts outside the cooldown. */
498async function raiseAlert($: EngineInterface, tool: string, isError: boolean, testFailed: boolean) {
499  const mission = (await repoOf($).activeMission())?.issueKey
500  const raised = redAlert({ tool, isError, testFailed, mission, now: await $.clock.now(), state: await read($, alert) })
501  if (!raised) return
502  await update($, alert, () => raised.state)
503  if (raised.toast) $.ui.toast(raised.toast)
504  $.clock.after(RED_ALERT.flashMs, () => {
505    void refresh($)
506  })
507}
508
509/**
510 * The Bridge's hull and shields (SPEC 9): this session's test runs, judged as
511 * a mission judges them, and the last lint or type-check verdict.
512 */
513async function updateGauges($: EngineInterface, signals: ReturnType<typeof classifyBash>, isError: boolean, output: string) {
514  const lint = lintVerdict(signals, isError)
515  if (!signals.isTestRun && lint === undefined) return
516  const passed = testRunPassed(signals, isError, output)
517  await update($, gauges, g => ({
518    tests: signals.isTestRun ? { runs: g.tests.runs + 1, passes: g.tests.passes + (passed ? 1 : 0) } : g.tests,
519    lint: lint ?? g.lint,
520  }))
521}
522
523/** Session state from the save: run at start, and again after a /clear starts the state over. */
524async function prepareSession($: EngineInterface) {
525  const now = await $.clock.now()
526  try {
527    await migrate(storeOf($), now, line => $.ui.log(line, { to: 'debug' }))
528    await update($, ready, () => true)
529    await update($, mood, m => ({ ...m, lastActivityAt: now }))
530    // A hot reload ends any charting in flight but keeps its marker: say so, and clear it.
531    const orphans = orphanedCharts(await read($, charting), live)
532    if (orphans.length > 0) {
533      await update($, charting, list => list.filter(c => live.has(c.key)))
534      for (const o of orphans) $.ui.toast(`Charting ${o.key} was interrupted by a reload. Run /epic ${o.key} again.`)
535      const q = await read($, queued)
536      if (q && orphans.some(o => o.key === q.epicKey)) {
537        await update($, queued, () => null)
538        $.ui.toast(`Mission ${q.issueKey} is no longer queued. Run /mission ${q.issueKey} after charting ${q.epicKey}.`)
539      }
540    }
541  } catch (err) {
542    // A save from a newer build, or a store that cannot be read: the game stays off rather than overwrite it.
543    await update($, ready, () => false)
544    $.ui.log(`final-commit: ${err instanceof Error ? err.message : String(err)}`)
545  }
546  if (await read($, ready)) {
547    await refresh($)
548    // A reload cut off a puzzle in the making: start it again.
549    void preparePuzzle($)
550  }
551}
552
553/** SPEC 9.1: the crew as agent types the Agent tool can dispatch (`final-commit:<role>`). */
554async function registerCrew($: EngineInterface) {
555  for (const role of ROLES) {
556    const spec = CREW_SPECS[role]
557    try {
558      await $.agent.register({ name: spec.name, description: spec.description, prompt: spec.prompt, tools: spec.tools, maxTurns: spec.maxTurns })
559    } catch (err) {
560      $.ui.log(`final-commit: crew ${role} not registered: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
561    }
562  }
563}
564
565async function startSession($: EngineInterface) {
566  await prepareSession($)
567  await $.command.register({ name: 'epic', description: 'Chart an epic as a star system, or survey it', argumentHint: '<KEY> | complete' })
568  await $.command.register({ name: 'mission', description: 'Start or complete a mission', argumentHint: '<KEY> | complete [anyway] | reopen <KEY>' })
569  await $.command.register({ name: 'contain', description: 'Open containment for a waiting encounter', argumentHint: '[reinforced | stasis | singularity]' })
570  await $.command.register({ name: 'craft', description: 'Craft a cell from flora samples', argumentHint: '[reinforced | stasis]' })
571  await $.command.register({ name: 'calibrate', description: "Measure this device's key latency for containment" })
572  await $.command.register({ name: 'bay', description: 'Open the specimen bay', argumentHint: '[companion N]' })
573  await $.command.register({ name: 'scan', description: 'Scan a star system: the lifeforms known so far', argumentHint: '[KEY]' })
574  await $.command.register({ name: 'dossier', description: 'Open the dossier: puzzle accuracy by category' })
575  await $.command.register({ name: 'setup', description: 'Choose and set up the work sources the game reads: plans, GitHub, Jira' })
576  await $.command.register({ name: 'bridge', description: 'Open the bridge: system, mission, hull, shields, fuel' })
577  await $.command.register({ name: 'captains-log', description: 'Write a summary of this session to the captain\'s log' })
578  await registerCrew($)
579  if (settings.devMode) await $.command.register({ name: 'encounter', description: 'Force an encounter (developer mode)' })
580  await startSync($)
581  // Idle animation (SPEC 9.3): a short blink every few seconds.
582  $.clock.every(COMPANION.blinkEveryMs, () => {
583    void update($, band, v => (v ? { ...v, isBlinking: true } : v))
584    $.clock.after(COMPANION.blinkMs, () => {
585      void update($, band, v => (v ? { ...v, isBlinking: false } : v))
586    })
587  })
588}
589
590/**
591 * SPEC 4.2: what one Bash call says about the active mission. Results of a
592 * backgrounded command are ignored: they return before the command finishes.
593 */
594async function observeBash($: EngineInterface, command: string, result: unknown, isError: boolean, interrupted: boolean) {
595  if (!(await read($, ready))) return
596  const record = (result ?? {}) as {
597    backgroundTaskId?: unknown
598    stdout?: unknown
599    stderr?: unknown
600    gitOperation?: { commit?: { kind?: unknown } }
601  }
602  if (record.backgroundTaskId !== undefined) return
603  const d = await deps($)
604  const signals = classifyBash(command)
605  const event: 'fed' | 'startled' | undefined = isError ? 'startled' : signals.isTestRun ? 'fed' : undefined
606  await update($, mood, m => ({ last: event ? { event, at: d.now } : m.last, lastActivityAt: d.now }))
607  const reported = record.gitOperation?.commit?.kind === 'committed' ? 1 : 0
608  const commits = isError ? 0 : Math.max(signals.commits, reported)
609  // The end of the output is where runners print their summaries.
610  const output = [record.stdout, record.stderr].filter((x): x is string => typeof x === 'string').join('\n').slice(-GIT.outputTailChars)
611  if (commits > 0 || signals.isTestRun || signals.isLintRun) await recordBash({ ...d, signals, commits, isError, output })
612  if (!interrupted) await raiseAlert($, 'Bash', isError, testRunFailed(signals, isError, output))
613  if (!interrupted) await updateGauges($, signals, isError, output)
614  if (signals.mayChangeBranch && !isError) {
615    const head = await $.process.run(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], { timeoutMs: GIT.timeoutMs })
616    if (head.exitCode === 0) {
617      const out = await onBranch({ ...d, branch: head.stdout.trim() })
618      if (out) $.ui.toast(out.text)
619    }
620  }
621  await refresh($)
622}
623
624export const register: Register = (on, options) => {
625  settings = readSettings(options)
626
627  wireBand(on)
628  wireLattice(on)
629  wireCalibration(on)
630  wireBay(on)
631  wireScan(on)
632  wireDossier(on)
633  wireSetup(on, options)
634  wireBridge(on)
635  wireCrew(on)
636
637  on('session.start', async ($, e, next) => {
638    await startSession($)
639    return next(e)
640  })
641
642  // A /clear fires no session.start and starts the session's state over
643  // (the band, the ready flag): set it up again from the save.
644  on('classic.SessionStart', async ($, e, next) => {
645    if (e.source === 'clear') await prepareSession($)
646    return next(e)
647  })
648
649  on('command.run', { command: 'epic' }, async ($, e) => epic($, e.args))
650  on('command.run', { command: 'mission' }, async ($, e) => mission($, e.args))
651  on('command.run', { command: 'encounter' }, async $ => encounter($))
652  on('command.run', { command: 'craft' }, async ($, e) => craft($, e.args))
653  on('command.run', { command: 'captains-log' }, async $ => captainsLog($))
654
655  on('ui.close', { id: EPIC_PANE }, async ($, e, next) => {
656    await update($, form, () => null)
657    return next(e)
658  })
659
660  on('ui.close', { id: CONFIRM_PANE }, async ($, e, next) => {
661    // Esc: the mission stays active.
662    await update($, confirm, () => null)
663    return next(e)
664  })
665
666  on('ui.render', { component: 'Pane', requestId: CONFIRM_PANE }, async ($, e) => {
667    const elements = $.ui.resolve(e)
668    const { Box, Text, Button } = elements
669    const Input = 'Input' in elements ? elements.Input : undefined
670    const c = await read($, confirm)
671    const width = e.props.bodyColumns
672    if (!c) return <Text dimColor>Nothing to confirm.</Text>
673    return (
674      <Box flexDirection="column">
675        {wrap(`Mission ${c.issueKey} has open items:`, width).map(line => (
676          <Text bold>{line}</Text>
677        ))}
678        {c.items.flatMap(item => wrap(`- ${item}`, width)).map(line => (
679          <Text>{line}</Text>
680        ))}
681        {wrap('Enter: complete anyway  Esc: keep working', width).map(line => (
682          <Text dimColor>{line}</Text>
683        ))}
684        {Input ? (
685          <Input key="confirm" autoFocus label="Enter" onInput={() => {}} onSubmit={() => void confirmComplete($)} />
686        ) : (
687          <Button key="confirm" label="Complete anyway" onPress={() => void confirmComplete($)} />
688        )}
689      </Box>
690    )
691  })
692
693  on('ui.render', { component: 'Pane', requestId: EPIC_PANE }, async ($, e) => {
694    const elements = $.ui.resolve(e)
695    const { Box, Text } = elements
696    const Input = 'Input' in elements ? elements.Input : undefined
697    const f = await read($, form)
698    if (!f) return <Text dimColor>No epic is being charted.</Text>
699    const asking = f.step === 'title' ? 'Epic title' : 'Description (optional)'
700    return (
701      <Box flexDirection="column">
702        <Text>{`Typed here, the text skips the transcript and is filtered (${settings.privacyMode}) before the model sees it.`}</Text>
703        {f.step === 'description' && <Text dimColor>{f.title}</Text>}
704        {Input ? (
705          <Input key={`epic-${f.step}`} autoFocus label={asking} onInput={() => {}} onSubmit={(value: string) => void submitForm($, value)} />
706        ) : (
707          <Text dimColor>This surface has no text input; chart epics from a terminal.</Text>
708        )}
709      </Box>
710    )
711  })
712
713  // SPEC 4.2: watch Bash for branch switches, commits and test runs. The tool
714  // call itself is never changed; a failure here is logged, never surfaced.
715  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
716    const ran = await next(e)
717    if (ran.deny !== undefined) return ran
718    try {
719      await observeBash($, e.command, ran.result, ran.isError === true, wasInterrupted(ran))
720    } catch (err) {
721      $.ui.log(`final-commit: Bash observer: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
722    }
723    return ran
724  })
725
726  // SPEC 9.2: any other tool failing during a mission raises a red alert (Bash is judged above).
727  on('tool.call', async ($, e, next) => {
728    const ran = await next(e)
729    if (e.tool === 'Bash' || ran.deny !== undefined || ran.isError !== true || wasInterrupted(ran)) return ran
730    try {
731      if (await read($, ready)) {
732        await raiseAlert($, e.tool, true, false)
733        await refresh($)
734      }
735    } catch (err) {
736      $.ui.log(`final-commit: alert: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
737    }
738    return ran
739  })
740}
741
src/bridge/alert.ts 31 lines
1import { RED_ALERT } from '../config'
2
3// SPEC 9.2 red alert. Pure: the glue keeps the state and draws it.
4
5/** `until`: the status line flashes before this time. `lastToastAt`: the last alert toast, for its cooldown. */
6export type AlertState = { until: number; lastToastAt: number | null }
7export const NO_ALERT: AlertState = { until: 0, lastToastAt: null }
8
9/**
10 * A failed test run, or a failed tool call, during a mission. A Bash call is
11 * judged as a test run only: a shell command exiting non-zero (`grep` with no
12 * match, `diff`) is routine, not a failure of the tool. Returns the new state
13 * and a toast when the cooldown allows one; undefined when nothing is raised.
14 */
15export function redAlert(s: {
16  tool: string
17  isError: boolean
18  testFailed: boolean
19  mission: string | undefined
20  now: number
21  state: AlertState
22}): { state: AlertState; toast?: string } | undefined {
23  if (!s.mission) return undefined
24  const isToolFailure = s.isError && s.tool !== 'Bash'
25  if (!s.testFailed && !isToolFailure) return undefined
26  const mayToast = s.state.lastToastAt === null || s.now - s.state.lastToastAt >= RED_ALERT.toastCooldownMs
27  const state = { until: s.now + RED_ALERT.flashMs, lastToastAt: mayToast ? s.now : s.state.lastToastAt }
28  if (!mayToast) return { state }
29  return { state, toast: s.testFailed ? `Red alert! Tests failing on ${s.mission}.` : `Red alert! ${s.tool} failed on ${s.mission}.` }
30}
31
src/bridge/bridge.ts 76 lines
1import { BRIDGE, COLORS, type Tier } from '../config'
2import { crewLines, type CrewState, type Role } from '../crew/roster'
3import type { LogEntry } from '../store/schema'
4
5// SPEC 9 Bridge pane, as text rows. Pure: the pane gathers the values.
6
7/** Session gauges the Bash observer keeps: test runs for the hull, the last lint verdict for the shields. */
8export type Gauges = { tests: { runs: number; passes: number }; lint: 'pass' | 'fail' | null }
9export const NO_GAUGES: Gauges = { tests: { runs: 0, passes: 0 }, lint: null }
10
11export type BridgeData = {
12  system?: { name: string; starClass: string; isSurveyed: boolean }
13  mission?: { key: string; commits: number; testRuns: number }
14  gauges: Gauges
15  /** Context window left, percent; undefined before the session's first reply. */
16  fuel?: number
17  log?: LogEntry
18  crew?: CrewState
19  /** Officers with a saved report (the number keys reopen it). */
20  reportRoles?: Role[]
21  isPending: boolean
22  /** The waiting encounter's tier, to color its line. */
23  pendingTier?: Tier
24  isAlert: boolean
25}
26
27/** A Bridge row: a status `color` (a theme key) or a `tier` the pane colors at draw time. */
28export type BridgeRow = { text: string; color?: string; tier?: Tier }
29
30const fit = (text: string, width: number) => [...text].slice(0, Math.max(0, width)).join('')
31
32/** `[######----]`: ASCII, so it is one cell per character everywhere. */
33export function gauge(fraction: number): string {
34  const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BRIDGE.gaugeCells)
35  return `[${'#'.repeat(filled)}${'-'.repeat(BRIDGE.gaugeCells - filled)}]`
36}
37
38const pct = (fraction: number) => `${Math.round(fraction * 100)}%`
39
40/** Hull color by pass rate: all passing, some, none. */
41function hullColor(rate: number): string {
42  return rate >= COLORS.hullCautionBelow ? COLORS.good : rate > 0 ? COLORS.caution : COLORS.bad
43}
44
45function fuelColor(fuel: number): string | undefined {
46  return fuel < COLORS.fuelBadBelow ? COLORS.bad : fuel < COLORS.fuelCautionBelow ? COLORS.caution : undefined
47}
48
49export function bridgeRows(d: BridgeData, width: number): BridgeRow[] {
50  const rows: BridgeRow[] = []
51  const add = (text: string, style: Omit<BridgeRow, 'text'> = {}) => rows.push({ text, ...style })
52  add(d.system ? `${d.system.name} (${d.system.starClass})${d.system.isSurveyed ? ' surveyed' : ''}` : 'No system charted: /epic <KEY>')
53  if (d.isAlert) add('! RED ALERT', { color: COLORS.bad })
54  if (d.isPending) add('Encounter waiting: /contain', d.pendingTier ? { tier: d.pendingTier } : {})
55  add('')
56  const m = d.mission
57  add(m ? `Mission  ${m.key} · ${m.commits} commit${m.commits === 1 ? '' : 's'}` : 'Mission  none')
58  const t = d.gauges.tests
59  if (t.runs > 0) add(`Hull     ${gauge(t.passes / t.runs)} ${pct(t.passes / t.runs)}`, { color: hullColor(t.passes / t.runs) })
60  else add('Hull     no test runs yet')
61  const lint = d.gauges.lint
62  add(`Shields  ${lint === 'pass' ? 'up' : lint === 'fail' ? 'DOWN: lint failing' : 'no lint run yet'}`, lint ? { color: lint === 'pass' ? COLORS.good : COLORS.bad } : {})
63  if (d.fuel === undefined) add('Fuel     not measured yet')
64  else {
65    const color = fuelColor(d.fuel)
66    add(`Fuel     ${gauge(d.fuel / 100)} ${Math.round(d.fuel)}%`, color ? { color } : {})
67  }
68  if (d.crew) for (const line of ['Crew', ...crewLines(d.crew, d.reportRoles ?? [])]) add(line)
69  if (d.log) {
70    add('')
71    add(`Captain's log ${d.log.stardate}`)
72    for (const line of d.log.lines.slice(0, BRIDGE.logLines)) add(`  ${line}`)
73  }
74  return rows.map(r => ({ ...r, text: fit(r.text, width) }))
75}
76
src/bridge/bridge-pane.tsx 213 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { BandView } from '../../types'
4import { COMPANION } from '../config'
5import { besideLines, blink, trimSprite } from './band'
6import { NO_ALERT } from './alert'
7import type { ReportView } from '../../types'
8import { BRIDGE_KEYS, crewTask, NO_CREW, REPORT_KEYS, ROLE_LABELS, TASK_ROLE, type CrewTask, type Role } from '../crew/roster'
9import { bridgeRows, NO_GAUGES, type BridgeData } from './bridge'
10import { tierColor } from './color'
11import { createRepo, type Repo } from '../store/repo'
12
13export const BRIDGE_PANE = 'fc-bridge'
14const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
15const gauges = atom({ plugin: 'final-commit', key: 'gauges' } as const, NO_GAUGES)
16const alert = atom({ plugin: 'final-commit', key: 'alert' } as const, NO_ALERT)
17const crew = atom({ plugin: 'final-commit', key: 'crew' } as const, NO_CREW)
18const input = atom({ plugin: 'final-commit', key: 'bridgeInput' } as const, { isAsking: false, generation: 0 })
19const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
20/** The report pane, drawn in src/contain/lattice.tsx. */
21const REPORT_PANE = 'fc-report'
22const isOpen = atom({ plugin: 'final-commit', key: 'bridgeOpen' } as const, false)
23const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
24
25function repoOf($: EngineInterface): Repo {
26  return createRepo(
27    { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
28    line => $.ui.log(line, { to: 'debug' }),
29  )
30}
31
32/** The fraction of the context window left, from the status line's figures. */
33async function fuelOf($: EngineInterface): Promise<number | undefined> {
34  try {
35    const percent = (await $.session.usage()).context.percent
36    return percent === undefined ? undefined : 100 - percent
37  } catch {
38    return undefined
39  }
40}
41
42/** Everything the bridge shows, read at draw time from the save and the session. */
43async function gather($: EngineInterface): Promise<BridgeData> {
44  const repo = repoOf($)
45  const meta = await repo.meta()
46  let system: BridgeData['system']
47  for (const id of await repo.systemIds()) {
48    const s = await repo.system(id)
49    if (s && s.epicKey === meta?.activeEpicKey) system = { name: s.name, starClass: s.starClass, isSurveyed: s.status === 'surveyed' }
50  }
51  const mission = await repo.activeMission()
52  const log = (await repo.captainsLog()).at(-1)
53  const fuel = await fuelOf($)
54  const reports = await repo.crewReports()
55  const pending = await repo.pending()
56  return {
57    ...(system ? { system } : {}),
58    ...(mission ? { mission: { key: mission.issueKey, commits: mission.commits, testRuns: mission.testRuns } } : {}),
59    gauges: await read($, gauges),
60    ...(fuel !== undefined ? { fuel } : {}),
61    ...(log ? { log } : {}),
62    crew: await read($, crew),
63    reportRoles: (Object.keys(reports) as Role[]).filter(r => reports[r] !== undefined),
64    isPending: pending !== undefined,
65    ...(pending ? { pendingTier: pending.tier } : {}),
66    isAlert: (await $.clock.now()) < (await read($, alert)).until,
67  }
68}
69
70/** Sends an officer from the Bridge (SPEC 9.1); its report opens when it finishes (src/crew/crew-wire.ts). */
71async function launch($: EngineInterface, taskName: CrewTask, question = '') {
72  const role = TASK_ROLE[taskName]
73  const c = await read($, crew)
74  if (Object.values(c.running).includes(role)) {
75    $.ui.toast(`${ROLE_LABELS[role]} is already on it.`)
76    return
77  }
78  const task = crewTask(taskName, question)
79  try {
80    const started = await $.agent.spawn({ subagentType: `final-commit:${role}`, prompt: task.prompt, description: task.description })
81    if (started.deny !== undefined || started.agentId === undefined) {
82      $.ui.toast(`${ROLE_LABELS[role]} could not be sent: ${started.deny ?? 'no agent started'}`)
83      return
84    }
85    const id = started.agentId
86    // The agent.spawn hook already marked it running; this marks where its report goes.
87    await update($, crew, s => ({ ...s, running: { ...s.running, [id]: role }, fromBridge: [...(s.fromBridge ?? []), id] }))
88    $.ui.toast(`${ROLE_LABELS[role]} is on it.`)
89  } catch (err) {
90    $.ui.toast(`${ROLE_LABELS[role]} could not be sent: ${err instanceof Error ? err.message : String(err)}`)
91  }
92}
93
94/** Opens an officer's last saved report in the report pane, unless that pane holds a waiting encounter. */
95async function reopen($: EngineInterface, role: Role) {
96  const saved = (await repoOf($).crewReports())[role]
97  if (!saved) {
98    $.ui.toast(`${ROLE_LABELS[role]} has no report yet.`)
99    return
100  }
101  if ((await read($, report))?.encounter) {
102    $.ui.toast('Close the encounter report first.')
103    return
104  }
105  const title = `${ROLE_LABELS[role]} report`
106  await update($, report, () => ({ title, lines: saved.lines, encounter: null }))
107  const placed = await $.ui.open({ id: REPORT_PANE, title, focus: true, closeOnEscape: true })
108  if (!placed.isPlaced) await update($, report, () => null)
109}
110
111/** A key typed into the Bridge's field: `e`, `l`, `s`, `t`, or `1` to `3` for a report. The field is cleared after each. */
112async function onKey($: EngineInterface, value: string) {
113  const last = value.slice(-1).toLowerCase()
114  const saved = REPORT_KEYS[last]
115  if (saved) {
116    await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
117    await reopen($, saved)
118    return
119  }
120  const task = BRIDGE_KEYS[last]
121  await update($, input, i => ({ isAsking: task === 'question', generation: i.generation + 1 }))
122  if (task && task !== 'question') await launch($, task)
123}
124
125async function onAsk($: EngineInterface, question: string) {
126  await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
127  if (question.trim() !== '') await launch($, 'question', question)
128}
129
130/** The companion at the foot of the pane, laid out as the band lays it out: facts to the left of the sprite. */
131function companionBlock(Box: any, Text: any, v: BandView, width: number, color: string) {
132  const sprite = trimSprite(v.isBlinking || v.mood === 'asleep' ? blink(v.sprite) : v.sprite)
133  const spriteColumns = Math.max(0, ...sprite.map(r => r.length))
134  const room = width - spriteColumns - COMPANION.spriteGap
135  const lines = room >= COMPANION.besideMinColumns ? besideLines(v, room) : []
136  if (sprite.length === 0 || spriteColumns > width) return <Text dimColor color={color}>{`${v.tier} ${v.name} · ${v.mood}`.slice(0, width)}</Text>
137  return (
138    <Box flexDirection="row" marginTop={1}>
139      {lines.length > 0 && (
140        <Box flexDirection="column" justifyContent="center" marginRight={COMPANION.spriteGap}>
141          {lines.map((line, n) => (n === 0 ? <Text bold color={color}>{line}</Text> : n === 1 ? <Text color={color}>{line}</Text> : <Text dimColor>{line}</Text>))}
142        </Box>
143      )}
144      <Box flexDirection="column">
145        {sprite.map(row => <Text color={color}>{row}</Text>)}
146      </Box>
147    </Box>
148  )
149}
150
151async function bridge($: EngineInterface): Promise<string> {
152  if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
153  await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
154  // The companion moves into the pane while it is open (the band steps aside).
155  await update($, isOpen, () => true)
156  const placed = await $.ui.open({ id: BRIDGE_PANE, title: 'Bridge', focus: true, closeOnEscape: true })
157  if (!placed.isPlaced) {
158    await update($, isOpen, () => false)
159    return 'The bridge could not open here.'
160  }
161  return 'Opened the bridge.'
162}
163
164/** SPEC 9 Bridge pane: the state of play at a glance. It never reaches the transcript. */
165export function wireBridge(on: On): void {
166  on('command.run', { command: 'bridge' }, async ($, _e) => ({ text: await bridge($) }))
167
168  on('ui.close', { id: BRIDGE_PANE }, async ($, e, next) => {
169    await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
170    await update($, isOpen, () => false)
171    return next(e)
172  })
173
174  on('ui.render', { component: 'Pane', requestId: BRIDGE_PANE }, async ($, e) => {
175    const elements = $.ui.resolve(e)
176    const { Box, Text, Button } = elements
177    const Input = 'Input' in elements ? elements.Input : undefined
178    // Reading the band subscribes this drawing to every refresh of the game's views (and the blink).
179    const companion = await read($, band)
180    const width = e.props.bodyColumns
181    const rows = bridgeRows(await gather($), width)
182    const now = await $.clock.now()
183    const color = companion ? tierColor(companion.tierName, now) : ''
184    const i = await read($, input)
185    const hints = ['e: run tests', 'l: lint', 's: ask Science', 't: security review', '1-3: last report', 'Esc: close']
186    const joined = hints.join('  ')
187    const hintRows = [...joined].length <= width ? [joined] : [`${hints[0]}  ${hints[1]}  ${hints[2]}`, `${hints[3]}  ${hints[4]}`, hints[5]!]
188    return (
189      <Box flexDirection="column">
190        {rows.map((r, n) => (
191          <Text bold={n === 0} color={r.tier ? tierColor(r.tier, now) : r.color}>{r.text === '' ? ' ' : r.text}</Text>
192        ))}
193        <Text> </Text>
194        {i.isAsking ? <Text dimColor>Type a question for Science, then Enter.</Text> : hintRows.map(hint => <Text dimColor>{hint}</Text>)}
195        {companion && companionBlock(Box, Text, companion, width, color)}
196        {Input && i.isAsking && (
197          <Input key={`bridge-ask-${i.generation}`} autoFocus label="Question" onInput={() => {}} onSubmit={(q: string) => void onAsk($, q)} />
198        )}
199        {Input && !i.isAsking && (
200          <Input key={`bridge-keys-${i.generation}`} autoFocus label="Key" onInput={(v: string) => void onKey($, v)} onSubmit={() => {}} />
201        )}
202        {!Input && (
203          <Box flexDirection="row">
204            <Button key="crew-e" label="Tests" onPress={() => void launch($, 'tests')} />
205            <Button key="crew-l" label="Lint" onPress={() => void launch($, 'lint')} />
206            <Button key="crew-t" label="Security" onPress={() => void launch($, 'review')} />
207          </Box>
208        )}
209      </Box>
210    )
211  })
212}
213
src/crew/crew-wire.ts 107 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { CalibrationView, EpicFormView, LatticeView, ReportView } from '../../types'
4import { recordLint, recordTactical } from '../game'
5import { createRepo, type Repo } from '../store/repo'
6import { NO_GAUGES } from '../bridge/bridge'
7import { NO_CREW, parseLint, parseVerdict, reportLines, roleOf, ROLE_LABELS, type Role } from './roster'
8
9/** The report pane, drawn in src/contain/lattice.tsx. */
10const REPORT_PANE = 'fc-report'
11
12// SPEC 9.1: follows the crew's subagents from spawn to finished turn. The
13// agent types are registered at session start (hooks/register.tsx, as a
14// plugin has one session.start hook).
15
16const crew = atom({ plugin: 'final-commit', key: 'crew' } as const, NO_CREW)
17const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
18const gauges = atom({ plugin: 'final-commit', key: 'gauges' } as const, NO_GAUGES)
19const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
20const lattice = atom({ plugin: 'final-commit', key: 'lattice' } as const, null as LatticeView | null)
21const calibration = atom({ plugin: 'final-commit', key: 'calibration' } as const, null as CalibrationView | null)
22const form = atom({ plugin: 'final-commit', key: 'epicForm' } as const, null as EpicFormView | null)
23
24function repoOf($: EngineInterface): Repo {
25  return createRepo(
26    { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
27    line => $.ui.log(line, { to: 'debug' }),
28  )
29}
30
31/**
32 * A run launched from the Bridge reports in the report pane, as nothing else
33 * relays its answer. A pane already in use (a report, a timing game, the
34 * epic form) is never taken over: a toast says the run finished instead.
35 */
36async function showReport($: EngineInterface, role: Role, answer: string, isAborted: boolean) {
37  const title = `${ROLE_LABELS[role]} report`
38  const busy = (await read($, report)) !== null || (await read($, lattice)) !== null || (await read($, calibration)) !== null || (await read($, form)) !== null
39  if (busy || isAborted) {
40    $.ui.toast(isAborted ? `${ROLE_LABELS[role]} stopped before reporting.` : `${title} finished while a pane was open; it was not shown.`)
41    return
42  }
43  const r: ReportView = { title, lines: reportLines(answer), encounter: null }
44  await update($, report, () => r)
45  const placed = await $.ui.open({ id: REPORT_PANE, title, focus: true, closeOnEscape: true })
46  if (!placed.isPlaced) {
47    await update($, report, () => null)
48    $.ui.toast(`${title} is ready, but no pane could open here.`)
49  }
50}
51
52async function finished($: EngineInterface, agentId: string, answer: string, isAborted: boolean) {
53  let role: ReturnType<typeof roleOf>
54  let isFromBridge = false
55  await update($, crew, c => {
56    role = c.running[agentId]
57    const { [agentId]: _done, ...running } = c.running
58    const fromBridge = c.fromBridge ?? []
59    isFromBridge = fromBridge.includes(agentId)
60    return role ? { ...c, running, fromBridge: fromBridge.filter(id => id !== agentId) } : c
61  })
62  if (!role) return
63  // Every report is kept, whoever sent the officer, so the Bridge can show it again.
64  if (!isAborted && answer.trim() !== '') {
65    await repoOf($).saveCrewReport({ role, at: await $.clock.now(), lines: reportLines(answer) })
66  }
67  // An Engineering lint run ends with LINT: PASS or FAIL: the Bridge's shields and the mission's lint, even where an exit status was not seen.
68  const lint = role === 'engineering' && !isAborted ? parseLint(answer) : undefined
69  if (lint) {
70    await update($, gauges, g => ({ ...g, lint }))
71    if (await read($, ready)) await recordLint({ repo: repoOf($), verdict: lint })
72  }
73  if (isFromBridge) await showReport($, role, answer, isAborted)
74  const now = await $.clock.now()
75  const verdict = role === 'tactical' && !isAborted ? parseVerdict(answer) : undefined
76  const outcome = isAborted ? 'stopped' : verdict ?? 'done'
77  const r = role
78  await update($, crew, c => ({ ...c, last: { ...c.last, [r]: { outcome, at: now } } }))
79  if (!verdict) return
80  if (!(await read($, ready))) return
81  const { counted } = await recordTactical({ repo: repoOf($), verdict })
82  $.ui.toast(verdict === 'clean'
83    ? `${ROLE_LABELS.tactical}: all clear${counted ? '. Mission quality up.' : '.'}`
84    : `${ROLE_LABELS.tactical}: security issues found. See the report.`)
85}
86
87export function wireCrew(on: On): void {
88  on('agent.spawn', async ($, e, next) => {
89    const started = await next(e)
90    const role = roleOf(e.subagentType)
91    const id = started.agentId
92    if (role && id !== undefined) await update($, crew, c => ({ ...c, running: { ...c.running, [id]: role } }))
93    return started
94  })
95
96  on('turn.complete', async ($, e, next) => {
97    if (e.agentId !== undefined) {
98      try {
99        await finished($, e.agentId, e.answer, e.isAborted)
100      } catch (err) {
101        $.ui.log(`final-commit: crew: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
102      }
103    }
104    return next(e)
105  })
106}
107
src/crew/roster.ts 135 lines
1import { CREW } from '../config'
2
3// SPEC 9.1 crew: real subagent types. The prompts and descriptions are working
4// instructions only (CLAUDE.md rule 1): the role names are the game's, nothing
5// else about the game reaches the model.
6
7export type Role = 'engineering' | 'science' | 'tactical'
8export const ROLES: readonly Role[] = ['engineering', 'science', 'tactical']
9
10export const ROLE_LABELS: Record<Role, string> = { engineering: 'Engineering', science: 'Science', tactical: 'Tactical' }
11
12const READ_ONLY = 'Do not modify, create or delete files, and do not commit or push.'
13
14export type CrewSpec = { name: Role; description: string; prompt: string; tools: string[]; maxTurns: number }
15
16export const CREW_SPECS: Record<Role, CrewSpec> = {
17  engineering: {
18    name: 'engineering',
19    description: 'Runs the project\'s tests and explains the failures. Read-only.',
20    prompt: [
21      'You run a project\'s tests and explain the results.',
22      'Find how the project runs its tests (package.json scripts, Makefile, composer.json, pyproject, CI config) and run the narrowest command that covers the request.',
23      'Report the command you ran, the pass and fail counts, and for each failure: the test name, the assertion or error, and the most likely cause with file:line.',
24      'If the tests cannot run, say why and what is missing.',
25      READ_ONLY,
26    ].join(' '),
27    tools: ['Bash', 'Read', 'Grep', 'Glob'],
28    maxTurns: CREW.maxTurns.engineering,
29  },
30  science: {
31    name: 'science',
32    description: 'Answers questions about the codebase and its docs by reading them, citing file:line. Read-only.',
33    prompt: [
34      'You answer questions about a codebase by reading its code and documentation.',
35      'Cite file:line for every claim. Keep what you verified apart from what you infer, and say what you could not find.',
36      READ_ONLY,
37    ].join(' '),
38    tools: ['Read', 'Grep', 'Glob'],
39    maxTurns: CREW.maxTurns.science,
40  },
41  tactical: {
42    name: 'tactical',
43    description: 'Security review of the current branch\'s changes before a push; ends with a CLEAN or ISSUES verdict. Read-only.',
44    prompt: [
45      'You review code changes for security problems before they are pushed.',
46      'Unless told otherwise, the changes under review are the commits on the current branch that its upstream lacks (git log @{u}..HEAD; with no upstream, compare against the default branch) plus uncommitted changes.',
47      'Look for injection (SQL, shell, template), authentication and authorization gaps, secrets or credentials in code or config, unsafe deserialization, path traversal, server-side request forgery, missing validation at trust boundaries, sensitive data written to logs, and risky dependency changes.',
48      'Report each finding with file:line, severity and a concrete fix.',
49      READ_ONLY,
50      'End your reply with exactly one line: "VERDICT: CLEAN" when you found no security issue, or "VERDICT: ISSUES" otherwise.',
51    ].join(' '),
52    tools: ['Bash', 'Read', 'Grep', 'Glob'],
53    maxTurns: CREW.maxTurns.tactical,
54  },
55}
56
57/** `final-commit:tactical` -> 'tactical'; undefined for any other agent type. */
58export function roleOf(subagentType: string): Role | undefined {
59  const m = /^final-commit:(engineering|science|tactical)$/.exec(subagentType)
60  return m ? (m[1] as Role) : undefined
61}
62
63export type Verdict = 'clean' | 'issues'
64
65/** The Tactical verdict: the last `VERDICT:` line of the reply, or undefined without one. */
66export function parseVerdict(answer: string): Verdict | undefined {
67  const all = [...answer.matchAll(/^\s*\**VERDICT:\s*(CLEAN|ISSUES)\b/gim)]
68  const last = all.at(-1)?.[1]
69  return last === undefined ? undefined : last.toUpperCase() === 'CLEAN' ? 'clean' : 'issues'
70}
71
72/** What the crew is doing this session: running agents by id, and each officer's last result. */
73export type CrewOutcome = 'done' | 'clean' | 'issues' | 'stopped'
74/** `fromBridge`: runs launched from the Bridge pane, whose reports open in the report pane. */
75export type CrewState = { running: Record<string, Role>; last: Partial<Record<Role, { outcome: CrewOutcome; at: number }>>; fromBridge: string[] }
76export const NO_CREW: CrewState = { running: {}, last: {}, fromBridge: [] }
77
78/** What a Bridge key sends an officer to do. */
79export type CrewTask = 'tests' | 'lint' | 'question' | 'review'
80export const TASK_ROLE: Record<CrewTask, Role> = { tests: 'engineering', lint: 'engineering', question: 'science', review: 'tactical' }
81
82/** The Bridge's keys: `e` tests and `l` lint (Engineering), `s` a question (Science), `t` a security review (Tactical). */
83export const BRIDGE_KEYS: Record<string, CrewTask> = { e: 'tests', l: 'lint', s: 'question', t: 'review' }
84
85/** The prompt a Bridge key gives an officer: plain working instructions, like the agent prompts. */
86export function crewTask(task: CrewTask, question = ''): { prompt: string; description: string } {
87  switch (task) {
88    case 'tests':
89      return { prompt: 'Run the project\'s test suite and report the results.', description: 'Run the tests' }
90    case 'lint':
91      return {
92        prompt: [
93          'Run the project\'s linters and type checkers (for example its lint and typecheck scripts, eslint, tsc, phpstan, ruff) and report every problem with file:line.',
94          'Run each command on its own, not piped into another command, so its exit status is kept.',
95          'End your reply with exactly one line: "LINT: PASS" when every check passed, or "LINT: FAIL" otherwise.',
96        ].join(' '),
97        description: 'Run lint and type checks',
98      }
99    case 'review':
100      return { prompt: 'Review the changes on the current branch that are not yet pushed, plus any uncommitted changes, for security issues.', description: 'Security review' }
101    case 'question':
102      return { prompt: question.trim(), description: 'Answer a question' }
103  }
104}
105
106/** The lint verdict an Engineering report ends with: the last `LINT:` line, or undefined. */
107export function parseLint(answer: string): 'pass' | 'fail' | undefined {
108  const last = [...answer.matchAll(/^\s*\**LINT:\s*(PASS|FAIL)\b/gim)].at(-1)?.[1]
109  return last === undefined ? undefined : last.toUpperCase() === 'PASS' ? 'pass' : 'fail'
110}
111
112/** A finished run's answer as report lines: blank lines dropped, cut to CREW.reportLines with a note. */
113export function reportLines(answer: string): string[] {
114  const lines = answer.split('\n').map(l => l.trimEnd()).filter(l => l.trim() !== '')
115  if (lines.length === 0) return ['(no report)']
116  if (lines.length <= CREW.reportLines) return lines
117  return [...lines.slice(0, CREW.reportLines - 1), `(${lines.length - CREW.reportLines + 1} more lines not shown)`]
118}
119
120/** The Bridge's number keys: each reopens that officer's last report. */
121export const REPORT_KEYS: Record<string, Role> = { '1': 'engineering', '2': 'science', '3': 'tactical' }
122
123/**
124 * One line per officer for the Bridge, numbered by its report key: busy,
125 * idle, or its last result, and `(report)` when one is saved.
126 */
127export function crewLines(s: CrewState, withReport: readonly Role[] = []): string[] {
128  const busy = new Set(Object.values(s.running))
129  const word: Record<CrewOutcome, string> = { done: 'idle', clean: 'all clear', issues: 'ISSUES FOUND', stopped: 'idle (stopped)' }
130  return ROLES.map((r, i) => {
131    const status = busy.has(r) ? 'busy' : s.last[r] ? word[s.last[r]!.outcome] : 'idle'
132    return `  ${i + 1} ${ROLE_LABELS[r].padEnd(12)}${status}${withReport.includes(r) ? ' (report)' : ''}`
133  })
134}
135
src/bridge/band.tsx 80 lines
1import { atom, read } from 'claude-code'
2import type { On } from 'claude-code'
3import type { BandView, CalibrationView, LatticeView, ReportView } from '../../types'
4import { COMPANION, SPRITE } from '../config'
5import { tierColor } from './color'
6
7const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
8const lattice = atom({ plugin: 'final-commit', key: 'lattice' } as const, null as LatticeView | null)
9const calibration = atom({ plugin: 'final-commit', key: 'calibration' } as const, null as CalibrationView | null)
10const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
11const bridgeOpen = atom({ plugin: 'final-commit', key: 'bridgeOpen' } as const, false)
12
13/** Eyes close for a blink frame. */
14export function blink(rows: string[]): string[] {
15  return rows.map(r => r.replace(/[oO@]/g, '-'))
16}
17
18/** The drawing without its blank margin: blank rows above and below, blank columns at either side. */
19export function trimSprite(rows: string[]): string[] {
20  const inked = rows.filter(r => r.trim().length > 0)
21  if (inked.length === 0) return []
22  const first = rows.findIndex(r => r.trim().length > 0)
23  const last = rows.length - 1 - [...rows].reverse().findIndex(r => r.trim().length > 0)
24  const kept = rows.slice(first, last + 1)
25  const left = Math.min(...inked.map(r => r.length - r.trimStart().length))
26  const right = Math.max(...inked.map(r => r.trimEnd().length))
27  return kept.map(r => r.padEnd(right, ' ').slice(left, right))
28}
29
30/** Cuts a line to `width` cells. Band text is printable ASCII and one cell per character. */
31const fit = (text: string, width: number): string => [...text].slice(0, Math.max(0, width)).join('')
32
33/** The text beside the sprite: one fact per line, each cut to the column left of it. */
34export function besideLines(v: Pick<BandView, 'name' | 'tier' | 'mood' | 'mission'>, width: number): string[] {
35  return [v.name, v.tier, v.mood, ...(v.mission ? [v.mission] : [])].map(line => fit(line, width))
36}
37
38/** The companion above the prompt (SPEC 9.3). The wiring files write the view (and the blink); this draws it. */
39export function wireBand(on: On): void {
40  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
41    const v = await read($, band)
42    // A game pane needs the rows: the band steps aside while one is open. The Bridge draws the companion itself.
43    const isPlaying = (await read($, lattice)) !== null || (await read($, calibration)) !== null || (await read($, report)) !== null || (await read($, bridgeOpen))
44    if (!v || e.props.hasSurvey || isPlaying) return next(e)
45    const { Box, Text } = $.ui.resolve(e)
46    const columns = e.props.bodyColumns
47    const color = tierColor(v.tierName, await $.clock.now())
48    // The companion keeps to the right edge; whatever else draws in the band keeps the left.
49    const beneath = await next(e)
50    const showSprite = e.props.maxRows >= COMPANION.spriteMinRows && columns >= SPRITE.cols && v.sprite.length > 0
51    if (!showSprite) {
52      const line = fit(`${v.tier} ${v.name} · ${v.mood}${v.mission ? ` · ${v.mission}` : ''}`, columns)
53      return (
54        <Box flexDirection="row">
55          <Box flexGrow={1} flexShrink={1}>{beneath}</Box>
56          <Box flexShrink={0}><Text dimColor color={color}>{line}</Text></Box>
57        </Box>
58      )
59    }
60    const sprite = trimSprite(v.isBlinking || v.mood === 'asleep' ? blink(v.sprite) : v.sprite)
61    const spriteColumns = Math.max(0, ...sprite.map(r => r.length))
62    const room = columns - spriteColumns - COMPANION.spriteGap
63    const lines = room >= COMPANION.besideMinColumns ? besideLines(v, room) : []
64    const textColumns = Math.max(0, ...lines.map(l => [...l].length))
65    return (
66      <Box flexDirection="row">
67        <Box flexGrow={1} flexShrink={1}>{beneath}</Box>
68        {lines.length > 0 && (
69          <Box flexDirection="column" justifyContent="center" flexShrink={0} width={textColumns} marginRight={COMPANION.spriteGap}>
70            {lines.map((line, i) => (i === 0 ? <Text bold color={color}>{line}</Text> : i === 1 ? <Text color={color}>{line}</Text> : <Text dimColor>{line}</Text>))}
71          </Box>
72        )}
73        <Box flexDirection="column" flexShrink={0} width={spriteColumns}>
74          {sprite.map(row => <Text color={color}>{row}</Text>)}
75        </Box>
76      </Box>
77    )
78  })
79}
80
src/bridge/bay-pane.tsx 74 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { BandView, BayRow, ChartingEntry } from '../../types'
4import { setCompanion } from '../game'
5import { NO_MOOD, rngFor, snapshot } from '../runtime'
6import { createRepo, type Repo } from '../store/repo'
7import type { StarSystem } from '../store/schema'
8import { bayRows, holdRows } from './bay'
9import { tierColor } from './color'
10
11export const BAY_PANE = 'fc-bay'
12const rows = atom({ plugin: 'final-commit', key: 'bay' } as const, [] as BayRow[])
13const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
14const mood = atom({ plugin: 'final-commit', key: 'mood' } as const, NO_MOOD)
15const charting = atom({ plugin: 'final-commit', key: 'charting' } as const, [] as ChartingEntry[])
16const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
17
18function repoOf($: EngineInterface): Repo {
19  return createRepo(
20    { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
21    line => $.ui.log(line, { to: 'debug' }),
22  )
23}
24
25async function refresh($: EngineInterface) {
26  const s = await snapshot(repoOf($), await read($, mood), await $.clock.now(), await read($, charting))
27  $.ui.status(s.status)
28  await update($, band, prev => (s.band && prev ? { ...s.band, isBlinking: prev.isBlinking } : s.band))
29}
30
31async function bay($: EngineInterface, args: string): Promise<string> {
32  if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
33  const repo = repoOf($)
34  const m = /^companion\s+(\d+)$/i.exec(args.trim())
35  if (m) {
36    const chosen = (await repo.specimens())[Number(m[1]) - 1]
37    if (!chosen) return 'No such specimen. /bay lists them.'
38    const out = await setCompanion({ repo, now: await $.clock.now(), rng: rngFor(undefined, 0), specimenId: chosen.id })
39    await refresh($)
40    return out.text
41  }
42  const specimens = await repo.specimens()
43  const systems: StarSystem[] = []
44  for (const id of await repo.systemIds()) {
45    const s = await repo.system(id)
46    if (s) systems.push(s)
47  }
48  const meta = await repo.meta()
49  // Rows are cut to the pane's width when drawn.
50  const inv = await repo.inventory()
51  await update($, rows, () => [...holdRows(inv, systems), ...bayRows(specimens, systems, meta?.companionId ?? null, Number.MAX_SAFE_INTEGER)])
52  await $.ui.open({ id: BAY_PANE, title: 'Specimen Bay', focus: true, closeOnEscape: true })
53  return `Opened the specimen bay (${specimens.length} specimen${specimens.length === 1 ? '' : 's'}).`
54}
55
56/** The Specimen Bay as a pane, so creature names stay out of the transcript the model reads. */
57export function wireBay(on: On): void {
58  on('command.run', { command: 'bay' }, async ($, e) => ({ text: await bay($, e.args) }))
59
60  on('ui.render', { component: 'Pane', requestId: BAY_PANE }, async ($, e) => {
61    const { Box, Text } = $.ui.resolve(e)
62    const list = await read($, rows)
63    const now = await $.clock.now()
64    return (
65      <Box flexDirection="column">
66        {list.map(r => (
67          <Text color={r.tier ? tierColor(r.tier, now) : undefined}>{[...r.text].slice(0, e.props.bodyColumns).join('')}</Text>
68        ))}
69        <Text dimColor>/bay companion N sets your companion. /craft makes cells. Esc closes.</Text>
70      </Box>
71    )
72  })
73}
74
src/bridge/dossier-pane.tsx 56 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { DossierStat } from '../../types'
4import { createRepo, type Repo } from '../store/repo'
5import { dossierRows } from './dossier'
6
7export const DOSSIER_PANE = 'fc-dossier'
8const stats = atom({ plugin: 'final-commit', key: 'dossier' } as const, null as DossierStat[] | null)
9const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
10
11function repoOf($: EngineInterface): Repo {
12  return createRepo(
13    { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
14    line => $.ui.log(line, { to: 'debug' }),
15  )
16}
17
18/** `/dossier`: puzzle accuracy by category, in a pane so category names stay out of the transcript the model reads. */
19async function dossier($: EngineInterface): Promise<string> {
20  if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
21  const drawn = (await repoOf($).puzzleStats()).map(({ category, type, attempts, correct, recent }) => ({ category, type, attempts, correct, recent }))
22  await update($, stats, () => drawn)
23  await $.ui.open({ id: DOSSIER_PANE, title: 'Dossier', focus: true, closeOnEscape: true })
24  return `Opened the dossier (${drawn.reduce((n, s) => n + s.attempts, 0)} answered).`
25}
26
27async function close($: EngineInterface) {
28  // A plugin's own $.ui.close does not reach its own ui.close hook, so the state is cleared here.
29  await update($, stats, () => null)
30  await $.ui.close({ id: DOSSIER_PANE })
31}
32
33export function wireDossier(on: On): void {
34  on('command.run', { command: 'dossier' }, async $ => ({ text: await dossier($) }))
35
36  on('ui.close', { id: DOSSIER_PANE }, async ($, e, next) => {
37    await update($, stats, () => null)
38    return next(e)
39  })
40
41  on('ui.render', { component: 'Pane', requestId: DOSSIER_PANE }, async ($, e) => {
42    const { Box, Text, Button } = $.ui.resolve(e)
43    const list = await read($, stats)
44    if (!list) return <Text dimColor>Nothing to show.</Text>
45    return (
46      <Box flexDirection="column">
47        {dossierRows(list, e.props.bodyColumns).map(r => (
48          <Text bold={r.style === 'bold'}>{r.text === '' ? ' ' : r.text}</Text>
49        ))}
50        <Text dimColor>Esc closes.</Text>
51        {e.surface === 'mobile' && <Button key="dossier-close" label="Close" onPress={() => void close($)} />}
52      </Box>
53    )
54  })
55}
56
src/bridge/scan-pane.tsx 62 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { ScanRow } from '../../types'
4import { parseSystemKey } from '../game'
5import { createRepo, type Repo } from '../store/repo'
6import { scanRows } from './scan'
7import { tierColor } from './color'
8import { wrap } from './text'
9
10export const SCAN_PANE = 'fc-scan'
11const rows = atom({ plugin: 'final-commit', key: 'scan' } as const, [] as ScanRow[])
12const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
13
14function repoOf($: EngineInterface): Repo {
15  return createRepo(
16    { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
17    line => $.ui.log(line, { to: 'debug' }),
18  )
19}
20
21/** `/scan [KEY]`: the active epic's system, or the one charted for KEY. */
22async function scan($: EngineInterface, args: string): Promise<string> {
23  if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
24  const repo = repoOf($)
25  const key = args.trim() === '' ? (await repo.meta())?.activeEpicKey ?? undefined : parseSystemKey(args)
26  if (!key) return args.trim() === '' ? 'No active epic. /scan <KEY> scans a charted one.' : 'Usage: /scan [KEY], for example /scan NOVA-1.'
27  let system
28  for (const id of await repo.systemIds()) {
29    const s = await repo.system(id)
30    if (s?.epicKey === key) system = s
31  }
32  if (!system) return `Epic ${key} is not charted.`
33  const drawn = scanRows(system, await repo.catalog(), await repo.resolved())
34  await update($, rows, () => drawn)
35  await $.ui.open({ id: SCAN_PANE, title: `Scan ${key}`, focus: true, closeOnEscape: true })
36  return `Opened the scan of epic ${key}.`
37}
38
39/** The system scan as a pane, so species names stay out of the transcript the model reads. */
40export function wireScan(on: On): void {
41  on('command.run', { command: 'scan' }, async ($, e) => ({ text: await scan($, e.args) }))
42
43  on('ui.render', { component: 'Pane', requestId: SCAN_PANE }, async ($, e) => {
44    const { Box, Text } = $.ui.resolve(e)
45    const width = e.props.bodyColumns
46    const list = await read($, rows)
47    const now = await $.clock.now()
48    // Sprites are cut to the width; prose wraps.
49    const lines = list.flatMap(r => (r.isArt ? [{ ...r, text: [...r.text].slice(0, width).join('') }] : wrap(r.text, width).map(text => ({ ...r, text }))))
50    return (
51      <Box flexDirection="column">
52        {lines.map(r => (
53          <Text bold={r.style === 'bold'} dimColor={r.style === 'dim'} color={r.tier ? tierColor(r.tier, now) : undefined}>
54            {r.text}
55          </Text>
56        ))}
57        <Text dimColor>Esc closes.</Text>
58      </Box>
59    )
60  })
61}
62
src/setup/setup-pane.tsx 141 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { SetupValues, SetupView } from '../../types'
4import { wrap } from '../bridge/text'
5import { GIT } from '../config'
6import { answer, changesOf, questionOf, reviewLines, startSetup } from './wizard'
7
8// `/setup` (SPEC 4.6): the wizard as a pane. Answers are typed into it, so
9// they skip the transcript the model reads, and are saved as the plugin's
10// own settings through `$.config.set`, as a change in `/config` would be.
11// The Jira API token is a secret no plugin can write: the review says where
12// to set it, and whether it is set, never what it is.
13
14export const SETUP_PANE = 'fc-setup'
15const view = atom({ plugin: 'final-commit', key: 'setup' } as const, null as SetupView | null)
16
17const text = (v: unknown) => (typeof v === 'string' ? v : '')
18let current: SetupValues = { workSources: '', plansFolders: '', githubRepos: '', jiraSite: '', jiraEmail: '' }
19let hasJiraToken = false
20
21/** This project's GitHub repo (owner/name) through gh, to suggest; undefined when there is none or gh cannot tell. */
22async function repoHere($: EngineInterface): Promise<string | undefined> {
23  try {
24    const r = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { timeoutMs: GIT.timeoutMs })
25    const name = r.stdout.trim()
26    return r.exitCode === 0 && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(name) ? name : undefined
27  } catch {
28    return undefined
29  }
30}
31
32async function setup($: EngineInterface): Promise<{ text: string }> {
33  const repo = await repoHere($)
34  await update($, view, () => startSetup(current, repo))
35  const placed = await $.ui.open({ id: SETUP_PANE, title: 'Set up work sources', focus: true, closeOnEscape: true })
36  if (placed.isPlaced) return { text: 'Opened the setup pane.' }
37  await update($, view, () => null)
38  return { text: 'The setup pane could not open here. Set workSources and the source settings in /config instead.' }
39}
40
41/** Enter on a question: the answer is checked and kept, or the pane says what is wrong. */
42async function submit($: EngineInterface, value: string) {
43  await update($, view, v => (v ? answer(v, value) : v))
44}
45
46/**
47 * Enter on the review: writes each changed setting, the source list last. A
48 * write reloads the plugin with the new settings; the writes still finish.
49 * The pane shows `saving` meanwhile, with no input, so a second Enter cannot
50 * write them again. Saving is not stopped halfway: if the pane was closed
51 * meanwhile, a toast says what was saved.
52 */
53async function save($: EngineInterface) {
54  const v = await read($, view)
55  if (!v || v.step !== 'review') return
56  const changes = changesOf(v)
57  if (changes.length === 0) {
58    await close($)
59    return
60  }
61  await update($, view, s => (s ? { ...s, step: 'saving' as const } : s))
62  const applied: string[] = []
63  for (const c of changes) {
64    try {
65      const r = await $.config.set({ key: `final-commit.${c.key}`, value: c.value })
66      applied.push('deny' in r && r.deny !== undefined ? `${c.key}: not saved (${r.deny})` : `${c.key}: saved`)
67    } catch (err) {
68      applied.push(`${c.key}: not saved (${err instanceof Error ? err.message : String(err)})`)
69    }
70  }
71  const after = await update($, view, s => (s ? { ...s, step: 'done' as const, applied } : s))
72  if (!after) $.ui.toast(`Setup: ${applied.join('; ')}.`)
73}
74
75async function close($: EngineInterface) {
76  // A plugin's own $.ui.close does not reach its own ui.close hook, so the view is cleared here.
77  await update($, view, () => null)
78  await $.ui.close({ id: SETUP_PANE })
79}
80
81/** Registers `/setup` and its pane. `options` are the settings this load runs with: the wizard starts from them. */
82export function wireSetup(on: On, options: PluginOptions): void {
83  current = {
84    workSources: text(options.workSources),
85    plansFolders: text(options.plansFolders),
86    githubRepos: text(options.githubRepos),
87    jiraSite: text(options.jiraSite),
88    jiraEmail: text(options.jiraEmail),
89  }
90  hasJiraToken = text(options.jiraToken).trim() !== ''
91
92  on('command.run', { command: 'setup' }, async $ => setup($))
93
94  on('ui.close', { id: SETUP_PANE }, async ($, e, next) => {
95    await update($, view, () => null)
96    return next(e)
97  })
98
99  on('ui.render', { component: 'Pane', requestId: SETUP_PANE }, async ($, e) => {
100    const elements = $.ui.resolve(e)
101    const { Box, Text } = elements
102    const Input = 'Input' in elements ? elements.Input : undefined
103    const width = e.props.bodyColumns
104    const v = await read($, view)
105    if (!v) return <Text dimColor>Nothing to set up.</Text>
106    const lines = (s: string, style?: 'bold' | 'dim' | 'error') => wrap(s, width).map(line => (
107      <Text bold={style === 'bold'} dimColor={style === 'dim'} color={style === 'error' ? 'error' : undefined}>{line}</Text>
108    ))
109    if (!Input) return <Box flexDirection="column">{lines('This surface has no text input; run /setup from a terminal.', 'dim')}</Box>
110    if (v.step === 'done') {
111      return (
112        <Box flexDirection="column">
113          {v.applied.flatMap(a => lines(a))}
114          {lines('Settings take effect at once. Enter or Esc closes.', 'dim')}
115          <Input key="setup-done" autoFocus label="Enter" onInput={() => {}} onSubmit={() => void close($)} />
116        </Box>
117      )
118    }
119    if (v.step === 'saving') return <Box flexDirection="column">{lines('Saving...', 'dim')}</Box>
120    if (v.step === 'review') {
121      return (
122        <Box flexDirection="column">
123          {lines('Review', 'bold')}
124          {reviewLines(v, hasJiraToken).flatMap(l => lines(l, l.startsWith('!') ? 'error' : undefined))}
125          <Input key="setup-review" autoFocus label="Enter" onInput={() => {}} onSubmit={() => void save($)} />
126        </Box>
127      )
128    }
129    const q = questionOf(v)
130    if (!q) return <Text dimColor>Nothing to set up.</Text>
131    return (
132      <Box flexDirection="column">
133        {lines(q.help, 'dim')}
134        {v.error ? lines(v.error, 'error') : null}
135        <Input key={`setup-${v.step}`} autoFocus label={q.label} value={q.value} onInput={() => {}} onSubmit={(value: string) => void submit($, value)} />
136        {lines('Enter: next  Esc: cancel, nothing saved', 'dim')}
137      </Box>
138    )
139  })
140}
141
src/bridge/text.ts 23 lines
1// Pure text layout for panes.
2
3function cut(text: string, width: number): string {
4  const chars = [...text]
5  return chars.length <= width ? text : `${chars.slice(0, Math.max(1, width - 1)).join('')}~`
6}
7
8/** `text` broken at spaces into lines of at most `width` (a longer word is cut). Continuations indent two cells. */
9export function wrap(text: string, width: number): string[] {
10  const out: string[] = []
11  let line = ''
12  for (const word of text.split(' ')) {
13    const candidate = line === '' ? word : `${line} ${word}`
14    if ([...candidate].length <= width || line.trim() === '') line = candidate
15    else {
16      out.push(line)
17      line = `  ${word}`
18    }
19  }
20  out.push(line)
21  return out.map(l => cut(l, width))
22}
23