SLOPSHOPPER

rx-handoff-band

A band above the prompt naming the newest handoff note in .claude/handoff/, with a button that fills the prompt to resume from it.

newbandprompt
A shopper browsing a rack in a slop shop
README

<img src="https://cdn.jsdelivr.net/npm/@rxova/brand@1/assets/rxova-logo-256.png" width="128" alt="rxova logo" />

<h1 align="center">rxova/shared</h1>

<a href="https://github.com/rxova/shared/actions/workflows/ci.yml"><img src="https://github.com/rxova/shared/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI status" /></a> <img src="https://img.shields.io/badge/Node.js-%E2%89%A522.13-5fa04e?logo=nodedotjs&logoColor=white" alt="Node.js 22.13 or newer" /> <img src="https://img.shields.io/badge/pnpm%20%2B%20Turborepo-%E2%9C%93-f69220" alt="Made for pnpm and Turborepo" /> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a>

<a href="#packages">Packages</a> · <a href="#ci-building-blocks">CI building blocks</a> · <a href="#using-it-in-an-rxova-repo">Using it</a> · <a href="#developing-this-repo">Developing</a> · <a href="#docs">Docs</a>

Every repository ends up with the same helpers, the same verify gate, the same CI jobs and the same Renovate rules. Copied, they drift. Here they live once: repositories depend on the packages and call the actions and workflows by path, so a fix lands everywhere on the next install or run.

Packages

PackagenpmWhat it is
@rxova/ts-utilsnpmSmall, dependency-free runtime helpers, inlined at build time, plus a /react entry.
@rxova/repo-confignpmThe rxova-repo-config bin (verify gate, changesets, scope, tarball and docs checks) and presets for ESLint, Prettier, tsconfig, Vitest and more.
@rxova/agent-kitnpm10 hooks, 51 skills and 25 agents for Claude Code and OpenCode, installed in profiles by rxova-agent-kit.
@rxova/docs-kitnpmMarkdown twins, llms.txt and the check-md-routes build check for an Astro Starlight docs site.

Each package README lists every export, command and option.

CI building blocks

Composite actions

- uses: rxova/shared/actions/setup-pnpm@main
ActionWhat it does
setup-pnpmpnpm at the packageManager version and Node, with the store cached.
turbo-cacheRestores and saves the local Turbo cache across runs.
turbo-remote-cachePoints Turbo at a remote cache backed by the Actions cache.
setup-playwrightResolves, caches and installs Playwright browsers.
pin-reactPins one React at the root and in the given packages, and proves only that one resolves.
require-jobsFails unless every job in needs passed or was skipped: the one check to require.
notify-websiteTells the rxova.dev website to publish this run's docs build.

Inputs and examples: actions/README.md.

Reusable workflows

jobs:
  commitlint:
    uses: rxova/shared/.github/workflows/commit-messages.yml@main
WorkflowWhat it does
commit-messages.ymlThe root CI job: lints commits and outputs code-changed, docs-only and docs-changed.
docs-checks.ymlThe light job a documentation-only range runs instead of the build and test matrix.
lint-pr-title.ymlLints the PR title, which becomes the squash subject.
changeset-gate.ymlRequires a changeset when a pull request changes a published package.
react-minimum-version.ymlBuilds, tests and typechecks against the oldest React a peer range allows.
node-floor-smoke.ymlInstalls each packed package on exactly the oldest Node its engines promises.
changesets-release.ymlThe version pull request, then publishing with npm trusted publishing and provenance.
snapshot-release.ymlPublishes a prerelease under a dist-tag other than latest, to try a change in another repo.

Inputs, outputs and a full CI graph: .github/workflows/README.md.

Renovate preset

{ extends: ["github>rxova/shared//renovate/default.json5"] }

The org rules live in renovate/default.json5; a repository keeps only its own.

Using it in an rxova repo

Tooling packages go in the root package.json only, never in a workspace package:

pnpm add -D -w @rxova/repo-config @rxova/ts-utils

Point each tool at its preset:

// eslint.config.js
import { rxova } from "@rxova/repo-config/eslint";

export default rxova({ tsconfigRootDir: import.meta.dirname, node: true, tests: true });
// .prettierrc
"@rxova/repo-config/prettier"
// tsconfig.json
{ "extends": "@rxova/repo-config/tsconfig.react.json", "include": ["src"] }

And CI at the shared workflows, pinned to @main:

jobs:
  commitlint:
    uses: rxova/shared/.github/workflows/commit-messages.yml@main
  changeset:
    needs: [commitlint]
    uses: rxova/shared/.github/workflows/changeset-gate.yml@main

The @rxova/repo-config quick start covers the rest: the verify script, the pre-push hook, lint-staged, commitlint, tsdown and Vitest.

Developing this repo

Node 22.13 or newer, and pnpm through Corepack:

corepack enable
pnpm install        # dependencies and git hooks
pnpm test           # unit tests, coverage enforced per file
pnpm docs           # the docs site (apps/docs) on a local dev server
pnpm run verify     # everything CI runs, in CI's order (also the pre-push hook)
pnpm changeset      # record a change to a published package
  • Releases. A change to a published package needs a changeset (see .changeset/README.md). Once CI is green on main, Changesets opens a chore: version packages pull request; merging it publishes to npm with trusted publishing and provenance.
  • Docs-only pull requests. When a range changes only Markdown (and changesets), CI skips the build, test and package jobs and runs the light docs checks job instead; the docs site still builds when apps/docs changed.
  • Releasing a README-only change. A package README change needs no changeset, but npm shows the README of the published version. To ship it, add a patch changeset; the range stays docs-only:
  pnpm exec rxova-repo-config add-changeset ts-utils patch "Rewrite the readme"

See CONTRIBUTING.md for hooks and commit rules, SECURITY.md to report a vulnerability, and SUPPORT.md for help.

Docs

Every README in the repository:

The documentation site's sources are in apps/docs; pnpm docs serves it locally.

License

MIT

Source 4 files
hooks/register.tsx 57 lines
1import { atom, read, update } from "claude-code";
2import type { Register } from "claude-code";
3
4import { age } from "./age";
5import { latestNote } from "./latest-note";
6
7const DIR = ".claude/handoff";
8const note = atom({ plugin: "rx-handoff-band", key: "note" } as const, null);
9const isHidden = atom({ plugin: "rx-handoff-band", key: "isHidden" } as const, false);
10
11export const register: Register = (on, options) => {
12  const maxDays = typeof options.maxDays === "number" ? options.maxDays : 7;
13
14  on("session.start", async ($, e, next) => {
15    const names = (await $.fs.exists(DIR))
16      ? (await $.fs.list(DIR)).filter((entry) => entry.kind === "file").map((entry) => entry.name)
17      : [];
18    const latest = latestNote(names, await $.clock.now(), maxDays);
19    await update($, note, () => latest);
20
21    return next(e);
22  });
23
24  on("prompt.submit", async ($, e, next) => {
25    await update($, isHidden, () => true);
26
27    return next(e);
28  }).catch(($, e, next) => next(e));
29
30  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
31    const latest = await read($, note);
32    if (e.props.hasSurvey || e.props.isWorking || latest === null || (await read($, isHidden))) {
33      return next(e);
34    }
35
36    const { Box, Button, Text } = $.ui.resolve(e);
37
38    return (
39      <Box flexDirection="row" gap={1}>
40        <Text dimColor>
41          Handoff {latest.path.slice(DIR.length + 1)} ({age(latest.ageDays)})
42        </Text>
43        <Button
44          key="resume"
45          hotkey="h"
46          variant="primary"
47          label="Resume"
48          onPress={() =>
49            $.prompt.fill({ text: `Read ${latest.path} and continue the work it describes.` })
50          }
51        />
52        <Button key="dismiss" label="Dismiss" onPress={() => update($, isHidden, () => true)} />
53      </Box>
54    );
55  });
56};
57
hooks/age.ts 4 lines
1/** A note's age in words: today, yesterday or n days ago. */
2export const age = (days: number): string =>
3  days === 0 ? "today" : days === 1 ? "yesterday" : `${days} days ago`;
4
hooks/latest-note.ts 25 lines
1import type { HandoffNote } from "../types";
2
3const DAY_MS = 86_400_000;
4
5/** The newest `yyyy-mm-dd*.md` note among `names` no older than `maxDays`, or null. */
6export const latestNote = (
7  names: readonly string[],
8  nowMs: number,
9  maxDays: number,
10): HandoffNote | null => {
11  const oldest = new Date(nowMs - maxDays * DAY_MS).toISOString().slice(0, 10);
12  const name = names
13    .filter((file) => /^\d{4}-\d{2}-\d{2}.*\.md$/.test(file) && file.slice(0, 10) >= oldest)
14    .sort()
15    .pop();
16  if (name === undefined) {
17    return null;
18  }
19
20  const date = name.slice(0, 10);
21  const ageDays = Math.max(0, Math.floor((nowMs - Date.parse(`${date}T00:00:00Z`)) / DAY_MS));
22
23  return { path: `.claude/handoff/${name}`, date, ageDays };
24};
25
types/index.d.ts 8 lines
1export type HandoffNote = { path: string; date: string; ageDays: number };
2
3declare module "claude-code" {
4  interface PluginState {
5    "rx-handoff-band": { note: HandoffNote | null; isHidden: boolean };
6  }
7}
8