SLOPSHOPPER

harness-fleet

The owner's interface to the plugin fleet: /fleet shows each owned plugin's installed and offered version, the repository's open PRs with their CI state and…

newpanecommandtoastprocess
v0.1.0no licenseupdated 2026-10-09cdeust/claude-mods/mods/harness-fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · harness-fleet
│ ┃ Fleet ✕ › fix the failing auth test and add an audit log call │ ┃ Harness fleet │ ┃ the owner's plugins and their repositories · ⏺ Read(src/auth.ts) │ ┃ every action starts with a button you press; ⎿ Read 6 lines │ ┃ the mod itself only reads ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ inventory: Error: ENOENT: no such file ⏺ Bash(bun test) │ ┃ /Users/dev/.claude/plugins/installed_plugins ⎿ 3 pass, 1 fail │ ┃ .json │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ no owned plugin found in │ ┃ installed_plugins.json ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ LESSONS › /fleet │ ┃ 0 guard refusals · 0 classifier errors · 0 ⎿ harness-fleet: Harness fleet opened. │ ┃ stuck escalations · 0 results cut │ ┃ [ Draft issue ] │ ┃ │ ┃ [ Refreshing… ] remote: not read yet │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet
Harness fleet the owner's plugins and their repositories · every action starts with a button you press; the mod itself only reads inventory: Error: ENOENT: no such file /Users/dev/.claude/plugins/installed_plugins.json no owned plugin found in installed_plugins.json LESSONS 0 guard refusals · 0 classifier errors · 0 stuck escalations · 0 results cut [ Draft issue ] [ Refreshing… ] remote: not read yet
README

claude-mods

Claude Code mods for the ai-architect.tools harness, one concern per mod, state shared through dependencies:

ModOwnsDepends on
cortex-guardrefusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/nothing
cortex-wiki/wiki <wiki/**.md> to PDFnothing
zetetic-geniusstate: the request grade (task class → effort), the genius patterns and skills it matchesnothing
zetetic-autopilotcontext, policy: effort ladder, model routing, pressure, lean resultszetetic-genius
cortex-cockpitstats, ledger, tally, stages, hygiene; the /cortex pane draws the restthe three above
harness-fleetfleet: the owner's plugins, installed vs offered version, open PRs with CI, open issues (a defect is taken and fixed, a feature request goes to the owner); /fleet. Reads only (gh pr list, gh issue list, git remote): every outward action is a button the owner presses, which puts a prompt in front of the modelguard, genius, autopilot

Developing

Each mod is validated, tested and type-checked on its own:

cd mods/<mod> && claude plugin validate . && claude plugin test && npx -y -p typescript tsc -p .

tsc needs the engine to have loaded the mod once (it lays .claude-plugin/types/). For hot reload in a session, link the mod into that session's ~/.claude/dev-mods/<session>/ folder; the engine watches the folder a link names.

Installing

.claude-plugin/marketplace.json lists each mod as "source": "./mods/<mod>", so the repository is the marketplace. From another machine:

/plugin install <mod> --marketplace cdeust/claude-mods

y adds the marketplace, then the user scope. On this machine a folder marketplace reads the mods from the checkout, with no copy: claude plugin marketplace add <this folder>, then /plugin install <mod> and /reload-plugins after an edit.

Source 4 files
hooks/register.tsx 186 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FleetState, IssueRow, PrRow, RepoRow } from '../types'
5import {
6  INSTALLED_PLUGINS_PATH,
7  KNOWN_MARKETPLACES_PATH,
8  MARKETPLACE_MANIFEST,
9  type Lessons,
10  draftIssuePrompt,
11  fleetRows,
12  issueListArgv,
13  parseInstalled,
14  parseIssues,
15  parseMarketplaces,
16  parseOffered,
17  parsePrList,
18  prListArgv,
19  remoteArgv,
20  repoOfRemote,
21  reviewPrompt,
22  takeIssuePrompt,
23} from './fleet'
24import { FleetView } from './fleetview'
25
26const PANE = 'harness-fleet'
27const GH_TIMEOUT_MS = 20_000 // source: own choice, gh answers in 1–3 s; a stalled call must not hold the pane
28
29const fleet = atom({ plugin: 'harness-fleet', key: 'fleet' } as const, {
30  repos: [],
31  inventoryError: null,
32  isRefreshing: false,
33  readAt: null,
34} as FleetState)
35
36// The signals the LESSONS section counts come from the other mods' state (dependencies); read only.
37const GUARD_REFUSALS = { plugin: 'cortex-guard', key: 'refusals' } as const
38const GENIUS_STATE = { plugin: 'zetetic-genius', key: 'state' } as const
39const AUTOPILOT_POLICY = { plugin: 'zetetic-autopilot', key: 'policy' } as const
40
41const expandHome = async ($: EngineInterface, path: string): Promise<string> =>
42  path.startsWith('~/') ? `${(await $.env.get('HOME')) ?? ''}/${path.slice(2)}` : path
43
44async function lessonsOf($: EngineInterface): Promise<Lessons> {
45  const l: Lessons = { refusals: 0, classifierErrors: 0, stuckEscalations: 0, leanCuts: 0 }
46  try {
47    l.refusals = ((await $.state.get(GUARD_REFUSALS)).value ?? []).length
48  } catch {
49    // the guard is not loaded: zero refusals seen
50  }
51  try {
52    const g = (await $.state.get(GENIUS_STATE)).value
53    l.classifierErrors = g?.classifierError === null || g?.classifierError === undefined ? 0 : 1
54  } catch {
55    // the genius router is not loaded
56  }
57  try {
58    const p = (await $.state.get(AUTOPILOT_POLICY)).value
59    l.stuckEscalations = p?.decisions.filter((d) => d.kind === 'stuck').length ?? 0
60    l.leanCuts = p?.decisions.filter((d) => d.kind === 'lean').length ?? 0
61  } catch {
62    // the autopilot is not loaded
63  }
64  return l
65}
66
67// The inventory is local: the engine's records and each owned marketplace's manifest. Rejects
68// when a record cannot be read.
69async function buildInventory($: EngineInterface, owner: string): Promise<RepoRow[]> {
70  const installed = parseInstalled(await $.fs.read(await expandHome($, INSTALLED_PLUGINS_PATH)))
71  const marketplaces = parseMarketplaces(await $.fs.read(await expandHome($, KNOWN_MARKETPLACES_PATH)))
72  const offered: Record<string, Record<string, string>> = {}
73  for (const m of marketplaces) {
74    try {
75      offered[m.name] = parseOffered(await $.fs.read(`${m.installLocation}/${MARKETPLACE_MANIFEST}`))
76    } catch {
77      offered[m.name] = {}
78    }
79  }
80  return fleetRows(installed, marketplaces, offered, owner)
81}
82
83async function loadInventory($: EngineInterface, owner: string): Promise<void> {
84  try {
85    const repos = await buildInventory($, owner)
86    await update($, fleet, (f) => ({ ...f, repos, inventoryError: null }))
87  } catch (error) {
88    await update($, fleet, (f) => ({ ...f, inventoryError: String(error).slice(0, 140) }))
89  }
90}
91
92// One repository's remote reading: open PRs with their CI, the open issue count. Read-only gh.
93async function readRepo($: EngineInterface, r: RepoRow): Promise<RepoRow> {
94  const readAt = await $.clock.now()
95  let repo = r.repo
96  if (repo === null && r.local !== null) {
97    const remote = await $.process.run(remoteArgv(r.local), { timeoutMs: GH_TIMEOUT_MS })
98    repo = remote.exitCode === 0 ? repoOfRemote(remote.stdout) : null
99  }
100  if (repo === null) return { ...r, readAt, error: r.local === null ? 'no repository' : 'no GitHub remote' }
101  const [prs, issues] = await Promise.all([
102    $.process.run(prListArgv(repo), { timeoutMs: GH_TIMEOUT_MS }),
103    $.process.run(issueListArgv(repo), { timeoutMs: GH_TIMEOUT_MS }),
104  ])
105  if (prs.exitCode !== 0) return { ...r, repo, readAt, error: prs.stderr.trim().slice(0, 120) || `gh exit ${prs.exitCode}` }
106  return {
107    ...r,
108    repo,
109    readAt,
110    error: null,
111    prs: parsePrList(prs.stdout),
112    issues: issues.exitCode === 0 ? parseIssues(issues.stdout) : null,
113  }
114}
115
116// source: the engine's session.start doc ("once per process for each loaded plugin ... never
117// /clear") and SessionEndInput.reason ("the process goes on under a new session id, and no
118// session.start fires for it"): after a /clear the session state starts empty and no event
119// rebuilds the inventory. So every refresh rebuilds it first; the local reads also pick up a
120// plugin installed or a marketplace updated since the session began. The rows already shown
121// stay on screen until the remote reading lands: the state is written once, at the end.
122async function refreshRemote($: EngineInterface, owner: string): Promise<void> {
123  const before = await read($, fleet)
124  if (before.isRefreshing) return
125  await update($, fleet, (f) => ({ ...f, isRefreshing: true }))
126  try {
127    let inventory: RepoRow[]
128    try {
129      inventory = await buildInventory($, owner)
130    } catch (error) {
131      await update($, fleet, (f) => ({ ...f, inventoryError: String(error).slice(0, 140) }))
132      return
133    }
134    const repos = await Promise.all(inventory.map((r) => readRepo($, r)))
135    await update($, fleet, (f) => ({ ...f, repos, inventoryError: null, readAt: repos[0]?.readAt ?? f.readAt }))
136  } finally {
137    await update($, fleet, (f) => ({ ...f, isRefreshing: false }))
138  }
139}
140
141export const register: Register = (on, options) => {
142  const owner = String(options.github_owner ?? 'cdeust')
143  const refreshOnStart = options.refresh_on_start !== false
144
145  on('session.start', async ($, e, next) => {
146    await loadInventory($, owner)
147    try {
148      await $.command.register({ name: 'fleet', description: 'Open the harness fleet pane: plugins, versions, PRs, CI', immediate: true })
149    } catch (error) {
150      $.ui.toast(`/fleet is taken by another plugin: ${String(error).slice(0, 80)}`)
151    }
152    // Not awaited: nine repositories of gh calls would outlast the hook's budget; the pane
153    // says "remote: not read yet" until the reading lands.
154    if (refreshOnStart) void refreshRemote($, owner)
155
156    return next(e)
157  })
158
159  on('command.run', { command: 'fleet' }, async ($) => {
160    await $.ui.open({ id: PANE, title: 'Fleet' })
161    // With nothing shown yet, the local inventory is awaited so the pane has its rows at once;
162    // the remote reading goes on in the background and writes the whole state when it lands.
163    if ((await read($, fleet)).repos.length === 0) await loadInventory($, owner)
164    void refreshRemote($, owner)
165
166    return { text: 'Harness fleet opened.' }
167  })
168
169  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
170    FleetView($.ui.resolve(e) as never, {
171      fleet: await read($, fleet),
172      lessons: await lessonsOf($),
173      now: await $.clock.now(),
174      onRefresh: () => void refreshRemote($, owner),
175      onReview: (repo: string, pr: PrRow) => void $.prompt.submit({ text: reviewPrompt(repo, pr) }),
176      onTake: (repo: string, issue: IssueRow) => void $.prompt.submit({ text: takeIssuePrompt(repo, issue) }),
177      onDraftIssue: () =>
178        void (async () => {
179          const f = await read($, fleet)
180          const repos = f.repos.map((r) => r.repo).filter((r): r is string => r !== null)
181          await $.prompt.submit({ text: draftIssuePrompt(repos, await lessonsOf($)) })
182        })(),
183    }),
184  )
185}
186
hooks/fleet.ts 244 lines
1// Pure side of the fleet: the engine's plugin records, the marketplaces' manifests and gh's JSON
2// become rows; the prompts a Button puts in front of the model are composed here.
3
4import type { CiState, IssueRow, PluginRow, PrRow, RepoRow } from '../types'
5
6// source: ~/.claude/plugins/installed_plugins.json and known_marketplaces.json, the engine's
7// records (read 2026-10-08): plugins["<name>@<marketplace>"][0].{version, installPath};
8// marketplaces[<name>].{source:{source:"github",repo}|{source:"directory",path}, installLocation,
9// lastUpdated}. A marketplace's own manifest is <installLocation>/.claude-plugin/marketplace.json,
10// plugins[].{name, version}.
11export const INSTALLED_PLUGINS_PATH = '~/.claude/plugins/installed_plugins.json'
12export const KNOWN_MARKETPLACES_PATH = '~/.claude/plugins/known_marketplaces.json'
13export const MARKETPLACE_MANIFEST = '.claude-plugin/marketplace.json'
14
15export type Installed = { name: string; marketplace: string; version: string }
16export type Marketplace = {
17  name: string
18  repo: string | null
19  path: string | null
20  installLocation: string
21  lastUpdated: string | null
22}
23
24const parseJson = (text: string): unknown => {
25  try {
26    return JSON.parse(text)
27  } catch {
28    return undefined
29  }
30}
31
32export const parseInstalled = (json: string): Installed[] => {
33  const d = parseJson(json) as { plugins?: Record<string, { version?: unknown }[]> } | undefined
34  return Object.entries(d?.plugins ?? {}).flatMap(([key, entries]) => {
35    const at = key.lastIndexOf('@')
36    const version = entries[0]?.version
37    if (at <= 0 || typeof version !== 'string') return []
38    return [{ name: key.slice(0, at), marketplace: key.slice(at + 1), version }]
39  })
40}
41
42export const parseMarketplaces = (json: string): Marketplace[] => {
43  const d = parseJson(json) as
44    | Record<string, { source?: { source?: string; repo?: string; path?: string }; installLocation?: string; lastUpdated?: string }>
45    | undefined
46  return Object.entries(d ?? {}).flatMap(([name, m]) => {
47    if (typeof m.installLocation !== 'string') return []
48    const src = m.source ?? {}
49    return [
50      {
51        name,
52        repo: src.source === 'github' && typeof src.repo === 'string' ? src.repo : null,
53        path: src.source === 'directory' && typeof src.path === 'string' ? src.path : null,
54        installLocation: m.installLocation,
55        lastUpdated: typeof m.lastUpdated === 'string' ? m.lastUpdated : null,
56      },
57    ]
58  })
59}
60
61// plugin name → the version the marketplace offers.
62export const parseOffered = (manifestJson: string): Record<string, string> => {
63  const d = parseJson(manifestJson) as { plugins?: { name?: unknown; version?: unknown }[] } | undefined
64  const out: Record<string, string> = {}
65  for (const p of d?.plugins ?? []) if (typeof p.name === 'string' && typeof p.version === 'string') out[p.name] = p.version
66  return out
67}
68
69// Numeric dot-separated versions compare field by field; anything else compares as text.
70export const isBehind = (installed: string, offered: string | null): boolean => {
71  if (offered === null || installed === offered) return false
72  const a = installed.split('.').map(Number)
73  const b = offered.split('.').map(Number)
74  if (a.some(Number.isNaN) || b.some(Number.isNaN)) return installed < offered
75  for (let i = 0; i < Math.max(a.length, b.length); i++) {
76    const x = a[i] ?? 0
77    const y = b[i] ?? 0
78    if (x !== y) return x < y
79  }
80  return false
81}
82
83// The owner's repositories alone: a GitHub marketplace under the owner, or a local directory.
84export const isOwned = (m: Marketplace, owner: string): boolean =>
85  m.path !== null || (m.repo !== null && m.repo.toLowerCase().startsWith(`${owner.toLowerCase()}/`))
86
87export const fleetRows = (
88  installed: readonly Installed[],
89  marketplaces: readonly Marketplace[],
90  offered: Readonly<Record<string, Record<string, string>>>,
91  owner: string,
92): RepoRow[] =>
93  marketplaces
94    .filter((m) => isOwned(m, owner))
95    .map((m) => {
96      const plugins: PluginRow[] = installed
97        .filter((p) => p.marketplace === m.name)
98        .map((p) => {
99          const off = offered[m.name]?.[p.name] ?? null
100          return { name: p.name, installed: p.version, offered: off, isBehind: isBehind(p.version, off) }
101        })
102      return {
103        repo: m.repo,
104        local: m.path,
105        marketplace: m.name,
106        marketplaceUpdatedAt: m.lastUpdated,
107        plugins,
108        prs: [],
109        issues: null,
110        error: null,
111        readAt: null,
112      }
113    })
114    .filter((r) => r.plugins.length > 0)
115
116// `git remote get-url origin` → owner/name, for a directory marketplace.
117export const repoOfRemote = (url: string): string | null => {
118  const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\s*$/.exec(url.trim())
119  return m === null ? null : (m[1] ?? null)
120}
121
122// source: `gh pr list --json statusCheckRollup` (read on cdeust/Cortex, 2026-10-08): CheckRun rows
123// carry status (COMPLETED, IN_PROGRESS, QUEUED, ...) and conclusion (SUCCESS, FAILURE, CANCELLED,
124// SKIPPED, NEUTRAL, TIMED_OUT, ACTION_REQUIRED, null while running); StatusContext rows carry
125// state (SUCCESS, FAILURE, ERROR, PENDING, EXPECTED). An empty list is no check, not green.
126type Rollup = { __typename?: string; status?: string; conclusion?: string | null; state?: string }
127
128const RED = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
129const IGNORED = new Set(['SKIPPED', 'NEUTRAL'])
130
131export const ciOf = (rollup: readonly Rollup[]): CiState => {
132  if (rollup.length === 0) return 'none'
133  let hasGreen = false
134  let isPending = false
135  for (const r of rollup) {
136    const verdict = r.__typename === 'StatusContext' ? r.state : r.conclusion
137    if (r.__typename !== 'StatusContext' && r.status !== undefined && r.status !== 'COMPLETED') {
138      isPending = true
139      continue
140    }
141    if (verdict === undefined || verdict === null || verdict === 'PENDING' || verdict === 'EXPECTED') {
142      isPending = true
143      continue
144    }
145    if (RED.has(verdict)) return 'failure'
146    if (verdict === 'SUCCESS') hasGreen = true
147    else if (!IGNORED.has(verdict)) isPending = true
148  }
149  if (isPending) return 'pending'
150  return hasGreen ? 'success' : 'none'
151}
152
153export const parsePrList = (json: string): PrRow[] => {
154  const d = parseJson(json)
155  if (!Array.isArray(d)) return []
156  return d.flatMap((p: Record<string, unknown>) =>
157    typeof p.number === 'number' && typeof p.title === 'string'
158      ? [
159          {
160            number: p.number,
161            title: p.title,
162            isDraft: p.isDraft === true,
163            updatedAt: typeof p.updatedAt === 'string' ? p.updatedAt : '',
164            url: typeof p.url === 'string' ? p.url : '',
165            ci: ciOf(Array.isArray(p.statusCheckRollup) ? (p.statusCheckRollup as Rollup[]) : []),
166          },
167        ]
168      : [],
169  )
170}
171
172export const parseIssues = (json: string): IssueRow[] | null => {
173  const d = parseJson(json)
174  if (!Array.isArray(d)) return null
175  return d.flatMap((i: Record<string, unknown>) =>
176    typeof i.number === 'number' && typeof i.title === 'string'
177      ? [
178          {
179            number: i.number,
180            title: i.title,
181            labels: Array.isArray(i.labels) ? i.labels.flatMap((l: { name?: unknown }) => (typeof l.name === 'string' ? [l.name] : [])) : [],
182            createdAt: typeof i.createdAt === 'string' ? i.createdAt : '',
183            comments: Array.isArray(i.comments) ? i.comments.length : 0,
184            url: typeof i.url === 'string' ? i.url : '',
185          },
186        ]
187      : [],
188  )
189}
190
191// source: the owner's standing rule (model-behavior.md): repair work is taken and finished
192// without asking; only adding a feature needs the owner's arbitration. A label that names a
193// request (not a defect) therefore routes the issue to an assessment, never to a fix.
194const FEATURE_LABELS = new Set(['enhancement', 'feature', 'feature request', 'question', 'proposal'])
195export type IssueKind = 'defect' | 'feature'
196export const issueKind = (labels: readonly string[]): IssueKind =>
197  labels.some((l) => FEATURE_LABELS.has(l.toLowerCase())) ? 'feature' : 'defect'
198
199// The read-only gh and git calls the mod makes; nothing here writes anywhere.
200export const PR_LIMIT = 10 // source: own choice, the newest open PRs a pane row can carry
201export const ISSUE_LIMIT = 100 // source: own choice, past it the count reads "100+"
202export const prListArgv = (repo: string): string[] => [
203  'gh', 'pr', 'list', '--repo', repo, '--state', 'open', '--json',
204  'number,title,isDraft,updatedAt,url,statusCheckRollup', '--limit', String(PR_LIMIT),
205]
206export const issueListArgv = (repo: string): string[] => [
207  'gh', 'issue', 'list', '--repo', repo, '--state', 'open', '--json', 'number,title,labels,createdAt,url,comments',
208  '--limit', String(ISSUE_LIMIT),
209]
210export const remoteArgv = (local: string): string[] => ['git', '-C', local, 'remote', 'get-url', 'origin']
211
212// What a Button puts in front of the model. The rules named are the owner's standing ones: the
213// lead posts the verdict as a standalone comment with the head sha on line 2; a merge goes
214// through merge-gate.py with --repo; every worktree lives inside the repo and is registered.
215export const reviewPrompt = (repo: string, pr: PrRow): string =>
216  [
217    `Review PR #${pr.number} of ${repo} ("${pr.title}", CI ${pr.ci}).`,
218    'Read the diff and the CI logs for any red check. Post the review verdict as the lead: a standalone `gh pr comment` whose line 2 is the head sha.',
219    'Merge only through merge-gate.py with --repo, and only when CI is green and the verdict is posted. Never merge, close or push from a mod.',
220  ].join(' ')
221
222export const takeIssuePrompt = (repo: string, issue: IssueRow): string =>
223  issueKind(issue.labels) === 'feature'
224    ? [
225        `Issue #${issue.number} of ${repo} ("${issue.title}") is a feature request.`,
226        "Adding a feature needs the owner's decision: read the issue and its comments, then state in three sentences what it would change, what it costs and the alternative, and wait for the owner's answer before writing any code.",
227      ].join(' ')
228    : [
229        `Take issue #${issue.number} of ${repo} ("${issue.title}", ${issue.labels.length === 0 ? 'no label' : issue.labels.join(', ')}, ${issue.comments} comment${issue.comments === 1 ? '' : 's'}).`,
230        'Read the issue and its comments, reproduce the defect on current main with a test that fails before the fix, fix the root cause in a worktree under <repo>/.claude/worktrees/ registered with disk-hygiene, run the gates exactly as CI runs them, and open a PR that closes it.',
231        'Have the PR reviewed independently and merge only through merge-gate.py with --repo, once CI is green and the verdict is posted.',
232        'Never close an issue without evidence: if the defect cannot be reproduced, say so with the commands run and ask the reporter on the issue.',
233      ].join(' ')
234
235export type Lessons = { refusals: number; classifierErrors: number; stuckEscalations: number; leanCuts: number }
236
237export const draftIssuePrompt = (repos: readonly string[], l: Lessons): string =>
238  [
239    'From this session\'s harness signals, draft one GitHub issue on the plugin repository the signal belongs to:',
240    `${l.refusals} guard refusals, ${l.classifierErrors} classifier errors, ${l.stuckEscalations} stuck escalations, ${l.leanCuts} tool results cut.`,
241    `Candidate repositories: ${repos.join(', ')}.`,
242    'Show me the issue title and body first; create it with `gh issue create` only after I confirm.',
243  ].join(' ')
244
hooks/fleetview.tsx 132 lines
1import type { Elements } from 'claude-code'
2
3import type { CiState, FleetState, IssueRow, PrRow, RepoRow } from '../types'
4import { ISSUE_LIMIT, type Lessons, issueKind } from './fleet'
5
6export type Ui = Elements['terminal']
7
8export type FleetData = {
9  fleet: FleetState
10  lessons: Lessons
11  now: number
12  onRefresh: () => void
13  onReview: (repo: string, pr: PrRow) => void
14  onTake: (repo: string, issue: IssueRow) => void
15  onDraftIssue: () => void
16}
17
18// source: the design system's verification lexicon: a state is named, never implied by colour alone.
19const CI_GLYPH: Record<CiState, string> = { success: '●', failure: '✗', pending: '◐', none: '○' }
20
21const ageLabel = (iso: string, now: number): string => {
22  const ms = now - Date.parse(iso)
23  if (!Number.isFinite(ms)) return ''
24  const h = Math.floor(ms / 3_600_000)
25  return h < 48 ? `${h} h ago` : `${Math.floor(h / 24)} d ago`
26}
27
28const PrLine = (ui: Ui, repo: string, pr: PrRow, now: number, onReview: FleetData['onReview']) => {
29  const { Box, Button, Text } = ui
30  return (
31    <Box gap={1}>
32      <Text color={pr.ci === 'failure' ? 'red' : pr.ci === 'success' ? 'green' : undefined}>
33        {CI_GLYPH[pr.ci]} #{pr.number} {pr.isDraft ? '(draft) ' : ''}
34        {pr.title.slice(0, 60)} · CI {pr.ci} · {ageLabel(pr.updatedAt, now)}
35      </Text>
36      <Button key={`review-${repo}-${pr.number}`} label="Review" onPress={() => onReview(repo, pr)} />
37    </Box>
38  )
39}
40
41const ISSUE_ROWS = 5 // source: own choice, the oldest open issues a pane row can carry
42
43// Defects first, each group oldest first: the owner's rule is that repair work is taken and
44// finished, so what is waiting longest and is not a feature request leads.
45const issueOrder = (a: IssueRow, b: IssueRow): number => {
46  const k = Number(issueKind(a.labels) === 'feature') - Number(issueKind(b.labels) === 'feature')
47  return k !== 0 ? k : a.createdAt.localeCompare(b.createdAt)
48}
49
50const IssueLine = (ui: Ui, repo: string, issue: IssueRow, now: number, onTake: FleetData['onTake']) => {
51  const { Box, Button, Text } = ui
52  const isFeature = issueKind(issue.labels) === 'feature'
53  return (
54    <Box gap={1}>
55      <Text dimColor={isFeature}>
56        #{issue.number} [{isFeature ? 'feature' : 'defect'}] {issue.title.slice(0, 60)} · {ageLabel(issue.createdAt, now)} ·{' '}
57        {issue.comments} comment{issue.comments === 1 ? '' : 's'}
58      </Text>
59      <Button key={`take-${repo}-${issue.number}`} label={isFeature ? 'Assess' : 'Take'} onPress={() => onTake(repo, issue)} />
60    </Box>
61  )
62}
63
64const RepoBlock = (ui: Ui, r: RepoRow, now: number, onReview: FleetData['onReview'], onTake: FleetData['onTake']) => {
65  const { Box, Text } = ui
66  const title = r.repo ?? r.local ?? r.marketplace
67  const plugins = r.plugins
68    .map((p) => `${p.name} ${p.installed}${p.offered === null ? '' : p.isBehind ? ` → ${p.offered} offered` : ' · current'}`)
69    .join(' · ')
70  return (
71    <Box flexDirection="column">
72      <Text bold>{title}</Text>
73      <Text dimColor>
74        {plugins}
75        {r.marketplaceUpdatedAt === null ? '' : ` · marketplace read ${ageLabel(r.marketplaceUpdatedAt, now)}`}
76      </Text>
77      {r.error !== null && <Text color="red">gh: {r.error}</Text>}
78      {r.repo !== null && r.readAt !== null && (
79        // A failed PR read carries an error and no list: say "no reading", never "0 open PRs".
80        <Text dimColor>
81          {r.error !== null ? 'PRs: no reading' : `${r.prs.length} open PR${r.prs.length === 1 ? '' : 's'}`} ·{' '}
82          {r.issues === null ? 'issues: no reading' : `${r.issues.length >= ISSUE_LIMIT ? `${ISSUE_LIMIT}+` : r.issues.length} open issues`}
83        </Text>
84      )}
85      {r.repo !== null && r.prs.map((pr) => PrLine(ui, r.repo ?? '', pr, now, onReview))}
86      {r.repo !== null &&
87        r.issues !== null &&
88        [...r.issues].sort(issueOrder).slice(0, ISSUE_ROWS).map((i) => IssueLine(ui, r.repo ?? '', i, now, onTake))}
89      {r.repo !== null && r.issues !== null && r.issues.length > ISSUE_ROWS && (
90        <Text dimColor>+{r.issues.length - ISSUE_ROWS} more issues</Text>
91      )}
92    </Box>
93  )
94}
95
96export const FleetView = (ui: Ui, d: FleetData) => {
97  const { Box, Button, Text } = ui
98  const f = d.fleet
99  const l = d.lessons
100  return (
101    <Box flexDirection="column" gap={1}>
102      <Box flexDirection="column">
103        <Text bold>Harness fleet</Text>
104        <Text dimColor>
105          the owner's plugins and their repositories · every action starts with a button you press; the mod itself
106          only reads
107        </Text>
108      </Box>
109      {f.inventoryError !== null && <Text color="red">inventory: {f.inventoryError}</Text>}
110      {f.repos.length === 0 ? (
111        <Text dimColor>no owned plugin found in installed_plugins.json</Text>
112      ) : (
113        f.repos.map((r) => RepoBlock(ui, r, d.now, d.onReview, d.onTake))
114      )}
115      <Box flexDirection="column">
116        <Text bold dimColor>
117          LESSONS
118        </Text>
119        <Text dimColor>
120          {l.refusals} guard refusals · {l.classifierErrors} classifier errors · {l.stuckEscalations} stuck escalations ·{' '}
121          {l.leanCuts} results cut
122        </Text>
123        <Button key="draft-issue" label="Draft issue" hotkey="i" onPress={d.onDraftIssue} />
124      </Box>
125      <Box gap={1}>
126        <Button key="refresh" label={f.isRefreshing ? 'Refreshing…' : 'Refresh'} hotkey="r" onPress={d.onRefresh} />
127        <Text dimColor>{f.readAt === null ? 'remote: not read yet' : `remote read ${Math.round((d.now - f.readAt) / 1000)} s ago`}</Text>
128      </Box>
129    </Box>
130  )
131}
132
types/index.d.ts 59 lines
1// The fleet: the owner's plugins, where each is installed from, and what its repository holds.
2
3export type PluginRow = {
4  name: string
5  installed: string
6  offered: string | null
7  isBehind: boolean
8}
9
10export type CiState = 'success' | 'failure' | 'pending' | 'none'
11
12export type PrRow = {
13  number: number
14  title: string
15  isDraft: boolean
16  updatedAt: string
17  url: string
18  ci: CiState
19}
20
21export type IssueRow = {
22  number: number
23  title: string
24  labels: string[]
25  createdAt: string
26  comments: number
27  url: string
28}
29
30export type RepoRow = {
31  // owner/name on GitHub; null until a local marketplace's remote is resolved.
32  repo: string | null
33  // the local checkout a directory marketplace reads from; null for a GitHub marketplace.
34  local: string | null
35  marketplace: string
36  marketplaceUpdatedAt: string | null
37  plugins: PluginRow[]
38  prs: PrRow[]
39  // the open issues, newest 100 at most; null when the gh reading failed (never an empty list).
40  issues: IssueRow[] | null
41  error: string | null
42  readAt: number | null
43}
44
45export type FleetState = {
46  repos: RepoRow[]
47  inventoryError: string | null
48  isRefreshing: boolean
49  readAt: number | null
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'harness-fleet': {
55      fleet: FleetState
56    }
57  }
58}
59