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

<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.
| Package | npm | What it is |
|---|---|---|
@rxova/ts-utils | Small, dependency-free runtime helpers, inlined at build time, plus a /react entry. | |
@rxova/repo-config | The rxova-repo-config bin (verify gate, changesets, scope, tarball and docs checks) and presets for ESLint, Prettier, tsconfig, Vitest and more. | |
@rxova/agent-kit | 10 hooks, 51 skills and 25 agents for Claude Code and OpenCode, installed in profiles by rxova-agent-kit. | |
@rxova/docs-kit | Markdown 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.
- uses: rxova/shared/actions/setup-pnpm@main
| Action | What it does |
|---|---|
setup-pnpm | pnpm at the packageManager version and Node, with the store cached. |
turbo-cache | Restores and saves the local Turbo cache across runs. |
turbo-remote-cache | Points Turbo at a remote cache backed by the Actions cache. |
setup-playwright | Resolves, caches and installs Playwright browsers. |
pin-react | Pins one React at the root and in the given packages, and proves only that one resolves. |
require-jobs | Fails unless every job in needs passed or was skipped: the one check to require. |
notify-website | Tells the rxova.dev website to publish this run's docs build. |
Inputs and examples: actions/README.md.
jobs:
commitlint:
uses: rxova/shared/.github/workflows/commit-messages.yml@main
| Workflow | What it does |
|---|---|
commit-messages.yml | The root CI job: lints commits and outputs code-changed, docs-only and docs-changed. |
docs-checks.yml | The light job a documentation-only range runs instead of the build and test matrix. |
lint-pr-title.yml | Lints the PR title, which becomes the squash subject. |
changeset-gate.yml | Requires a changeset when a pull request changes a published package. |
react-minimum-version.yml | Builds, tests and typechecks against the oldest React a peer range allows. |
node-floor-smoke.yml | Installs each packed package on exactly the oldest Node its engines promises. |
changesets-release.yml | The version pull request, then publishing with npm trusted publishing and provenance. |
snapshot-release.yml | Publishes 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.
{ extends: ["github>rxova/shared//renovate/default.json5"] }
The org rules live in renovate/default.json5; a repository keeps only its own.
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.
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
main, Changesets opens a chore: version packages pull request; merging it publishes to npm with trusted publishing and provenance.docs checks job instead; the docs site still builds when apps/docs changed. 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.
Every README in the repository:
packages/ts-utils/README.md: @rxova/ts-utilspackages/repo-config/README.md: @rxova/repo-configpackages/agent-kit/README.md: @rxova/agent-kitpackages/docs-kit/README.md: @rxova/docs-kitactions/README.md: the composite actions.github/workflows/README.md: the reusable workflows.changeset/README.md: adding a changeset and how it is releasedThe documentation site's sources are in apps/docs; pnpm docs serves it locally.
hooks/register.tsx 57 lines1import { 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};
57hooks/age.ts 4 lines1/** 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`;
4hooks/latest-note.ts 25 lines1import 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};
25types/index.d.ts 8 lines1export 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