SLOPSHOPPER

gitmatrix-work

A card above the prompt with the Timelines issue this session works on; installed by `gm agent mod`

newbandguardcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gitmatrix-work
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gm-work ⎿ gitmatrix-work: Timelines work card hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

The Claude Code work card

A Claude Code mod: a card above the prompt with the Timelines issue the session works on. gm agent mod installs it (see apps/cli/README.md); gm embeds this folder's runtime files (.claude-plugin, hooks, types).

  • hooks/register.tsx: the hooks module (the card, the toggle, polling, and following the session's gm commands).
  • hooks/gm.ts: pure logic over gm's JSON and the session's commands.
  • types/index.d.ts: the mod's self-contained $.state and allowlisted agent identity contract. Claude loads it independently: no imports, re-exports from another file, require, or reference directives. Runtime helpers import its named types, never the reverse.

Developing it

Load this folder in Claude Code: it reloads on every save.

claude --plugin-dir apps/cli/assets/claude-code-mod

Claude Code then lays its API declarations in .claude-plugin/types/ (git-ignored), which tsconfig.json extends; repository lint skips this folder because those declarations only exist once Claude Code has loaded it.

claude plugin validate apps/cli/assets/claude-code-mod
tsc -p apps/cli/assets/claude-code-mod                # after a load
node --test apps/cli/assets/claude-code-mod/tests/*.test.ts   # also in cli#test

The tests are Node unit tests of hooks/gm.ts and hooks/identity.ts, not claude plugin test ones. Node tests do not validate the manifest contract; run claude plugin validate as well.

Source 4 files
hooks/register.tsx 264 lines
1// Claude Code mod (CORE-303), installed by `gm agent mod`: a corner card
2// above the prompt with the gitmatrix issue this session works on. The chord
3// `gm agent mod` binds to ACTION (ctrl+x g) or /gm-work toggles it.
4import { atom, read, update } from "claude-code";
5import type { EngineInterface, Register } from "claude-code";
6
7import type { GmSnapshot } from "../types";
8import { resolveIdentity } from "./identity";
9import { currentTimeline, gmCalls, metaLine, myClaims, nextFocus, toWork } from "./gm";
10
11type Engine = EngineInterface;
12
13/**
14 * The engine keybinding action the hidden toggle Button answers to. Mods
15 * can't declare actions of their own, so this borrows one that is idle
16 * unless the diff panel has pre-session changes. `gm agent mod` binds a
17 * chord to the same action (claude_mod.rs's ACTION).
18 */
19const ACTION = "app:toggleDiffPreSession";
20const COMMAND = "gm-work";
21const STORE_SHOWN = "isShown";
22const REFRESH_MS = 60_000;
23const CARD_COLUMNS = 46;
24
25const isShown = atom({ plugin: "gitmatrix-work", key: "isShown" } as const, true);
26const snapshot = atom({ plugin: "gitmatrix-work", key: "snapshot" } as const, null);
27const focusKey = atom({ plugin: "gitmatrix-work", key: "focusKey" } as const, null);
28
29// Module state: a hot reload starts these over, which is fine.
30let checkout: string | null = null;
31let isLoading = false;
32let isStale = false;
33
34async function gm($: Engine, args: string[]): Promise<unknown> {
35  const ran = await $.process.run(["gm", ...args, "-o", "json"], { timeoutMs: 20_000 });
36  if (ran.exitCode !== 0) {
37    throw new Error("Timelines work unavailable");
38  }
39  return JSON.parse(ran.stdout);
40}
41
42async function load($: Engine): Promise<GmSnapshot> {
43  const now = await $.clock.now();
44  const cwd = await $.session.cwd();
45  if (checkout !== cwd) {
46    checkout = cwd;
47    await update($, focusKey, () => null);
48    await update($, snapshot, () => null);
49  }
50  const identity = await resolveIdentity(
51    (argv, cwd) => $.process.run(argv, { cwd, timeoutMs: 20_000 }),
52    cwd,
53  );
54  if (!identity) {
55    await update($, focusKey, () => null);
56    return { identity: null, work: null, error: null, fetchedAt: now };
57  }
58  const agentId = identity.id;
59  const previous = await read($, snapshot);
60  if (previous?.identity && previous.identity.id !== agentId) {
61    await update($, focusKey, () => null);
62  }
63  try {
64    const focus = await read($, focusKey);
65    const listing = gm($, [
66      "issue",
67      "list",
68      "--status",
69      "in_progress",
70      "--all-teams",
71      "--limit",
72      "100",
73    ]);
74    const timelines = gm($, ["timeline", "list"]).catch(() => null);
75    const focused = focus === null ? null : gm($, ["issue", "show", focus]);
76    const claims = myClaims(await listing, agentId, now);
77    const key = focus ?? claims[0]?.key ?? null;
78    if (key === null) return { identity, work: null, error: null, fetchedAt: now };
79    const shown = await (focused ?? gm($, ["issue", "show", key]));
80    const work = toWork(shown, {
81      agentId,
82      timeline: currentTimeline(await timelines),
83      otherClaims: claims.filter((claim) => claim.key !== key).length,
84    });
85    return { identity, work, error: null, fetchedAt: now };
86  } catch {
87    return { identity, work: null, error: "Timelines work unavailable", fetchedAt: now };
88  }
89}
90
91/** Reloads the card; a call during a load runs one more after it. */
92async function refresh($: Engine): Promise<void> {
93  if (isLoading) {
94    isStale = true;
95    return;
96  }
97  isLoading = true;
98  try {
99    do {
100      isStale = false;
101      const cwd = await $.session.cwd();
102      const next = await load($);
103      if (cwd !== (await $.session.cwd())) isStale = true;
104      if (!isStale) await update($, snapshot, () => next);
105    } while (isStale);
106  } finally {
107    isLoading = false;
108  }
109}
110
111async function toggle($: Engine): Promise<boolean> {
112  const shown = !(await read($, isShown));
113  await update($, isShown, () => shown);
114  await $.store.set(STORE_SHOWN, shown);
115  if (shown) void refresh($);
116  return shown;
117}
118
119export const register: Register = (on) => {
120  on("session.start", async ($, e, next) => {
121    await $.command.register({
122      name: COMMAND,
123      description: "Toggle the Timelines work card",
124    });
125    checkout = null;
126    await update($, snapshot, () => null);
127    await update($, focusKey, () => null);
128    await update($, isShown, () => true);
129    if ((await $.store.get(STORE_SHOWN)) === false) await update($, isShown, () => false);
130    $.clock.after(0, () => void refresh($));
131    $.clock.every(REFRESH_MS, () => {
132      void read($, isShown).then((shown) => (shown ? refresh($) : undefined));
133    });
134    return next(e);
135  });
136
137  on("command.run", { command: COMMAND }, async ($) => {
138    const shown = await toggle($);
139    return { text: shown ? "Timelines work card shown." : "Timelines work card hidden." };
140  });
141
142  // Follows the session's own gm commands: what it claims, checkpoints,
143  // completes or releases becomes (or stops being) the card's issue.
144  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
145    const ran = await next(e);
146    const calls = gmCalls(e.command);
147    if (calls.length === 0 || ran.deny !== undefined || ran.isError) return ran;
148    const focus = await read($, focusKey);
149    const focused = nextFocus(focus, calls, ran.text ?? "");
150    if (focused !== focus) await update($, focusKey, () => focused);
151    $.clock.after(0, () => void refresh($));
152    return ran;
153  }).catch(($, e, next) => next(e));
154
155  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
156    if (e.props.hasSurvey) return next(e);
157    const { Box, Button, Text } = $.ui.resolve(e);
158
159    // Always mounted, so the chord works while the card is hidden too.
160    const toggleButton = (
161      <Box display="none">
162        <Button key="gm-work-toggle" label="gm" plain action={ACTION} onPress={() => toggle($)} />
163      </Box>
164    );
165    if (!(await read($, isShown))) return toggleButton;
166
167    const snap = await read($, snapshot);
168    const now = await $.clock.now();
169    const width = Math.min(CARD_COLUMNS, e.props.bodyColumns);
170    const isFramed = e.props.maxRows >= 5 && width >= 20;
171    const lines: JSX.Element[] = [];
172    if (snap?.identity) {
173      lines.push(
174        <Text bold wrap="truncate-end">
175          Agent: {snap.identity.name}
176        </Text>,
177      );
178      if (snap.identity.accountable) {
179        lines.push(
180          <Text dimColor wrap="truncate-end">
181            Accountable: {snap.identity.accountable}
182          </Text>,
183        );
184      }
185    }
186
187    if (snap === null) {
188      lines.push(
189        <Text dimColor wrap="truncate-end">
190          Timelines: loading…
191        </Text>,
192      );
193    } else if (snap.work === null) {
194      lines.push(
195        <Text dimColor wrap="truncate-end">
196          {!snap.identity
197            ? "No Timelines agent identity"
198            : snap.error
199              ? "Timelines: unavailable"
200              : "No Timelines issue claimed"}
201        </Text>,
202      );
203      lines.push(
204        <Text dimColor wrap="truncate-end">
205          {!snap.identity
206            ? "gm agent setup configures this checkout"
207            : (snap.error ?? "gm issue next picks one")}
208        </Text>,
209      );
210    } else {
211      const { work } = snap;
212      lines.push(
213        <Text wrap="truncate-end">
214          <Text bold color="claude">
215            {work.key}
216          </Text>{" "}
217          {work.title}
218        </Text>,
219      );
220      lines.push(
221        <Text dimColor wrap="truncate-end">
222          {metaLine(work, now)}
223        </Text>,
224      );
225      if (snap.error) {
226        lines.push(
227          <Text color="warning" wrap="truncate-end">
228            ⚠ {snap.error}
229          </Text>,
230        );
231      } else if (work.checkpoint) {
232        lines.push(
233          <Text dimColor wrap="truncate-end">
234            ↳ {work.checkpoint}
235          </Text>,
236        );
237      } else if (work.otherClaims > 0) {
238        lines.push(
239          <Text dimColor wrap="truncate-end">
240            +{work.otherClaims} more claimed issue{work.otherClaims === 1 ? "" : "s"}
241          </Text>,
242        );
243      }
244    }
245
246    const shownLines = lines.slice(0, Math.max(1, e.props.maxRows - (isFramed ? 2 : 0)));
247    return (
248      <Box flexDirection="row" justifyContent="flex-end">
249        {toggleButton}
250        <Box
251          key="gm-work-card"
252          flexDirection="column"
253          width={width}
254          borderStyle={isFramed ? "round" : undefined}
255          borderDimColor
256          paddingX={isFramed ? 1 : 0}
257        >
258          {shownLines}
259        </Box>
260      </Box>
261    );
262  });
263};
264
hooks/identity.ts 54 lines
1// Shared by the Claude mod and the Oh My Pi extension. Never retain the raw
2// response: /agents/self may gain fields that don't belong in a harness UI.
3import type { GmAgentIdentity } from "../types";
4type Run = (argv: string[], cwd: string) => Promise<{ exitCode: number; stdout: string }>;
5
6function label(value: unknown): string | null {
7  return typeof value === "string" &&
8    value.trim().length > 0 &&
9    value.length <= 200 &&
10    !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value)
11    ? value.trim()
12    : null;
13}
14
15export function agentIdentity(value: unknown): GmAgentIdentity | null {
16  if (!value || typeof value !== "object") return null;
17  const agent = (value as Record<string, unknown>).agent;
18  if (!agent || typeof agent !== "object" || Array.isArray(agent)) return null;
19  const fields = agent as Record<string, unknown>;
20  const id = label(fields.id);
21  const name = label(fields.name);
22  if (
23    !id ||
24    !name ||
25    (fields.kind !== undefined && fields.kind !== "agent") ||
26    fields.revokedAt != null
27  )
28    return null;
29  const owner = fields.accountable;
30  const accountable =
31    owner && typeof owner === "object" && !Array.isArray(owner)
32      ? (label((owner as Record<string, unknown>).displayName) ??
33        label((owner as Record<string, unknown>).id))
34      : null;
35  return { id, name, accountable };
36}
37
38export function identityLine(identity: GmAgentIdentity): string {
39  return `Agent: ${identity.name}${identity.accountable ? ` · accountable: ${identity.accountable}` : ""}`;
40}
41
42export async function resolveIdentity(run: Run, cwd: string): Promise<GmAgentIdentity | null> {
43  try {
44    const checkout = await run(["git", "rev-parse", "--show-toplevel"], cwd);
45    if (checkout.exitCode !== 0) return null;
46    const result = await run(["gm", "agent", "show", "-o", "json"], cwd);
47    return result.exitCode === 0 ? agentIdentity(JSON.parse(result.stdout)) : null;
48  } catch {
49    // No checkout, credentials, gm, network, or valid identity: fail closed.
50    // In particular, never turn stderr or a thrown subprocess error into UI.
51    return null;
52  }
53}
54
hooks/gm.ts 182 lines
1// Pure helpers: reading gm's JSON and the Bash commands that run gm.
2import type { GmWork } from "../types";
3
4const ISSUE_KEY = /^[A-Z][A-Z0-9]*-\d+$/;
5
6/** One `gm issue <sub> [KEY]` (or any other gm command, as `sub: null`). */
7export type GmCall = { sub: string | null; key: string | null };
8
9/**
10 * The gm commands in a shell command line, in order. `agent gm` (the
11 * isolated product-testing wrapper) is skipped: it talks to another server.
12 */
13export function gmCalls(command: string): GmCall[] {
14  const calls: GmCall[] = [];
15  for (const segment of command.split(/&&|\|\||[;&|\n]/)) {
16    const words = segment.trim().split(/\s+/).filter(Boolean);
17    let at = 0;
18    while (at < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at] ?? "")) at += 1;
19    if (words[at] !== "gm" && !(words[at] ?? "").endsWith("/gm")) continue;
20    if (segment.includes("agent gm")) continue;
21    const rest = words.slice(at + 1);
22    const issueAt = rest.indexOf("issue");
23    if (issueAt === -1) {
24      calls.push({ sub: null, key: null });
25      continue;
26    }
27    const sub = rest[issueAt + 1] ?? null;
28    const key = rest.slice(issueAt + 2).find((word) => ISSUE_KEY.test(word)) ?? null;
29    calls.push({ sub, key });
30  }
31  return calls;
32}
33
34/** What the session's gm commands mean for the issue it is focused on. */
35export function nextFocus(
36  focus: string | null,
37  calls: readonly GmCall[],
38  output: string,
39): string | null {
40  let next = focus;
41  for (const call of calls) {
42    if (call.sub === "next") {
43      next = claimedKey(output) ?? next;
44    } else if (
45      call.key &&
46      ["claim", "resume", "checkpoint", "renew", "evidence"].includes(call.sub ?? "")
47    ) {
48      next = call.key;
49    } else if (
50      call.key &&
51      ["complete", "release"].includes(call.sub ?? "") &&
52      (next === null || next === call.key)
53    ) {
54      next = null;
55    }
56  }
57  return next;
58}
59
60/** The key `gm issue next` claimed, from its JSON output. */
61export function claimedKey(output: string): string | null {
62  try {
63    const parsed = JSON.parse(output) as { claimed?: { key?: unknown } | null };
64    const key = parsed.claimed?.key;
65    if (typeof key === "string") return key;
66  } catch {
67    // Not whole JSON (other output around it): fall back to a scan.
68  }
69  return /"claimed"\s*:\s*\{[^}]*?"key"\s*:\s*"([A-Z][A-Z0-9]*-\d+)"/.exec(output)?.[1] ?? null;
70}
71
72type Claim = { actorId?: string; agent?: string; expiresAt?: string } | null;
73type ListedIssue = { key: string; updatedAt?: string; claim?: Claim };
74
75/** In-progress issues whose live claim belongs to `agentId`, newest first. */
76export function myClaims(list: unknown, agentId: string | null, now: number): ListedIssue[] {
77  const issues = (list as { issues?: ListedIssue[] } | null)?.issues ?? [];
78  return issues
79    .filter((issue) => {
80      const claim = issue.claim;
81      if (!claim || !agentId || claim.actorId !== agentId) return false;
82      return !claim.expiresAt || Date.parse(claim.expiresAt) > now;
83    })
84    .toSorted((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
85}
86
87/** The checkout's timeline from `gm timeline list`, if it works in one. */
88export function currentTimeline(listed: unknown): string | null {
89  const current = (listed as { current?: unknown } | null)?.current;
90  if (typeof current === "string") return current;
91  if (current && typeof current === "object") {
92    const { name, slug } = current as { name?: unknown; slug?: unknown };
93    if (typeof name === "string") return name;
94    if (typeof slug === "string") return slug;
95  }
96  return null;
97}
98
99type Note = { kind?: string; body?: string; createdAt?: string };
100
101/** The newest checkpoint's first line, markdown stripped. */
102export function latestCheckpoint(notes: readonly Note[] | undefined): string | null {
103  const checkpoint = (notes ?? [])
104    .filter((note) => note.kind === "checkpoint" && note.body)
105    .toSorted((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0];
106  if (!checkpoint?.body) return null;
107  const line = checkpoint.body.split("\n").find((text) => text.trim() !== "") ?? "";
108  return (
109    line
110      .replace(/[*_`]+/g, "")
111      .replace(/\s+/g, " ")
112      .trim() || null
113  );
114}
115
116/** The card's view of `gm issue show <key>`. */
117export function toWork(
118  shown: unknown,
119  context: { agentId: string | null; timeline: string | null; otherClaims: number },
120): GmWork {
121  const { issue, notes } = shown as {
122    issue: {
123      key: string;
124      title?: string;
125      status?: string;
126      priority?: number | null;
127      claim?: Claim;
128    };
129    notes?: Note[];
130  };
131  const claim = issue.claim ?? null;
132  const isMine = claim !== null && claim.actorId === context.agentId;
133  return {
134    key: issue.key,
135    title: issue.title ?? "",
136    status: issue.status ?? "unknown",
137    priority: typeof issue.priority === "number" ? issue.priority : null,
138    claimedBy: claim && !isMine ? (claim.agent ?? "someone else") : null,
139    leaseExpiresAt: isMine ? (claim?.expiresAt ?? null) : null,
140    timeline: context.timeline,
141    checkpoint: latestCheckpoint(notes),
142    otherClaims: context.otherClaims,
143  };
144}
145
146/** `lease 41m`, `lease 1h05m`, or `lease expired`. */
147export function leaseLabel(expiresAt: string | null, now: number): string | null {
148  if (!expiresAt) return null;
149  const minutes = Math.floor((Date.parse(expiresAt) - now) / 60_000);
150  if (Number.isNaN(minutes)) return null;
151  if (minutes < 0) return "lease expired";
152  if (minutes < 60) return `lease ${minutes}m`;
153  return `lease ${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
154}
155
156/** The second line: status, priority, lease or holder, timeline. */
157export function metaLine(work: GmWork, now: number): string {
158  const parts = [work.status.replace(/_/g, " ")];
159  if (work.priority !== null) parts.push(`P${work.priority}`);
160  const lease = leaseLabel(work.leaseExpiresAt, now);
161  if (lease) parts.push(lease);
162  if (work.claimedBy) parts.push(`held by ${work.claimedBy}`);
163  if (work.timeline) parts.push(`⎇ ${work.timeline}`);
164  return parts.join(" · ");
165}
166
167/** gm's error message from its JSON stderr, else the first line. */
168export function gmErrorMessage(stderr: string): string | null {
169  try {
170    const parsed = JSON.parse(stderr) as { error?: { message?: unknown } };
171    if (typeof parsed.error?.message === "string") return parsed.error.message;
172  } catch {
173    // Plain text.
174  }
175  return (
176    stderr
177      .split("\n")
178      .find((line) => line.trim() !== "")
179      ?.trim() ?? null
180  );
181}
182
types/index.d.ts 34 lines
1// Claude loads this manifest contract on its own: keep it free of imports.
2export type GmAgentIdentity = { id: string; name: string; accountable: string | null };
3
4export type GmWork = {
5  key: string;
6  title: string;
7  status: string;
8  priority: number | null;
9  /** Who holds the claim when it is not this checkout's agent. */
10  claimedBy: string | null;
11  leaseExpiresAt: string | null;
12  timeline: string | null;
13  checkpoint: string | null;
14  /** Other in-progress issues this agent also holds a claim on. */
15  otherClaims: number;
16};
17
18export type GmSnapshot = {
19  identity: GmAgentIdentity | null;
20  work: GmWork | null;
21  error: string | null;
22  fetchedAt: number;
23};
24
25declare module "claude-code" {
26  interface PluginState {
27    "gitmatrix-work": {
28      isShown: boolean;
29      snapshot: GmSnapshot | null;
30      focusKey: string | null;
31    };
32  }
33}
34