SLOPSHOPPER

mod

No description.

newpanecommandpromptmodelprocess
★ 1v?GPL-3.0updated 2026-10-15kurokeita/ai-agent/skills/statusline-setup/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod
│ ┃ Work ✕ › fix the failing auth test and add an audit log call │ ┃ Summary updating… │ ┃ Press Refresh to summarize this session. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Pull requests 0 · updating… ⏺ Update(src/auth.ts) │ ┃ None found for this session yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Work items 0 ⎿ 3 pass, 1 fail │ ┃ None linked yet. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Refresh ] PRs not fetched yet │ ✻ Worked for 42s · done 4:20 PM │ │ › /work │ ⎿ mod: Work pane opened. Up/Down or PgUp/PgDn scroll, Esc closes. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Work
Summary updating… Press Refresh to summarize this session. Pull requests 0 · updating… None found for this session yet. Work items 0 None linked yet. [ Refresh ] PRs not fetched yet
README

@kurokeita/add-skill (AI Manager)

CLI to manage and install AI agent skills, agents, and workflows to various platforms.

Features

  • List Items: View a list of available AI skills, agents, and workflows.
  • Local status: Check which items are installed locally.
  • Add Items: Interactively add skills, agents, and workflows with live search/filtering support.
  • Remove Items: Remove locally installed items with live search/filtering support.
  • Import: Import items directly from GitHub URLs.

Usage

pnpx @kurokeita/add-skill --help

List Items

List available items in the repository:

# List skills (default)
pnpx @kurokeita/add-skill list

# List agents
pnpx @kurokeita/add-skill list agent

# List workflows
pnpx @kurokeita/add-skill list workflow

List locally installed items:

pnpx @kurokeita/add-skill list --local
pnpx @kurokeita/add-skill list agent --local

# Pick a scope non-interactively (default: global)
pnpx @kurokeita/add-skill list --local --scope project
pnpx @kurokeita/add-skill list --local --scope both

Add Items

Starts an interactive session to select and install items. Supports live search/filtering as you type.

# Add skills
pnpx @kurokeita/add-skill add

# Add agents
pnpx @kurokeita/add-skill add agent

# Add workflows
pnpx @kurokeita/add-skill add workflow

# Install at project scope (writes under the current directory's
# `.<platform>/...` paths instead of `~/...`). Project scope is
# refused at $HOME or outside a git work tree.
pnpx @kurokeita/add-skill add --scope project

Remove Items

Interactively select items and platforms to remove them from. Supports live search/filtering as you type.

# Remove skills
pnpx @kurokeita/add-skill remove skill

# Remove agents
pnpx @kurokeita/add-skill remove agent

# Pick a scope non-interactively
pnpx @kurokeita/add-skill remove skill --scope project
pnpx @kurokeita/add-skill remove skill --scope both

Add Item from GitHub

Install an item directly from a GitHub URL.

pnpx @kurokeita/add-skill add https://github.com/owner/repo/tree/main/skills/skill-name

Import to Repository (For Maintainers)

Import a skill, agent, or workflow from GitHub into this repository.

pnpm dev import https://github.com/owner/repo/tree/main/skills/skill-name

How Installs Work

add copies every item into a single canonical directory:

  • Global scope → ~/.agents/{skills,agents,commands}
  • Project scope → <project-root>/.agents/{skills,agents,commands}

Skills go under skills, agents under agents, and workflows under commands. After copying, add can optionally wire each platform you select: it installs a session-start hook (under .agents/hooks/) and registers it in that platform's config. On each new session the hook symlinks the canonical .agents/ entries back into the platform's own directories, so every tool sees the same set of items from one source of truth.

A skill can also ship Claude Code hooks in a hooks/ dir. hooks/hooks.json holds the hooks object of .claude/settings.json, and ${HOOKS_DIR} in its commands resolves to .claude/hooks. When add installs a skill that has this manifest, it offers to copy the other files in hooks/ to .claude/hooks/ and merge the entries into .claude/settings.json (global or project, matching the install scope). git-commit uses this to install its commit-gate.py hook.

list and remove operate on the canonical .agents/ dir. Removing an entry also prunes any platform symlink left pointing at it.

Supported Platforms

The tables below show where each platform consumes items from — i.e. the symlink destinations the session-start hook creates.

PlatformAgents PathSkills PathWorkflows Path
AntigravityNot Supported~/.gemini/antigravity/global_skills~/.gemini/antigravity/global_workflows
Claude Code~/.claude/agents~/.claude/skills~/.claude/commands
Codex~/.codex/skills~/.codex/skills~/.codex/skills
Gemini CLI~/.gemini/agents~/.gemini/skills~/.gemini/commands
GitHub Copilot~/.copilot/agents~/.copilot/skills~/.copilot/prompts
WindsurfNot Supported~/.codeium/windsurf/skills~/.codeium/windsurf/global_workflows

Codex consumes skills, agents, and workflows from a single ~/.codex/skills directory, so all three symlink there.

Project-scope paths

When you pick --scope project, the canonical .agents/ dir and the symlink destinations live under the current working directory instead of ~. The mapping below uses <project-root> to mean process.cwd() (no git-toplevel relocation: a monorepo subdirectory installs into that subdirectory, not the repo root).

PlatformAgents PathSkills PathWorkflows Path
AntigravityNot Supported<project-root>/.gemini/antigravity/skills<project-root>/.gemini/antigravity/workflows
Claude Code<project-root>/.claude/agents<project-root>/.claude/skills<project-root>/.claude/commands
Codex<project-root>/.codex/skills<project-root>/.codex/skills<project-root>/.codex/skills
Gemini CLI<project-root>/.gemini/agents<project-root>/.gemini/skills<project-root>/.gemini/commands
GitHub Copilot<project-root>/.copilot/agents<project-root>/.copilot/skills<project-root>/.copilot/prompts
WindsurfNot Supported<project-root>/.codeium/windsurf/skills<project-root>/.codeium/windsurf/workflows

Project scope is refused when cwd equals your home directory or cwd is not inside a git work tree. When refused interactively, you can fall back to global scope without losing your already-selected items and platforms.

Development

  1. Clone the repository:
    git clone https://github.com/kurokeita/ai-agent.git
    cd ai-agent
  1. Install dependencies:
    pnpm install
  1. Run in development mode:
    pnpm dev
  1. Lint and Format:
    pnpm lint
    pnpm format
  1. Build:
    pnpm build

License

This project is licensed under the GNU General Public License v3.0.

Source 4 files
hooks/register.tsx 424 lines
1import type { EngineInterface, Register } from "claude-code"
2import { atom, read, update } from "claude-code"
3
4import type { CheckState, Pr, Step } from "../types"
5import {
6	parseSummary,
7	platformOf,
8	prNumbersIn,
9	repoNameOf,
10	SUMMARY_SYSTEM,
11	transcriptOf,
12} from "./discover"
13import { fetchAzure, fetchGitHub, git, type Run } from "./fetch"
14
15const PANE = "work-items"
16const branches = atom({ plugin: "work-items", key: "branches" } as const, [])
17const work = atom({ plugin: "work-items", key: "work" } as const, null)
18const isFetching = atom(
19	{ plugin: "work-items", key: "isFetching" } as const,
20	false,
21)
22const summary = atom({ plugin: "work-items", key: "summary" } as const, null)
23const isSummarizing = atom(
24	{ plugin: "work-items", key: "isSummarizing" } as const,
25	false,
26)
27const collapsed = atom({ plugin: "work-items", key: "collapsed" } as const, [])
28
29let isFetchRunning = false
30let isAutoOpenWanted = false
31let isAutoOpened = false
32// Which conversation this process shows, and whether a prompt was sent in it here.
33let watchedId = ""
34let hasPromptedHere = false
35let isClearPending = false
36let sessionStartedAt = Date.now()
37
38const runner =
39	($: EngineInterface): Run =>
40	(argv) =>
41		$.process
42			.run(argv, { timeoutMs: 60_000 })
43			.catch((error) => ({ exitCode: 1, stdout: "", stderr: String(error) }))
44
45const isPaneOpen = async ($: EngineInterface) =>
46	(await $.ui.panes()).some((pane) => pane.id === PANE)
47
48// The branches this session sat on at the end of a turn, newest first.
49async function noteBranch($: EngineInterface): Promise<string> {
50	const branch = await git(runner($), ["branch", "--show-current"])
51	if (branch)
52		await update($, branches, (list) =>
53			[branch, ...list.filter((b) => b !== branch)].slice(0, 10),
54		)
55	return branch
56}
57
58// The current branch, plus the branches this session committed to. A branch the
59// session only started on or passed through has no commits since it began.
60async function trackedBranches(
61	$: EngineInterface,
62	current: string,
63): Promise<string[]> {
64	const since = `--since=${new Date(sessionStartedAt).toISOString()}`
65	const tracked = current ? [current] : []
66	for (const branch of await read($, branches)) {
67		if (branch === current) continue
68		if (await git(runner($), ["log", "-1", "--format=%H", since, branch]))
69			tracked.push(branch)
70	}
71	return tracked
72}
73
74async function refreshWork($: EngineInterface) {
75	if (isFetchRunning) return
76	isFetchRunning = true
77	await update($, isFetching, () => true)
78	try {
79		const remote = await git(runner($), ["remote", "get-url", "origin"])
80		const platform = platformOf(remote)
81		if (!platform) {
82			await update($, work, () => ({
83				prs: [],
84				items: [],
85				fetchedAt: Date.now(),
86				error: "origin is neither GitHub nor Azure DevOps.",
87			}))
88			return
89		}
90		const current = await noteBranch($)
91		const prNumbers = prNumbersIn(
92			await $.session.messages(),
93			platform,
94			repoNameOf(remote),
95		)
96		const fetch = platform === "github" ? fetchGitHub : fetchAzure
97		const result = await fetch(
98			runner($),
99			await trackedBranches($, current),
100			prNumbers,
101		)
102		await update($, work, () => result)
103	} finally {
104		isFetchRunning = false
105		await update($, isFetching, () => false)
106	}
107}
108
109async function summarize($: EngineInterface) {
110	if (await read($, isSummarizing)) return
111	await update($, isSummarizing, () => true)
112	try {
113		const prompt = transcriptOf(await $.session.messages())
114		// The API refuses an empty user message with a 400.
115		if (!prompt.trim()) {
116			await update($, summary, () => ({
117				error: "Nothing to summarize yet. Send a prompt first.",
118				at: Date.now(),
119			}))
120			return
121		}
122		const reply = await $.model.complete({
123			model: "haiku",
124			system: SUMMARY_SYSTEM,
125			prompt,
126			maxTokens: 1500,
127		})
128		const parsed = reply.isAnswered ? parseSummary(reply.text) : null
129		const error = reply.isAnswered
130			? "The summary came back unreadable."
131			: reply.reason === "api-error"
132				? `Summary failed: api-error ${reply.status ?? ""} ${reply.error}`
133				: `Summary failed: ${reply.reason}`
134		await update($, summary, () =>
135			parsed ? { ...parsed, at: Date.now() } : { error, at: Date.now() },
136		)
137	} finally {
138		await update($, isSummarizing, () => false)
139	}
140}
141
142// Summarizes once when the pane shows and no summary exists yet, or the last try failed.
143async function summarizeIfMissing($: EngineInterface) {
144	const current = await read($, summary)
145	if (!current || "error" in current) await summarize($)
146}
147
148// Opens the pane once in a resumed session, the first time it has a PR or item.
149async function autoOpen($: EngineInterface) {
150	if (!isAutoOpenWanted || isAutoOpened) return
151	await refreshWork($)
152	const current = await read($, work)
153	if (
154		isAutoOpened ||
155		!current ||
156		current.prs.length + current.items.length === 0
157	)
158		return
159	isAutoOpened = true
160	// No focus: the prompt keeps the keyboard. Ctrl+X Tab or a click focuses it to scroll.
161	await $.ui.open({ id: PANE, title: "Work", columns: 64 })
162	await summarizeIfMissing($)
163}
164
165// A conversation is resumed when it has turns no prompt of this process made:
166// --resume and --continue load them before session.start, while the picker and
167// an in-session /resume load them later, under a new session id.
168async function checkResumed($: EngineInterface) {
169	const id = await $.session.id()
170	if (id !== watchedId) {
171		watchedId = id
172		// A /clear starts its conversation here, though the transcript's /clear row counts as a turn.
173		hasPromptedHere = isClearPending
174		isClearPending = false
175		isAutoOpenWanted = false
176		isAutoOpened = false
177		// Another conversation now: its branches start over from here.
178		sessionStartedAt = Date.now()
179		await update($, branches, () => [])
180	}
181	if (isAutoOpenWanted || hasPromptedHere || (await $.session.turns()) === 0)
182		return
183	isAutoOpenWanted = true
184	await autoOpen($)
185}
186
187function ago(at: number): string {
188	const s = Math.max(0, Math.round((Date.now() - at) / 1000))
189	if (s < 60) return `${s}s ago`
190	if (s < 3600) return `${Math.round(s / 60)}m ago`
191	return `${Math.round(s / 3600)}h ago`
192}
193
194const STATE_COLOR: Record<string, string> = {
195	open: "success",
196	draft: "subtle",
197	merged: "merged",
198	closed: "error",
199	done: "success",
200	resolved: "success",
201	removed: "subtle",
202	"to do": "subtle",
203	new: "subtle",
204	active: "suggestion",
205	committed: "suggestion",
206	"in progress": "suggestion",
207}
208const CHECKS: Record<CheckState, { text: string; color: string }> = {
209	passing: { text: "✓ checks", color: "success" },
210	failing: { text: "✗ checks", color: "error" },
211	pending: { text: "● checks", color: "warning" },
212	none: { text: "no checks", color: "subtle" },
213}
214const STEP: Record<Step["status"], { mark: string; color: string }> = {
215	done: { mark: "✓", color: "success" },
216	current: { mark: "●", color: "warning" },
217	todo: { mark: "○", color: "subtle" },
218}
219
220export const register: Register = (on) => {
221	on("session.start", async ($, e, next) => {
222		await $.command.register({
223			name: "work",
224			description: "Show this session's PRs, work items and summary in a pane",
225		})
226		void checkResumed($)
227		$.clock.every(5_000, () => checkResumed($))
228		$.clock.every(60_000, async () => {
229			if (await isPaneOpen($)) await refreshWork($)
230		})
231
232		return next(e)
233	})
234
235	on("turn.complete", async ($, e, next) => {
236		const result = await next(e)
237		if (e.agentId) return result
238		// A main-conversation turn is when the session pushes branches and opens PRs,
239		// and moves its goal and steps on.
240		void (async () => {
241			await noteBranch($)
242			if (isAutoOpenWanted && !isAutoOpened) return autoOpen($)
243			if (await isPaneOpen($)) await Promise.all([refreshWork($), summarize($)])
244		})()
245
246		return result
247	})
248
249	on("session.end", async (_$, e, next) => {
250		if (e.reason === "clear") isClearPending = true
251
252		return next(e)
253	})
254
255	on("prompt.submit", async (_$, e, next) => {
256		hasPromptedHere = true
257
258		return next(e)
259	})
260
261	on("command.run", { command: "work" }, async ($) => {
262		// Opened by hand: auto-open has nothing left to do, so turns refresh the pane.
263		isAutoOpened = true
264		void refreshWork($)
265		await $.ui.open({
266			id: PANE,
267			title: "Work",
268			columns: 64,
269			focus: true,
270			closeOnEscape: true,
271		})
272		void summarizeIfMissing($)
273
274		return {
275			text: "Work pane opened. Up/Down or PgUp/PgDn scroll, Esc closes.",
276		}
277	})
278
279	on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
280		const { Box, Text, Link, Button } = $.ui.resolve(e)
281		const current = await read($, work)
282		const fetching = await read($, isFetching)
283		const sum = await read($, summary)
284		const summarizing = await read($, isSummarizing)
285		const shut = await read($, collapsed)
286
287		const toggle = (pr: Pr) =>
288			update($, collapsed, (list) =>
289				list.includes(pr.number)
290					? list.filter((n) => n !== pr.number)
291					: [...list, pr.number],
292			)
293		const prs = current?.prs ?? []
294		const items = current?.items ?? []
295
296		return (
297			<Box flexDirection="column" rowGap={1}>
298				<Box flexDirection="column">
299					<Text>
300						<Text bold color="planMode">
301							Summary
302						</Text>
303						<Text dimColor>
304							{summarizing ? " updating…" : sum ? ` ${ago(sum.at)}` : ""}
305						</Text>
306					</Text>
307					{!sum && (
308						<Text dimColor>Press Refresh to summarize this session.</Text>
309					)}
310					{sum && "error" in sum && <Text color="error">{sum.error}</Text>}
311					{sum && "goal" in sum && <Text>{sum.goal}</Text>}
312				</Box>
313
314				{sum && "steps" in sum && sum.steps.length > 0 && (
315					<Box flexDirection="column">
316						<Text bold color="planMode">
317							Steps
318						</Text>
319						{sum.steps.map((step) => (
320							<Text
321								wrap="truncate-end"
322								color={
323									step.status === "todo" ? undefined : STEP[step.status].color
324								}
325								dimColor={step.status === "todo"}
326								bold={step.status === "current"}
327							>
328								{STEP[step.status].mark} {step.text}
329							</Text>
330						))}
331					</Box>
332				)}
333
334				<Box flexDirection="column">
335					<Text>
336						<Text bold color="planMode">
337							Pull requests
338						</Text>
339						<Text dimColor>
340							{" "}
341							{prs.length}
342							{fetching ? " · updating…" : ""}
343						</Text>
344					</Text>
345					{current?.error && <Text color="error">{current.error}</Text>}
346					{prs.length === 0 && !current?.error && (
347						<Text dimColor>None found for this session yet.</Text>
348					)}
349					{prs.map((pr) => (
350						<Box flexDirection="column">
351							<Box flexDirection="row" columnGap={1}>
352								<Button
353									key={`toggle-${pr.number}`}
354									plain
355									label={shut.includes(pr.number) ? "▸" : "▾"}
356									onPress={() => void toggle(pr)}
357								/>
358								<Text wrap="truncate-end">
359									<Link href={pr.url}>#{pr.number}</Link>
360									<Text color={STATE_COLOR[pr.state] ?? "text"}>
361										{" "}
362										{pr.state}
363									</Text>
364									<Text dimColor> [{pr.branch}]</Text> {pr.title}
365								</Text>
366							</Box>
367							{!shut.includes(pr.number) && (
368								<Text>
369									{"  "}
370									{pr.review && (
371										<Text
372											color={pr.review === "approved" ? "success" : "warning"}
373										>
374											{pr.review}{" "}
375										</Text>
376									)}
377									<Text color={CHECKS[pr.checks].color}>
378										{CHECKS[pr.checks].text}
379									</Text>
380									<Text dimColor> {pr.comments} comments</Text>
381								</Text>
382							)}
383						</Box>
384					))}
385				</Box>
386
387				<Box flexDirection="column">
388					<Text>
389						<Text bold color="planMode">
390							Work items
391						</Text>
392						<Text dimColor> {items.length}</Text>
393					</Text>
394					{items.length === 0 && <Text dimColor>None linked yet.</Text>}
395					{items.map((item) => (
396						<Text wrap="truncate-end">
397							<Link href={item.url}>#{item.id}</Link>
398							<Text color={STATE_COLOR[item.state] ?? "text"}>
399								{" "}
400								{item.state}
401							</Text>{" "}
402							{item.title}
403						</Text>
404					))}
405				</Box>
406
407				<Box flexDirection="row" columnGap={1}>
408					<Button
409						key="refresh"
410						label="Refresh"
411						hotkey="r"
412						onPress={() => void Promise.all([refreshWork($), summarize($)])}
413					/>
414					<Text dimColor>
415						{current
416							? `PRs updated ${ago(current.fetchedAt)}`
417							: "PRs not fetched yet"}
418					</Text>
419				</Box>
420			</Box>
421		)
422	})
423}
424
hooks/discover.ts 143 lines
1import type { SessionMessage } from "claude-code"
2
3import type { CheckState, Step } from "../types"
4
5export type Platform = "github" | "azure"
6
7export function platformOf(remote: string): Platform | null {
8	if (/github\.com[:/]/.test(remote)) return "github"
9	if (/dev\.azure\.com[:/]|\.visualstudio\.com\//.test(remote)) return "azure"
10	return null
11}
12
13export function repoNameOf(remote: string): string {
14	return (
15		remote
16			.trim()
17			.replace(/\.git$/, "")
18			.split(/[/:]/)
19			.pop() ?? ""
20	).toLowerCase()
21}
22
23function decoded(name: string): string {
24	try {
25		return decodeURIComponent(name).toLowerCase()
26	} catch {
27		return name.toLowerCase()
28	}
29}
30
31// PR links anywhere in the conversation: a command's output (gh pr create and
32// gh pr edit print the URL), the model's own text, or a link the person pasted.
33// Only links into this repository count, since the fetch runs against it.
34export function prNumbersIn(
35	messages: readonly SessionMessage[],
36	platform: Platform,
37	repo: string,
38): number[] {
39	const pattern =
40		platform === "github"
41			? /github\.com\/[\w.-]+\/([\w.-]+)\/pull\/(\d{1,9})/g
42			: /_git\/([^/\s?#"']+)\/pullrequest\/(\d{1,9})/gi
43	const found = new Set<number>()
44	for (const message of messages) {
45		const texts = [message.text]
46		for (const use of message.toolUses)
47			texts.push(JSON.stringify(use.input), use.text ?? "")
48		for (const result of message.toolResults ?? []) texts.push(result.text)
49		for (const text of texts) {
50			for (const match of text.matchAll(pattern)) {
51				if (decoded(match[1] ?? "") === repo) found.add(Number(match[2]))
52			}
53		}
54	}
55	return [...found]
56}
57
58// Items a branch without a PR points at: a 3-9 digit segment of its name (plus
59// gh issue develop's "<n>-slug"), then AB#N or #N in its own commits. 9 digits
60// at most, as both APIs take 32-bit ids and one bad literal fails the query.
61export function itemRefs(
62	platform: Platform,
63	branch: string,
64	commitLog: string,
65): number[] {
66	const refs: string[] = []
67	const lead =
68		platform === "github" ? /^(\d{1,9})-/.exec(branch)?.[1] : undefined
69	if (lead) refs.push(lead)
70	refs.push(
71		...branch.split(/[^A-Za-z0-9]+/).filter((part) => /^\d{3,9}$/.test(part)),
72	)
73	const pattern = platform === "github" ? /#(\d{1,9})/g : /AB#(\d{1,9})/g
74	for (const match of commitLog.matchAll(pattern)) refs.push(match[1] ?? "")
75	return [...new Set(refs.filter(Boolean).map(Number))].slice(0, 10)
76}
77
78export function githubChecks(rollup: string | null | undefined): CheckState {
79	if (rollup === "SUCCESS") return "passing"
80	if (rollup === "FAILURE" || rollup === "ERROR") return "failing"
81	if (rollup === "PENDING" || rollup === "EXPECTED") return "pending"
82	return "none"
83}
84
85export function azureChecks(statuses: readonly string[]): CheckState {
86	if (statuses.some((s) => s === "rejected" || s === "broken")) return "failing"
87	if (statuses.some((s) => s === "running" || s === "queued")) return "pending"
88	if (statuses.some((s) => s === "approved")) return "passing"
89	return "none"
90}
91
92export function azureReview(votes: readonly number[]): string {
93	if (votes.includes(-10)) return "rejected"
94	if (votes.includes(-5)) return "waiting for author"
95	if (votes.some((vote) => vote >= 5)) return "approved"
96	return ""
97}
98
99export const SUMMARY_SYSTEM = `You summarize a coding session for a side panel.
100Reply with JSON only, no prose and no code fence:
101{"goal": "<2-4 sentences: what the session is trying to achieve and where it stands now>",
102 "steps": [{"text": "<one short line>", "status": "done" | "current" | "todo"}]}
103Steps run in order, at most 12, and at most one is "current".`
104
105// The newest part of the conversation that fits the model's budget.
106export function transcriptOf(messages: readonly SessionMessage[]): string {
107	const lines = messages.map((m) => {
108		const tools = m.toolUses.map(
109			(use) => `[${use.tool}] ${JSON.stringify(use.input).slice(0, 200)}`,
110		)
111		return `${m.role}: ${[m.text.slice(0, 2000), ...tools].filter(Boolean).join("\n")}`
112	})
113	return lines.join("\n\n").slice(-60_000)
114}
115
116export function parseSummary(
117	text: string,
118): { goal: string; steps: Step[] } | null {
119	const start = text.indexOf("{")
120	const end = text.lastIndexOf("}")
121	if (start < 0 || end < start) return null
122	try {
123		const value = JSON.parse(text.slice(start, end + 1)) as {
124			goal?: unknown
125			steps?: unknown
126		}
127		if (typeof value.goal !== "string" || !Array.isArray(value.steps))
128			return null
129		const steps: Step[] = []
130		for (const step of value.steps as { text?: unknown; status?: unknown }[]) {
131			if (typeof step?.text !== "string") continue
132			const status =
133				step.status === "done" || step.status === "current"
134					? step.status
135					: "todo"
136			steps.push({ text: step.text, status })
137		}
138		return { goal: value.goal, steps }
139	} catch {
140		return null
141	}
142}
143
hooks/fetch.ts 328 lines
1import type { Item, Pr, Work } from "../types"
2import {
3	azureChecks,
4	azureReview,
5	githubChecks,
6	itemRefs,
7	type Platform,
8} from "./discover"
9
10// Runs a command; register.tsx supplies it, since only that file may use $.
11export type Run = (
12	argv: string[],
13) => Promise<{ exitCode: number; stdout: string; stderr: string }>
14
15export async function git(run: Run, args: string[]): Promise<string> {
16	const { exitCode, stdout } = await run(["git", ...args])
17	return exitCode === 0 ? stdout.trim() : ""
18}
19
20function firstLine(text: string): string {
21	return text.trim().split("\n")[0] ?? ""
22}
23
24async function refsByBranch(
25	run: Run,
26	platform: Platform,
27	branches: readonly string[],
28) {
29	const base = await git(run, [
30		"symbolic-ref",
31		"--short",
32		"refs/remotes/origin/HEAD",
33	])
34	const refs = new Map<string, number[]>()
35	for (const branch of branches) {
36		const log = base
37			? await git(run, ["log", "--format=%B", `${base}..${branch}`])
38			: ""
39		refs.set(branch, itemRefs(platform, branch, log))
40	}
41	return refs
42}
43
44const PR_FIELDS = `number title url state isDraft reviewDecision headRefName
45  comments { totalCount } reviewThreads { totalCount }
46  commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
47  closingIssuesReferences(first: 10) { nodes { number title url state } }`
48
49type GhIssue = { number: number; title: string; url: string; state: string }
50type GhPr = {
51	number: number
52	title: string
53	url: string
54	state: string
55	isDraft: boolean
56	reviewDecision: string | null
57	headRefName: string
58	comments: { totalCount: number }
59	reviewThreads: { totalCount: number }
60	commits: {
61		nodes: { commit: { statusCheckRollup: { state: string } | null } }[]
62	}
63	closingIssuesReferences: { nodes: GhIssue[] }
64}
65
66const ghItem = (issue: GhIssue): Item => ({
67	id: issue.number,
68	url: issue.url,
69	title: issue.title,
70	state: issue.state.toLowerCase(),
71})
72
73// One GraphQL call: each branch's newest PR, each PR the conversation linked,
74// and every item ref, as aliases. Branch names go in as variables, never into
75// the query text. gh exits 1 when an alias does not resolve yet still prints
76// the rest, so success is judged by data.repository.
77export async function fetchGitHub(
78	run: Run,
79	branches: readonly string[],
80	prNumbers: readonly number[],
81): Promise<Work> {
82	const refs = await refsByBranch(run, "github", branches)
83	const vars: string[] = []
84	const fields: string[] = []
85	const args: string[] = []
86	branches.forEach((branch, i) => {
87		vars.push(`, $b${i}: String!`)
88		args.push("-f", `b${i}=${branch}`)
89		fields.push(`b${i}: pullRequests(headRefName: $b${i}, first: 1, states: [OPEN, MERGED, CLOSED],
90      orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ...P } }`)
91	})
92	for (const n of prNumbers)
93		fields.push(`p${n}: pullRequest(number: ${n}) { ...P }`)
94	for (const n of new Set([...refs.values()].flat())) {
95		fields.push(
96			`i${n}: issueOrPullRequest(number: ${n}) { ... on Issue { number title url state } }`,
97		)
98	}
99	if (fields.length === 0) return { prs: [], items: [], fetchedAt: Date.now() }
100	const query = `query($owner: String!, $name: String!${vars.join("")}) {
101    repository(owner: $owner, name: $name) { ${fields.join("\n")} }
102  } fragment P on PullRequest { ${PR_FIELDS} }`
103	const out = await run([
104		"gh",
105		"api",
106		"graphql",
107		"-F",
108		"owner={owner}",
109		"-F",
110		"name={repo}",
111		...args,
112		"-f",
113		`query=${query}`,
114	])
115	let repo: Record<string, unknown> | undefined
116	try {
117		repo = (
118			JSON.parse(out.stdout) as {
119				data?: { repository?: Record<string, unknown> }
120			}
121		).data?.repository
122	} catch {
123		repo = undefined
124	}
125	if (!repo)
126		return {
127			prs: [],
128			items: [],
129			fetchedAt: Date.now(),
130			error: `gh: ${firstLine(out.stderr) || "no data"}`,
131		}
132
133	const prs = new Map<number, Pr>()
134	const items = new Map<number, Item>()
135	const addPr = (pr: GhPr) => {
136		prs.set(pr.number, {
137			number: pr.number,
138			url: pr.url,
139			title: pr.title,
140			branch: pr.headRefName,
141			state:
142				pr.isDraft && pr.state === "OPEN" ? "draft" : pr.state.toLowerCase(),
143			review: (pr.reviewDecision ?? "").toLowerCase().replace(/_/g, " "),
144			checks: githubChecks(
145				pr.commits.nodes[0]?.commit.statusCheckRollup?.state,
146			),
147			comments: pr.comments.totalCount + pr.reviewThreads.totalCount,
148		})
149		for (const issue of pr.closingIssuesReferences.nodes)
150			items.set(issue.number, ghItem(issue))
151	}
152	branches.forEach((branch, i) => {
153		const pr = (repo[`b${i}`] as { nodes: GhPr[] } | null)?.nodes[0]
154		if (pr) {
155			addPr(pr)
156			return
157		}
158		for (const n of refs.get(branch) ?? []) {
159			const issue = repo[`i${n}`] as GhIssue | null
160			if (issue?.number) items.set(issue.number, ghItem(issue))
161		}
162	})
163	for (const n of prNumbers) {
164		const pr = repo[`p${n}`] as GhPr | null
165		if (pr) addPr(pr)
166	}
167	return {
168		prs: [...prs.values()],
169		items: [...items.values()],
170		fetchedAt: Date.now(),
171	}
172}
173
174async function az(run: Run, args: string[]): Promise<unknown> {
175	const out = await run(["az", ...args, "--only-show-errors", "-o", "json"])
176	if (out.exitCode !== 0) return null
177	try {
178		return JSON.parse(out.stdout)
179	} catch {
180		return null
181	}
182}
183
184type AzPr = {
185	pullRequestId: number
186	title: string
187	status: string
188	isDraft: boolean
189	url: string
190	sourceRefName: string
191	reviewers?: { vote: number }[]
192	repository: { id: string; project: { id: string } }
193}
194type AzWorkItem = { id: number; url: string; fields: Record<string, string> }
195
196const AZ_STATE: Record<string, string> = {
197	active: "open",
198	completed: "merged",
199	abandoned: "closed",
200}
201
202const azItem = (w: AzWorkItem): Item => ({
203	id: w.id,
204	url: w.url.replace("/_apis/wit/workItems/", "/_workitems/edit/"),
205	title: w.fields["System.Title"] ?? "",
206	state: (w.fields["System.State"] ?? "").toLowerCase(),
207})
208
209// One az call per lookup, run in parallel: each branch's newest PR, each linked
210// PR, then each PR's work items, blocking policies (its checks) and threads.
211export async function fetchAzure(
212	run: Run,
213	branches: readonly string[],
214	prNumbers: readonly number[],
215): Promise<Work> {
216	const refs = await refsByBranch(run, "azure", branches)
217	const byBranch = await Promise.all(
218		branches.map((b) =>
219			az(run, [
220				"repos",
221				"pr",
222				"list",
223				"--source-branch",
224				b,
225				"--status",
226				"all",
227				"--top",
228				"1",
229			]),
230		),
231	)
232	const byNumber = await Promise.all(
233		prNumbers.map((n) => az(run, ["repos", "pr", "show", "--id", String(n)])),
234	)
235	if (
236		[...byBranch, ...byNumber].length > 0 &&
237		[...byBranch, ...byNumber].every((r) => r === null)
238	) {
239		return {
240			prs: [],
241			items: [],
242			fetchedAt: Date.now(),
243			error: "az: every lookup failed (logged in?)",
244		}
245	}
246
247	const raw = new Map<number, AzPr>()
248	const refsWithoutPr: number[] = []
249	byBranch.forEach((list, i) => {
250		const pr = (list as AzPr[] | null)?.[0]
251		if (pr) raw.set(pr.pullRequestId, pr)
252		else refsWithoutPr.push(...(refs.get(branches[i] ?? "") ?? []))
253	})
254	for (const pr of byNumber as (AzPr | null)[])
255		if (pr) raw.set(pr.pullRequestId, pr)
256
257	const items = new Map<number, Item>()
258	const prs = await Promise.all(
259		[...raw.values()].map(async (pr): Promise<Pr> => {
260			const id = String(pr.pullRequestId)
261			const [workItems, policies, threads] = await Promise.all([
262				az(run, ["repos", "pr", "work-item", "list", "--id", id]),
263				az(run, ["repos", "pr", "policy", "list", "--id", id]),
264				az(run, [
265					"devops",
266					"invoke",
267					"--area",
268					"git",
269					"--resource",
270					"pullRequestThreads",
271					"--api-version",
272					"7.1",
273					"--route-parameters",
274					`project=${pr.repository.project.id}`,
275					`repositoryId=${pr.repository.id}`,
276					`pullRequestId=${id}`,
277				]),
278			])
279			for (const w of (workItems as AzWorkItem[] | null) ?? [])
280				items.set(w.id, azItem(w))
281			const blocking = (
282				(policies as
283					| { status: string; configuration: { isBlocking: boolean } }[]
284					| null) ?? []
285			)
286				.filter((p) => p.configuration.isBlocking)
287				.map((p) => p.status)
288			const comments = (
289				(
290					threads as {
291						value?: {
292							isDeleted: boolean
293							comments: { commentType: string }[]
294						}[]
295					} | null
296				)?.value ?? []
297			).filter(
298				(t) => !t.isDeleted && t.comments[0]?.commentType === "text",
299			).length
300			return {
301				number: pr.pullRequestId,
302				url: pr.url
303					.replace("/_apis/git/repositories/", "/_git/")
304					.replace("/pullRequests/", "/pullrequest/"),
305				title: pr.title,
306				branch: pr.sourceRefName.replace(/^refs\/heads\//, ""),
307				state:
308					pr.isDraft && pr.status === "active"
309						? "draft"
310						: (AZ_STATE[pr.status] ?? pr.status),
311				review: azureReview((pr.reviewers ?? []).map((r) => r.vote)),
312				checks: azureChecks(blocking),
313				comments,
314			}
315		}),
316	)
317
318	const missing = [...new Set(refsWithoutPr)].filter((n) => !items.has(n))
319	if (missing.length > 0) {
320		const wiql = `SELECT [System.Id], [System.Title], [System.State] FROM WorkItems WHERE [System.Id] IN (${missing.join(", ")})`
321		for (const w of ((await az(run, ["boards", "query", "--wiql", wiql])) as
322			| AzWorkItem[]
323			| null) ?? [])
324			items.set(w.id, azItem(w))
325	}
326	return { prs, items: [...items.values()], fetchedAt: Date.now() }
327}
328
types/index.d.ts 36 lines
1export type CheckState = "passing" | "failing" | "pending" | "none"
2export type Pr = {
3	number: number
4	url: string
5	title: string
6	branch: string
7	state: string
8	review: string
9	checks: CheckState
10	comments: number
11}
12export type Item = { id: number; url: string; title: string; state: string }
13export type Work = {
14	prs: Pr[]
15	items: Item[]
16	fetchedAt: number
17	error?: string
18}
19export type Step = { text: string; status: "done" | "current" | "todo" }
20export type Summary =
21	| { goal: string; steps: Step[]; at: number }
22	| { error: string; at: number }
23
24declare module "claude-code" {
25	interface PluginState {
26		"work-items": {
27			branches: string[]
28			work: Work | null
29			isFetching: boolean
30			summary: Summary | null
31			isSummarizing: boolean
32			collapsed: number[]
33		}
34	}
35}
36