GitHub Projects boards for Claude Code through gh board: the skill, a guard that denies destructive board commands, and the /board pane with the alerts gh…

gh board is a GitHub CLI extension that lets a team run a GitHub Projects v2 board with AI coding agents (Claude Code, Cursor, OpenAI Codex CLI). The agent reads the board, answers "where are we", creates and moves work, comments and runs the routine steps of the team's process, always as the person who is signed in to gh. The board starts from a template and is customized through one file, board.yml. Alerts and the weekly digest run without a server, a bot or a shared token. One binary serves people in a terminal, the scheduler and the three agents, by the command line or by MCP.
gh extension install cristianargotti/gh-board
gh board use owner/number
gh board agent install --agent all
gh board context
For Claude Code, also install the marketplace plugin, which carries the skill, the guard hook and the /board pane:
claude plugin marketplace add cristianargotti/gh-board
claude plugin install gh-board@gh-board
The extension installs the binary; use records the board so that no command needs --project; agent install configures the selected agents (--agent claude, --agent cursor or --agent codex installs one, --mcp also registers the MCP server); context prints the snapshot the agent starts every session with. Releases are signed with Sigstore and carry GitHub artifact attestations, checksums and an SBOM, and gh board doctor compares the installed binary with the release checksum. Prerequisites and platform notes are in docs/setup.md.
GitHub has no permission that distinguishes moving an item from deleting it, so the kit does not claim that an agent holding a developer token cannot destroy anything. It claims, and tests, five things: the binary itself has no code path that deletes, archives, hides or reconfigures anything (a closed mutation allowlist and a source scan test enforce it); consequential changes such as closing an issue, moving to a done status or posting the digest never execute directly, they become a plan that a human applies in a terminal, and the agents are denied apply; the known raw destructive commands (gh project delete, gh api -X DELETE, the forbidden GraphQL mutations and more) are denied inside the three agents; what the kit cannot stop is written down in docs/security.md; and reversible operations stay reversible, with before and after values journaled. Every write uses the person's own gh auth token, is attributed to them on GitHub and recorded in a local audit log. Strict mode, which also denies shell wrappers and absolute paths, is opt-in.
One skill (agents/SKILL.md) teaches the three agents the verbs by tier, the context snapshot and the rules. gh board agent install writes it next to each agent's deny rules and a PreToolUse hook that calls gh board guard check, idempotently and after printing a diff; agent uninstall removes exactly what was installed. Claude Code gets the marketplace plugin above, with a mod that shows the alerts gh board watch writes in the /board pane. gh board doctor reports every installed artifact and hook by hash and the binary each hook names. The verified hook and rule contracts are in docs/agent-contracts.md; the offline acceptance that proves the guards without sending a destructive command to GitHub is in docs/releasing.md.
gh board mcp serves the kit as a Model Context Protocol server over stdio, inside the same binary and with the same rules: the read verbs, the direct writes and the plan verbs close, tidy and digest_post become tools whose catalog is introspected from the command tree, so the help and the tools cannot drift apart. A plan tool returns the plan id and the exact gh board apply <id> line; apply is never a tool. gh board agent install --mcp registers the server in Claude Code, Cursor and Codex, and doctor checks the entries. Details, result shapes and limits are in docs/mcp.md.
| Page | Content |
|---|---|
| docs/setup.md | Install the extension, point it at a board, install the agents, first context. |
| docs/commands.md | The command catalog by tier, flags, references and exit codes. |
| docs/customizing.md | board.yml key by key, resolution order, what init infers, structure and views. |
| docs/security.md | Assurance levels, allowlist, guards and their limits, strict mode, residual risks. |
| docs/mcp.md | The MCP transport: gh board mcp and agent install --mcp, the tool catalog and its limits. |
| docs/agent-contracts.md | The Codex and Claude Code hook contracts, rule checks and the skill source. |
| docs/onboarding.md | Team onboarding checklist (PT-BR) with the measured time of each step. |
| docs/faq.md | Frequently asked questions. |
| docs/releasing.md | Release checks, including offline guard acceptance. |
| templates/ | The reference board.yml (an example board, acme/7), the issue forms and the template README (PT-BR). |
Board content shown to the team (template, forms, alerts, digest, plan descriptions) is in PT-BR; everything else is in English.
Releases are built by the release workflow from tags, signed and attested; binaries exist for macOS, Linux and Windows on amd64 and arm64. Changes are listed in CHANGELOG.md, the repository laws in CONTRIBUTING.md, and the license is MIT (LICENSE).
hooks/register.tsx 226 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, ProcessRunResult, Register } from "claude-code";
3
4import type { BoardAlertState } from "../types";
5import {
6 UNREADABLE,
7 newAlerts,
8 parseAlertState,
9 statusText,
10 toastRoom,
11 toastText,
12} from "./alerts";
13import {
14 CONTEXT_ARGV,
15 contextError,
16 messageOf,
17 parseSnapshot,
18} from "./context";
19import { GUARD_ARGV, GUARD_UNAVAILABLE, guardInput, verdictOf } from "./guard";
20import { PANE_ID, PANE_TITLE, paneTree } from "./pane";
21import type { PaneView } from "./pane";
22import { alertsPathFrom } from "./paths";
23
24// How often the mod stats alerts.json: a local file, never GitHub, which
25// watch polls on its own schedule (ten minutes by default).
26const POLL_MS = 60_000;
27const CONTEXT_TIMEOUT_MS = 60_000;
28const GUARD_TIMEOUT_MS = 10_000;
29
30// One atom per key of the contract, declared here because the engine reads
31// the state a module touches off the hooks module's own source.
32const alertsAtom = atom({ plugin: "gh-board", key: "alerts" } as const, null);
33const snapshotAtom = atom(
34 { plugin: "gh-board", key: "snapshot" } as const,
35 null,
36);
37const watchAtom = atom({ plugin: "gh-board", key: "watch" } as const, {
38 path: "",
39 mtimeMs: 0,
40 isInstalled: false,
41 error: "",
42});
43const refreshAtom = atom({ plugin: "gh-board", key: "refresh" } as const, {
44 isRunning: false,
45 error: "",
46 at: 0,
47});
48const toastTimesAtom = atom(
49 { plugin: "gh-board", key: "toastTimes" } as const,
50 [],
51);
52
53type Loaded = {
54 state: BoardAlertState | null;
55 mtimeMs: number;
56 isInstalled: boolean;
57};
58
59// Every function that takes $ lives in this file: the engine follows $ only
60// inside the hooks module, never across an import.
61
62async function resolveAlertsPath($: EngineInterface): Promise<string> {
63 return alertsPathFrom({
64 GH_BOARD_HOME: await $.env.get("GH_BOARD_HOME"),
65 XDG_STATE_HOME: await $.env.get("XDG_STATE_HOME"),
66 OS: await $.env.get("OS"),
67 LocalAppData: await $.env.get("LocalAppData"),
68 HOME: await $.env.get("HOME"),
69 USERPROFILE: await $.env.get("USERPROFILE"),
70 });
71}
72
73async function loadAlertFile(
74 $: EngineInterface,
75 path: string,
76): Promise<Loaded> {
77 if (!(await $.fs.exists(path)))
78 return { state: null, mtimeMs: 0, isInstalled: false };
79 const stat = await $.fs.stat(path);
80 const text = await $.fs.read(path);
81 const state = parseAlertState(typeof text === "string" ? text : "");
82 return { state, mtimeMs: stat.mtimeMs, isInstalled: true };
83}
84
85// Reads alerts.json again only when it changed since the last look, toasts
86// what is new when asked, and keeps the status line current.
87async function syncAlerts($: EngineInterface, toast: boolean): Promise<void> {
88 const watch = await read($, watchAtom);
89 if (watch.path === "") return;
90 const loaded = await loadAlertFile($, watch.path);
91 if (
92 loaded.isInstalled === watch.isInstalled &&
93 loaded.mtimeMs === watch.mtimeMs
94 )
95 return;
96 const previous = await read($, alertsAtom);
97 await update($, alertsAtom, () => loaded.state);
98 const error = loaded.isInstalled && loaded.state === null ? UNREADABLE : "";
99 await update($, watchAtom, (w) => ({
100 ...w,
101 mtimeMs: loaded.mtimeMs,
102 isInstalled: loaded.isInstalled,
103 error,
104 }));
105 if (toast && loaded.state !== null) await toastNew($, previous, loaded.state);
106 await showStatus($);
107}
108
109async function toastNew(
110 $: EngineInterface,
111 previous: BoardAlertState | null,
112 current: BoardAlertState,
113): Promise<void> {
114 const fresh = newAlerts(previous, current);
115 if (fresh.length === 0) return;
116 const now = await $.clock.now();
117 const { recent, room } = toastRoom(await read($, toastTimesAtom), now);
118 const shown = fresh.slice(0, room);
119 for (const alert of shown) $.ui.toast(toastText(alert));
120 await update($, toastTimesAtom, () => [...recent, ...shown.map(() => now)]);
121}
122
123async function showStatus($: EngineInterface): Promise<void> {
124 const snapshot = await read($, snapshotAtom);
125 const state = await read($, alertsAtom);
126 const watch = await read($, watchAtom);
127 $.ui.status(statusText(snapshot, state, watch));
128}
129
130// One run at a time: the button and /board may both ask while one is going.
131async function refreshSnapshot($: EngineInterface): Promise<void> {
132 if ((await read($, refreshAtom)).isRunning) return;
133 await update($, refreshAtom, (r) => ({ ...r, isRunning: true }));
134 const error = await runContext($);
135 const at = await $.clock.now();
136 await update($, refreshAtom, () => ({ isRunning: false, error, at }));
137 await syncAlerts($, true);
138 await showStatus($);
139}
140
141async function runContext($: EngineInterface): Promise<string> {
142 let ran: ProcessRunResult;
143 try {
144 ran = await $.process.run(CONTEXT_ARGV, { timeoutMs: CONTEXT_TIMEOUT_MS });
145 } catch (err) {
146 return `gh board context could not run: ${messageOf(err)}`;
147 }
148 const error = contextError(ran);
149 if (error !== "") return error;
150 const snapshot = parseSnapshot(ran.stdout);
151 if (snapshot === null) return "gh board context returned no JSON snapshot";
152 await update($, snapshotAtom, () => snapshot);
153 return "";
154}
155
156// Asks the kit with the same JSON a PreToolUse command hook receives.
157async function guardVerdict(
158 $: EngineInterface,
159 command: string,
160 toolUseId: string | undefined,
161 tool: "Bash" | "PowerShell",
162): Promise<string | undefined> {
163 const stdin = guardInput(command, toolUseId, await $.session.cwd(), tool);
164 const ran = await $.process.run(GUARD_ARGV, {
165 stdin,
166 timeoutMs: GUARD_TIMEOUT_MS,
167 });
168 return verdictOf(ran);
169}
170
171// The baseline load at session start toasts nothing: the person has not
172// seen this session's alerts yet, and the pane lists them all.
173async function startSession($: EngineInterface): Promise<void> {
174 await $.command.register({
175 name: "board",
176 description:
177 "Open the board pane: sprint and days left, my items, attention and triage queue",
178 });
179 const path = await resolveAlertsPath($);
180 await update($, watchAtom, (watch) => ({ ...watch, path }));
181 await syncAlerts($, false);
182 await showStatus($);
183 $.clock.every(POLL_MS, () => void syncAlerts($, true));
184}
185
186async function openBoard($: EngineInterface): Promise<string> {
187 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE });
188 await refreshSnapshot($);
189 return opened.isPlaced
190 ? "Board pane opened."
191 : `Board pane is waiting: ${opened.reason}`;
192}
193
194async function viewOf($: EngineInterface): Promise<PaneView> {
195 return {
196 snapshot: await read($, snapshotAtom),
197 alerts: await read($, alertsAtom),
198 watch: await read($, watchAtom),
199 refresh: await read($, refreshAtom),
200 };
201}
202
203export const register: Register = (on) => {
204 on("session.start", async ($, e, next) => {
205 await startSession($);
206 return next(e);
207 });
208
209 on("command.run", { command: "board" }, async ($) => ({
210 text: await openBoard($),
211 }));
212
213 on("ui.render", { component: "Pane", requestId: "board" }, async ($, e) =>
214 paneTree($.ui.resolve(e), await viewOf($), e.props.bodyColumns, () => {
215 void refreshSnapshot($);
216 }),
217 );
218
219 on("tool.call", { tool: ["Bash", "PowerShell"] }, async ($, e, next) => {
220 const deny = await guardVerdict($, e.command, e.tool_use_id, e.tool);
221 return deny === undefined ? next(e) : { deny: `${$.plugin.name}: ${deny}` };
222 }).catch(($, e, next) =>
223 next.called ? next(e) : { deny: `${$.plugin.name}: ${GUARD_UNAVAILABLE}` },
224 );
225};
226hooks/alerts.ts 138 lines1import type {
2 BoardAlert,
3 BoardAlertState,
4 BoardProject,
5 BoardSnapshot,
6 BoardWatch,
7} from "../types";
8
9/** Mirrors alerts.MaxToastsPerHour: what does not fit stays in the pane. */
10export const MAX_TOASTS_PER_HOUR = 5;
11export const HOUR_MS = 3_600_000;
12
13export const NO_WATCH = "sem watch instalado";
14export const INSTALL_WATCH = "gh board watch --install";
15export const UNREADABLE = "alerts.json unreadable";
16
17// The alert words of the status line, one row per rule id of section 9,
18// singular then plural.
19const RULE_WORDS: ReadonlyArray<readonly [string, string, string]> = [
20 ["overdue", "atrasada", "atrasadas"],
21 ["blocked", "bloqueada", "bloqueadas"],
22 ["triage_sla", "triagem vencida", "triagens vencidas"],
23 ["wip_exceeded", "WIP excedido", "WIP excedidos"],
24 ["sprint_ending", "sprint acabando", "sprints acabando"],
25 ["stale_active", "parada", "paradas"],
26 ["epic_without_dates", "épico sem datas", "épicos sem datas"],
27];
28
29export const fingerprint = (alert: BoardAlert): string =>
30 `${alert.rule_id}|${alert.item.node_id}`;
31
32export function parseAlertState(text: string): BoardAlertState | null {
33 const record = recordOf(text);
34 if (record === null || !Array.isArray(record.alerts)) return null;
35 return {
36 generated_at: stringOf(record.generated_at),
37 project: projectOf(record.project),
38 summary: stringOf(record.summary),
39 alerts: record.alerts.filter(isAlert),
40 };
41}
42
43export function recordOf(text: string): Record<string, unknown> | null {
44 let data: unknown;
45 try {
46 data = JSON.parse(text);
47 } catch {
48 return null;
49 }
50 return typeof data === "object" && data !== null
51 ? (data as Record<string, unknown>)
52 : null;
53}
54
55export const stringOf = (value: unknown): string =>
56 typeof value === "string" ? value : "";
57
58export function projectOf(value: unknown): BoardProject {
59 const record =
60 typeof value === "object" && value !== null
61 ? (value as Record<string, unknown>)
62 : {};
63 return {
64 owner: stringOf(record.owner),
65 number: typeof record.number === "number" ? record.number : 0,
66 };
67}
68
69export function isAlert(value: unknown): value is BoardAlert {
70 if (typeof value !== "object" || value === null) return false;
71 const record = value as Record<string, unknown>;
72 const item = record.item;
73 return (
74 typeof record.rule_id === "string" &&
75 typeof record.message === "string" &&
76 typeof item === "object" &&
77 item !== null &&
78 typeof (item as Record<string, unknown>).node_id === "string"
79 );
80}
81
82/** The alerts of current whose fingerprint previous did not hold. */
83export function newAlerts(
84 previous: BoardAlertState | null,
85 current: BoardAlertState,
86): BoardAlert[] {
87 const seen = new Set((previous?.alerts ?? []).map(fingerprint));
88 return current.alerts.filter((alert) => !seen.has(fingerprint(alert)));
89}
90
91/** How many toasts fit now, given when the last hour's were shown. */
92export function toastRoom(
93 times: number[],
94 now: number,
95): { recent: number[]; room: number } {
96 const recent = times.filter((at) => now - at < HOUR_MS);
97 return { recent, room: Math.max(0, MAX_TOASTS_PER_HOUR - recent.length) };
98}
99
100export function toastText(alert: BoardAlert): string {
101 const ref =
102 alert.item.number > 0
103 ? ` (${alert.item.repository}#${alert.item.number})`
104 : "";
105 return `${alert.message}${ref}`;
106}
107
108export function statusText(
109 snapshot: BoardSnapshot | null,
110 state: BoardAlertState | null,
111 watch: BoardWatch,
112): string {
113 const parts: string[] = [];
114 const sprint = snapshot?.sprint ?? null;
115 if (sprint !== null) parts.push(sprint.title, daysText(sprint.days_left));
116 if (!watch.isInstalled) parts.push(NO_WATCH);
117 else if (state === null)
118 parts.push(watch.error === "" ? UNREADABLE : watch.error);
119 else parts.push(summaryText(state));
120 return parts.join(" · ");
121}
122
123export const daysText = (days: number): string =>
124 days === 1 ? "1 dia" : `${days} dias`;
125
126// The summary watch wrote wins; the counts are the fallback when it is empty.
127export function summaryText(state: BoardAlertState): string {
128 if (state.summary !== "") return state.summary;
129 const counts = new Map<string, number>();
130 for (const alert of state.alerts)
131 counts.set(alert.rule_id, (counts.get(alert.rule_id) ?? 0) + 1);
132 const parts = RULE_WORDS.flatMap(([rule, one, many]) => {
133 const n = counts.get(rule) ?? 0;
134 return n === 0 ? [] : [`${n} ${n === 1 ? one : many}`];
135 });
136 return parts.length === 0 ? "sem alertas" : parts.join(" · ");
137}
138hooks/context.ts 96 lines1import type { ProcessRunResult } from "claude-code";
2
3import type { BoardItem, BoardSnapshot, BoardSprint } from "../types";
4import { isAlert, projectOf, recordOf, stringOf } from "./alerts";
5
6export const CONTEXT_ARGV: readonly string[] = [
7 "gh",
8 "board",
9 "context",
10 "--json",
11];
12
13export const messageOf = (err: unknown): string =>
14 err instanceof Error ? err.message : String(err);
15
16/** The error text of a failed gh board context run, empty on success. */
17export function contextError(ran: ProcessRunResult): string {
18 if (ran.exitCode === 0) return "";
19 return `gh board context exited ${ran.exitCode}: ${ran.stderr.trim() || ran.stdout.trim()}`;
20}
21
22export function parseSnapshot(text: string): BoardSnapshot | null {
23 const record = recordOf(text);
24 if (record === null || typeof record.generated_at !== "string") return null;
25 return {
26 generated_at: record.generated_at,
27 project: projectOf(record.project),
28 title: stringOf(record.title),
29 viewer: stringOf(record.viewer),
30 sprint: sprintOf(record.sprint),
31 mine: itemsOf(record.mine),
32 attention: Array.isArray(record.attention)
33 ? record.attention.filter(isAlert)
34 : [],
35 triage: itemsOf(record.triage),
36 completeness: record.completeness === "partial" ? "partial" : "complete",
37 omitted: omittedOf(record.omitted),
38 };
39}
40
41function sprintOf(value: unknown): BoardSprint | null {
42 if (typeof value !== "object" || value === null) return null;
43 const record = value as Record<string, unknown>;
44 return {
45 title: stringOf(record.title),
46 start: stringOf(record.start),
47 end: stringOf(record.end),
48 days_left: numberOf(record.days_left),
49 open: numberOf(record.open),
50 done: numberOf(record.done),
51 };
52}
53
54function itemsOf(value: unknown): BoardItem[] {
55 if (!Array.isArray(value)) return [];
56 return value.flatMap((entry) => {
57 if (typeof entry !== "object" || entry === null) return [];
58 const record = entry as Record<string, unknown>;
59 const item: BoardItem = {
60 ref: stringOf(record.ref),
61 node_id: stringOf(record.node_id),
62 title: stringOf(record.title),
63 status: stringOf(record.status),
64 assignees: Array.isArray(record.assignees)
65 ? record.assignees.filter((a) => typeof a === "string")
66 : [],
67 url: stringOf(record.url),
68 };
69 return [withOptional(item, record)];
70 });
71}
72
73function withOptional(
74 item: BoardItem,
75 record: Record<string, unknown>,
76): BoardItem {
77 const out = { ...item };
78 for (const key of ["lane", "epic", "sprint", "target"] as const) {
79 const value = record[key];
80 if (typeof value === "string" && value !== "") out[key] = value;
81 }
82 return out;
83}
84
85function omittedOf(value: unknown): Record<string, number> {
86 if (typeof value !== "object" || value === null) return {};
87 const out: Record<string, number> = {};
88 for (const [key, n] of Object.entries(value as Record<string, unknown>)) {
89 if (typeof n === "number") out[key] = n;
90 }
91 return out;
92}
93
94const numberOf = (value: unknown): number =>
95 typeof value === "number" ? value : 0;
96hooks/guard.ts 82 lines1import type { ProcessRunResult } from "claude-code";
2
3export const GUARD_ARGV: readonly string[] = [
4 "gh",
5 "board",
6 "guard",
7 "check",
8 "--agent",
9 "claude",
10];
11const DENIED = "denied by gh board guard";
12
13export const GUARD_UNAVAILABLE =
14 "gh board guard could not judge the command, so it was not run. Install or upgrade the gh-board extension and run gh board doctor";
15
16type Decision = {
17 isDenied: boolean;
18 reason?: string;
19};
20
21/** The JSON a PreToolUse command hook receives, which the kit reads on stdin. */
22export function guardInput(
23 command: string,
24 toolUseId: string | undefined,
25 cwd: string,
26 tool: "Bash" | "PowerShell" = "Bash",
27): string {
28 return JSON.stringify({
29 hook_event_name: "PreToolUse",
30 tool_name: tool,
31 tool_input: { command },
32 tool_use_id: toolUseId ?? "",
33 session_id: "",
34 transcript_path: "",
35 cwd,
36 });
37}
38
39// Exit 2 denies, exit 0 allows unless the JSON says deny, and any other
40// exit denies too: a guard that cannot judge fails closed.
41export function verdictOf(ran: ProcessRunResult): string | undefined {
42 const decision = parseDecision(ran.stdout);
43 const text = textOf(ran);
44 if (ran.exitCode === 2)
45 return decision.reason ?? (text === "" ? DENIED : text);
46 if (ran.exitCode !== 0)
47 return `${GUARD_UNAVAILABLE} (exit ${ran.exitCode}${text === "" ? "" : `: ${text}`})`;
48 return decision.isDenied ? (decision.reason ?? DENIED) : undefined;
49}
50
51export function parseDecision(stdout: string): Decision {
52 let data: unknown;
53 try {
54 data = JSON.parse(stdout);
55 } catch {
56 return { isDenied: false };
57 }
58 if (typeof data !== "object" || data === null) return { isDenied: false };
59 const record = data as Record<string, unknown>;
60 const specific = record.hookSpecificOutput;
61 if (typeof specific === "object" && specific !== null) {
62 const out = specific as Record<string, unknown>;
63 const isDenied = out.permissionDecision === "deny";
64 return isDenied
65 ? { isDenied, reason: reasonOf(out.permissionDecisionReason) }
66 : { isDenied };
67 }
68 if (record.decision === "block")
69 return { isDenied: true, reason: reasonOf(record.reason) };
70 return { isDenied: false };
71}
72
73const reasonOf = (value: unknown): string | undefined =>
74 typeof value === "string" && value !== "" ? value : undefined;
75
76function textOf(ran: ProcessRunResult): string {
77 const err = ran.stderr.trim();
78 return err !== "" ? firstLine(err) : firstLine(ran.stdout.trim());
79}
80
81const firstLine = (text: string): string => text.split("\n")[0] ?? "";
82hooks/pane.tsx 165 lines1import type {
2 BoxProps,
3 ButtonProps,
4 ElementConstructor,
5 RenderElement,
6 TextProps,
7} from "claude-code";
8
9import type {
10 BoardAlert,
11 BoardAlertState,
12 BoardItem,
13 BoardRefresh,
14 BoardSnapshot,
15 BoardWatch,
16} from "../types";
17import { INSTALL_WATCH, NO_WATCH, daysText } from "./alerts";
18
19export const PANE_ID = "board";
20export const PANE_TITLE = "Board";
21
22// Rows per section; the pane is a glance, the verbs are the detail.
23const MAX_ROWS = 8;
24const MIN_WIDTH = 20;
25
26const SEVERITY_MARK: Record<string, string> = {
27 critical: "!!",
28 warning: "!",
29 info: "i",
30};
31
32export type PaneElements = {
33 Box: ElementConstructor<BoxProps>;
34 Text: ElementConstructor<TextProps>;
35 Button: ElementConstructor<ButtonProps>;
36};
37
38export type PaneView = {
39 snapshot: BoardSnapshot | null;
40 alerts: BoardAlertState | null;
41 watch: BoardWatch;
42 refresh: BoardRefresh;
43};
44
45type Line = { text: string; isBold: boolean; isDim: boolean };
46
47export function paneTree(
48 elements: PaneElements,
49 view: PaneView,
50 columns: number,
51 onRefresh: () => void,
52): RenderElement {
53 const { Box, Text, Button } = elements;
54 const width = Math.max(MIN_WIDTH, columns);
55 const label = view.refresh.isRunning ? "Atualizando..." : "Atualizar";
56 const lines = sectionLines(view, width);
57 return (
58 <Box flexDirection="column">
59 <Box gap={1}>
60 <Text bold>
61 {cut(headerText(view.snapshot), width - label.length - 3)}
62 </Text>
63 <Button key="refresh" hotkey="r" label={label} onPress={onRefresh} />
64 </Box>
65 {view.refresh.error !== "" && (
66 <Text color="error">{cut(view.refresh.error, width)}</Text>
67 )}
68 {lines.map((line) => (
69 <Text dimColor={line.isDim} bold={line.isBold}>
70 {line.text}
71 </Text>
72 ))}
73 <Text dimColor>{cut(footerText(view), width)}</Text>
74 </Box>
75 );
76}
77
78function sectionLines(view: PaneView, width: number): Line[] {
79 return [
80 ...section(
81 "Meus itens",
82 itemLines(view.snapshot?.mine ?? [], "nenhum item aberto"),
83 width,
84 ),
85 ...section("Atenção", attentionLines(view), width),
86 ...section(
87 "Fila de triagem",
88 itemLines(view.snapshot?.triage ?? [], "fila vazia"),
89 width,
90 ),
91 ];
92}
93
94function section(title: string, lines: string[], width: number): Line[] {
95 const shown = lines.slice(0, MAX_ROWS).map((text) => ({
96 text: cut(` ${text}`, width),
97 isBold: false,
98 isDim: false,
99 }));
100 const rest = lines.length - shown.length;
101 const tail =
102 rest > 0
103 ? [{ text: ` ... e mais ${rest}`, isBold: false, isDim: true }]
104 : [];
105 return [{ text: title, isBold: true, isDim: false }, ...shown, ...tail];
106}
107
108export function headerText(snapshot: BoardSnapshot | null): string {
109 if (snapshot === null) return "Board: pressione Atualizar para carregar";
110 const sprint = snapshot.sprint;
111 if (sprint === null) return `${snapshot.title}: sem sprint ativa`;
112 return `${sprint.title} · ${daysText(sprint.days_left)} · ${sprint.open} abertas · ${sprint.done} concluídas`;
113}
114
115export function itemLines(items: BoardItem[], empty: string): string[] {
116 if (items.length === 0) return [empty];
117 return items.map((item) => `${item.ref} [${item.status}] ${item.title}`);
118}
119
120export function attentionLines(view: PaneView): string[] {
121 const install = `${NO_WATCH}: ${INSTALL_WATCH}`;
122 if (!view.watch.isInstalled && view.snapshot === null) return [install];
123 const alerts = view.snapshot?.attention ?? view.alerts?.alerts ?? [];
124 if (alerts.length === 0)
125 return view.watch.isInstalled
126 ? ["nada pendente"]
127 : [`nada pendente · ${install}`];
128 return alerts.map(alertLine);
129}
130
131const alertLine = (alert: BoardAlert): string => {
132 const mark = SEVERITY_MARK[alert.severity] ?? "-";
133 const ref =
134 alert.item.number > 0
135 ? ` (${alert.item.repository}#${alert.item.number})`
136 : "";
137 return `${mark} ${alert.message}${ref}`;
138};
139
140export function footerText(view: PaneView): string {
141 const parts: string[] = [];
142 if (view.snapshot !== null) {
143 const omitted = Object.values(view.snapshot.omitted).reduce(
144 (sum, n) => sum + n,
145 0,
146 );
147 const cutNote =
148 view.snapshot.completeness === "partial"
149 ? ` · parcial, ${omitted} omitidos`
150 : "";
151 parts.push(`contexto de ${view.snapshot.generated_at}${cutNote}`);
152 }
153 if (view.alerts !== null)
154 parts.push(`alertas de ${view.alerts.generated_at}`);
155 return parts.length === 0 ? "sem dados ainda" : parts.join(" · ");
156}
157
158export function cut(text: string, width: number): string {
159 const limit = Math.max(MIN_WIDTH, width);
160 const chars = Array.from(text);
161 return chars.length <= limit
162 ? text
163 : `${chars.slice(0, limit - 3).join("")}...`;
164}
165hooks/paths.ts 41 lines1export const STATE_FILE = "alerts.json";
2const APP_DIR = "gh-board";
3const GH_DIR = "gh";
4
5/** The variables the state directory depends on, as the module read them. */
6export type PathEnvironment = {
7 GH_BOARD_HOME?: string;
8 XDG_STATE_HOME?: string;
9 OS?: string;
10 LocalAppData?: string;
11 HOME?: string;
12 USERPROFILE?: string;
13};
14
15// Mirrors internal/config.Paths over go-gh's StateDir: GH_BOARD_HOME roots
16// every directory (tests and CI), XDG_STATE_HOME wins on every operating
17// system, LocalAppData on Windows, and ~/.local/state otherwise.
18export function alertsPathFrom(env: PathEnvironment): string {
19 if (isSet(env.GH_BOARD_HOME))
20 return join(env.GH_BOARD_HOME, "state", STATE_FILE);
21 if (isSet(env.XDG_STATE_HOME))
22 return join(env.XDG_STATE_HOME, GH_DIR, APP_DIR, STATE_FILE);
23 if (env.OS === "Windows_NT" && isSet(env.LocalAppData)) {
24 return join(env.LocalAppData, "GitHub CLI", APP_DIR, STATE_FILE);
25 }
26 const user = isSet(env.HOME) ? env.HOME : (env.USERPROFILE ?? "");
27 return join(user, ".local", "state", GH_DIR, APP_DIR, STATE_FILE);
28}
29
30function isSet(value: string | undefined): value is string {
31 return value !== undefined && value !== "";
32}
33
34// A Windows base keeps its backslashes; everything else joins with a slash,
35// which every file system call of the engine accepts.
36function join(base: string, ...parts: string[]): string {
37 const separator = base.includes("\\") ? "\\" : "/";
38 const trimmed = base.replace(/[\\/]+$/, "");
39 return [trimmed, ...parts].join(separator);
40}
41types/index.d.ts 101 lines1// The $.state contract of the gh-board plugin: what the mod keeps for the
2// session so that a hot reload of its code loses nothing. BoardAlertState
3// mirrors alerts.json as gh board watch writes it (domain.AlertState) and
4// BoardSnapshot mirrors gh board context --json (domain.Snapshot), reduced
5// to the sections the pane draws. Field names are the JSON names of the Go
6// types, so a change there is a change here.
7
8export type BoardProject = {
9 owner: string;
10 number: number;
11};
12
13export type BoardAlertItem = {
14 node_id: string;
15 project_item_id?: string;
16 repository: string;
17 number: number;
18 title: string;
19 url: string;
20};
21
22export type BoardSeverity = "info" | "warning" | "critical";
23
24export type BoardAlert = {
25 rule_id: string;
26 severity: BoardSeverity;
27 item: BoardAlertItem;
28 message: string;
29 first_seen: string;
30};
31
32export type BoardAlertState = {
33 generated_at: string;
34 project: BoardProject;
35 summary: string;
36 alerts: BoardAlert[];
37};
38
39export type BoardSprint = {
40 title: string;
41 start: string;
42 end: string;
43 days_left: number;
44 open: number;
45 done: number;
46};
47
48export type BoardItem = {
49 ref: string;
50 node_id: string;
51 title: string;
52 status: string;
53 assignees: string[];
54 lane?: string;
55 epic?: string;
56 sprint?: string;
57 target?: string;
58 url: string;
59};
60
61export type BoardSnapshot = {
62 generated_at: string;
63 project: BoardProject;
64 title: string;
65 viewer: string;
66 sprint: BoardSprint | null;
67 mine: BoardItem[];
68 attention: BoardAlert[];
69 triage: BoardItem[];
70 completeness: "complete" | "partial";
71 omitted: Record<string, number>;
72};
73
74/** Where alerts.json is and what the mod last saw of it. */
75export type BoardWatch = {
76 path: string;
77 mtimeMs: number;
78 isInstalled: boolean;
79 error: string;
80};
81
82/** The last run of gh board context --json. */
83export type BoardRefresh = {
84 isRunning: boolean;
85 error: string;
86 at: number;
87};
88
89declare module "claude-code" {
90 interface PluginState {
91 "gh-board": {
92 alerts: BoardAlertState | null;
93 snapshot: BoardSnapshot | null;
94 watch: BoardWatch;
95 refresh: BoardRefresh;
96 /** When each toast of the last hour was shown, for the cap of five. */
97 toastTimes: number[];
98 };
99 }
100}
101