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…

Claude Code mods for the ai-architect.tools harness, one concern per mod, state shared through dependencies:
| Mod | Owns | Depends on |
|---|---|---|
cortex-guard | refusals: wiki pages only through wiki_write, worktrees inside <repo>/.claude/worktrees/ | nothing |
cortex-wiki | /wiki <wiki/**.md> to PDF | nothing |
zetetic-genius | state: the request grade (task class → effort), the genius patterns and skills it matches | nothing |
zetetic-autopilot | context, policy: effort ladder, model routing, pressure, lean results | zetetic-genius |
cortex-cockpit | stats, ledger, tally, stages, hygiene; the /cortex pane draws the rest | the three above |
harness-fleet | fleet: 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 model | guard, genius, autopilot |
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.
.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.
hooks/register.tsx 186 lines1import { 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}
186hooks/fleet.ts 244 lines1// 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(' ')
244hooks/fleetview.tsx 132 lines1import 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}
132types/index.d.ts 59 lines1// 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