From 5f950f7a0f29ac30170b2ed9a33c597ff6a56738 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:11:51 -0400 Subject: [PATCH 01/47] #387 route a deactivated live session to an explanatory screen resolveRealUser used to return null after a valid session had already resolved, silently bouncing a deactivated user back to /login forever. Distinguishes that case and sends it to /login/deactivated instead. Co-Authored-By: Claude Sonnet 4.6 --- app/login/deactivated/page.tsx | 49 ++++++++++++++++++++ app/login/page.tsx | 24 +++------- components/features/deactivated-sign-out.tsx | 43 +++++++++++++++++ lib/auth/server.ts | 28 +++++++---- lib/constants.ts | 7 +-- prisma/actions/auth.ts | 18 ++++++- tests/stubs/auth-server.ts | 6 +++ 7 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 app/login/deactivated/page.tsx create mode 100644 components/features/deactivated-sign-out.tsx diff --git a/app/login/deactivated/page.tsx b/app/login/deactivated/page.tsx new file mode 100644 index 00000000..2b991e2f --- /dev/null +++ b/app/login/deactivated/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { UserRoundX } from 'lucide-react'; + +import { getDeactivatedSessionUser, getOptionalUser } from '@/lib/auth/server'; + +import { DeactivatedSignOut } from '@/components/features/deactivated-sign-out'; +import { Button } from '@/components/ui/button'; + +export const metadata: Metadata = { + title: 'Account Deactivated', + robots: { index: false }, +}; + +export default async function AccountDeactivatedPage() { + const activeUser = await getOptionalUser(); + if (activeUser) redirect('/positions'); + + const deactivatedUser = await getDeactivatedSessionUser(); + if (!deactivatedUser) redirect('/login'); + + return ( +
+ +
+

Account deactivated

+

+ Your account has been deactivated, so you can't access Aplio + right now. +

+

+ If you think this is a mistake, contact an administrator to have it + restored. +

+
+

+ Signed in as {deactivatedUser.email} +

+
+ + +
+
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 4a6c053b..b960d5cb 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -2,21 +2,13 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { TriangleAlert } from 'lucide-react'; - import { safeRedirectTo, withRedirectTo } from '@/lib/auth/redirect'; -import { getOptionalUser } from '@/lib/auth/server'; -import { - ACCOUNT_DEACTIVATED_MESSAGE, - LOGIN_DEACTIVATED_REASON, - PRIVACY_HREF, - TERMS_HREF, -} from '@/lib/constants'; +import { getDeactivatedSessionUser, getOptionalUser } from '@/lib/auth/server'; +import { PRIVACY_HREF, TERMS_HREF } from '@/lib/constants'; import { isBypassAllowed } from '@/lib/utils'; import { LoginView } from '@/components/features/login-view'; import { NameField } from '@/components/features/name-field'; -import { WarningCallout } from '@/components/ui/warning-callout'; export const metadata: Metadata = { title: 'Sign In' }; @@ -30,16 +22,19 @@ export default async function SignInPage({ }: { searchParams: Promise>; }) { - const { redirectTo, reason } = await searchParams; + const { redirectTo } = await searchParams; const safeTo = safeRedirectTo(redirectTo); const applyContext = isApplyRedirect(safeTo); - const isDeactivated = reason === LOGIN_DEACTIVATED_REASON; const user = await getOptionalUser(); // Authenticated user with a name set — send them into the app. if (user?.name?.trim()) redirect(safeTo); // Authenticated user with no name — fall through to render the name form below. + // A live session for a deactivated row — routing, not denial. + if (!user && (await getDeactivatedSessionUser())) + redirect('/login/deactivated'); + // Must match isBypassAllowed, so the affordance and the action agree. const isDev = isBypassAllowed(); @@ -60,11 +55,6 @@ export default async function SignInPage({ return (
- {isDeactivated && !user && ( - - {ACCOUNT_DEACTIVATED_MESSAGE} - - )} {user ? ( ) : ( diff --git a/components/features/deactivated-sign-out.tsx b/components/features/deactivated-sign-out.tsx new file mode 100644 index 00000000..4a76de9e --- /dev/null +++ b/components/features/deactivated-sign-out.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { unstable_rethrow, useRouter } from 'next/navigation'; +import { useTransition } from 'react'; + +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { signOutDeactivatedSession } from '@/prisma/actions/auth'; + +import { isError } from '@/lib/utils'; + +import { Button } from '@/components/ui/button'; + +export function DeactivatedSignOut() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + function handleSignOut() { + startTransition(async () => { + try { + const result = await signOutDeactivatedSession(); + if (isError(result)) { + toast.error(result.error); + return; + } + toast.success('Signed out.'); + router.push('/login'); + } catch (error) { + unstable_rethrow(error); + console.error('Deactivated sign-out failed unexpectedly', error); + toast.error('Something went wrong. Please try again.'); + } + }); + } + + return ( + + ); +} diff --git a/lib/auth/server.ts b/lib/auth/server.ts index e9af1893..8a54bc74 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -6,13 +6,12 @@ import type { User } from '@/prisma/client'; import { auth } from '@/lib/auth/config'; import { withRedirectTo } from '@/lib/auth/redirect'; -import { LOGIN_DEACTIVATED_REASON } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; import { isBypassAllowed } from '@/lib/utils'; type UserResolution = | { status: 'active'; user: User } - | { status: 'deactivated' } + | { status: 'deactivated'; user: User } | { status: 'anonymous' }; // Better Auth owns the User row, so the session id is the row id. @@ -26,7 +25,7 @@ const resolveUser = cache( }); if (row) return row.deletedAt - ? { status: 'deactivated' } + ? { status: 'deactivated', user: row } : { status: 'active', user: row }; } } @@ -39,7 +38,7 @@ const resolveUser = cache( }); if (!row) return { status: 'anonymous' }; return row.deletedAt - ? { status: 'deactivated' } + ? { status: 'deactivated', user: row } : { status: 'active', user: row }; }, ); @@ -49,7 +48,8 @@ export async function getIsBypass(): Promise { return Boolean((await cookies()).get('dev-bypass-user-id')?.value); } -// Treats a deactivated caller as anonymous. +// Treats a deactivated caller as anonymous — getDeactivatedSessionUser is the +// one that routes it to the explanatory screen. export const getOptionalUser = cache( async function getOptionalUser(): Promise { const resolution = await resolveUser(); @@ -57,6 +57,16 @@ export const getOptionalUser = cache( }, ); +// A live session whose row has since been soft-deleted — distinct from "no +// session" so getCurrentUser can route it to the explanatory screen instead +// of a silent sign-in loop. +export const getDeactivatedSessionUser = cache( + async function getDeactivatedSessionUser(): Promise { + const resolution = await resolveUser(); + return resolution.status === 'deactivated' ? resolution.user : null; + }, +); + export async function currentPath(): Promise { return (await headers()).get('x-current-path'); } @@ -67,11 +77,9 @@ export const getCurrentUser = cache( const resolution = await resolveUser(); if (resolution.status === 'active') return resolution.user; - if (resolution.status === 'deactivated') { - const loginUrl = withRedirectTo('/login', await currentPath()); - const separator = loginUrl.includes('?') ? '&' : '?'; - redirect(`${loginUrl}${separator}reason=${LOGIN_DEACTIVATED_REASON}`); - } + // Routing, not denial: a live session for a deactivated row goes to the + // explanatory screen instead of silently bouncing back to /login forever. + if (resolution.status === 'deactivated') redirect('/login/deactivated'); const base = isBypassAllowed() ? '/login/bypass' : '/login'; redirect(withRedirectTo(base, await currentPath())); diff --git a/lib/constants.ts b/lib/constants.ts index 9d87cdfc..66f754e5 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -535,15 +535,10 @@ export const nameSchema = z.object({ // branch on this specific refusal instead of a generic OTP failure. export const ACCOUNT_DEACTIVATED_ERROR_CODE = 'ACCOUNT_DEACTIVATED'; -// Shared across checkSignInAllowed, LoginView's OTP failure, and /login's -// reason=deactivated notice so the copy can't drift between surfaces. +// Shared between checkSignInAllowed and LoginView's OTP failure so the copy can't drift between surfaces. export const ACCOUNT_DEACTIVATED_MESSAGE = 'Your account has been deactivated. Please contact an administrator.'; -// Value of /login's ?reason= query param when getCurrentUser redirects a -// deactivated caller there. -export const LOGIN_DEACTIVATED_REASON = 'deactivated'; - // Shared between the checkSignInAllowed server action and LoginView's email step resolver. export const signInEmailSchema = z.object({ email: z.string().email('Please enter a valid email address'), diff --git a/prisma/actions/auth.ts b/prisma/actions/auth.ts index e5ebc90e..4f43f758 100644 --- a/prisma/actions/auth.ts +++ b/prisma/actions/auth.ts @@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; import { auth } from '@/lib/auth/config'; -import { getCurrentUser } from '@/lib/auth/server'; +import { getCurrentUser, getDeactivatedSessionUser } from '@/lib/auth/server'; import { ACCOUNT_DEACTIVATED_MESSAGE, OTP_RESEND_COOLDOWN_SECONDS, @@ -64,3 +64,19 @@ export async function signOutUser(): Promise { revalidatePath('/', 'layout'); } + +// signOutUser can't serve this: its getCurrentUser() call would redirect a +// deactivated caller to /login/deactivated instead of signing them out. +export async function signOutDeactivatedSession(): Promise { + if (!(await getDeactivatedSessionUser())) + throw new Error('Forbidden: no deactivated session'); + + try { + await auth.api.signOut({ headers: await headers() }); + } catch (error) { + console.error('signOutDeactivatedSession: signOut failed', error); + return { error: 'Could not sign out. Please try again.' }; + } + + revalidatePath('/', 'layout'); +} diff --git a/tests/stubs/auth-server.ts b/tests/stubs/auth-server.ts index 992d3921..c25ee73e 100644 --- a/tests/stubs/auth-server.ts +++ b/tests/stubs/auth-server.ts @@ -17,6 +17,12 @@ export async function getOptionalUser(): Promise { return caller; } +// No live-session concept in this stub — actAs(null) models "signed out", +// never "deactivated session", so this is always null. +export async function getDeactivatedSessionUser(): Promise { + return null; +} + export async function getIsBypass(): Promise { return false; } From 43a616e1efbbd9e86db1106b682c94edc772694a Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:12:00 -0400 Subject: [PATCH 02/47] #387 add admin-only page to reactivate deactivated accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deactivation was previously one-way, with no way to restore access. Adds Users → Deactivated accounts, listing soft-deleted rows with a reactivate action scoped by the existing authorization helpers, and points createUser's duplicate-email error at it. Co-Authored-By: Claude Sonnet 4.6 --- .../(auth)/users/deactivated/loading.tsx | 44 ++++ app/(main)/(auth)/users/deactivated/page.tsx | 29 +++ app/(main)/(auth)/users/page.tsx | 10 +- .../features/deactivated-users-table.tsx | 229 ++++++++++++++++++ lib/types.ts | 12 + prisma/actions/users.ts | 38 ++- prisma/data/users.ts | 23 +- 7 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 app/(main)/(auth)/users/deactivated/loading.tsx create mode 100644 app/(main)/(auth)/users/deactivated/page.tsx create mode 100644 components/features/deactivated-users-table.tsx diff --git a/app/(main)/(auth)/users/deactivated/loading.tsx b/app/(main)/(auth)/users/deactivated/loading.tsx new file mode 100644 index 00000000..3507caf2 --- /dev/null +++ b/app/(main)/(auth)/users/deactivated/loading.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function DeactivatedUsersLoading() { + return ( +
+
+ + + +
+ + + +
+
+
+ + + + + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ + +
+ + + +
+ +
+
+ ))} +
+
+ ); +} diff --git a/app/(main)/(auth)/users/deactivated/page.tsx b/app/(main)/(auth)/users/deactivated/page.tsx new file mode 100644 index 00000000..95edb817 --- /dev/null +++ b/app/(main)/(auth)/users/deactivated/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; + +import { getDeactivatedUsersForAdmin } from '@/prisma/data/users'; + +import { requireAdminOr404 } from '@/lib/auth/guards'; + +import { DeactivatedUsersTable } from '@/components/features/deactivated-users-table'; +import { PageHeader } from '@/components/layouts/page-header'; + +export const metadata: Metadata = { title: 'Deactivated Accounts' }; + +export default async function DeactivatedUsersPage() { + await requireAdminOr404(); + + const users = await getDeactivatedUsersForAdmin(); + + return ( +
+ + + +
+ ); +} diff --git a/app/(main)/(auth)/users/page.tsx b/app/(main)/(auth)/users/page.tsx index 95628dad..83f1dd6d 100644 --- a/app/(main)/(auth)/users/page.tsx +++ b/app/(main)/(auth)/users/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import Link from 'next/link'; import { getUsersForAdmin } from '@/prisma/data/users'; @@ -21,7 +22,14 @@ export default async function UsersPage() { Create user} />} + actions={ + <> + + Create user} /> + + } /> diff --git a/components/features/deactivated-users-table.tsx b/components/features/deactivated-users-table.tsx new file mode 100644 index 00000000..3d16244f --- /dev/null +++ b/components/features/deactivated-users-table.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { UserRoundCheck } from 'lucide-react'; +import { toast } from 'sonner'; + +import { reactivateUser } from '@/prisma/actions/users'; + +import type { AdminDeactivatedUserListItem } from '@/lib/types'; +import { + type SortableColumn, + useSortableTable, +} from '@/lib/use-sortable-table'; +import { formatDate, formatTableCount } from '@/lib/utils'; + +import { SortableHeader } from '@/components/features/sortable-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface DeactivatedUsersTableProps { + users: AdminDeactivatedUserListItem[]; +} + +const COLUMNS: SortableColumn[] = [ + { key: 'user', accessor: (u) => u.name ?? u.email }, + { key: 'deactivated', accessor: (u) => u.deletedAt }, +]; + +// Snapshot at click time — the dialog keeps naming the right user through +// Radix's exit animation even if a revalidation drops the row mid-flight. +interface ReactivateTarget { + id: string; + displayName: string; +} + +export function DeactivatedUsersTable({ users }: DeactivatedUsersTableProps) { + const [query, setQuery] = useState(''); + const [reactivateTarget, setReactivateTarget] = + useState(null); + const [reactivateDialogOpen, setReactivateDialogOpen] = useState(false); + const [isReactivating, startReactivateTransition] = useTransition(); + + const q = query.trim().toLowerCase(); + const filtered = q + ? users.filter( + (u) => + (u.name ?? '').toLowerCase().includes(q) || + u.email.toLowerCase().includes(q), + ) + : users; + + const { sortedRows, sort, toggle, ariaSort } = useSortableTable( + filtered, + COLUMNS, + { defaultSort: { key: 'deactivated', direction: 'desc' } }, + ); + + function handleReactivateConfirm() { + if (!reactivateTarget) return; + const target = reactivateTarget; + startReactivateTransition(async () => { + try { + const result = await reactivateUser({ userId: target.id }); + if (result?.error) { + toast.error(result.error); + return; + } + toast.success('User reactivated.'); + setReactivateDialogOpen(false); + } catch { + toast.error('Something went wrong. Please try again.'); + } + }); + } + + if (users.length === 0) + return ( + + ); + + return ( + <> +
+
+
+ + setQuery(e.target.value)} + className="max-w-sm" + /> +
+ +

+ {formatTableCount({ + shown: filtered.length, + total: users.length, + noun: 'account', + isFiltered: !!q, + })} +

+
+ + + + + + toggle('user')} + /> + Roles + toggle('deactivated')} + /> + Deactivated by + Actions + + + + {sortedRows.length === 0 ? ( + + + No accounts match your search. + + + ) : ( + sortedRows.map((user) => { + const displayName = user.name ?? user.email; + return ( + + +
+ {displayName} + {user.name && ( + + {user.email} + + )} +
+
+ + {user.isAdmin ? ( + Admin + ) : ( + + )} + + + {user.deletedAt ? ( + formatDate(user.deletedAt) + ) : ( + + )} + + + {user.deletedBy ? ( + (user.deletedBy.name ?? user.deletedBy.email) + ) : ( + + )} + + + + +
+ ); + }) + )} +
+
+
+
+ + + + ); +} diff --git a/lib/types.ts b/lib/types.ts index 9c1f34ff..5b933a2f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -310,6 +310,18 @@ export type AdminUserListItem = Prisma.UserGetPayload<{ }; }>; +// Exposes other users' identities — admin-gated contexts only, never a non-admin client. +export type AdminDeactivatedUserListItem = Prisma.UserGetPayload<{ + select: { + id: true; + name: true; + email: true; + isAdmin: true; + deletedAt: true; + deletedBy: { select: { name: true; email: true } }; + }; +}>; + // Aggregate only — never exposes individual applicant identity. export type PositionApplicationStats = { positionId: string; diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index 43c192d3..3d8cf0b1 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -17,6 +17,8 @@ const toggleAdminSchema = z.object({ const deactivateSchema = z.object({ userId: z.string().min(1) }); +const reactivateSchema = z.object({ userId: z.string().min(1) }); + type ActionError = { error: string }; export async function toggleUserAdmin( @@ -78,6 +80,32 @@ export async function deactivateUser( revalidatePath('/users'); } +// Caller is active by definition (getCurrentUser resolves only live rows), so +// they can never appear in the deactivated list — no self-reactivation guard needed. +export async function reactivateUser( + input: unknown, +): Promise { + const admin = await requireAdmin(); + + const parsed = reactivateSchema.safeParse(input); + if (!parsed.success) return { error: 'Invalid input' }; + + const { userId } = parsed.data; + + // Scoping to deletedAt: { not: null } makes a double-submit a no-op + // instead of a spurious audit write. + const result = await prisma.user.updateMany({ + where: { id: userId, deletedAt: { not: null } }, + data: { deletedAt: null, deletedById: null, updatedById: admin.id }, + }); + + // Not reachable from the freshly-rendered deactivated list → unexpected → throw. + if (result.count === 0) throw new Error('User not found or already active'); + + revalidatePath('/users'); + revalidatePath('/users/deactivated'); +} + export async function createUser(input: unknown): Promise { const admin = await requireAdmin(); @@ -89,9 +117,15 @@ export async function createUser(input: unknown): Promise { // Racy — the P2002 catch below is what actually guarantees uniqueness. const existing = await prisma.user.findFirst({ where: { email }, - select: { id: true }, + select: { id: true, deletedAt: true }, }); - if (existing) return { error: 'A user with this email already exists.' }; + if (existing) + return existing.deletedAt + ? { + error: + 'That email belongs to a deactivated account. Reactivate it from Users → Deactivated accounts.', + } + : { error: 'A user with this email already exists.' }; // Verified in better-auth's sign-in/email-otp route: it matches this row // by exact email before ever inserting, so it's found here, never duplicated. diff --git a/prisma/data/users.ts b/prisma/data/users.ts index 724ba87e..fa0b266c 100644 --- a/prisma/data/users.ts +++ b/prisma/data/users.ts @@ -2,7 +2,10 @@ import 'server-only'; import { PUBLISHED_POSITION_WHERE } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; -import type { AdminUserListItem } from '@/lib/types'; +import type { + AdminDeactivatedUserListItem, + AdminUserListItem, +} from '@/lib/types'; // Exposes user identities — admin-gated callers only. export async function getUsersForAdmin(): Promise { @@ -34,3 +37,21 @@ export async function getUsersForAdmin(): Promise { orderBy: { createdAt: 'desc' }, }); } + +// Exposes other users' identities — admin-gated callers only. +export async function getDeactivatedUsersForAdmin(): Promise< + AdminDeactivatedUserListItem[] +> { + return prisma.user.findMany({ + where: { deletedAt: { not: null } }, + select: { + id: true, + name: true, + email: true, + isAdmin: true, + deletedAt: true, + deletedBy: { select: { name: true, email: true } }, + }, + orderBy: { deletedAt: 'desc' }, + }); +} From 3880d2b2c10466e13dd826281d22311fbafa4032 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:12:05 -0400 Subject: [PATCH 03/47] #387 add authorization tests for reactivateUser Co-Authored-By: Claude Sonnet 4.6 --- tests/db/authorization.test.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 47b11263..4da78366 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -29,6 +29,7 @@ import { import { createUser, deactivateUser, + reactivateUser, toggleUserAdmin, } from '@/prisma/actions/users'; import type { Application, Position, User } from '@/prisma/client'; @@ -560,6 +561,41 @@ describe('toggleUserAdmin / deactivateUser / createUser', () => { }); }); +describe('reactivateUser', () => { + it('rejects a non-admin caller', async () => { + const target = await createTestUser({ deletedAt: new Date() }); + actAs(applicant); + await expect(reactivateUser({ userId: target.id })).rejects.toThrow(); + }); + + it('clears the soft delete and records the reactivating admin', async () => { + const deactivator = await createTestUser({ isAdmin: true }); + const target = await createTestUser({ + deletedAt: new Date(), + deletedBy: { connect: { id: deactivator.id } }, + }); + + actAs(admin); + const result = await reactivateUser({ userId: target.id }); + expect(result).toBeUndefined(); + + const updated = await prisma.user.findUniqueOrThrow({ + where: { id: target.id }, + select: { deletedAt: true, deletedById: true, updatedById: true }, + }); + expect(updated.deletedAt).toBeNull(); + expect(updated.deletedById).toBeNull(); + expect(updated.updatedById).toBe(admin.id); + }); + + it('throws when reactivating an already-active user', async () => { + actAs(admin); + await expect(reactivateUser({ userId: applicant.id })).rejects.toThrow( + 'User not found or already active', + ); + }); +}); + describe('createGlobalQuestion / updateGlobalQuestion / deleteGlobalQuestion', () => { it('rejects a manager and an applicant', async () => { const input = { From ad3ccb9807377aa74227fc75d011ea57d879f72c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:20:00 -0400 Subject: [PATCH 04/47] #387 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- prisma/actions/users.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index 3d8cf0b1..ee493eb3 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -15,9 +15,7 @@ const toggleAdminSchema = z.object({ makeAdmin: z.boolean(), }); -const deactivateSchema = z.object({ userId: z.string().min(1) }); - -const reactivateSchema = z.object({ userId: z.string().min(1) }); +const userIdSchema = z.object({ userId: z.string().min(1) }); type ActionError = { error: string }; @@ -52,7 +50,7 @@ export async function deactivateUser( ): Promise { const user = await requireAdmin(); - const parsed = deactivateSchema.safeParse(input); + const parsed = userIdSchema.safeParse(input); if (!parsed.success) return { error: 'Invalid input' }; const { userId } = parsed.data; @@ -87,7 +85,7 @@ export async function reactivateUser( ): Promise { const admin = await requireAdmin(); - const parsed = reactivateSchema.safeParse(input); + const parsed = userIdSchema.safeParse(input); if (!parsed.success) return { error: 'Invalid input' }; const { userId } = parsed.data; From f8e96d6346e8c8d55a136b2b558e4c8959d0e4e7 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 10:21:01 -0400 Subject: [PATCH 05/47] #387 remove in-app deactivated-user insight and reactivation #387 was rescoped: reactivation stays a database operation, so this drops the admin-only deactivated-accounts page/table, the reactivateUser action and its query, the entry point link, and the now-unused type. Copy that referenced the removed page is updated to point at contacting an administrator instead. Extends the authorization test suite to assert deactivated users are excluded from admin-facing queries. Co-Authored-By: Claude Sonnet 4.6 --- .../(auth)/users/deactivated/loading.tsx | 44 ---- app/(main)/(auth)/users/deactivated/page.tsx | 29 --- app/(main)/(auth)/users/page.tsx | 10 +- .../features/deactivated-users-table.tsx | 229 ------------------ lib/types.ts | 12 - prisma/actions/users.ts | 28 +-- prisma/data/users.ts | 23 +- tests/db/authorization.test.ts | 36 +-- 8 files changed, 13 insertions(+), 398 deletions(-) delete mode 100644 app/(main)/(auth)/users/deactivated/loading.tsx delete mode 100644 app/(main)/(auth)/users/deactivated/page.tsx delete mode 100644 components/features/deactivated-users-table.tsx diff --git a/app/(main)/(auth)/users/deactivated/loading.tsx b/app/(main)/(auth)/users/deactivated/loading.tsx deleted file mode 100644 index 3507caf2..00000000 --- a/app/(main)/(auth)/users/deactivated/loading.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export default function DeactivatedUsersLoading() { - return ( -
-
- - - -
- - - -
-
-
- - - - - -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - -
- - - -
- -
-
- ))} -
-
- ); -} diff --git a/app/(main)/(auth)/users/deactivated/page.tsx b/app/(main)/(auth)/users/deactivated/page.tsx deleted file mode 100644 index 95edb817..00000000 --- a/app/(main)/(auth)/users/deactivated/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { Metadata } from 'next'; - -import { getDeactivatedUsersForAdmin } from '@/prisma/data/users'; - -import { requireAdminOr404 } from '@/lib/auth/guards'; - -import { DeactivatedUsersTable } from '@/components/features/deactivated-users-table'; -import { PageHeader } from '@/components/layouts/page-header'; - -export const metadata: Metadata = { title: 'Deactivated Accounts' }; - -export default async function DeactivatedUsersPage() { - await requireAdminOr404(); - - const users = await getDeactivatedUsersForAdmin(); - - return ( -
- - - -
- ); -} diff --git a/app/(main)/(auth)/users/page.tsx b/app/(main)/(auth)/users/page.tsx index 83f1dd6d..95628dad 100644 --- a/app/(main)/(auth)/users/page.tsx +++ b/app/(main)/(auth)/users/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import Link from 'next/link'; import { getUsersForAdmin } from '@/prisma/data/users'; @@ -22,14 +21,7 @@ export default async function UsersPage() { - - Create user} /> - - } + actions={Create user} />} /> diff --git a/components/features/deactivated-users-table.tsx b/components/features/deactivated-users-table.tsx deleted file mode 100644 index 3d16244f..00000000 --- a/components/features/deactivated-users-table.tsx +++ /dev/null @@ -1,229 +0,0 @@ -'use client'; - -import { useState, useTransition } from 'react'; - -import { UserRoundCheck } from 'lucide-react'; -import { toast } from 'sonner'; - -import { reactivateUser } from '@/prisma/actions/users'; - -import type { AdminDeactivatedUserListItem } from '@/lib/types'; -import { - type SortableColumn, - useSortableTable, -} from '@/lib/use-sortable-table'; -import { formatDate, formatTableCount } from '@/lib/utils'; - -import { SortableHeader } from '@/components/features/sortable-header'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { EmptyState } from '@/components/ui/empty-state'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; - -interface DeactivatedUsersTableProps { - users: AdminDeactivatedUserListItem[]; -} - -const COLUMNS: SortableColumn[] = [ - { key: 'user', accessor: (u) => u.name ?? u.email }, - { key: 'deactivated', accessor: (u) => u.deletedAt }, -]; - -// Snapshot at click time — the dialog keeps naming the right user through -// Radix's exit animation even if a revalidation drops the row mid-flight. -interface ReactivateTarget { - id: string; - displayName: string; -} - -export function DeactivatedUsersTable({ users }: DeactivatedUsersTableProps) { - const [query, setQuery] = useState(''); - const [reactivateTarget, setReactivateTarget] = - useState(null); - const [reactivateDialogOpen, setReactivateDialogOpen] = useState(false); - const [isReactivating, startReactivateTransition] = useTransition(); - - const q = query.trim().toLowerCase(); - const filtered = q - ? users.filter( - (u) => - (u.name ?? '').toLowerCase().includes(q) || - u.email.toLowerCase().includes(q), - ) - : users; - - const { sortedRows, sort, toggle, ariaSort } = useSortableTable( - filtered, - COLUMNS, - { defaultSort: { key: 'deactivated', direction: 'desc' } }, - ); - - function handleReactivateConfirm() { - if (!reactivateTarget) return; - const target = reactivateTarget; - startReactivateTransition(async () => { - try { - const result = await reactivateUser({ userId: target.id }); - if (result?.error) { - toast.error(result.error); - return; - } - toast.success('User reactivated.'); - setReactivateDialogOpen(false); - } catch { - toast.error('Something went wrong. Please try again.'); - } - }); - } - - if (users.length === 0) - return ( - - ); - - return ( - <> -
-
-
- - setQuery(e.target.value)} - className="max-w-sm" - /> -
- -

- {formatTableCount({ - shown: filtered.length, - total: users.length, - noun: 'account', - isFiltered: !!q, - })} -

-
- - - - - - toggle('user')} - /> - Roles - toggle('deactivated')} - /> - Deactivated by - Actions - - - - {sortedRows.length === 0 ? ( - - - No accounts match your search. - - - ) : ( - sortedRows.map((user) => { - const displayName = user.name ?? user.email; - return ( - - -
- {displayName} - {user.name && ( - - {user.email} - - )} -
-
- - {user.isAdmin ? ( - Admin - ) : ( - - )} - - - {user.deletedAt ? ( - formatDate(user.deletedAt) - ) : ( - - )} - - - {user.deletedBy ? ( - (user.deletedBy.name ?? user.deletedBy.email) - ) : ( - - )} - - - - -
- ); - }) - )} -
-
-
-
- - - - ); -} diff --git a/lib/types.ts b/lib/types.ts index 5b933a2f..9c1f34ff 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -310,18 +310,6 @@ export type AdminUserListItem = Prisma.UserGetPayload<{ }; }>; -// Exposes other users' identities — admin-gated contexts only, never a non-admin client. -export type AdminDeactivatedUserListItem = Prisma.UserGetPayload<{ - select: { - id: true; - name: true; - email: true; - isAdmin: true; - deletedAt: true; - deletedBy: { select: { name: true; email: true } }; - }; -}>; - // Aggregate only — never exposes individual applicant identity. export type PositionApplicationStats = { positionId: string; diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index ee493eb3..e908bffd 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -78,32 +78,6 @@ export async function deactivateUser( revalidatePath('/users'); } -// Caller is active by definition (getCurrentUser resolves only live rows), so -// they can never appear in the deactivated list — no self-reactivation guard needed. -export async function reactivateUser( - input: unknown, -): Promise { - const admin = await requireAdmin(); - - const parsed = userIdSchema.safeParse(input); - if (!parsed.success) return { error: 'Invalid input' }; - - const { userId } = parsed.data; - - // Scoping to deletedAt: { not: null } makes a double-submit a no-op - // instead of a spurious audit write. - const result = await prisma.user.updateMany({ - where: { id: userId, deletedAt: { not: null } }, - data: { deletedAt: null, deletedById: null, updatedById: admin.id }, - }); - - // Not reachable from the freshly-rendered deactivated list → unexpected → throw. - if (result.count === 0) throw new Error('User not found or already active'); - - revalidatePath('/users'); - revalidatePath('/users/deactivated'); -} - export async function createUser(input: unknown): Promise { const admin = await requireAdmin(); @@ -121,7 +95,7 @@ export async function createUser(input: unknown): Promise { return existing.deletedAt ? { error: - 'That email belongs to a deactivated account. Reactivate it from Users → Deactivated accounts.', + 'That email belongs to a deactivated account. Contact an administrator to reactivate it.', } : { error: 'A user with this email already exists.' }; diff --git a/prisma/data/users.ts b/prisma/data/users.ts index fa0b266c..724ba87e 100644 --- a/prisma/data/users.ts +++ b/prisma/data/users.ts @@ -2,10 +2,7 @@ import 'server-only'; import { PUBLISHED_POSITION_WHERE } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; -import type { - AdminDeactivatedUserListItem, - AdminUserListItem, -} from '@/lib/types'; +import type { AdminUserListItem } from '@/lib/types'; // Exposes user identities — admin-gated callers only. export async function getUsersForAdmin(): Promise { @@ -37,21 +34,3 @@ export async function getUsersForAdmin(): Promise { orderBy: { createdAt: 'desc' }, }); } - -// Exposes other users' identities — admin-gated callers only. -export async function getDeactivatedUsersForAdmin(): Promise< - AdminDeactivatedUserListItem[] -> { - return prisma.user.findMany({ - where: { deletedAt: { not: null } }, - select: { - id: true, - name: true, - email: true, - isAdmin: true, - deletedAt: true, - deletedBy: { select: { name: true, email: true } }, - }, - orderBy: { deletedAt: 'desc' }, - }); -} diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 4da78366..a0d091e7 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -29,7 +29,6 @@ import { import { createUser, deactivateUser, - reactivateUser, toggleUserAdmin, } from '@/prisma/actions/users'; import type { Application, Position, User } from '@/prisma/client'; @@ -44,6 +43,7 @@ import { } from '@/prisma/data/applications'; import { checkPositionAccess, isManager } from '@/prisma/data/managers'; import { getManagedPositions } from '@/prisma/data/positions'; +import { getUsersForAdmin } from '@/prisma/data/users'; import { requireAdmin, @@ -561,37 +561,21 @@ describe('toggleUserAdmin / deactivateUser / createUser', () => { }); }); -describe('reactivateUser', () => { - it('rejects a non-admin caller', async () => { - const target = await createTestUser({ deletedAt: new Date() }); - actAs(applicant); - await expect(reactivateUser({ userId: target.id })).rejects.toThrow(); - }); - - it('clears the soft delete and records the reactivating admin', async () => { - const deactivator = await createTestUser({ isAdmin: true }); +describe('deactivated users excluded from admin queries', () => { + it('never appears in getUsersForAdmin or searchUsers', async () => { const target = await createTestUser({ + name: 'Deactivated Target', deletedAt: new Date(), - deletedBy: { connect: { id: deactivator.id } }, }); - actAs(admin); - const result = await reactivateUser({ userId: target.id }); - expect(result).toBeUndefined(); - - const updated = await prisma.user.findUniqueOrThrow({ - where: { id: target.id }, - select: { deletedAt: true, deletedById: true, updatedById: true }, - }); - expect(updated.deletedAt).toBeNull(); - expect(updated.deletedById).toBeNull(); - expect(updated.updatedById).toBe(admin.id); - }); + const adminList = (await getUsersForAdmin()).map((u) => u.id); + expect(adminList).not.toContain(target.id); - it('throws when reactivating an already-active user', async () => { actAs(admin); - await expect(reactivateUser({ userId: applicant.id })).rejects.toThrow( - 'User not found or already active', + const searchResult = await searchUsers({ query: target.email }); + if (isError(searchResult)) throw new Error('expected a result array'); + expect(searchResult.some((row) => row.primaryEmail === target.email)).toBe( + false, ); }); }); From 3e5a5b7cb4cf9400d3b78be1e884a893e4fafe15 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 15:40:20 -0400 Subject: [PATCH 06/47] #387 address review feedback Points admin-facing deactivation copy at the real remediation (database change, contact engineering) instead of "an administrator," trims an over-length comment, and fixes deactivated-screen text hierarchy. Co-Authored-By: Claude Sonnet 4.6 --- app/login/deactivated/page.tsx | 2 +- components/features/users-table.tsx | 4 ++-- lib/auth/server.ts | 3 +-- prisma/actions/users.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/login/deactivated/page.tsx b/app/login/deactivated/page.tsx index 2b991e2f..3ac9bfe0 100644 --- a/app/login/deactivated/page.tsx +++ b/app/login/deactivated/page.tsx @@ -26,7 +26,7 @@ export default async function AccountDeactivatedPage() {

Account deactivated

-

+

Your account has been deactivated, so you can't access Aplio right now.

diff --git a/components/features/users-table.tsx b/components/features/users-table.tsx index 47b91675..f1550b22 100644 --- a/components/features/users-table.tsx +++ b/components/features/users-table.tsx @@ -377,8 +377,8 @@ export function UsersTable({ users, currentUserId }: UsersTableProps) { description={ <> {deactivateTarget?.displayName} will be signed out immediately and - blocked from signing back in. This can't be undone from this - page. + blocked from signing back in. Reactivating requires a direct + database change — contact engineering. } confirmLabel="Deactivate" diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 8a54bc74..c24fee5e 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -58,8 +58,7 @@ export const getOptionalUser = cache( ); // A live session whose row has since been soft-deleted — distinct from "no -// session" so getCurrentUser can route it to the explanatory screen instead -// of a silent sign-in loop. +// session" so getCurrentUser avoids a silent sign-in loop. export const getDeactivatedSessionUser = cache( async function getDeactivatedSessionUser(): Promise { const resolution = await resolveUser(); diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index e908bffd..6046fd4d 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -95,7 +95,7 @@ export async function createUser(input: unknown): Promise { return existing.deletedAt ? { error: - 'That email belongs to a deactivated account. Contact an administrator to reactivate it.', + 'That email belongs to a deactivated account. Reactivating requires a direct database change — contact engineering.', } : { error: 'A user with this email already exists.' }; From c0d88ef9530d0aec5658eae6a26f527d25d97596 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:56:08 +0000 Subject: [PATCH 07/47] Bump @hookform/resolvers from 5.8.0 to 5.9.0 Bumps [@hookform/resolvers](https://github.com/react-hook-form/resolvers) from 5.8.0 to 5.9.0. - [Release notes](https://github.com/react-hook-form/resolvers/releases) - [Commits](https://github.com/react-hook-form/resolvers/compare/v5.8.0...v5.9.0) --- updated-dependencies: - dependency-name: "@hookform/resolvers" dependency-version: 5.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 70 ++++++----------------------------------------- package.json | 2 +- 2 files changed, 10 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f158303..1a3127fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.6.0", "dependencies": { "@better-auth/prisma-adapter": "^1.6.29", - "@hookform/resolvers": "^5.8.0", + "@hookform/resolvers": "^5.9.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@radix-ui/react-alert-dialog": "^1.1.17", @@ -457,7 +457,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -474,7 +473,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -491,7 +489,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -508,7 +505,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -525,7 +521,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -542,7 +537,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -559,7 +553,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -576,7 +569,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -593,7 +585,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -610,7 +601,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -627,7 +617,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -644,7 +633,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -661,7 +649,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -678,7 +665,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -695,7 +681,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -712,7 +697,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -729,7 +713,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -746,7 +729,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -763,7 +745,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -780,7 +761,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -797,7 +777,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -814,7 +793,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -831,7 +809,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -848,7 +825,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -865,7 +841,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -882,7 +857,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1112,9 +1086,9 @@ } }, "node_modules/@hookform/resolvers": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.8.0.tgz", - "integrity": "sha512-2m6GvRLmYYK1Fwt093lGMf7db9l/+8pNuAtwoNkpBntJT4xcA5lNthYGWKViOc3z2SuaPD0HjE81pyXmqc1JyA==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.9.0.tgz", + "integrity": "sha512-8sNo1IklGaONrsKzXjwZJNsPbccAXLXGX//G+VFEcQOviMtbvR8/fM6HWyA0qrjgpOYauwoodda53CQIWOZ7Ag==", "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" @@ -1136,7 +1110,7 @@ "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", - "joi": "^17.0.0", + "joi": "^17.0.0 || ^18.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", @@ -4523,7 +4497,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4540,7 +4513,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4557,7 +4529,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4574,7 +4545,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4591,7 +4561,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4608,7 +4577,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4625,7 +4593,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4642,7 +4609,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4659,7 +4625,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4676,7 +4641,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4693,7 +4657,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4710,7 +4673,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4727,7 +4689,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4744,7 +4705,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7556,7 +7516,7 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -8327,7 +8287,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8498,7 +8457,7 @@ "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -12484,7 +12443,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -13413,7 +13372,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -13980,7 +13939,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14001,7 +13959,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14022,7 +13979,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14043,7 +13999,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14064,7 +14019,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14085,7 +14039,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14106,7 +14059,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14127,7 +14079,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14148,7 +14099,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14169,7 +14119,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -14190,7 +14139,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 7611463e..1f6fcdc9 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@better-auth/prisma-adapter": "^1.6.29", - "@hookform/resolvers": "^5.8.0", + "@hookform/resolvers": "^5.9.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@radix-ui/react-alert-dialog": "^1.1.17", From 5d81747d6ce1ecd542a54771eb3d21847cb7e907 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 14:56:38 -0400 Subject: [PATCH 08/47] #443 add edit name dialog to the user dropdown Reuses the existing setUserName action and nameSchema; warns that a rename propagates to every application, submitted and decided alike. Co-Authored-By: Claude Sonnet 4.6 --- components/features/edit-name-dialog.tsx | 76 ++++++++++++++++++++++++ components/layouts/user-menu.tsx | 23 ++++++- 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 components/features/edit-name-dialog.tsx diff --git a/components/features/edit-name-dialog.tsx b/components/features/edit-name-dialog.tsx new file mode 100644 index 00000000..352cdbf3 --- /dev/null +++ b/components/features/edit-name-dialog.tsx @@ -0,0 +1,76 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { TriangleAlert } from 'lucide-react'; +import { toast } from 'sonner'; +import type { z } from 'zod/v4'; + +import { setUserName } from '@/prisma/actions/profile'; + +import { nameSchema } from '@/lib/constants'; + +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { FormDialog } from '@/components/ui/form-dialog'; +import { Input } from '@/components/ui/input'; +import { WarningCallout } from '@/components/ui/warning-callout'; + +type NameFormValues = z.infer; + +interface EditNameDialogProps { + currentName: string | null; + trigger: ReactNode; +} + +export function EditNameDialog({ currentName, trigger }: EditNameDialogProps) { + async function onSubmit(data: NameFormValues): Promise { + const result = await setUserName(data); + if (result?.error) { + toast.error(result.error); + return false; + } + toast.success('Name updated.'); + return true; + } + + return ( + + ( + + Full name + + + + + + )} + /> + + Changing your name updates it everywhere — including applications + you've already submitted, and ones that have already been reviewed + or decided. + + + ); +} diff --git a/components/layouts/user-menu.tsx b/components/layouts/user-menu.tsx index d93c2997..00a3dbbf 100644 --- a/components/layouts/user-menu.tsx +++ b/components/layouts/user-menu.tsx @@ -13,6 +13,7 @@ import { Sun, SunMoon, UserCircle, + UserPen, } from 'lucide-react'; import { toast } from 'sonner'; @@ -22,6 +23,7 @@ import { logoutBypassUser } from '@/prisma/services/dev-bypass'; import type { NavIdentity } from '@/lib/types'; import { isError } from '@/lib/utils'; +import { EditNameDialog } from '@/components/features/edit-name-dialog'; import { DropdownMenu, DropdownMenuContent, @@ -36,6 +38,8 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +const MENU_ITEM_TOUCH_TARGET = 'min-h-11 md:min-h-0'; + interface UserMenuProps { identity: NavIdentity; onNavigate?: () => void; @@ -113,7 +117,7 @@ export function UserMenu({ identity, onNavigate }: UserMenuProps) { )} - + + e.preventDefault()} + className={`cursor-pointer text-sm ${MENU_ITEM_TOUCH_TARGET}`} + > + + Edit name + + } + /> + - + Theme @@ -145,7 +162,7 @@ export function UserMenu({ identity, onNavigate }: UserMenuProps) { variant="destructive" disabled={pending} onSelect={handleLogout} - className="cursor-pointer text-sm" + className={`cursor-pointer text-sm ${MENU_ITEM_TOUCH_TARGET}`} > Log out From 242ba7268e683a27e9d3eda929789fd4e6b88dd3 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 14:56:44 -0400 Subject: [PATCH 09/47] #443 drop redundant auth client sync from the sign-up name step Better Auth's updateUser writes the same public.User row through the Prisma adapter, so it was a no-op round trip that only failed for bypass users. setUserName is the single write path now. Co-Authored-By: Claude Sonnet 4.6 --- components/features/name-field.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/components/features/name-field.tsx b/components/features/name-field.tsx index 99c4101b..6eb0f39e 100644 --- a/components/features/name-field.tsx +++ b/components/features/name-field.tsx @@ -11,7 +11,6 @@ import type { z } from 'zod/v4'; import { setUserName } from '@/prisma/actions/profile'; -import { authClient } from '@/lib/auth/client'; import { nameSchema } from '@/lib/constants'; import { Button } from '@/components/ui/button'; @@ -49,15 +48,7 @@ export function NameField({ defaultName, redirectTo }: NameFieldProps) { return; } - // Sync the Neon Auth account — client-only singleton, runs after the - // server action (which is the gate-clearing source of truth). - const authResult = await authClient.updateUser({ name: values.name }); - if (authResult.error) - toast.warning( - 'Name saved, but account sync failed. Reload if issues persist.', - ); - else toast.success('Name saved'); - + toast.success('Name saved'); router.replace(redirectTo); }); } From 72b1a904f5810ca4a9ba85ca174f32c926d4a23f Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:08:21 -0400 Subject: [PATCH 10/47] #443 snapshot applicant name on submission Editable profile names mean a display name can drift after a user applies. Freeze it on Application.applicantName at submit time, same pattern as GlobalApplicationAnswer.questionLabel, and prefer it over the live profile name on reviewer-facing read paths. Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/applications/[id]/page.tsx | 12 +- components/features/activity-feed.tsx | 2 +- components/features/applications-table.tsx | 10 +- components/features/recent-applications.tsx | 3 +- lib/types.ts | 2 + prisma/actions/applications.ts | 2 + prisma/data/applications.ts | 3 + .../migration.sql | 11 ++ prisma/schema.prisma | 16 ++- prisma/seed.ts | 3 + tests/db/applicant-name-snapshot.test.ts | 106 ++++++++++++++++++ 11 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20260819000000_add_applicant_name_to_application/migration.sql create mode 100644 tests/db/applicant-name-snapshot.test.ts diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index 6b8ea5cb..f497d4a2 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -29,7 +29,12 @@ export async function generateMetadata({ const user = await getCurrentUser(); const application = await getApplicationForReview(id, user); if (!application) return {}; - return { title: application.user.name ?? application.user.email }; + return { + title: + application.applicantName ?? + application.user.name ?? + application.user.email, + }; } export default async function ApplicationDetailPage({ @@ -42,7 +47,10 @@ export default async function ApplicationDetailPage({ if (!application) notFound(); - const applicantName = application.user.name ?? application.user.email; + const applicantName = + application.applicantName ?? + application.user.name ?? + application.user.email; return (
diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index a371970b..fb2af175 100644 --- a/components/features/activity-feed.tsx +++ b/components/features/activity-feed.tsx @@ -122,7 +122,7 @@ export async function ReviewerActivityFeed({ const applications = await getRecentApplications(reviewer, 10); const items: ActivityItem[] = applications.map((app) => { - const applicantLabel = app.user.name ?? app.user.email; + const applicantLabel = app.applicantName ?? app.user.name ?? app.user.email; const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status]; return { id: app.id, diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index 2e0a9133..fa109142 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -213,7 +213,8 @@ export function ApplicationsTable({ {applications.map((app) => { - const displayName = app.user.name ?? app.user.email; + const displayName = + app.applicantName ?? app.user.name ?? app.user.email; const isChecked = selectedIds.has(app.id); return ( {displayName} - {app.user.name && ( + {(app.applicantName ?? app.user.name) && ( {app.user.email} @@ -262,7 +263,8 @@ export function ApplicationsTable({ {/* Mobile stacked cards — shown only on mobile */}
{applications.map((app) => { - const displayName = app.user.name ?? app.user.email; + const displayName = + app.applicantName ?? app.user.name ?? app.user.email; const isChecked = selectedIds.has(app.id); return (
@@ -282,7 +284,7 @@ export function ApplicationsTable({
- {app.user.name && ( + {(app.applicantName ?? app.user.name) && ( {app.user.email} diff --git a/components/features/recent-applications.tsx b/components/features/recent-applications.tsx index d8ada93f..d6dfa10c 100644 --- a/components/features/recent-applications.tsx +++ b/components/features/recent-applications.tsx @@ -73,7 +73,8 @@ function ApplicationList({ return (
    {applications.map((app) => { - const applicantLabel = app.user.name ?? app.user.email; + const applicantLabel = + app.applicantName ?? app.user.name ?? app.user.email; return (
  • { + admin = await createTestUser({ isAdmin: true }); + openPosition = await createTestPosition(admin); +}); + +afterAll(async () => { + await cleanupFixtures(); +}); + +describe('Application.applicantName snapshot', () => { + it('is null on a draft and captured at submit time', async () => { + const applicant = await createTestUser({ name: 'Ada Lovelace' }); + const draft = await createTestApplication(applicant, openPosition, { + status: 'draft', + }); + expect(draft.applicantName).toBeNull(); + + actAs(applicant); + const result = await submitApplication(draft.id); + expect(result).toBeUndefined(); + + const submitted = await prisma.application.findUniqueOrThrow({ + where: { id: draft.id }, + select: { applicantName: true }, + }); + expect(submitted.applicantName).toBe('Ada Lovelace'); + }); + + it('keeps the submitted snapshot after the applicant renames their profile', async () => { + const applicant = await createTestUser({ name: 'Grace Hopper' }); + const draft = await createTestApplication(applicant, openPosition, { + status: 'draft', + }); + + actAs(applicant); + await submitApplication(draft.id); + + const result = await setUserName({ name: 'Grace M. Hopper' }); + expect(result).toBeUndefined(); + + const renamedUser = await prisma.user.findUniqueOrThrow({ + where: { id: applicant.id }, + select: { name: true }, + }); + expect(renamedUser.name).toBe('Grace M. Hopper'); + + const application = await prisma.application.findUniqueOrThrow({ + where: { id: draft.id }, + select: { applicantName: true }, + }); + expect(application.applicantName).toBe('Grace Hopper'); + }); + + it('surfaces the frozen name (not the live profile name) on the reviewer detail read path', async () => { + const applicant = await createTestUser({ name: 'Katherine Johnson' }); + const draft = await createTestApplication(applicant, openPosition, { + status: 'draft', + }); + + actAs(applicant); + await submitApplication(draft.id); + await setUserName({ name: 'K. Johnson' }); + + const forReview = await getApplicationForReview(draft.id, admin); + expect(forReview?.applicantName).toBe('Katherine Johnson'); + expect(forReview?.user.name).toBe('K. Johnson'); + }); + + it('surfaces the frozen name on the reviewer list read path', async () => { + const applicant = await createTestUser({ name: 'Margaret Hamilton' }); + const draft = await createTestApplication(applicant, openPosition, { + status: 'draft', + }); + + actAs(applicant); + await submitApplication(draft.id); + await setUserName({ name: 'Peggy Hamilton' }); + + const list = await getApplications(admin, {}); + const row = list.find((a) => a.id === draft.id); + expect(row?.applicantName).toBe('Margaret Hamilton'); + expect(row?.user.name).toBe('Peggy Hamilton'); + }); +}); From 964ed26e81bae7a7b4793879c7be7b7b62b39b34 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:40:57 -0400 Subject: [PATCH 11/47] #443 fix rename warning copy and trim schema comment Ticket #443 now documents the applicant-name snapshot as intended behavior, so update the dialog's warning to match: renaming applies going forward only, and already-submitted applications keep the name on file at the time. Also trims an over-length schema comment. Co-Authored-By: Claude Sonnet 4.6 --- components/features/edit-name-dialog.tsx | 5 ++--- prisma/schema.prisma | 4 +--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/components/features/edit-name-dialog.tsx b/components/features/edit-name-dialog.tsx index 352cdbf3..de0a8fc7 100644 --- a/components/features/edit-name-dialog.tsx +++ b/components/features/edit-name-dialog.tsx @@ -67,9 +67,8 @@ export function EditNameDialog({ currentName, trigger }: EditNameDialogProps) { )} /> - Changing your name updates it everywhere — including applications - you've already submitted, and ones that have already been reviewed - or decided. + This updates your name going forward. Applications you've already + submitted keep the name you used at the time. ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd8292d0..e5f2763a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -192,9 +192,7 @@ model Application { positionId String status ApplicationStatus @default(applied) submittedAt DateTime @default(now()) - // Snapshot of User.name as of submission, so a later profile rename can't - // rewrite what the applicant was called when they applied. Same idea as - // GlobalApplicationAnswer.questionLabel. Null until submitApplication runs. + // Frozen User.name at submission; null until then. applicantName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 172a3c1d8a258bc973c97fa5b3db356e770fc6ac Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 12:42:36 -0400 Subject: [PATCH 12/47] #443 show current name beside a renamed applicant Application.applicantName freezes the name at submission time; show it inline as "Snapshot (Current)" wherever the applicant name appears so a rename since submission stays visible without losing the snapshot. Co-Authored-By: Claude Sonnet 5 --- app/(main)/(auth)/applications/[id]/page.tsx | 8 ++++- components/features/applications-table.tsx | 36 ++++++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index f497d4a2..eff5cd3c 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -51,12 +51,18 @@ export default async function ApplicationDetailPage({ application.applicantName ?? application.user.name ?? application.user.email; + const renamedTo = + application.applicantName && + application.user.name && + application.applicantName !== application.user.name + ? application.user.name + : null; return (

    diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index fa109142..a0580a3c 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -215,6 +215,12 @@ export function ApplicationsTable({ {applications.map((app) => { const displayName = app.applicantName ?? app.user.name ?? app.user.email; + const renamedTo = + app.applicantName && + app.user.name && + app.applicantName !== app.user.name + ? app.user.name + : null; const isChecked = selectedIds.has(app.id); return ( {displayName} + {renamedTo && ( + + ({renamedTo}) + + )} {(app.applicantName ?? app.user.name) && ( {app.user.email} @@ -265,6 +276,12 @@ export function ApplicationsTable({ {applications.map((app) => { const displayName = app.applicantName ?? app.user.name ?? app.user.email; + const renamedTo = + app.applicantName && + app.user.name && + app.applicantName !== app.user.name + ? app.user.name + : null; const isChecked = selectedIds.has(app.id); return (

    @@ -276,12 +293,19 @@ export function ApplicationsTable({ />
    - - {displayName} - +
    + + {displayName} + + {renamedTo && ( + + ({renamedTo}) + + )} +
    {(app.applicantName ?? app.user.name) && ( From 86a08fb56aa890544e862c281af1e313ef8af41e Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 12:57:53 -0400 Subject: [PATCH 13/47] #443 dedupe renamed-applicant check and show it everywhere Extracts the applicantName/user.name comparison into lib/utils.ts's getRenamedTo, reused in the application detail page and both table layouts, and adds the same "(current name)" cue to the activity feed and recent-applications widget so it's consistent across every surface that renders a rename-eligible applicant name. Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/applications/[id]/page.tsx | 8 ++------ components/features/activity-feed.tsx | 4 +++- components/features/applications-table.tsx | 15 +++------------ components/features/recent-applications.tsx | 7 +++++++ lib/utils.ts | 12 ++++++++++++ 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index eff5cd3c..fc8a2720 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'; import { getApplicationForReview } from '@/prisma/data/applications'; import { getCurrentUser } from '@/lib/auth/server'; +import { getRenamedTo } from '@/lib/utils'; import { ApplicationAnswersList } from '@/components/features/application-answers-list'; import { ApplicationStatusControl } from '@/components/features/application-status-control'; @@ -51,12 +52,7 @@ export default async function ApplicationDetailPage({ application.applicantName ?? application.user.name ?? application.user.email; - const renamedTo = - application.applicantName && - application.user.name && - application.applicantName !== application.user.name - ? application.user.name - : null; + const renamedTo = getRenamedTo(application); return (
    diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index fb2af175..1e08758c 100644 --- a/components/features/activity-feed.tsx +++ b/components/features/activity-feed.tsx @@ -11,6 +11,7 @@ import { STATUS_BADGE_VARIANT_TO_DOT, } from '@/lib/constants'; import { type ActivityItem, type Reviewer } from '@/lib/types'; +import { getRenamedTo } from '@/lib/utils'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { LocalTime } from '@/components/ui/local-time'; @@ -123,11 +124,12 @@ export async function ReviewerActivityFeed({ const items: ActivityItem[] = applications.map((app) => { const applicantLabel = app.applicantName ?? app.user.name ?? app.user.email; + const renamedTo = getRenamedTo(app); const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status]; return { id: app.id, statusVariant: variant, - sentence: `${applicantLabel} applied for ${app.position.title}`, + sentence: `${applicantLabel}${renamedTo ? ` (${renamedTo})` : ''} applied for ${app.position.title}`, timestamp: app.submittedAt, }; }); diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index a0580a3c..35b9cf5e 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -14,6 +14,7 @@ import type { ApplicationSortDirection, ApplicationSortField, } from '@/lib/types'; +import { getRenamedTo } from '@/lib/utils'; import { ApplicationsBulkBar } from '@/components/features/applications-bulk-bar'; // Server-side sort; its param format stays decoupled from useSortableTable's. @@ -215,12 +216,7 @@ export function ApplicationsTable({ {applications.map((app) => { const displayName = app.applicantName ?? app.user.name ?? app.user.email; - const renamedTo = - app.applicantName && - app.user.name && - app.applicantName !== app.user.name - ? app.user.name - : null; + const renamedTo = getRenamedTo(app); const isChecked = selectedIds.has(app.id); return ( { const displayName = app.applicantName ?? app.user.name ?? app.user.email; - const renamedTo = - app.applicantName && - app.user.name && - app.applicantName !== app.user.name - ? app.user.name - : null; + const renamedTo = getRenamedTo(app); const isChecked = selectedIds.has(app.id); return (
    diff --git a/components/features/recent-applications.tsx b/components/features/recent-applications.tsx index d6dfa10c..508b1b8d 100644 --- a/components/features/recent-applications.tsx +++ b/components/features/recent-applications.tsx @@ -5,6 +5,7 @@ import { ArrowRight, Inbox } from 'lucide-react'; import { getRecentApplications } from '@/prisma/data/applications'; import { type AdminApplicationListItem, type Reviewer } from '@/lib/types'; +import { getRenamedTo } from '@/lib/utils'; import { ApplicationStatusBadge } from '@/components/features/status-badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -75,6 +76,7 @@ function ApplicationList({ {applications.map((app) => { const applicantLabel = app.applicantName ?? app.user.name ?? app.user.email; + const renamedTo = getRenamedTo(app); return (
  • {applicantLabel} + {renamedTo && ( + + ({renamedTo}) + + )} {app.position.title} diff --git a/lib/utils.ts b/lib/utils.ts index f4171790..4208ac88 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -21,6 +21,18 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** The live name, when it differs from the frozen `applicantName` on the application. */ +export function getRenamedTo(app: { + applicantName: string | null; + user: { name: string | null }; +}): string | null { + return app.applicantName && + app.user.name && + app.applicantName !== app.user.name + ? app.user.name + : null; +} + export type ErrorType = { error: string }; export type ResponseType = T | ErrorType; From a476f071f03c974868610c0e7160f1b6ab333c5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:09:51 +0000 Subject: [PATCH 14/47] Bump @prisma/adapter-pg from 7.8.0 to 7.9.1 Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.8.0 to 7.9.1. - [Release notes](https://github.com/prisma/prisma/releases) - [Commits](https://github.com/prisma/prisma/commits/7.9.1/packages/adapter-pg) --- updated-dependencies: - dependency-name: "@prisma/adapter-pg" dependency-version: 7.9.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 30 +++++++++++++++--------------- package.json | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1a3127fe..e66dc02c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@better-auth/prisma-adapter": "^1.6.29", "@hookform/resolvers": "^5.9.0", - "@prisma/adapter-pg": "^7.8.0", + "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.8.0", "@radix-ui/react-alert-dialog": "^1.1.17", "@radix-ui/react-checkbox": "^1.3.5", @@ -2034,12 +2034,12 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", - "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz", + "integrity": "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.8.0", + "@prisma/driver-adapter-utils": "7.9.1", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" @@ -2088,6 +2088,12 @@ "empathic": "2.0.0" } }, + "node_modules/@prisma/debug": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.1.tgz", + "integrity": "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==", + "license": "Apache-2.0" + }, "node_modules/@prisma/dev": { "version": "0.24.3", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", @@ -2115,20 +2121,14 @@ } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", - "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.1.tgz", + "integrity": "sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.9.1" } }, - "node_modules/@prisma/driver-adapter-utils/node_modules/@prisma/debug": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", - "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", - "license": "Apache-2.0" - }, "node_modules/@prisma/engines": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", diff --git a/package.json b/package.json index 1f6fcdc9..545cbb6a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@better-auth/prisma-adapter": "^1.6.29", "@hookform/resolvers": "^5.9.0", - "@prisma/adapter-pg": "^7.8.0", + "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.8.0", "@radix-ui/react-alert-dialog": "^1.1.17", "@radix-ui/react-checkbox": "^1.3.5", From 913674076be38f4467fcc9ecc88a0ee63ff1efec Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:29 -0400 Subject: [PATCH 15/47] #364 add the application status transition graph to constants Single source of truth for the six-status pipeline direction, so the server guard and the rendered quick actions can't drift apart. Co-Authored-By: Claude Sonnet 4.6 --- .../features/application-status-control.tsx | 117 ------------------ lib/constants.ts | 91 ++++++++++++++ 2 files changed, 91 insertions(+), 117 deletions(-) delete mode 100644 components/features/application-status-control.tsx diff --git a/components/features/application-status-control.tsx b/components/features/application-status-control.tsx deleted file mode 100644 index 368aadc3..00000000 --- a/components/features/application-status-control.tsx +++ /dev/null @@ -1,117 +0,0 @@ -'use client'; - -import { useId, useTransition } from 'react'; - -import { Loader2 } from 'lucide-react'; -import { toast } from 'sonner'; - -import { updateApplicationStatus } from '@/prisma/actions/applications'; -import { type $Enums } from '@/prisma/client'; - -import { - APPLICATION_STATUS_LABELS, - NON_REVIEWABLE_APPLICATION_STATUS_NOTES, - REVIEWER_APPLICATION_STATUS_OPTIONS, - isNonReviewableApplicationStatus, -} from '@/lib/constants'; -import { cn } from '@/lib/utils'; - -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -interface ApplicationStatusControlProps { - applicationId: string; - currentStatus: $Enums.ApplicationStatus; - labelText?: string; - // Visually hides the label — kept in the a11y tree and still focuses the select on click. - hideLabel?: boolean; -} - -export function ApplicationStatusControl({ - applicationId, - currentStatus, - labelText = 'Status', - hideLabel, -}: ApplicationStatusControlProps) { - const [isPending, startTransition] = useTransition(); - const fieldId = useId(); - const noteId = useId(); - - // Reviewer-selectable options — 'draft' is already excluded from this constant. - const options = REVIEWER_APPLICATION_STATUS_OPTIONS; - - // Derived from the shared constant (not a hand-written check) so this can - // never drift from the action's own non-reviewable guard. - const isReadOnly = isNonReviewableApplicationStatus(currentStatus); - - function handleValueChange(value: string) { - startTransition(async () => { - try { - const result = await updateApplicationStatus({ - applicationId, - status: value, - }); - if (result && 'error' in result) { - toast.error(result.error); - } else { - toast.success('Status updated'); - } - } catch { - toast.error('Something went wrong. Please try again.'); - } - }); - } - - return ( -
    - -
    - {isPending && ( - - )} - -
    - {isReadOnly && ( -

    - {NON_REVIEWABLE_APPLICATION_STATUS_NOTES[currentStatus]} -

    - )} -
    - ); -} diff --git a/lib/constants.ts b/lib/constants.ts index 66f754e5..ed8abd58 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -357,6 +357,97 @@ export const REVIEWER_APPLICATION_STATUSES = [ export const REVIEWER_APPLICATION_STATUS_OPTIONS = APPLICATION_STATUS_OPTIONS.filter((o) => o.value !== 'draft'); +// Single source of truth for both the server guard and the rendered quick +// actions. Array order is display order — the first `forward` entry is the +// primary button. `draft`/`withdrawn` have no reviewer-initiated moves. +export const APPLICATION_STATUS_TRANSITIONS = { + draft: { forward: [], back: [] }, + applied: { forward: ['reached_out', 'reviewing'], back: [] }, + reached_out: { + forward: ['interview_scheduled', 'reviewing'], + back: ['applied'], + }, + interview_scheduled: { + forward: ['reviewing', 'accepted'], + back: ['reached_out'], + }, + reviewing: { + forward: ['interview_scheduled', 'accepted'], + back: ['reached_out'], + }, + accepted: { forward: [], back: ['reviewing', 'interview_scheduled'] }, + rejected: { forward: [], back: ['reviewing', 'interview_scheduled'] }, + withdrawn: { forward: [], back: [] }, +} as const satisfies Record< + $Enums.ApplicationStatus, + { + forward: readonly $Enums.ApplicationStatus[]; + back: readonly $Enums.ApplicationStatus[]; + } +>; + +// Same members as UNRESOLVED_APPLICATION_STATUSES, but a different meaning — +// 'rejected' is reachable from every one of these. +export const REJECTABLE_APPLICATION_STATUSES = [ + 'applied', + 'reached_out', + 'interview_scheduled', + 'reviewing', +] as const satisfies $Enums.ApplicationStatus[]; + +export function getAllowedApplicationStatusTransitions( + from: $Enums.ApplicationStatus, +): $Enums.ApplicationStatus[] { + const { forward, back } = APPLICATION_STATUS_TRANSITIONS[from]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(from); + return [ + ...forward, + ...(isRejectable ? (['rejected'] as const) : []), + ...back, + ]; +} + +export function isAllowedApplicationStatusTransition( + from: $Enums.ApplicationStatus, + to: $Enums.ApplicationStatus, +): boolean { + return getAllowedApplicationStatusTransitions(from).includes(to); +} + +// Inverts the graph rather than hand-listing sources, so the two can't drift. +export function getApplicationStatusSources( + to: $Enums.ApplicationStatus, +): $Enums.ApplicationStatus[] { + return ( + Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] + ).filter((from) => isAllowedApplicationStatusTransition(from, to)); +} + +// Imperative copy for forward/decision quick-action buttons. 'applied' is a +// back-target only — a reviewer never moves an application forward into it. +export const APPLICATION_STATUS_ACTION_LABELS: Record< + (typeof REVIEWER_APPLICATION_STATUSES)[number], + string +> = { + applied: 'Move to applied', + reached_out: 'Mark reached out', + reviewing: 'Move to reviewing', + interview_scheduled: 'Schedule interview', + accepted: 'Accept', + rejected: 'Reject', +}; + +// The one line explaining why the panel has no forward actions from here. +export const TERMINAL_DECISION_STATUS_NOTES: Record< + 'accepted' | 'rejected', + string +> = { + accepted: 'Accepted. The applicant can no longer withdraw this application.', + rejected: 'Rejected. The applicant can no longer withdraw this application.', +}; + // States a reviewer may not act *on*, unlike REVIEWER_APPLICATION_STATUSES (may set *to*). export const NON_REVIEWABLE_APPLICATION_STATUSES = [ 'draft', From 32e5866b3e92d6e98663efb024284d146eaa01d9 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:43 -0400 Subject: [PATCH 16/47] #364 enforce the status transition graph in application actions updateApplicationStatus and updateApplicationStatuses now scope their writes to the graph's legal source states, so an illegal move fails even from a forged request or a stale tab. Bulk failures and concurrent-change misses now return a user-facing error naming the target instead of throwing. Co-Authored-By: Claude Sonnet 4.6 --- prisma/actions/applications.ts | 38 +++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index d0a0b240..f1546dbd 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -19,12 +19,15 @@ import { ANSWER_LONG_MAX_LENGTH, ANSWER_MAX_VALUES, APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_STATUS_LABELS, NON_REVIEWABLE_APPLICATION_STATUSES, PUBLISHED_POSITION_WHERE, REVIEWER_APPLICATION_STATUSES, SHORT_ANSWER_FORMAT_ERROR_MESSAGES, TERMINAL_DECISION_STATUSES, getAnswerValueError, + getApplicationStatusSources, + isAllowedApplicationStatusTransition, matchesShortAnswerFormat, } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; @@ -500,17 +503,33 @@ export async function updateApplicationStatus( const application = await prisma.application.findFirst({ where, - select: { id: true }, + select: { id: true, status: true }, }); // IDOR-style miss, unreachable from the UI — throw, don't return. if (!application) throw new Error('Application not found or not authorized'); - await prisma.application.update({ - where: { id: applicationId }, + if (!isAllowedApplicationStatusTransition(application.status, status)) + return { + error: `This application is now ${APPLICATION_STATUS_LABELS[application.status]}, so that move is no longer available. Refresh to see the current options.`, + }; + + // Narrower than the `where` above: sources never include draft/withdrawn, + // which that `notIn` already excludes. + const updateResult = await prisma.application.updateMany({ + where: { + id: applicationId, + status: { in: getApplicationStatusSources(status) }, + }, data: { status, updatedById: user.id }, }); + if (updateResult.count === 0) + return { + error: + 'This application just changed. Refresh to see its current status.', + }; + revalidatePath(`/applications/${applicationId}`); revalidatePath('/applications'); } @@ -533,18 +552,19 @@ export async function updateApplicationStatuses( const applicationIds = Array.from(new Set(parsed.data.applicationIds)); const { status } = parsed.data; - // The scoped where silently excludes forged and out-of-scope ids. + // The scoped where silently excludes forged, out-of-scope, and + // graph-illegal ids — ineligible rows are skipped, not a batch failure. const where = user.isAdmin ? { id: { in: applicationIds }, deletedAt: null, - status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + status: { in: getApplicationStatusSources(status) }, position: PUBLISHED_POSITION_WHERE, } : { id: { in: applicationIds }, deletedAt: null, - status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + status: { in: getApplicationStatusSources(status) }, // Merge, don't overwrite — see prisma/data/applications.ts#buildBaseWhere. position: { ...PUBLISHED_POSITION_WHERE, @@ -557,8 +577,10 @@ export async function updateApplicationStatuses( data: { status, updatedById: user.id }, }); - // IDOR-style miss, unreachable from the UI — throw, don't return. - if (result.count === 0) throw new Error('No applications were updated'); + if (result.count === 0) + return { + error: `None of the selected applications can move to ${APPLICATION_STATUS_LABELS[status]}.`, + }; revalidatePath('/applications'); // Wildcard segment: a bulk update has no individual positionIds to hand. From 9f4d9af261408e458e767d65bae1be32cb41f417 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:50 -0400 Subject: [PATCH 17/47] #364 replace the status dropdown with graph-driven quick actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApplicationStatusActions renders only the legal next moves for the current status — primary/secondary forward buttons, a destructive Reject, and explicit move-back controls — as a roomy panel on the review detail page and a constrained menu in the per-position table. Accept and Reject route through the shared confirm dialog. Co-Authored-By: Claude Sonnet 4.6 --- app/(main)/(auth)/applications/[id]/page.tsx | 5 +- .../features/application-status-actions.tsx | 266 ++++++++++++++++++ 2 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 components/features/application-status-actions.tsx diff --git a/app/(main)/(auth)/applications/[id]/page.tsx b/app/(main)/(auth)/applications/[id]/page.tsx index fc8a2720..434c29eb 100644 --- a/app/(main)/(auth)/applications/[id]/page.tsx +++ b/app/(main)/(auth)/applications/[id]/page.tsx @@ -7,7 +7,7 @@ import { getCurrentUser } from '@/lib/auth/server'; import { getRenamedTo } from '@/lib/utils'; import { ApplicationAnswersList } from '@/components/features/application-answers-list'; -import { ApplicationStatusControl } from '@/components/features/application-status-control'; +import { ApplicationStatusActions } from '@/components/features/application-status-actions'; import { ApplicationStatusBadge } from '@/components/features/status-badge'; import { PageHeader } from '@/components/layouts/page-header'; import { @@ -111,9 +111,10 @@ export default async function ApplicationDetailPage({ - diff --git a/components/features/application-status-actions.tsx b/components/features/application-status-actions.tsx new file mode 100644 index 00000000..82a738b5 --- /dev/null +++ b/components/features/application-status-actions.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { Loader2, MoreHorizontal } from 'lucide-react'; +import { toast } from 'sonner'; + +import { updateApplicationStatus } from '@/prisma/actions/applications'; +import type { $Enums } from '@/prisma/client'; + +import { + APPLICATION_STATUS_ACTION_LABELS, + APPLICATION_STATUS_LABELS, + APPLICATION_STATUS_TRANSITIONS, + NON_REVIEWABLE_APPLICATION_STATUS_NOTES, + REJECTABLE_APPLICATION_STATUSES, + TERMINAL_DECISION_STATUS_NOTES, + isNonReviewableApplicationStatus, +} from '@/lib/constants'; + +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface ApplicationStatusActionsProps { + applicationId: string; + currentStatus: $Enums.ApplicationStatus; + applicantName?: string; + compact?: boolean; +} + +const CONFIRM_COPY = { + accepted: { + title: (name: string) => `Accept ${name}?`, + description: + "They'll see Accepted on their application and can no longer withdraw it. You can move this back later.", + confirmLabel: 'Accept', + pendingLabel: 'Accepting…', + }, + rejected: { + title: (name: string) => `Reject ${name}?`, + description: + "They'll see Rejected on their application and can no longer withdraw it. You can move this back later.", + confirmLabel: 'Reject', + pendingLabel: 'Rejecting…', + }, +} as const; + +export function ApplicationStatusActions({ + applicationId, + currentStatus, + applicantName, + compact, +}: ApplicationStatusActionsProps) { + const [isPending, startTransition] = useTransition(); + const [pendingTarget, setPendingTarget] = + useState<$Enums.ApplicationStatus | null>(null); + const [menuOpen, setMenuOpen] = useState(false); + const [confirmTarget, setConfirmTarget] = useState<'accepted' | 'rejected'>( + 'accepted', + ); + const [confirmOpen, setConfirmOpen] = useState(false); + + const displayName = applicantName ?? 'this application'; + + function performMove( + target: $Enums.ApplicationStatus, + onSuccess?: () => void, + ) { + setPendingTarget(target); + startTransition(async () => { + try { + const result = await updateApplicationStatus({ + applicationId, + status: target, + }); + if (result && 'error' in result) { + toast.error(result.error); + return; + } + toast.success(`Moved to ${APPLICATION_STATUS_LABELS[target]}`); + onSuccess?.(); + } catch { + toast.error('Something went wrong. Please try again.'); + } finally { + setPendingTarget(null); + } + }); + } + + function openConfirm(target: 'accepted' | 'rejected') { + setConfirmTarget(target); + setConfirmOpen(true); + } + + function handleForwardSelect(target: $Enums.ApplicationStatus) { + if (target === 'accepted') openConfirm('accepted'); + else performMove(target); + } + + if (isNonReviewableApplicationStatus(currentStatus)) { + if (compact) return null; + return ( +

    + {NON_REVIEWABLE_APPLICATION_STATUS_NOTES[currentStatus]} +

    + ); + } + + const isTerminalDecision = + currentStatus === 'accepted' || currentStatus === 'rejected'; + const { forward, back } = APPLICATION_STATUS_TRANSITIONS[currentStatus]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(currentStatus); + + const confirmCopy = CONFIRM_COPY[confirmTarget]; + + const confirmDialog = ( + performMove(confirmTarget, () => setConfirmOpen(false))} + /> + ); + + if (compact) { + return ( + <> + + + + + + {forward.map((target) => ( + { + e.preventDefault(); + setMenuOpen(false); + handleForwardSelect(target); + }} + > + {APPLICATION_STATUS_ACTION_LABELS[target]} + + ))} + {isRejectable && ( + <> + + { + e.preventDefault(); + setMenuOpen(false); + openConfirm('rejected'); + }} + > + {APPLICATION_STATUS_ACTION_LABELS.rejected} + + + )} + {back.length > 0 && ( + <> + + Move back + {back.map((target) => ( + { + e.preventDefault(); + setMenuOpen(false); + performMove(target); + }} + > + Move back to {APPLICATION_STATUS_LABELS[target]} + + ))} + + )} + + + {confirmDialog} + + ); + } + + return ( +
    + {isTerminalDecision && ( +

    + {TERMINAL_DECISION_STATUS_NOTES[currentStatus]} +

    + )} + {!isTerminalDecision && ( +
    + {forward.map((target, i) => ( + + ))} + {isRejectable && ( + + )} +
    + )} + {back.length > 0 && ( +
    + {back.map((target) => ( + + ))} +
    + )} + {confirmDialog} +
    + ); +} From 6e800b2baa8d18a14bd58b39b2d26ab99b1c00e7 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:09:57 -0400 Subject: [PATCH 18/47] #364 add tests for the application status transition graph Unit tests assert the graph invariants (totality, no self-loops, rejected reachability, source/target inversion). DB tests replace the old "every target reachable from applied" loop with the full 6x6 source x target matrix and add mixed-selection bulk cases. Co-Authored-By: Claude Sonnet 4.6 --- tests/db/application-transitions.test.ts | 143 +++++++++++++++------ tests/db/authorization.test.ts | 15 ++- tests/unit/application-transitions.test.ts | 72 +++++++++++ 3 files changed, 185 insertions(+), 45 deletions(-) create mode 100644 tests/unit/application-transitions.test.ts diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index cd7e5663..c2bb6a12 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -15,14 +15,18 @@ import { deleteDraftApplication, submitApplication, updateApplicationStatus, + updateApplicationStatuses, withdrawApplication, } from '@/prisma/actions/applications'; import type { $Enums, GlobalQuestion, Position, User } from '@/prisma/client'; import { APPLICANT_EDITABLE_APPLICATION_STATUSES, + APPLICATION_STATUS_LABELS, APPLICATION_STATUS_VALUES, + NON_REVIEWABLE_APPLICATION_STATUSES, REVIEWER_APPLICATION_STATUSES, + isAllowedApplicationStatusTransition, } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; import { isError } from '@/lib/utils'; @@ -151,47 +155,58 @@ describe('deleteDraftApplication', () => { }); describe('updateApplicationStatus', () => { - for (const target of REVIEWER_APPLICATION_STATUSES) { - it(`allows updating an in-scope application into ${target}`, async () => { - const applicant = await createTestUser(); - const managingAdmin = admin; - const application = await createTestApplication(applicant, openPosition, { - status: 'applied', - }); - - actAs(managingAdmin); - const result = await updateApplicationStatus({ - applicationId: application.id, - status: target, - }); - expect(result).toBeUndefined(); - - const updated = await prisma.application.findUniqueOrThrow({ - where: { id: application.id }, - select: { status: true }, + // Full source x target matrix, driven off isAllowedApplicationStatusTransition + // so this suite can't drift from the graph it's asserting against. + for (const from of ALL_STATUSES) { + const isNonReviewable = ( + NON_REVIEWABLE_APPLICATION_STATUSES as readonly string[] + ).includes(from); + + for (const to of REVIEWER_APPLICATION_STATUSES) { + const isLegal = + !isNonReviewable && isAllowedApplicationStatusTransition(from, to); + + it(`${isNonReviewable ? 'throws' : isLegal ? 'allows' : 'blocks'} ${from} -> ${to}`, async () => { + const applicant = await createTestUser(); + const application = await createTestApplication( + applicant, + openPosition, + { status: from }, + ); + + actAs(admin); + + if (isNonReviewable) { + await expect( + updateApplicationStatus({ + applicationId: application.id, + status: to, + }), + ).rejects.toThrow('Application not found or not authorized'); + return; + } + + const result = await updateApplicationStatus({ + applicationId: application.id, + status: to, + }); + + if (isLegal) { + expect(result).toBeUndefined(); + const updated = await prisma.application.findUniqueOrThrow({ + where: { id: application.id }, + select: { status: true }, + }); + expect(updated.status).toBe(to); + } else { + expect(result).toEqual({ + error: `This application is now ${APPLICATION_STATUS_LABELS[from]}, so that move is no longer available. Refresh to see the current options.`, + }); + } }); - expect(updated.status).toBe(target); - }); + } } - it.each(['draft', 'withdrawn'] as const)( - 'throws when the current status is %s', - async (currentStatus) => { - const applicant = await createTestUser(); - const application = await createTestApplication(applicant, openPosition, { - status: currentStatus, - }); - - actAs(admin); - await expect( - updateApplicationStatus({ - applicationId: application.id, - status: 'applied', - }), - ).rejects.toThrow('Application not found or not authorized'); - }, - ); - it('returns a zod error when the target status is draft', async () => { const applicant = await createTestUser(); const application = await createTestApplication(applicant, openPosition, { @@ -207,6 +222,58 @@ describe('updateApplicationStatus', () => { }); }); +describe('updateApplicationStatuses bulk mixed selections', () => { + it('updates only the legal-source rows in a mixed selection and skips the rest', async () => { + const appliedApplicant = await createTestUser(); + const appliedApp = await createTestApplication( + appliedApplicant, + openPosition, + { status: 'applied' }, + ); + const acceptedApplicant = await createTestUser(); + const acceptedApp = await createTestApplication( + acceptedApplicant, + openPosition, + { status: 'accepted' }, + ); + + actAs(admin); + const result = await updateApplicationStatuses({ + applicationIds: [appliedApp.id, acceptedApp.id], + status: 'reviewing', + }); + expect(result).toEqual({ updated: 1, skipped: 1 }); + + const updatedApplied = await prisma.application.findUniqueOrThrow({ + where: { id: appliedApp.id }, + select: { status: true }, + }); + expect(updatedApplied.status).toBe('reviewing'); + + const untouchedAccepted = await prisma.application.findUniqueOrThrow({ + where: { id: acceptedApp.id }, + select: { status: true }, + }); + expect(untouchedAccepted.status).toBe('accepted'); + }); + + it('returns an error naming the target when no selected row can legally move there', async () => { + const applicant = await createTestUser(); + const application = await createTestApplication(applicant, openPosition, { + status: 'applied', + }); + + actAs(admin); + const result = await updateApplicationStatuses({ + applicationIds: [application.id], + status: 'accepted', + }); + expect(result).toEqual({ + error: 'None of the selected applications can move to Accepted.', + }); + }); +}); + describe('precedence', () => { it('an already-submitted application on a soft-deleted position returns the status error', async () => { const applicant = await createTestUser(); diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index a0d091e7..372f99c1 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -418,14 +418,15 @@ describe('updateApplicationStatuses', () => { expect(untouched.status).toBe('applied'); }); - it('throws when every id is out of scope', async () => { + it('returns an error when every id is out of scope', async () => { actAs(managerA); - await expect( - updateApplicationStatuses({ - applicationIds: [applicationB1.id], - status: 'reviewing', - }), - ).rejects.toThrow('No applications were updated'); + const result = await updateApplicationStatuses({ + applicationIds: [applicationB1.id], + status: 'reviewing', + }); + expect(result).toEqual({ + error: 'None of the selected applications can move to Reviewing.', + }); }); it('skips a withdrawn row while updating the rest', async () => { diff --git a/tests/unit/application-transitions.test.ts b/tests/unit/application-transitions.test.ts new file mode 100644 index 00000000..fe4b07e1 --- /dev/null +++ b/tests/unit/application-transitions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import type { $Enums } from '@/prisma/client'; + +import { + APPLICATION_STATUS_TRANSITIONS, + APPLICATION_STATUS_VALUES, + REJECTABLE_APPLICATION_STATUSES, + getAllowedApplicationStatusTransitions, + getApplicationStatusSources, + isAllowedApplicationStatusTransition, +} from '@/lib/constants'; + +const ALL_STATUSES: $Enums.ApplicationStatus[] = [ + ...APPLICATION_STATUS_VALUES, + 'withdrawn', +]; + +describe('APPLICATION_STATUS_TRANSITIONS', () => { + it('is total over every ApplicationStatus', () => { + for (const status of ALL_STATUSES) + expect(APPLICATION_STATUS_TRANSITIONS[status]).toBeDefined(); + }); + + it('never lists a state as its own forward or back target', () => { + for (const status of ALL_STATUSES) { + const { forward, back } = APPLICATION_STATUS_TRANSITIONS[status]; + expect(forward).not.toContain(status); + expect(back).not.toContain(status); + } + }); + + it('gives draft and withdrawn no moves at all', () => { + for (const status of ['draft', 'withdrawn'] as const) + expect(getAllowedApplicationStatusTransitions(status)).toEqual([]); + }); + + it('never allows a move into draft or withdrawn', () => { + for (const from of ALL_STATUSES) + for (const to of getAllowedApplicationStatusTransitions(from)) { + expect(to).not.toBe('draft'); + expect(to).not.toBe('withdrawn'); + } + }); +}); + +describe('rejected reachability', () => { + it('allows rejected from exactly REJECTABLE_APPLICATION_STATUSES', () => { + for (const status of ALL_STATUSES) { + const canReject = isAllowedApplicationStatusTransition( + status, + 'rejected', + ); + const expected = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(status); + expect(canReject).toBe(expected); + } + }); +}); + +describe('getApplicationStatusSources', () => { + it('is the exact inverse of getAllowedApplicationStatusTransitions', () => { + for (const to of ALL_STATUSES) { + const sources = getApplicationStatusSources(to); + const expected = ALL_STATUSES.filter((from) => + isAllowedApplicationStatusTransition(from, to), + ); + expect(new Set(sources)).toEqual(new Set(expected)); + } + }); +}); From a3200a2fda4e57e40f959cd743540196b949f71c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 18:57:20 -0400 Subject: [PATCH 19/47] #364 address review feedback Scope bulk status updates to forward-only sources so a batch move can't silently walk an already-decided row backward, matching the PR's stated bulk behavior. Also closes the confirm dialog on a failed move and trims two comments to the one/two-line rule. Co-Authored-By: Claude Sonnet 4.6 --- .../features/application-status-actions.tsx | 4 +-- lib/constants.ts | 25 ++++++++++++++++--- prisma/actions/applications.ts | 9 ++++--- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/components/features/application-status-actions.tsx b/components/features/application-status-actions.tsx index 82a738b5..780924b8 100644 --- a/components/features/application-status-actions.tsx +++ b/components/features/application-status-actions.tsx @@ -72,7 +72,7 @@ export function ApplicationStatusActions({ function performMove( target: $Enums.ApplicationStatus, - onSuccess?: () => void, + onSettled?: () => void, ) { setPendingTarget(target); startTransition(async () => { @@ -86,11 +86,11 @@ export function ApplicationStatusActions({ return; } toast.success(`Moved to ${APPLICATION_STATUS_LABELS[target]}`); - onSuccess?.(); } catch { toast.error('Something went wrong. Please try again.'); } finally { setPendingTarget(null); + onSettled?.(); } }); } diff --git a/lib/constants.ts b/lib/constants.ts index ed8abd58..7623ee77 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -357,9 +357,8 @@ export const REVIEWER_APPLICATION_STATUSES = [ export const REVIEWER_APPLICATION_STATUS_OPTIONS = APPLICATION_STATUS_OPTIONS.filter((o) => o.value !== 'draft'); -// Single source of truth for both the server guard and the rendered quick -// actions. Array order is display order — the first `forward` entry is the -// primary button. `draft`/`withdrawn` have no reviewer-initiated moves. +// Single source of truth for the server guard and the rendered quick actions. +// Array order is display order; `draft`/`withdrawn` have no reviewer moves. export const APPLICATION_STATUS_TRANSITIONS = { draft: { forward: [], back: [] }, applied: { forward: ['reached_out', 'reviewing'], back: [] }, @@ -425,6 +424,25 @@ export function getApplicationStatusSources( ).filter((from) => isAllowedApplicationStatusTransition(from, to)); } +// Bulk targets exclude move-back sources: a batch moving several applications +// toward a target should skip a row already past it, not walk it backward. +export function getApplicationStatusForwardSources( + to: $Enums.ApplicationStatus, +): $Enums.ApplicationStatus[] { + return ( + Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] + ).filter((from) => { + const { forward } = APPLICATION_STATUS_TRANSITIONS[from]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(from); + return ( + (forward as readonly $Enums.ApplicationStatus[]).includes(to) || + (isRejectable && to === 'rejected') + ); + }); +} + // Imperative copy for forward/decision quick-action buttons. 'applied' is a // back-target only — a reviewer never moves an application forward into it. export const APPLICATION_STATUS_ACTION_LABELS: Record< @@ -439,7 +457,6 @@ export const APPLICATION_STATUS_ACTION_LABELS: Record< rejected: 'Reject', }; -// The one line explaining why the panel has no forward actions from here. export const TERMINAL_DECISION_STATUS_NOTES: Record< 'accepted' | 'rejected', string diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index f1546dbd..d861bd41 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -26,6 +26,7 @@ import { SHORT_ANSWER_FORMAT_ERROR_MESSAGES, TERMINAL_DECISION_STATUSES, getAnswerValueError, + getApplicationStatusForwardSources, getApplicationStatusSources, isAllowedApplicationStatusTransition, matchesShortAnswerFormat, @@ -552,19 +553,19 @@ export async function updateApplicationStatuses( const applicationIds = Array.from(new Set(parsed.data.applicationIds)); const { status } = parsed.data; - // The scoped where silently excludes forged, out-of-scope, and - // graph-illegal ids — ineligible rows are skipped, not a batch failure. + // Forward-only sources: a bulk move-back would silently walk an already- + // decided row backward, so it's skipped like any other ineligible id. const where = user.isAdmin ? { id: { in: applicationIds }, deletedAt: null, - status: { in: getApplicationStatusSources(status) }, + status: { in: getApplicationStatusForwardSources(status) }, position: PUBLISHED_POSITION_WHERE, } : { id: { in: applicationIds }, deletedAt: null, - status: { in: getApplicationStatusSources(status) }, + status: { in: getApplicationStatusForwardSources(status) }, // Merge, don't overwrite — see prisma/data/applications.ts#buildBaseWhere. position: { ...PUBLISHED_POSITION_WHERE, From 75af2009480bc698db0100a60e05e6d605a80870 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:06:45 -0400 Subject: [PATCH 20/47] #364 fix bulk moves to back-only targets like applied getApplicationStatusForwardSources returned [] for 'applied' since no state's forward list ever contains it, making bulk moves to Applied always no-op. Fall back to back-sources when a target has no forward source at all. Co-Authored-By: Claude Sonnet 4.6 --- lib/constants.ts | 15 ++++++++++- tests/db/application-transitions.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/constants.ts b/lib/constants.ts index 7623ee77..218a6192 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -426,10 +426,13 @@ export function getApplicationStatusSources( // Bulk targets exclude move-back sources: a batch moving several applications // toward a target should skip a row already past it, not walk it backward. +// A target with no forward source at all (e.g. 'applied') is back-only, so +// there's no "already past it" row to protect — fall back to its back +// sources rather than making that target permanently unreachable in bulk. export function getApplicationStatusForwardSources( to: $Enums.ApplicationStatus, ): $Enums.ApplicationStatus[] { - return ( + const forwardSources = ( Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] ).filter((from) => { const { forward } = APPLICATION_STATUS_TRANSITIONS[from]; @@ -441,6 +444,16 @@ export function getApplicationStatusForwardSources( (isRejectable && to === 'rejected') ); }); + if (forwardSources.length > 0) return forwardSources; + + return ( + Object.keys(APPLICATION_STATUS_TRANSITIONS) as $Enums.ApplicationStatus[] + ).filter((from) => + ( + APPLICATION_STATUS_TRANSITIONS[from] + .back as readonly $Enums.ApplicationStatus[] + ).includes(to), + ); } // Imperative copy for forward/decision quick-action buttons. 'applied' is a diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index c2bb6a12..442d5e88 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -272,6 +272,40 @@ describe('updateApplicationStatuses bulk mixed selections', () => { error: 'None of the selected applications can move to Accepted.', }); }); + + it('bulk-moves a back-only target (applied) since it has no forward source', async () => { + const reachedOutApplicant = await createTestUser(); + const reachedOutApp = await createTestApplication( + reachedOutApplicant, + openPosition, + { status: 'reached_out' }, + ); + const reviewingApplicant = await createTestUser(); + const reviewingApp = await createTestApplication( + reviewingApplicant, + openPosition, + { status: 'reviewing' }, + ); + + actAs(admin); + const result = await updateApplicationStatuses({ + applicationIds: [reachedOutApp.id, reviewingApp.id], + status: 'applied', + }); + expect(result).toEqual({ updated: 1, skipped: 1 }); + + const updatedReachedOut = await prisma.application.findUniqueOrThrow({ + where: { id: reachedOutApp.id }, + select: { status: true }, + }); + expect(updatedReachedOut.status).toBe('applied'); + + const untouchedReviewing = await prisma.application.findUniqueOrThrow({ + where: { id: reviewingApp.id }, + select: { status: true }, + }); + expect(untouchedReviewing.status).toBe('reviewing'); + }); }); describe('precedence', () => { From 71f8cd9a1b097da76ebef2d8406f39d5d4bf0f1d Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 10:33:31 -0400 Subject: [PATCH 21/47] #364 address review feedback Adds direct unit invariants for getApplicationStatusForwardSources' back-only fallback, per R3-L1. Co-Authored-By: Claude Sonnet 4.6 --- tests/unit/application-transitions.test.ts | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/unit/application-transitions.test.ts b/tests/unit/application-transitions.test.ts index fe4b07e1..743d7246 100644 --- a/tests/unit/application-transitions.test.ts +++ b/tests/unit/application-transitions.test.ts @@ -7,6 +7,7 @@ import { APPLICATION_STATUS_VALUES, REJECTABLE_APPLICATION_STATUSES, getAllowedApplicationStatusTransitions, + getApplicationStatusForwardSources, getApplicationStatusSources, isAllowedApplicationStatusTransition, } from '@/lib/constants'; @@ -70,3 +71,52 @@ describe('getApplicationStatusSources', () => { } }); }); + +describe('getApplicationStatusForwardSources', () => { + const forwardOrRejectSources = ( + to: $Enums.ApplicationStatus, + ): $Enums.ApplicationStatus[] => + ALL_STATUSES.filter((from) => { + const { forward } = APPLICATION_STATUS_TRANSITIONS[from]; + const isRejectable = ( + REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] + ).includes(from); + return ( + (forward as readonly $Enums.ApplicationStatus[]).includes(to) || + (isRejectable && to === 'rejected') + ); + }); + + const backSources = ( + to: $Enums.ApplicationStatus, + ): $Enums.ApplicationStatus[] => + ALL_STATUSES.filter((from) => + ( + APPLICATION_STATUS_TRANSITIONS[from] + .back as readonly $Enums.ApplicationStatus[] + ).includes(to), + ); + + it('prefers forward/reject sources whenever any exist', () => { + for (const to of ALL_STATUSES) { + const forward = forwardOrRejectSources(to); + if (forward.length === 0) continue; + expect(new Set(getApplicationStatusForwardSources(to))).toEqual( + new Set(forward), + ); + } + }); + + it('falls back to back-sources when a target has no forward source', () => { + const backOnlyTargets = ALL_STATUSES.filter( + (to) => forwardOrRejectSources(to).length === 0, + ); + // Guards against this loop vacuously passing if the graph ever changes. + expect(backOnlyTargets).not.toHaveLength(0); + + for (const to of backOnlyTargets) + expect(new Set(getApplicationStatusForwardSources(to))).toEqual( + new Set(backSources(to)), + ); + }); +}); From ce630b9ddafef1c83683460f9efdcfc59d65126e Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:02:32 -0400 Subject: [PATCH 22/47] #364 apply human-directed status label, quick-action, and bulk copy fixes Reword interview_scheduled to a status label, wire the compact quick- actions menu into the live applications table, and name reachable source statuses in bulk-move error/toast copy. Co-Authored-By: Claude Sonnet 4.6 --- components/features/applications-bulk-bar.tsx | 9 +++++--- components/features/applications-table.tsx | 21 +++++++++++++++-- lib/constants.ts | 2 +- lib/utils.ts | 7 ++++++ prisma/actions/applications.ts | 9 ++++++-- tests/db/application-transitions.test.ts | 3 ++- tests/unit/application-transitions.test.ts | 9 ++++++++ tests/unit/utils.test.ts | 23 +++++++++++++++++++ 8 files changed, 74 insertions(+), 9 deletions(-) diff --git a/components/features/applications-bulk-bar.tsx b/components/features/applications-bulk-bar.tsx index 5b77b982..9e1ac6d6 100644 --- a/components/features/applications-bulk-bar.tsx +++ b/components/features/applications-bulk-bar.tsx @@ -11,10 +11,11 @@ import type { $Enums } from '@/prisma/client'; import { APPLICATION_STATUS_LABELS, REVIEWER_APPLICATION_STATUS_OPTIONS, + getApplicationStatusSources, isNonReviewableApplicationStatus, } from '@/lib/constants'; import type { ApplicationListRow } from '@/lib/types'; -import { summarizeBulkStatusChange } from '@/lib/utils'; +import { formatAlternatives, summarizeBulkStatusChange } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -73,11 +74,13 @@ export function ApplicationsBulkBar({ if (skipped === 0) { toast.success(`Updated ${updated} ${applicationNoun(updated)}`); } else { + const sourceLabels = getApplicationStatusSources(status).map( + (source) => APPLICATION_STATUS_LABELS[source], + ); toast.success( `Updated ${updated} of ${updated + skipped} applications`, { - description: - "Withdrawn applications can't be updated. The skipped rows are still selected.", + description: `${statusLabel} is only reachable from ${formatAlternatives(sourceLabels)}. The skipped rows are still selected.`, }, ); } diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index 35b9cf5e..d91ce9a8 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -16,6 +16,7 @@ import type { } from '@/lib/types'; import { getRenamedTo } from '@/lib/utils'; +import { ApplicationStatusActions } from '@/components/features/application-status-actions'; import { ApplicationsBulkBar } from '@/components/features/applications-bulk-bar'; // Server-side sort; its param format stays decoupled from useSortableTable's. import { SortableHeader } from '@/components/features/sortable-header'; @@ -255,7 +256,15 @@ export function ApplicationsTable({ {app.position.title} - +
    + + +
    @@ -297,7 +306,15 @@ export function ApplicationsTable({
    )}
- +
+ + +
{(app.applicantName ?? app.user.name) && ( diff --git a/lib/constants.ts b/lib/constants.ts index 218a6192..da7afa0f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -465,7 +465,7 @@ export const APPLICATION_STATUS_ACTION_LABELS: Record< applied: 'Move to applied', reached_out: 'Mark reached out', reviewing: 'Move to reviewing', - interview_scheduled: 'Schedule interview', + interview_scheduled: 'Interview scheduled', accepted: 'Accept', rejected: 'Reject', }; diff --git a/lib/utils.ts b/lib/utils.ts index 4208ac88..628248b9 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -67,6 +67,13 @@ export function toStringArray(v: unknown): string[] { return []; } +/** Alternatives ("reachable from X or Y"), not a conjunction — "or" throughout. */ +export function formatAlternatives(labels: string[]): string { + if (labels.length <= 1) return labels.join(''); + if (labels.length === 2) return labels.join(' or '); + return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}`; +} + /** Joins truthy ids for `aria-describedby`; `undefined` when none apply. */ export function composeDescribedBy( ...ids: Array diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index d861bd41..f21cf63b 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -35,6 +35,7 @@ import { prisma } from '@/lib/prisma'; import { type AnswerQuestion } from '@/lib/types'; import { type ResponseType, + formatAlternatives, isAcceptingApplications, isAnswered, isError, @@ -578,10 +579,14 @@ export async function updateApplicationStatuses( data: { status, updatedById: user.id }, }); - if (result.count === 0) + if (result.count === 0) { + const sourceLabels = getApplicationStatusSources(status).map( + (source) => APPLICATION_STATUS_LABELS[source], + ); return { - error: `None of the selected applications can move to ${APPLICATION_STATUS_LABELS[status]}.`, + error: `None of the selected applications can move to ${APPLICATION_STATUS_LABELS[status]} — that's only reachable from ${formatAlternatives(sourceLabels)}.`, }; + } revalidatePath('/applications'); // Wildcard segment: a bulk update has no individual positionIds to hand. diff --git a/tests/db/application-transitions.test.ts b/tests/db/application-transitions.test.ts index 442d5e88..82761084 100644 --- a/tests/db/application-transitions.test.ts +++ b/tests/db/application-transitions.test.ts @@ -269,7 +269,8 @@ describe('updateApplicationStatuses bulk mixed selections', () => { status: 'accepted', }); expect(result).toEqual({ - error: 'None of the selected applications can move to Accepted.', + error: + "None of the selected applications can move to Accepted — that's only reachable from Interview scheduled or Reviewing.", }); }); diff --git a/tests/unit/application-transitions.test.ts b/tests/unit/application-transitions.test.ts index 743d7246..16a16cc6 100644 --- a/tests/unit/application-transitions.test.ts +++ b/tests/unit/application-transitions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { $Enums } from '@/prisma/client'; import { + APPLICATION_STATUS_ACTION_LABELS, APPLICATION_STATUS_TRANSITIONS, APPLICATION_STATUS_VALUES, REJECTABLE_APPLICATION_STATUSES, @@ -45,6 +46,14 @@ describe('APPLICATION_STATUS_TRANSITIONS', () => { }); }); +describe('APPLICATION_STATUS_ACTION_LABELS', () => { + it('describes interview_scheduled as a status, not an imperative action', () => { + expect(APPLICATION_STATUS_ACTION_LABELS.interview_scheduled).toBe( + 'Interview scheduled', + ); + }); +}); + describe('rejected reachability', () => { it('allows rejected from exactly REJECTABLE_APPLICATION_STATUSES', () => { for (const status of ALL_STATUSES) { diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 721ff2b7..93da9696 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { MANAGED_POSITIONS_WINDOW_DAYS } from '@/lib/constants'; import type { AnswerQuestion, PositionActivity } from '@/lib/types'; import { + formatAlternatives, formatTableCount, getPositionAvailability, isAnswered, @@ -399,6 +400,28 @@ describe('summarizeBulkStatusChange', () => { }); }); +describe('formatAlternatives', () => { + it('returns a single label as-is', () => { + expect(formatAlternatives(['Reviewing'])).toBe('Reviewing'); + }); + + it('joins two labels with "or"', () => { + expect(formatAlternatives(['Reached out', 'Reviewing'])).toBe( + 'Reached out or Reviewing', + ); + }); + + it('joins three or more labels with a comma list and a trailing "or"', () => { + expect(formatAlternatives(['Applied', 'Reached out', 'Reviewing'])).toBe( + 'Applied, Reached out, or Reviewing', + ); + }); + + it('returns an empty string for no labels', () => { + expect(formatAlternatives([])).toBe(''); + }); +}); + describe('isBypassAllowed', () => { afterEach(() => { delete process.env.VERCEL_ENV; From c4a5cc48d1515e3f595940b21dfc0734c750f562 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:40:51 -0400 Subject: [PATCH 23/47] #364 fix bulk skip messaging to use forward-only sources The bulk "unreachable target" copy computed sources with the full source set instead of the forward-only set that actually scopes the updateMany where-clause, so it could list back-only statuses as valid bulk targets. Match the message to the query and update the stale test assertion. Co-Authored-By: Claude Sonnet 4.6 --- components/features/applications-bulk-bar.tsx | 4 ++-- prisma/actions/applications.ts | 2 +- tests/db/authorization.test.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/components/features/applications-bulk-bar.tsx b/components/features/applications-bulk-bar.tsx index 9e1ac6d6..1c6cb73c 100644 --- a/components/features/applications-bulk-bar.tsx +++ b/components/features/applications-bulk-bar.tsx @@ -11,7 +11,7 @@ import type { $Enums } from '@/prisma/client'; import { APPLICATION_STATUS_LABELS, REVIEWER_APPLICATION_STATUS_OPTIONS, - getApplicationStatusSources, + getApplicationStatusForwardSources, isNonReviewableApplicationStatus, } from '@/lib/constants'; import type { ApplicationListRow } from '@/lib/types'; @@ -74,7 +74,7 @@ export function ApplicationsBulkBar({ if (skipped === 0) { toast.success(`Updated ${updated} ${applicationNoun(updated)}`); } else { - const sourceLabels = getApplicationStatusSources(status).map( + const sourceLabels = getApplicationStatusForwardSources(status).map( (source) => APPLICATION_STATUS_LABELS[source], ); toast.success( diff --git a/prisma/actions/applications.ts b/prisma/actions/applications.ts index f21cf63b..daa79bf7 100644 --- a/prisma/actions/applications.ts +++ b/prisma/actions/applications.ts @@ -580,7 +580,7 @@ export async function updateApplicationStatuses( }); if (result.count === 0) { - const sourceLabels = getApplicationStatusSources(status).map( + const sourceLabels = getApplicationStatusForwardSources(status).map( (source) => APPLICATION_STATUS_LABELS[source], ); return { diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 372f99c1..54c0593c 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -425,7 +425,8 @@ describe('updateApplicationStatuses', () => { status: 'reviewing', }); expect(result).toEqual({ - error: 'None of the selected applications can move to Reviewing.', + error: + "None of the selected applications can move to Reviewing — that's only reachable from Applied or Reached out.", }); }); From 6971dad5b76067b83b8e06cc0380e51aacc6333b Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 16:08:57 -0400 Subject: [PATCH 24/47] #364 fix stale bulk-skip message assertion getApplicationStatusForwardSources('reviewing') resolves to [applied, reached_out, interview_scheduled] now that the action uses forward-only sources; the test still expected the old two-source text. Co-Authored-By: Claude Sonnet 5 --- tests/db/authorization.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 54c0593c..774d1875 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -426,7 +426,7 @@ describe('updateApplicationStatuses', () => { }); expect(result).toEqual({ error: - "None of the selected applications can move to Reviewing — that's only reachable from Applied or Reached out.", + "None of the selected applications can move to Reviewing — that's only reachable from Applied, Reached out, or Interview scheduled.", }); }); From 06531be2beb61e3ff5d5ad78e4e662831b4fcb76 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 18:14:39 -0400 Subject: [PATCH 25/47] #364 stop claiming skipped rows stay selected on a source mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk-status toast said skipped rows were "still selected", but onApplied only retains ids skipped for a non-reviewable status — rows skipped for a forward-source mismatch (e.g. applied -> interview_scheduled) are deselected, making the claim false. Co-Authored-By: Claude Sonnet 4.6 --- components/features/applications-bulk-bar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/features/applications-bulk-bar.tsx b/components/features/applications-bulk-bar.tsx index 1c6cb73c..e7b5880d 100644 --- a/components/features/applications-bulk-bar.tsx +++ b/components/features/applications-bulk-bar.tsx @@ -80,7 +80,7 @@ export function ApplicationsBulkBar({ toast.success( `Updated ${updated} of ${updated + skipped} applications`, { - description: `${statusLabel} is only reachable from ${formatAlternatives(sourceLabels)}. The skipped rows are still selected.`, + description: `${statusLabel} is only reachable from ${formatAlternatives(sourceLabels)}.`, }, ); } From e9bdf2b5f8c1916deb7aed5d85b7fc7847f7b362 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 18:17:49 -0400 Subject: [PATCH 26/47] #513 gate the approval check on the claude label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate exists to stop mid-pipeline PRs merging early, not to enforce review universally, so PRs outside the pipeline now skip it — which also unblocks Dependabot with no special case. Skipping at the job level still reports the required check as `skipped`; filtering the trigger would leave it pending forever. Trade-off is fail-open: a pipeline PR missing `claude` loses its gate, so impl-agent applies the label at `gh pr create` and the cockpit sweeps for tracked PRs without it. Co-Authored-By: Claude Opus 5 --- .claude/agents/impl-agent.md | 3 +++ .claude/docs/PIPELINE.md | 24 ++++++++++++++---------- .claude/skills/pipeline/SKILL.md | 12 ++++++++++-- .github/workflows/approval-check.yml | 16 +++++++++++++--- CLAUDE.md | 2 +- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/.claude/agents/impl-agent.md b/.claude/agents/impl-agent.md index 881b2fb9..8f22af95 100644 --- a/.claude/agents/impl-agent.md +++ b/.claude/agents/impl-agent.md @@ -97,11 +97,14 @@ gh issue edit N --repo SGAOperations/aplio --remove-label "plan approved" --add- --title "#N " \ --body-file .temp/pr-N.md \ --assignee "" \ + --label "claude" \ --head N-ticket-name-in-kebab-case ``` `--assignee` is the **issue's assignee login recorded in pre-flight** (`@me` if the issue had none) — the PR must carry the same owner as its issue or it never shows up in that operator's `ready for review` query. **Substitute the literal login string you read in pre-flight; never `$(...)` command substitution** — it isn't allow-listed and would silently produce an empty argument. + `--label "claude"` is what **activates the approval gate**: `.github/workflows/approval-check.yml` runs only on PRs carrying `claude`, so a PR opened without it merges with **no** approval gate at all. + **Always target `dev`** (the integration branch) — never open feature PRs against `main`. Releases flow `dev → main` via the `/release` command. Note the PR number returned. diff --git a/.claude/docs/PIPELINE.md b/.claude/docs/PIPELINE.md index 47f956aa..b533dcdc 100644 --- a/.claude/docs/PIPELINE.md +++ b/.claude/docs/PIPELINE.md @@ -112,16 +112,17 @@ Rule: **every stage agent's first action is swapping its trigger label for its i ### PR labels -| Label | Set by | Type | Meaning | -| ------------------ | ----------------------------- | --------- | ------------------------------------------------------------------------------------------------ | -| `ready for review` | `impl-agent` / `revise-agent` | trigger | Dispatch `review-agent` | -| `reviewing` | `review-agent` | in-flight | Review underway | -| `needs revision` | `review-agent` | trigger | Dispatch `revise-agent` (subject to cycle cap) | -| `revising` | `revise-agent` | in-flight | Fixes underway | -| `approved` | `review-agent` | terminal | Findings are at/under the current cycle's bar (escalating, below); human merges on GitHub | -| `needs human` | Cockpit / `revise-agent` | gate | 5 cycles without convergence, or an ambiguous rebase conflict needing the author; pipeline stops | -| `refresh branch` | Cockpit / human | trigger | Dispatch `revise-agent` in refresh mode — rebase onto base and force-push, no code changes | -| `refreshing` | `revise-agent` | in-flight | Branch refresh underway; the PR's other labels (e.g. `approved`) are left in place | +| Label | Set by | Type | Meaning | +| ------------------ | -------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `claude` | `impl-agent` (at `gh pr create`) | marker | Pipeline owns this PR — activates the `approved` merge gate in `approval-check.yml`; a pipeline PR missing it merges ungated | +| `ready for review` | `impl-agent` / `revise-agent` | trigger | Dispatch `review-agent` | +| `reviewing` | `review-agent` | in-flight | Review underway | +| `needs revision` | `review-agent` | trigger | Dispatch `revise-agent` (subject to cycle cap) | +| `revising` | `revise-agent` | in-flight | Fixes underway | +| `approved` | `review-agent` | terminal | Findings are at/under the current cycle's bar (escalating, below); human merges on GitHub | +| `needs human` | Cockpit / `revise-agent` | gate | 5 cycles without convergence, or an ambiguous rebase conflict needing the author; pipeline stops | +| `refresh branch` | Cockpit / human | trigger | Dispatch `revise-agent` in refresh mode — rebase onto base and force-push, no code changes | +| `refreshing` | `revise-agent` | in-flight | Branch refresh underway; the PR's other labels (e.g. `approved`) are left in place | ## Stages and models @@ -158,6 +159,8 @@ Permission mode: every stage agent runs **`permissionMode: dontAsk`** (auto-deny **Mitigation chosen instead of the hook: keep content emitters off the allowlist.** Since redirection can't be blocked, the allowlist itself is the control — so `echo`, `cat`, `head`, `tail`, `cut`, `diff` and `true` are excluded, because their whole purpose is emitting bytes to stdout and a redirect turns each into a clean file-write (`echo "…" > lib/types.ts`, `cat src > dst`). The 11 that remain emit search results, paths, or metadata. **This narrows the bypass; it does not eliminate it** — `grep -v x f > f` still strips lines, any allowed command can truncate a redirect target, and the long-standing `Bash(git *)` allow has always offered write primitives (`git apply`, `git checkout -- …`). So the "write files with the Write/Edit tools" rule above remains a **convention agents are expected to follow, not a technical guarantee** — `plan-agent`'s and `review-agent`'s read-only status rests on them following it. #326 is the real fix whenever it's worth the hook. +**CI merge gate — `approval-check.yml`.** PRs into `dev` are gated on the `approved` label **only when the PR carries `claude`** (a job-level `if:`). Every other PR — human, Dependabot — gets a `skipped` check run, which GitHub counts as satisfied, so it merges on its own merits. This is deliberately **fail-open**: an unlabelled pipeline PR is indistinguishable in CI from a human one and simply loses its gate. Nothing in the workflow can close that, so the mitigations live upstream — `impl-agent` passes `--label "claude"` at `gh pr create` (so the gate is live on the PR's first event) and the cockpit's **ungated-PR sweep** reports any tracked PR missing it. Never narrow the workflow's trigger to exclude a PR: a workflow that never runs creates no check run, leaving the required check pending forever. + ## Pipeline output formats Defined once here; the stage agents follow these exactly. @@ -271,6 +274,7 @@ Closing the cockpit session also halts dispatch (it is the only dispatcher) but | Nothing dispatches for an item | It has no trigger label (paused, in-flight, or gated) | `status` shows where it is; `resume #N` re-applies the right trigger | | Nothing dispatches **and** `status` doesn't list it at all | It is unassigned, or owned by another operator (queries are assignee-filtered) | The tick's unowned sweep reports it — claim it with `work on #N`. If another operator owns it, that's their cockpit's job; `work on #N` offers an explicit take-over | | No GitHub Actions check runs at all on a new push, while `Vercel` still runs | The PR conflicts with its base, so GitHub cannot build the merge ref that `pull_request` workflows run against. Vercel deploys from the head commit, so it is unaffected | `gh pr view --json mergeable` reports `CONFLICTING`. Rebase onto the base branch and force-push; the checks return on the next push | +| `run-approval-check` shows **Skipped** on a pipeline PR | The PR is missing the `claude` label, so the approval gate is inactive | `gh pr edit --repo SGAOperations/aplio --add-label "claude"` — the `labeled` event re-evaluates the job condition and the gate activates on that run | | A stage misbehaved and you want to run it by hand | — | @-mention the subagent (`@agent-impl-agent implement #N`) or run `claude --agent impl-agent` | | Cockpit session closed | All state is in labels | Start `/pipeline` again; it resumes from the labels. `retry #N` anything parked in an in-flight label | | Labels manually changed on GitHub | Fine — labels are the source of truth | The next tick acts on whatever the labels say | diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md index 1c889f54..b092bd8c 100644 --- a/.claude/skills/pipeline/SKILL.md +++ b/.claude/skills/pipeline/SKILL.md @@ -49,9 +49,12 @@ gh pr list --repo SGAOperations/aplio --assignee "@me" --label "needs human" --j # Unowned sweep → report only, never act (see Ownership above) gh issue list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready" or . == "plan changes requested" or . == "plan approved" or . == "plan review" or . == "blocked"))]' gh pr list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready for review" or . == "needs revision" or . == "approved" or . == "needs human"))]' + +# Ungated-PR sweep → report only, never act (see Ungated report below) +gh pr list --repo SGAOperations/aplio --assignee "@me" --json number,title,labels --jq '[.[] | select((.labels | map(.name)) as $l | ($l | any(. == "ready for review" or . == "reviewing" or . == "needs revision" or . == "revising" or . == "approved" or . == "refresh branch" or . == "refreshing" or . == "needs human")) and ($l | index("claude") | not)) | {number, title}]' ``` -Then, in order: **(1)** reconcile merged PRs (below), **(2)** handle human gates, **(3)** **unless draining,** dispatch for every actionable trigger item (all Agent calls in one message), **(4)** report the unowned sweep if its set changed, **(5)** schedule the next wakeup (**skip while draining**). +Then, in order: **(1)** reconcile merged PRs (below), **(2)** handle human gates, **(3)** **unless draining,** dispatch for every actionable trigger item (all Agent calls in one message), **(4)** report the unowned and ungated-PR sweeps if their sets changed, **(5)** schedule the next wakeup (**skip while draining**). **Merged-PR reconciliation (each tick):** the `approved` query above is open-only, so a merged PR silently drops out of it — never trust in-session memory for "awaiting merge." Diff the set of PRs you have **announced as approved** against the live `approved` result; for each announced PR no longer present, confirm and announce it **once**: @@ -77,6 +80,10 @@ gh pr view --repo SGAOperations/aplio --json headRefOid,statusCheckRollup -- An **empty** sweep result is only meaningful if the `--jq` filter works — verify it once against a known unassigned, trigger-labeled issue rather than trusting silence. +**Ungated-PR sweep (each tick):** `approval-check.yml` gates a PR on `approved` **only if it carries `claude`** (`.claude/docs/PIPELINE.md` → "Permission rationale"), so a pipeline PR that lost that label merges with no gate at all — and CI cannot tell it from a human PR. The sweep query above lists exactly those. Report them **only when the set changes**, in one line, and **never add the label automatically**: + +> ⚠️ Pipeline PRs without the `claude` label (approval gate inactive): #501. Say "gate #501" or add the label on GitHub. + **Denial report (each tick):** stage agents auto-deny disallowed commands (`dontAsk`) instead of prompting; a `PreToolUse` hook logs each one to **`.agents/denials.log`** (gitignored, base repo). Read it each tick and track how many lines are new since the previous tick. If denials **cluster** — say **≥3 new**, or the same command repeated — report it **once**, e.g. _"⚠️ 4 commands auto-denied this tick (e.g. `npx prisma migrate …` ×2, `printf … >` ×1) — the pipeline likely needs a permission/instruction change."_ Do **not** prompt or act on it automatically; this is visibility so the human knows when to harden the pipeline. A few isolated denials are normal and need no report. ## Dispatching @@ -166,10 +173,11 @@ Interpret intent, not literal syntax: Dispatch the plan agent the same tick. - **"scope out X" / "break down X"** — Stage 0 deserves a stronger model than haiku; suggest the human run `/scope` in their main session. -- **"status"** — re-run the tick queries **live** and build the table from them (never from session memory): each in-flight item + stage, each item waiting on the human, and each PR currently labeled `approved` (the live `gh pr list --assignee "@me" --label approved` result — a merged PR has already dropped out, so it must not appear). It **inherits the assignee filter**, so it reports only this operator's items; append the unowned sweep result as a separate **"unowned"** line so a stalled ticket is diagnosable from one command. +- **"status"** — re-run the tick queries **live** and build the table from them (never from session memory): each in-flight item + stage, each item waiting on the human, and each PR currently labeled `approved` (the live `gh pr list --assignee "@me" --label approved` result — a merged PR has already dropped out, so it must not appear). It **inherits the assignee filter**, so it reports only this operator's items; append the unowned sweep result as a separate **"unowned"** line, and the ungated-PR sweep result as an **"ungated"** line, so a stalled ticket or a PR with no approval gate is diagnosable from one command. - **"pause #N"** — remove the item's current trigger label; confirm what was removed. Same ownership rule as opt-in: if the item belongs to **another operator**, say so and stop rather than touch its labels. - **"resume #N" / "retry #N"** — re-apply the trigger label for where it stalled (issue stuck in `planning` → `ready`; PR stuck in `revising` → `needs revision`; PR stuck in `refreshing` → `refresh branch`; etc.). Same ownership rule as opt-in: if the item is **unassigned**, add `--add-assignee "@me"` in the same command (re-applying a trigger to an unassigned item is a no-op for every cockpit); if it belongs to **another operator**, say so and stop rather than re-trigger. - **"refresh #N"** — apply `refresh branch` to that PR and dispatch it this tick, bypassing the per-merge cap. Use it to force a fresh preview deployment on a PR left quota-red. Same ownership rule as opt-in. +- **"gate #N"** — apply the missing `claude` marker to a PR the ungated sweep reported: `gh pr edit --repo SGAOperations/aplio --add-label "claude"`. The `labeled` event re-evaluates `approval-check.yml`'s condition, so the approval gate is live on that run. Same ownership rule as opt-in. ## Stop controls diff --git a/.github/workflows/approval-check.yml b/.github/workflows/approval-check.yml index 25d8534f..d733f6e5 100644 --- a/.github/workflows/approval-check.yml +++ b/.github/workflows/approval-check.yml @@ -1,5 +1,14 @@ -# Gates PRs into `dev` on the `approved` pipeline label. `labeled`/`unlabeled` -# re-trigger so the verdict updates as the pipeline swaps labels. +# Gates PRs into `dev` on the `approved` pipeline label — but **only** for +# pipeline PRs, i.e. those carrying `claude` (the job-level `if:` below). Every +# other PR (human, Dependabot) gets a `skipped` check run, which GitHub counts +# as a satisfied required check, so it merges on its own merits. +# `labeled`/`unlabeled` re-trigger so both the gate condition and the verdict +# update as labels change — adding `claude` activates the gate on that run. +# NEVER narrow the trigger (`paths`, `branches`, dropping an event) to skip the +# check: a workflow that never runs creates no check run, so the required check +# sits pending forever and blocks the merge permanently. Skip at the JOB level. +# Renaming the job `run-approval-check` does the same — that ID is the check +# registered as required. # `branches: [dev]` is safe only because the required-check registration is # `dev`-scoped too — NEVER add this to the `main/dev` ruleset (id 15248252); # dev → main release PRs carry no pipeline labels. @@ -18,6 +27,7 @@ permissions: jobs: run-approval-check: runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'claude') timeout-minutes: 5 steps: - name: Check pipeline labels @@ -58,7 +68,7 @@ jobs: EOF if ! grep -Fxq "approved" "$RUNNER_TEMP/labels.txt"; then - echo "::error::Missing the 'approved' label. A PR into dev is merge-able only after review-agent (or a human, for manual PRs) applies 'approved'." + echo "::error::Missing the 'approved' label. This PR carries 'claude', so it is a pipeline PR and merges only once review-agent applies 'approved'. If it is not a pipeline PR, remove the 'claude' label and this check will skip." FAILED=1 fi diff --git a/CLAUDE.md b/CLAUDE.md index 801317e8..fae5b46c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ Next.js 16 (App Router, React 19) · Prisma 7 · Tailwind CSS 4 · shadcn/ui (Ra - **Commit:** subject `#XXX message in lowercase imperative mood` (no colon after the number, **under 80 chars**, no trailing period); then — only if the _why_ isn't obvious — a blank line and a short body (wrap ~72, a few lines max; narrative belongs in the PR, not the commit); then a blank line and a `Co-Authored-By: Claude Sonnet 4.6 ` trailer. Commit each logical unit separately. **Write the message to a file and `git commit -F .temp/commit-msg.txt`** — inline multi-line `-m … -m …` collapses on Windows, dropping the subject and the co-authorship. Delete tracked files with `git rm`. **The subject-line format is enforced locally by a `commit-msg` hook** (installed automatically via `npm run prepare` / `npm ci`) **and in CI by `run-commit-message-check`**, which validates every commit in a PR by invoking that same hook — so the two cannot drift. - **Branch:** `XXX-ticket-name-in-kebab-case`, branched off `dev`. -- **PR:** title `#XXX Ticket Name In Title Case`; **base branch `dev`** (never open feature PRs against `main`); body contains `Closes #XXX`; assign the **issue's assignee** (fallback: yourself — `@me`). `dev → main` is promoted only by `/release`. +- **PR:** title `#XXX Ticket Name In Title Case`; **base branch `dev`** (never open feature PRs against `main`); body contains `Closes #XXX`; assign the **issue's assignee** (fallback: yourself — `@me`); **pipeline-authored PRs also carry the `claude` label** — it is what activates the `approved` merge gate (`approval-check.yml`), and without it the PR merges ungated. `dev → main` is promoted only by `/release`. - **Rebase conflicts in pipeline:** `revise-agent` attempts autonomous resolution for structurally unambiguous conflicts (non-overlapping sections, generated files, dual independent imports). It escalates to `needs human` only when both sides modified the same logical unit. Agents should document every resolution in the revision summary. Full protocol: `.claude/docs/PIPELINE.md` → "Rebase conflict protocol". ## Preview databases (Neon branch budget) From 82dceec401929b35a7c47fca56783e2a9083e486 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 18:18:50 -0400 Subject: [PATCH 27/47] #514 make the applications page full width and codify page width tiers max-w-6xl was used on this one table page while every sibling list page is full-bleed. loading.tsx carries the same container, so both change or the skeleton shifts width on resolve. Co-Authored-By: Claude Opus 5 --- .claude/docs/DESIGN.md | 10 +++++++++- app/(main)/(auth)/applications/loading.tsx | 2 +- app/(main)/(auth)/applications/page.tsx | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.claude/docs/DESIGN.md b/.claude/docs/DESIGN.md index 517da825..2fdbc5e9 100644 --- a/.claude/docs/DESIGN.md +++ b/.claude/docs/DESIGN.md @@ -45,7 +45,15 @@ Any change to a brand/status token must keep ≥4.5:1 contrast against its paire - **Radius:** `--radius: 0.75rem`. Use `rounded-md`/`rounded-lg`/`rounded-xl` (derived from the scale); don't use arbitrary radii. Inputs/buttons/cards inherit the shadcn defaults. - **Spacing:** Tailwind 4-point scale. Card padding `p-6` (compact `p-4`); stack gaps `gap-4`/`gap-6`; form field gap `gap-2`. Be consistent rather than pixel-tuning. -- **Containers:** constrain reading width (`max-w-*`), center with `mx-auto`; full-bleed only for tables/dashboards. +- **Page width tiers:** every route inside the app shell picks exactly one tier for its top-level container. `app-shell.tsx` supplies only `p-6`, so the page owns the width. + + | Tier | Container classes | Use for | + | -------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- | + | **Full-bleed** | none (no `max-w`) | list, table and dashboard pages — `/`, `/positions`, `/users`, `/applications`, `/my-applications`, `/global-questions` | + | **Wide** | `mx-auto max-w-5xl` | two-column review/detail pages — `/applications/[id]`, `/my-applications/[id]` | + | **Narrow** | `mx-auto max-w-2xl` | single-column forms and reading views — `/profile`, `/positions/[id]/apply`, `/positions/[id]/edit` | + + There is no fourth tier: `max-w-6xl`/`4xl`/`3xl` on a page container is a bug. Inside a full-bleed page, constraining an individual prose block (`max-w-2xl` on a description) is correct and not a tier violation — see `/positions/[id]`. A route's `loading.tsx` must use the same tier as its `page.tsx`, or the skeleton shifts on resolve. Routes outside the app shell (`(legal)`, `login`) set their own width. ## 5. Components diff --git a/app/(main)/(auth)/applications/loading.tsx b/app/(main)/(auth)/applications/loading.tsx index ab8de85d..67d7a890 100644 --- a/app/(main)/(auth)/applications/loading.tsx +++ b/app/(main)/(auth)/applications/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton'; export default function ApplicationsLoading() { return ( -
+
{/* Header skeleton */}
diff --git a/app/(main)/(auth)/applications/page.tsx b/app/(main)/(auth)/applications/page.tsx index 462c7692..99ad5d9e 100644 --- a/app/(main)/(auth)/applications/page.tsx +++ b/app/(main)/(auth)/applications/page.tsx @@ -86,7 +86,7 @@ export default async function ApplicationsPage({ : fetchedApplications; return ( -
+
Date: Wed, 19 Aug 2026 18:27:41 -0400 Subject: [PATCH 28/47] #513 trim the approval-check header comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENGINEERING.md §7 caps comments at two lines; PIPELINE.md's new "CI merge gate" paragraph already carries the full rationale. Co-Authored-By: Claude Opus 5 --- .github/workflows/approval-check.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/approval-check.yml b/.github/workflows/approval-check.yml index d733f6e5..158f0aa0 100644 --- a/.github/workflows/approval-check.yml +++ b/.github/workflows/approval-check.yml @@ -1,14 +1,5 @@ -# Gates PRs into `dev` on the `approved` pipeline label — but **only** for -# pipeline PRs, i.e. those carrying `claude` (the job-level `if:` below). Every -# other PR (human, Dependabot) gets a `skipped` check run, which GitHub counts -# as a satisfied required check, so it merges on its own merits. -# `labeled`/`unlabeled` re-trigger so both the gate condition and the verdict -# update as labels change — adding `claude` activates the gate on that run. -# NEVER narrow the trigger (`paths`, `branches`, dropping an event) to skip the -# check: a workflow that never runs creates no check run, so the required check -# sits pending forever and blocks the merge permanently. Skip at the JOB level. -# Renaming the job `run-approval-check` does the same — that ID is the check -# registered as required. +# Gates `dev` PRs on `approved`, only when labeled `claude` — others skip. +# NEVER narrow the trigger or rename the job: no check run = pending forever. # `branches: [dev]` is safe only because the required-check registration is # `dev`-scoped too — NEVER add this to the `main/dev` ruleset (id 15248252); # dev → main release PRs carry no pipeline labels. From d9f31ae523b9578f664c49c140f4eeda41e27747 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 18:44:03 -0400 Subject: [PATCH 29/47] #503 route config tickets to an operator session via /implement Co-Authored-By: Claude Opus 5 --- .claude/agents/plan-agent.md | 2 + .claude/docs/PIPELINE.md | 56 +++++++++--- .claude/skills/implement/SKILL.md | 114 +++++++++++++++++++++++++ .claude/skills/pipeline/SKILL.md | 58 ++++++++++--- .claude/skills/worktree-clean/SKILL.md | 2 +- CLAUDE.md | 1 + 6 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 .claude/skills/implement/SKILL.md diff --git a/.claude/agents/plan-agent.md b/.claude/agents/plan-agent.md index 5da2d54f..7b5665b1 100644 --- a/.claude/agents/plan-agent.md +++ b/.claude/agents/plan-agent.md @@ -68,6 +68,8 @@ Construct the **full new issue body** (original ticket description preserved on gh issue edit N --repo SGAOperations/aplio --body-file .temp/plan-N.md ``` +**Route declaration (config tickets).** If any file in your **## Changes** section is `CLAUDE.md` or lives under `.claude/`, the plan body's **first line — directly under the `## Implementation Plan` heading, before `## Overview` — must be exactly `ROUTE: operator session`**, followed by one sentence naming why. The harness blocks a dispatched subagent from editing those paths, so the cockpit routes stages 2 and 4 to an operator session instead (`.claude/docs/PIPELINE.md` → "Config tickets"). Emit it in **revision mode** too — a revised plan can change route. Never emit it for a plan that touches neither. + **Use the fixed structure** in `.claude/docs/PIPELINE.md` → "Implementation plan" — the canonical section list, order, and writing style. Think through how the feature should actually work and look (it's a product/UX design, not just a file checklist), but write it tight: bullets, short sentences, **don't restate the ticket**, omit sections that don't apply. The plan must still _decide_ the substance — even though it's brief: - **Design each UX state** (happy + unhappy/edge), layout/hierarchy, key interactions, and the actual **copy** — in the **## UX states** section (only if there's UI). diff --git a/.claude/docs/PIPELINE.md b/.claude/docs/PIPELINE.md index b533dcdc..21c0c003 100644 --- a/.claude/docs/PIPELINE.md +++ b/.claude/docs/PIPELINE.md @@ -97,18 +97,19 @@ Rule: **every stage agent's first action is swapping its trigger label for its i ### Issue labels -| Label | Set by | Type | Meaning | -| ------------------------ | ---------------------------------------- | --------- | ------------------------------------------------- | -| `claude` | Cockpit (at opt-in) | marker | Claude is handling this ticket | -| `ready` | Cockpit (at opt-in) | trigger | Dispatch `plan-agent` | -| `planning` | `plan-agent` | in-flight | Plan being researched/written | -| `plan review` | `plan-agent` | gate | Plan written — awaiting human approval in cockpit | -| `plan changes requested` | Cockpit (human feedback) | trigger | Dispatch `plan-agent` in revision mode | -| `plan approved` | Cockpit (human approval, or `auto plan`) | trigger | Dispatch `impl-agent` | -| `auto plan` | Cockpit (at opt-in) | marker | Plan gate skipped: `plan review` auto-approved | -| `in progress` | `impl-agent` | in-flight | Implementation underway | -| `pr opened` | `impl-agent` | terminal | PR open; remaining state tracked on the PR | -| `blocked` | `impl-agent` | gate | Needs human decision; details in issue comment | +| Label | Set by | Type | Meaning | +| ------------------------ | ---------------------------------------- | --------- | ---------------------------------------------------------------------- | +| `claude` | Cockpit (at opt-in) | marker | Claude is handling this ticket | +| `ready` | Cockpit (at opt-in) | trigger | Dispatch `plan-agent` | +| `planning` | `plan-agent` | in-flight | Plan being researched/written | +| `plan review` | `plan-agent` | gate | Plan written — awaiting human approval in cockpit | +| `plan changes requested` | Cockpit (human feedback) | trigger | Dispatch `plan-agent` in revision mode | +| `plan approved` | Cockpit (human approval, or `auto plan`) | trigger | Dispatch `impl-agent` | +| `auto plan` | Cockpit (at opt-in) | marker | Plan gate skipped: `plan review` auto-approved | +| `operator route` | Cockpit (at the plan gate) | marker | Stages 2/4 run in an operator session (`/implement`); never dispatched | +| `in progress` | `impl-agent` | in-flight | Implementation underway | +| `pr opened` | `impl-agent` | terminal | PR open; remaining state tracked on the PR | +| `blocked` | `impl-agent` | gate | Needs human decision; details in issue comment | ### PR labels @@ -123,6 +124,7 @@ Rule: **every stage agent's first action is swapping its trigger label for its i | `needs human` | Cockpit / `revise-agent` | gate | 5 cycles without convergence, or an ambiguous rebase conflict needing the author; pipeline stops | | `refresh branch` | Cockpit / human | trigger | Dispatch `revise-agent` in refresh mode — rebase onto base and force-push, no code changes | | `refreshing` | `revise-agent` | in-flight | Branch refresh underway; the PR's other labels (e.g. `approved`) are left in place | +| `operator route` | `/implement` (at PR creation) | marker | Stages 2/4 run in an operator session; the cockpit never dispatches `revise-agent` for it | ## Stages and models @@ -137,6 +139,8 @@ Rule: **every stage agent's first action is swapping its trigger label for its i All four workers read `.claude/docs/ENGINEERING.md` before working; the review agent treats it as a review dimension. +**Stages 2 and 4 have an operator variant.** A ticket touching `CLAUDE.md` or `.claude/**` can't be implemented by a dispatched agent (the harness denies its `Edit`), so those two stages run in the operator's own session via `/implement`, following these same agent files — see "Config tickets". + ## Permission rationale The model is **broad allow + authoritative deny**: stage agents do real dev work (install packages, read CI logs, manage git in their worktree), so the allowlist grants broad categories and the `deny` list draws the safety line. **Any permission change must update this section.** @@ -161,6 +165,32 @@ Permission mode: every stage agent runs **`permissionMode: dontAsk`** (auto-deny **CI merge gate — `approval-check.yml`.** PRs into `dev` are gated on the `approved` label **only when the PR carries `claude`** (a job-level `if:`). Every other PR — human, Dependabot — gets a `skipped` check run, which GitHub counts as satisfied, so it merges on its own merits. This is deliberately **fail-open**: an unlabelled pipeline PR is indistinguishable in CI from a human one and simply loses its gate. Nothing in the workflow can close that, so the mitigations live upstream — `impl-agent` passes `--label "claude"` at `gh pr create` (so the gate is live on the PR's first event) and the cockpit's **ungated-PR sweep** reports any tracked PR missing it. Never narrow the workflow's trigger to exclude a PR: a workflow that never runs creates no check run, leaving the required check pending forever. +## Config tickets (`CLAUDE.md` / `.claude/**`) + +**The harness denies `Edit`/`Write` under `.claude/` to dispatched subagents, and `settings.json` cannot grant it back.** This repo already allows `Edit(**)`/`Write(**)` with no `.claude/` deny rule and the denial persists anyway — it sits above project config, so there is nothing to fix in the permission model. An operator's **main session** is unaffected: reading `.claude/agents/impl-agent.md` and acting on it spawns no subagent, so no subagent restriction applies. That asymmetry is the whole basis of this route. + +**The route** — `plan-agent` declares it, the cockpit marks it, an operator runs it: + +1. **Declare.** When the plan's **## Changes** touches `CLAUDE.md` or `.claude/**`, its first line is exactly `ROUTE: operator session` (see "Implementation plan"). +2. **Mark.** At the plan gate the cockpit reads the issue body for that sentinel and adds **`operator route`** in the same edit as `plan approved`. The marker is sticky and travels to the PR (`/implement` passes `--label "operator route"` to `gh pr create`), so every later cockpit decision is a pure label check. +3. **Run.** The operator opens a **named session** and runs `/implement `. That skill resolves the stage from the item's labels, creates a worktree under `.claude/worktrees/impl-`, and follows the **unmodified** `impl-agent.md` / `revise-agent.md` plus a short list of subagent-only overrides. The override list lives in the skill and nowhere else — one place to drift, one place to check. + +**What moves and what doesn't.** Only stages **2** and **4**. `plan-agent` and `review-agent` are read-only and work through `gh`, so stages 1 and 3 run unchanged. `refresh branch` is still dispatched to `revise-agent` — a rebase and force-push edit no files, and a conflict inside `CLAUDE.md` / `.claude/docs/**` is already on the never-touch list and escalates. + +**The invariant this deviates from.** Everywhere else a trigger label means something is dispatching. An `operator route` item **keeps** its trigger label (`plan approved` / `needs revision`) and is **never** dispatched — the cockpit announces the command instead. Recovery is unchanged (re-apply the trigger), but the label alone no longer implies motion. That is why `operator route` appears in the cockpit's gate queries, its unowned sweep, and its `status` output: an unowned config ticket is otherwise invisible in a way an unowned normal ticket isn't, because no cockpit will ever nag about it. + +**Session naming.** These sessions are long-lived and several run at once, so launch each with the issue number in its display name: + +```bash +claude -n "#503: operator config route" # then, in that session: /implement 503 +``` + +`-n/--name` sets the name shown in the prompt box, the `/resume` picker, and the terminal title. It is settable **only at launch** — a running session can neither read nor change its own name — so the cockpit's announcement hands over the command with the name pre-filled, and the skill re-states the expected string. The name always carries the **issue** number, even when the command takes a PR number. + +**Always in a worktree.** `/implement` never works in the main checkout, for two reasons: editing `.claude/` from the session that is _using_ it mutates your live configuration mid-task, and the ticket may be editing the very agent file the session is following. In a worktree the session reads its instructions by absolute path from the main checkout while every edit lands on the worktree copy, so the committed behaviour holds for the whole run. Reclaim the worktree with `/worktree-clean` once the PR merges. + +**Unverified:** whether the manual escape hatch `claude --agent impl-agent` carries the same restriction (its frontmatter forces `dontAsk` and worktree isolation). Untested — use `/implement` for config tickets regardless. If a future Claude Code version lifts the harness restriction, this whole route can be deleted; the sentinel and the label are the only things to unwind. + ## Pipeline output formats Defined once here; the stage agents follow these exactly. @@ -179,6 +209,7 @@ Every plan, review, summary, and comment is written for a human scanning fast: Appended below the ticket under a `---` then `## Implementation Plan`; revision mode replaces only that block. **Do not restate the ticket** — reference it. Fixed sections in this order; the conditional ones appear **only when they apply** (omit otherwise — no stub): +- **`ROUTE: operator session`** _(only when the plan touches `CLAUDE.md` or `.claude/**`)_ — a bare line before `## Overview`, exactly that text, plus one sentence naming why. It routes stages 2 and 4 to an operator session; see "Config tickets". - **## Overview** — 2–4 sentences: what, why, the approach. - **## Changes** — files to create/modify, one bullet each: `` `path` — one-line reason ``. - **## Implementation** — ordered `- [ ]` checkboxes, one line each; fold validation / states / error-model notes into the step they belong to. @@ -280,6 +311,7 @@ Closing the cockpit session also halts dispatch (it is the only dispatcher) but | Labels manually changed on GitHub | Fine — labels are the source of truth | The next tick acts on whatever the labels say | | Stale worktrees / orphan `node_modules` dirs accumulating under `.claude/worktrees/` | Agents cut off mid-run; on Windows the harness leaves dirs git can't delete | Run **`/worktree-clean`** from the main checkout — it prunes registrations and force-deletes orphan dirs (`git worktree remove` alone fails with `Invalid argument` once `node_modules` exists). The cockpit reports these but never auto-deletes them. | | An agent stopped with `BLOCKED:` or hit `maxTurns` | Clean stop by design (not a crash) | Resolve the blocker (or widen scope/permissions), then `retry #N` | +| An issue sits at `plan approved`, or a PR at `needs revision`, and nothing dispatches | It carries `operator route` — a config ticket (`CLAUDE.md` / `.claude/**`) the cockpit never dispatches for | Open a named session and run it yourself: `claude -n "#N: "`, then `/implement `. See "Config tickets" | ## Reading current state without the cockpit diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 00000000..415a4206 --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,114 @@ +--- +name: implement +description: Run pipeline stage 2 or 4 yourself, in your own session, for a config ticket — one touching CLAUDE.md or .claude/**, which a dispatched agent cannot edit. Resolves the stage from the item's labels, works in a dedicated worktree, and follows the existing impl-agent / revise-agent definitions unchanged. Manual only. Usage: /implement +disable-model-invocation: true +allowed-tools: Read, Edit, Write, Glob, Grep, Bash, AskUserQuestion +--- + +# Implement — operator-run pipeline stage + +**Trigger:** manual, in an operator's own session. **Input:** an issue or PR number (``). **Repo:** `SGAOperations/aplio`. + +The harness denies `Edit`/`Write` under `.claude/` to **dispatched subagents**, and `settings.json` cannot grant it back — so `impl-agent` and `revise-agent` can't run for tickets that touch `CLAUDE.md` or `.claude/**`. Your session has no such restriction. This skill is a **thin wrapper**: it points you at the existing agent definition and overrides only the rules that exist because that agent is a subagent. Background and rationale: `.claude/docs/PIPELINE.md` → "Config tickets". + +**The agent files are the workflow. Do not modify them, and do not reimplement them here.** + +## 1. Name this session (first output line) + +Derive `#: <2–5 lowercase words>` from the issue title — e.g. `#503: operator config route` — and print it as your first line: + +> Name this session: `#503: operator config route` + +If the session wasn't launched with `claude -n ""`, tell the operator to relaunch with `-n` (or rename in-client, if their client supports it). You can neither read nor set your own display name, so this is an instruction to the human — **state it and move on; never block on it.** In revise mode the name still carries the **issue** number, not the PR number. + +## 2. Record the main checkout + +```bash +git rev-parse --show-toplevel +``` + +Keep that path. **Every instruction file is read by absolute path from there** — `.claude/agents/impl-agent.md`, `.claude/agents/revise-agent.md`, `.claude/docs/ENGINEERING.md`, `.claude/docs/PIPELINE.md`, `CLAUDE.md` — and **every edit goes only to paths inside the worktree.** That split is what makes a ticket that edits `impl-agent.md` safe: you follow the committed version while changing the worktree copy. + +## 3. Resolve the mode from state + +```bash +gh pr view --repo SGAOperations/aplio --json labels,headRefName,baseRefName,title +``` + +- Resolves to a PR labeled `needs revision` → **revise mode** (`.claude/agents/revise-agent.md`). +- Otherwise: + + ```bash + gh issue view --repo SGAOperations/aplio --json labels,assignees,title + ``` + + Labeled `plan approved` → **impl mode** (`.claude/agents/impl-agent.md`). **Record the issue's assignee login** (`@me` if none) — the PR must carry it. + +- Anything else → stop, report the current labels, change nothing: `# is not awaiting an operator (labels: …). Nothing was changed.` + +**If the item carries the trigger label but not `operator route`,** ask first (AskUserQuestion): _"#412 has no `operator route` label, so the cockpit may dispatch `impl-agent` for it too. Proceed anyway / Cancel."_ A double dispatch — cockpit and operator on the same item — is the hazard this guards. + +Then report the resolved mode, worktree path and branch before the slow steps, so the operator can see you picked the right stage. + +## 4. Create the worktree + +Never work in the main checkout: editing `.claude/` from the session using it mutates your live configuration mid-task. Never touch another worktree, and never `--force`. + +**impl mode** — branch straight off `dev` (this replaces the agent's checkout-`main`-then-rebase): + +```bash +git fetch origin +git worktree add -b -ticket-name-in-kebab-case .claude/worktrees/impl- origin/dev +``` + +**revise mode** — detached at the PR's head, rebased onto its base (per `revise-agent.md` step 2; push by refspec at the end): + +```bash +git fetch origin +git worktree add --detach .claude/worktrees/impl- origin/ +``` + +Then `cd` into the worktree and bootstrap it — this is also what activates the commit hooks there: + +```bash +npm ci +npm run prisma:generate +``` + +In revise mode, rebase onto the base branch from inside the worktree: `git rebase origin/`. + +## 5. Follow the agent file + +Read the resolved agent file from the **main checkout** and follow it end to end: label swaps, the plan checklist, the `.temp/commit-msg.txt` commit format, the three CI checks, push by refspec, PR body format, base `dev`, the issue's assignee, thread resolution, the revision note. Also read `.claude/docs/ENGINEERING.md` and its **Pre-PR self-check**, as the agent file requires. + +**One addition to `gh pr create` (impl mode):** pass `--label "operator route"` so the marker travels to the PR — the same precedent as copying the assignee, and it makes every later cockpit decision a pure label check. + +```bash +gh pr create --repo SGAOperations/aplio \ + --base dev \ + --title "# " \ + --body-file .temp/pr-.md \ + --assignee "" \ + --label "operator route" \ + --head -ticket-name-in-kebab-case +``` + +## 6. Overrides + +**This is the whole list. Anything not here applies unchanged** — if the agent files gain a rule that only makes sense for a subagent, it belongs here. + +1. **`permissionMode: dontAsk` and "auto-denied silently"** — not your mode. Your session prompts normally. +2. **"STOP and emit `BLOCKED:`"** — pointless with a human present. Ask the operator directly (AskUserQuestion) and wait. Never emit a `BLOCKED:` sentinel, never guess. +3. **"Never spawn subagents"** — not applicable. +4. **The shell-allowlist discipline** (no `cat`/`grep`/`sed`/`find`, bare commands only, no `cd`, quoted cwd-relative paths) — that exists for the subagent's allowlist. Use whatever is clearest. **Still preferred:** `npm run …` over `npx …` for the toolchain, and `git rm` for tracked deletions. +5. **"You are already in a worktree; never run `git worktree`"** — inverted. You create the worktree (§4) and `cd` into it. +6. **Instruction files are read from the recorded main checkout** (§2), not the cwd — the worktree copy may be the thing you are editing. + +## 7. Handoff + +Per the agent file's own Handoff step: + +- **impl** — issue `in progress` → `pr opened`; PR gets `ready for review`. +- **revise** — PR `revising` → `ready for review`. + +The cockpit picks up review on its next tick. Finish by telling the operator the PR URL, the labels applied, and that `/worktree-clean` reclaims `.claude/worktrees/impl-` once the PR merges. diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md index b092bd8c..ea3d2cb7 100644 --- a/.claude/skills/pipeline/SKILL.md +++ b/.claude/skills/pipeline/SKILL.md @@ -18,6 +18,7 @@ You are the orchestrator of the agent pipeline in `.claude/docs/PIPELINE.md`. Th - Never act on issues/PRs that lack a pipeline **trigger** label — opt-in is human-initiated. - **Never act on an item assigned to another operator** — ownership transfers only through an explicit human take-over (see Ownership). - Never dispatch for an item with an **in-flight** label (`planning`, `in progress`, `reviewing`, `revising`, `refreshing`) — an agent owns it or a human paused it. +- **Never dispatch `impl-agent` or `revise-agent` for an item labelled `operator route`** — the harness blocks a dispatched agent from editing `CLAUDE.md` / `.claude/**`, so stages 2 and 4 run in the operator's own session. **Announce the command instead** (see Operator-route items). `review-agent` and refresh-mode dispatches are unaffected. - Every dispatch runs in the background (`run_in_background: true`). Worktree isolation, model, tool scope, and **permission mode (`dontAsk` — auto-denies anything not allow-listed)** all come from the subagent definition in `.claude/agents/` — you do not set them at the call site. (Since CC v2.1.186 a background subagent's prompts surface to you unless it runs `dontAsk` **and** this session is in default mode — see Model & permission mode.) - **Respect the draining flag:** while draining (see Stop controls), dispatch nothing new and schedule no wakeup; only report state and relay completions. @@ -45,10 +46,12 @@ gh issue list --repo SGAOperations/aplio --assignee "@me" --label "plan review" gh issue list --repo SGAOperations/aplio --assignee "@me" --label "blocked" --json number,title gh pr list --repo SGAOperations/aplio --assignee "@me" --label "approved" --json number,title gh pr list --repo SGAOperations/aplio --assignee "@me" --label "needs human" --json number,title +gh issue list --repo SGAOperations/aplio --assignee "@me" --label "operator route" --json number,title,labels +gh pr list --repo SGAOperations/aplio --assignee "@me" --label "operator route" --json number,title,labels # Unowned sweep → report only, never act (see Ownership above) -gh issue list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready" or . == "plan changes requested" or . == "plan approved" or . == "plan review" or . == "blocked"))]' -gh pr list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready for review" or . == "needs revision" or . == "approved" or . == "needs human"))]' +gh issue list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready" or . == "plan changes requested" or . == "plan approved" or . == "plan review" or . == "blocked" or . == "operator route"))]' +gh pr list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready for review" or . == "needs revision" or . == "approved" or . == "needs human" or . == "operator route"))]' # Ungated-PR sweep → report only, never act (see Ungated report below) gh pr list --repo SGAOperations/aplio --assignee "@me" --json number,title,labels --jq '[.[] | select((.labels | map(.name)) as $l | ($l | any(. == "ready for review" or . == "reviewing" or . == "needs revision" or . == "revising" or . == "approved" or . == "refresh branch" or . == "refreshing" or . == "needs human")) and ($l | index("claude") | not)) | {number, title}]' @@ -101,14 +104,14 @@ Agent({ Stage → trigger mapping: -| Trigger query result | subagent_type | -| -------------------------------------- | ---------------------------------------------- | -| Issue labeled `ready` | `plan-agent` (fresh plan) | -| Issue labeled `plan changes requested` | `plan-agent` (revision) | -| Issue labeled `plan approved` | `impl-agent` | -| PR labeled `ready for review` | `review-agent` | -| PR labeled `needs revision` | `revise-agent` — **after the cycle-cap check** | -| PR labeled `refresh branch` | `revise-agent` in **refresh mode** | +| Trigger query result | subagent_type | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Issue labeled `ready` | `plan-agent` (fresh plan) | +| Issue labeled `plan changes requested` | `plan-agent` (revision) | +| Issue labeled `plan approved` | `impl-agent` — **unless `operator route`: announce, never dispatch** | +| PR labeled `ready for review` | `review-agent` | +| PR labeled `needs revision` | `revise-agent` — **after the cycle-cap check**; **unless `operator route`: announce, never dispatch** | +| PR labeled `refresh branch` | `revise-agent` in **refresh mode** | For a refresh, say so in the prompt so the agent takes its Refresh mode path: `Run your pipeline stage for PR # in refresh mode (label: refresh branch).` @@ -133,11 +136,38 @@ then notify the human. For each issue labeled `plan review`: +- **Read the route first** — the plan declares whether stages 2/4 can be dispatched at all: + + ```bash + gh issue view --repo SGAOperations/aplio --json body --jq '.body | contains("ROUTE: operator session")' + ``` + + `true` means a **config ticket** (`CLAUDE.md` / `.claude/**`) — add `operator route` in the **same** label edit that approves the plan, and announce instead of dispatching (see Operator-route items). + - **Without `auto plan`:** summarize the plan from the issue body in a few sentences, then ask (AskUserQuestion): **Approve** / **Request changes** / **Discuss**. - - Approve → `gh issue edit --repo SGAOperations/aplio --remove-label "plan review" --add-label "plan approved"` (impl dispatches this tick). + - Approve → + ```bash + gh issue edit --repo SGAOperations/aplio --remove-label "plan review" --add-label "plan approved" # route false + gh issue edit --repo SGAOperations/aplio --remove-label "plan review" --add-label "plan approved,operator route" # route true + ``` + Route `false` → impl dispatches this tick. Route `true` → dispatch nothing; announce the operator command. - Request changes → write the human's feedback to `.temp/feedback-.md`, `gh issue comment --repo SGAOperations/aplio --body-file .temp/feedback-.md`, then `--remove-label "plan review" --add-label "plan changes requested"`. - Discuss → converse; finish with one of the two transitions above. -- **With `auto plan`:** swap `plan review` → `plan approved` immediately, no interaction, and dispatch impl this tick. +- **With `auto plan`:** run the same route check, swap `plan review` → `plan approved` (plus `operator route` when the route is `true`) immediately, no interaction — and dispatch impl this tick **only** when the route is `false`. + +### Operator-route items + +An item labelled `operator route` keeps its trigger label but is **never** dispatched (`.claude/docs/PIPELINE.md` → "Config tickets"). Announce it **once per session per item** — the same tracked-announcement pattern as `approved` PRs — and take no other action. Derive the session name from the issue title: `#: <2–5 lowercase words>`. + +- **Issue at `plan approved` + `operator route`:** + + > 🧰 #503 is a **config ticket** (touches `CLAUDE.md` / `.claude/**`) — dispatched agents can't edit those. Open a named session and run it yourself: + > `claude -n "#503: operator config route"` then `/implement 503` + > I'll pick it back up at review. + +- **PR at `needs revision` + `operator route`** (announce **after** the cycle-cap check, which still runs and can still escalate to `needs human`): + + > 🧰 PR #512 needs revision and is a config ticket — `claude -n "#503: operator config route"` then `/implement 512` in your own session. I'll review again once it's back at `ready for review`. (The session name carries the **issue** number; the command takes the PR number.) ### Approved PRs @@ -173,7 +203,7 @@ Interpret intent, not literal syntax: Dispatch the plan agent the same tick. - **"scope out X" / "break down X"** — Stage 0 deserves a stronger model than haiku; suggest the human run `/scope` in their main session. -- **"status"** — re-run the tick queries **live** and build the table from them (never from session memory): each in-flight item + stage, each item waiting on the human, and each PR currently labeled `approved` (the live `gh pr list --assignee "@me" --label approved` result — a merged PR has already dropped out, so it must not appear). It **inherits the assignee filter**, so it reports only this operator's items; append the unowned sweep result as a separate **"unowned"** line, and the ungated-PR sweep result as an **"ungated"** line, so a stalled ticket or a PR with no approval gate is diagnosable from one command. +- **"status"** — re-run the tick queries **live** and build the table from them (never from session memory): each in-flight item + stage, each item waiting on the human, and each PR currently labeled `approved` (the live `gh pr list --assignee "@me" --label approved` result — a merged PR has already dropped out, so it must not appear). It **inherits the assignee filter**, so it reports only this operator's items; append the unowned sweep result as a separate **"unowned"** line, and the ungated-PR sweep result as an **"ungated"** line, so a stalled ticket or a PR with no approval gate is diagnosable from one command. List `operator route` items under the human-gated group with the commands to run, e.g. `#503 — plan approved · operator route → claude -n "#503: operator config route" · /implement 503`. - **"pause #N"** — remove the item's current trigger label; confirm what was removed. Same ownership rule as opt-in: if the item belongs to **another operator**, say so and stop rather than touch its labels. - **"resume #N" / "retry #N"** — re-apply the trigger label for where it stalled (issue stuck in `planning` → `ready`; PR stuck in `revising` → `needs revision`; PR stuck in `refreshing` → `refresh branch`; etc.). Same ownership rule as opt-in: if the item is **unassigned**, add `--add-assignee "@me"` in the same command (re-applying a trigger to an unassigned item is a no-op for every cockpit); if it belongs to **another operator**, say so and stop rather than re-trigger. - **"refresh #N"** — apply `refresh branch` to that PR and dispatch it this tick, bypassing the per-merge cap. Use it to force a fresh preview deployment on a PR left quota-red. Same ownership rule as opt-in. @@ -201,4 +231,4 @@ Background-agent completions wake this session automatically; the scheduled wake ## Manual / recovery -Each stage is also runnable by hand without the cockpit — @-mention the subagent (e.g. `@agent-impl-agent implement #142`) or run a whole session as it via `claude --agent impl-agent`. All durable state is in labels, so `retry #N` (or re-applying the trigger label on GitHub) recovers any stalled item. +Each stage is also runnable by hand without the cockpit — @-mention the subagent (e.g. `@agent-impl-agent implement #142`) or run a whole session as it via `claude --agent impl-agent`. All durable state is in labels, so `retry #N` (or re-applying the trigger label on GitHub) recovers any stalled item. **Exception:** an `operator route` item is never dispatched — run `/implement ` in your own named session, since no subagent can edit `CLAUDE.md` / `.claude/**`. diff --git a/.claude/skills/worktree-clean/SKILL.md b/.claude/skills/worktree-clean/SKILL.md index 114453f5..c4efa899 100644 --- a/.claude/skills/worktree-clean/SKILL.md +++ b/.claude/skills/worktree-clean/SKILL.md @@ -7,7 +7,7 @@ allowed-tools: Read, Bash # Clean up pipeline worktrees -Pipeline agents run in isolated worktrees under `.claude/worktrees/agent-*`. On Windows, once an agent has run `npm ci`, the harness often **de-registers** the worktree (removes its `.git` file) but **can't delete the directory** — the populated `node_modules` (and long/`(app)`-parenthesized paths) defeat `git worktree remove` (`Invalid argument`) and `git worktree prune` (which only clears registrations whose directory is already gone). These **orphan directories** then accumulate (hundreds of MB of `node_modules` each). The cockpit deliberately does **not** force-delete them in its autonomous loop; this skill does it, interactively, run by you from the **main checkout**. +Pipeline agents run in isolated worktrees under `.claude/worktrees/agent-*`; operator-run config tickets (`/implement`) add `.claude/worktrees/impl-*`. Both are in scope here. On Windows, once an agent has run `npm ci`, the harness often **de-registers** the worktree (removes its `.git` file) but **can't delete the directory** — the populated `node_modules` (and long/`(app)`-parenthesized paths) defeat `git worktree remove` (`Invalid argument`) and `git worktree prune` (which only clears registrations whose directory is already gone). These **orphan directories** then accumulate (hundreds of MB of `node_modules` each). The cockpit deliberately does **not** force-delete them in its autonomous loop; this skill does it, interactively, run by you from the **main checkout**. > **Scope guard:** this skill only ever deletes directories **directly under `.claude/worktrees/`**. Never delete anything outside that directory, never the main checkout, and never a worktree of an **in-flight** pipeline item (check `gh pr list`/the cockpit first if unsure). diff --git a/CLAUDE.md b/CLAUDE.md index fae5b46c..de0f021a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ All tests live under `tests/`, never co-located with the source they cover: `tes ## Worktrees & local dev - Pipeline agents get their own isolated worktree automatically (`isolation: worktree`) — they handle setup; see `.claude/docs/PIPELINE.md`. Do not script worktree creation for them. +- **Config tickets are implemented by you, not by an agent** — anything touching `CLAUDE.md` or `.claude/**`, because the harness blocks a dispatched agent's `Edit` there. The plan declares it, the cockpit labels it `operator route` and announces the command: launch a named session (`claude -n "#XXX: "`) and run `/implement XXX`, which runs stage 2/4 in its own worktree. See `.claude/docs/PIPELINE.md` → "Config tickets". - For manual local work in a worktree, install deps with `npm ci` (then `npm run prisma:generate`). **Do not `ln -s node_modules` — symlinks fall back to copies on Windows here.** Sync before resuming: `git fetch origin && git rebase origin/dev`. - **`npm ci` is what activates Git hooks** — it runs `prepare` (`husky && npm run hooks:check`), which regenerates the untracked `.husky/_` bootstrap dir, sets `core.hooksPath`, and fails `npm ci` itself if activation didn't take. Each worktree/clone needs its own `npm ci` for hooks to fire there. If hooks stop firing, re-run `npm ci` (or `npm run prepare`) and verify with `npm run hooks:check`. - **The same step sets `core.commentChar=';'`** — git's default `#` makes it strip the mandated `#XXX` subject as a comment every time it re-reads a message through the editor machinery (`git rebase --continue`, `git commit --amend`), silently promoting the first body line into the subject; the `commit-msg` hook does not run on that path. Side effect: git's own instructional lines in the commit editor are `;`-prefixed. This writes to the shared `.git/config`, so one worktree's `npm ci` fixes every worktree of that clone. It cannot be enforced across fresh clones or forks, which is why CI validates subjects too. From f019e79f0f1c9890b8022b5bb393b0fbc50c21d6 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 18:57:49 -0400 Subject: [PATCH 30/47] #503 replace the operator route label with a SESSION REQUIRED body marker One marker string in the issue plan and the PR description, read from the body field the cockpit's trigger queries already return. Names the routing rather than the cause, so future non-.claude categories reuse it. Co-Authored-By: Claude Opus 5 --- .claude/agents/plan-agent.md | 8 ++- .claude/docs/PIPELINE.md | 70 +++++++++++++++++--------- .claude/skills/implement/SKILL.md | 17 +++++-- .claude/skills/pipeline/SKILL.md | 67 +++++++++++------------- .claude/skills/worktree-clean/SKILL.md | 2 +- CLAUDE.md | 2 +- 6 files changed, 94 insertions(+), 72 deletions(-) diff --git a/.claude/agents/plan-agent.md b/.claude/agents/plan-agent.md index 7b5665b1..561f4c99 100644 --- a/.claude/agents/plan-agent.md +++ b/.claude/agents/plan-agent.md @@ -68,7 +68,13 @@ Construct the **full new issue body** (original ticket description preserved on gh issue edit N --repo SGAOperations/aplio --body-file .temp/plan-N.md ``` -**Route declaration (config tickets).** If any file in your **## Changes** section is `CLAUDE.md` or lives under `.claude/`, the plan body's **first line — directly under the `## Implementation Plan` heading, before `## Overview` — must be exactly `ROUTE: operator session`**, followed by one sentence naming why. The harness blocks a dispatched subagent from editing those paths, so the cockpit routes stages 2 and 4 to an operator session instead (`.claude/docs/PIPELINE.md` → "Config tickets"). Emit it in **revision mode** too — a revised plan can change route. Never emit it for a plan that touches neither. +**Session-required declaration.** Some tickets can't be handed to a dispatched agent at all. Today that means any plan whose **## Changes** touches `CLAUDE.md` or `.claude/**` — the harness blocks a subagent's `Edit` there — but the marker is deliberately about the _routing_, not the cause, so future categories reuse it. For those plans, the body's **first line, directly under the `## Implementation Plan` heading and before `## Overview`**, is the marker, with the reason after the colon: + +``` +> **SESSION REQUIRED:** touches `CLAUDE.md` / `.claude/**` — a dispatched agent can't edit those +``` + +The literal string `SESSION REQUIRED` is what the cockpit greps for — **never reword it**. The reason after the colon is free text and is the part that generalizes. Emit it in **revision mode** too, since a revised plan can change the routing, and never emit it for a plan that doesn't need a session. Full rules: `.claude/docs/PIPELINE.md` → "Session-required tickets". **Use the fixed structure** in `.claude/docs/PIPELINE.md` → "Implementation plan" — the canonical section list, order, and writing style. Think through how the feature should actually work and look (it's a product/UX design, not just a file checklist), but write it tight: bullets, short sentences, **don't restate the ticket**, omit sections that don't apply. The plan must still _decide_ the substance — even though it's brief: diff --git a/.claude/docs/PIPELINE.md b/.claude/docs/PIPELINE.md index 21c0c003..9b2dfad2 100644 --- a/.claude/docs/PIPELINE.md +++ b/.claude/docs/PIPELINE.md @@ -97,19 +97,18 @@ Rule: **every stage agent's first action is swapping its trigger label for its i ### Issue labels -| Label | Set by | Type | Meaning | -| ------------------------ | ---------------------------------------- | --------- | ---------------------------------------------------------------------- | -| `claude` | Cockpit (at opt-in) | marker | Claude is handling this ticket | -| `ready` | Cockpit (at opt-in) | trigger | Dispatch `plan-agent` | -| `planning` | `plan-agent` | in-flight | Plan being researched/written | -| `plan review` | `plan-agent` | gate | Plan written — awaiting human approval in cockpit | -| `plan changes requested` | Cockpit (human feedback) | trigger | Dispatch `plan-agent` in revision mode | -| `plan approved` | Cockpit (human approval, or `auto plan`) | trigger | Dispatch `impl-agent` | -| `auto plan` | Cockpit (at opt-in) | marker | Plan gate skipped: `plan review` auto-approved | -| `operator route` | Cockpit (at the plan gate) | marker | Stages 2/4 run in an operator session (`/implement`); never dispatched | -| `in progress` | `impl-agent` | in-flight | Implementation underway | -| `pr opened` | `impl-agent` | terminal | PR open; remaining state tracked on the PR | -| `blocked` | `impl-agent` | gate | Needs human decision; details in issue comment | +| Label | Set by | Type | Meaning | +| ------------------------ | ---------------------------------------- | --------- | ------------------------------------------------- | +| `claude` | Cockpit (at opt-in) | marker | Claude is handling this ticket | +| `ready` | Cockpit (at opt-in) | trigger | Dispatch `plan-agent` | +| `planning` | `plan-agent` | in-flight | Plan being researched/written | +| `plan review` | `plan-agent` | gate | Plan written — awaiting human approval in cockpit | +| `plan changes requested` | Cockpit (human feedback) | trigger | Dispatch `plan-agent` in revision mode | +| `plan approved` | Cockpit (human approval, or `auto plan`) | trigger | Dispatch `impl-agent` | +| `auto plan` | Cockpit (at opt-in) | marker | Plan gate skipped: `plan review` auto-approved | +| `in progress` | `impl-agent` | in-flight | Implementation underway | +| `pr opened` | `impl-agent` | terminal | PR open; remaining state tracked on the PR | +| `blocked` | `impl-agent` | gate | Needs human decision; details in issue comment | ### PR labels @@ -124,7 +123,6 @@ Rule: **every stage agent's first action is swapping its trigger label for its i | `needs human` | Cockpit / `revise-agent` | gate | 5 cycles without convergence, or an ambiguous rebase conflict needing the author; pipeline stops | | `refresh branch` | Cockpit / human | trigger | Dispatch `revise-agent` in refresh mode — rebase onto base and force-push, no code changes | | `refreshing` | `revise-agent` | in-flight | Branch refresh underway; the PR's other labels (e.g. `approved`) are left in place | -| `operator route` | `/implement` (at PR creation) | marker | Stages 2/4 run in an operator session; the cockpit never dispatches `revise-agent` for it | ## Stages and models @@ -139,7 +137,7 @@ Rule: **every stage agent's first action is swapping its trigger label for its i All four workers read `.claude/docs/ENGINEERING.md` before working; the review agent treats it as a review dimension. -**Stages 2 and 4 have an operator variant.** A ticket touching `CLAUDE.md` or `.claude/**` can't be implemented by a dispatched agent (the harness denies its `Edit`), so those two stages run in the operator's own session via `/implement`, following these same agent files — see "Config tickets". +**Stages 2 and 4 have an operator variant.** Some tickets can't be implemented by a dispatched agent at all — today, any that touch `CLAUDE.md` or `.claude/**`, where the harness denies its `Edit`. Those two stages then run in the operator's own session via `/implement`, following these same agent files — see "Session-required tickets". ## Permission rationale @@ -165,19 +163,41 @@ Permission mode: every stage agent runs **`permissionMode: dontAsk`** (auto-deny **CI merge gate — `approval-check.yml`.** PRs into `dev` are gated on the `approved` label **only when the PR carries `claude`** (a job-level `if:`). Every other PR — human, Dependabot — gets a `skipped` check run, which GitHub counts as satisfied, so it merges on its own merits. This is deliberately **fail-open**: an unlabelled pipeline PR is indistinguishable in CI from a human one and simply loses its gate. Nothing in the workflow can close that, so the mitigations live upstream — `impl-agent` passes `--label "claude"` at `gh pr create` (so the gate is live on the PR's first event) and the cockpit's **ungated-PR sweep** reports any tracked PR missing it. Never narrow the workflow's trigger to exclude a PR: a workflow that never runs creates no check run, leaving the required check pending forever. -## Config tickets (`CLAUDE.md` / `.claude/**`) +## Session-required tickets + +**Some tickets can't be handed to a dispatched agent at all, so stages 2 and 4 run in the operator's own session.** Today there is exactly one such category — anything touching `CLAUDE.md` or `.claude/**` — but the mechanism is built around the _routing_, not the cause, so a future category reuses it by supplying a different reason. **The harness denies `Edit`/`Write` under `.claude/` to dispatched subagents, and `settings.json` cannot grant it back.** This repo already allows `Edit(**)`/`Write(**)` with no `.claude/` deny rule and the denial persists anyway — it sits above project config, so there is nothing to fix in the permission model. An operator's **main session** is unaffected: reading `.claude/agents/impl-agent.md` and acting on it spawns no subagent, so no subagent restriction applies. That asymmetry is the whole basis of this route. -**The route** — `plan-agent` declares it, the cockpit marks it, an operator runs it: +### The marker + +One string, one rendering, both surfaces — **`SESSION REQUIRED`**, with the reason after the colon: + +``` +> **SESSION REQUIRED:** touches `CLAUDE.md` / `.claude/**` — a dispatched agent can't edit those +``` + +| Surface | Written by | Where | +| --------- | ------------ | --------------------------------------------------------------------------------- | +| **Issue** | `plan-agent` | First line of the plan body, under `## Implementation Plan`, before `## Overview` | +| **PR** | `/implement` | Directly under `Closes #N` in the PR description | + +The literal string `SESSION REQUIRED` is the contract — **never reword it**; the reason after the colon is free text and is the part that generalizes. **There is deliberately no label.** The marker lives in the body on both surfaces, and the cockpit reads it from the `body` field of the trigger query it already runs, so the check costs no extra call and there is nothing to keep in sync: + +```bash +gh issue list --repo SGAOperations/aplio --assignee "@me" --label "plan approved" --json number,title,body +gh pr list --repo SGAOperations/aplio --assignee "@me" --label "needs revision" --json number,title,body +``` + +### The route -1. **Declare.** When the plan's **## Changes** touches `CLAUDE.md` or `.claude/**`, its first line is exactly `ROUTE: operator session` (see "Implementation plan"). -2. **Mark.** At the plan gate the cockpit reads the issue body for that sentinel and adds **`operator route`** in the same edit as `plan approved`. The marker is sticky and travels to the PR (`/implement` passes `--label "operator route"` to `gh pr create`), so every later cockpit decision is a pure label check. -3. **Run.** The operator opens a **named session** and runs `/implement `. That skill resolves the stage from the item's labels, creates a worktree under `.claude/worktrees/impl-`, and follows the **unmodified** `impl-agent.md` / `revise-agent.md` plus a short list of subagent-only overrides. The override list lives in the skill and nowhere else — one place to drift, one place to check. +1. **Declare.** `plan-agent` emits the marker when the plan needs it (see "Implementation plan"). +2. **Skip dispatch.** The cockpit finds it in the trigger query's `body` and **announces the command instead of dispatching**. The item keeps its trigger label. +3. **Run.** The operator opens a **named session** and runs `/implement `. That skill resolves the stage from the item's labels, creates a worktree under `.claude/worktrees/impl-`, follows the **unmodified** `impl-agent.md` / `revise-agent.md` plus a short list of subagent-only overrides, and repeats the marker in the PR description it writes. The override list lives in the skill and nowhere else — one place to drift, one place to check. -**What moves and what doesn't.** Only stages **2** and **4**. `plan-agent` and `review-agent` are read-only and work through `gh`, so stages 1 and 3 run unchanged. `refresh branch` is still dispatched to `revise-agent` — a rebase and force-push edit no files, and a conflict inside `CLAUDE.md` / `.claude/docs/**` is already on the never-touch list and escalates. +**What moves and what doesn't.** Only stages **2** and **4**. `plan-agent` and `review-agent` are read-only and work through `gh`, so stages 1 and 3 run unchanged — a session-required ticket is **not** out of the pipeline. `refresh branch` is still dispatched to `revise-agent` too: a rebase and force-push edit no files, and a conflict inside `CLAUDE.md` / `.claude/docs/**` is already on the never-touch list and escalates. -**The invariant this deviates from.** Everywhere else a trigger label means something is dispatching. An `operator route` item **keeps** its trigger label (`plan approved` / `needs revision`) and is **never** dispatched — the cockpit announces the command instead. Recovery is unchanged (re-apply the trigger), but the label alone no longer implies motion. That is why `operator route` appears in the cockpit's gate queries, its unowned sweep, and its `status` output: an unowned config ticket is otherwise invisible in a way an unowned normal ticket isn't, because no cockpit will ever nag about it. +**The invariant this deviates from.** Everywhere else a trigger label means something is dispatching. A session-required item **keeps** its trigger label (`plan approved` / `needs revision`) and is **never** dispatched — the cockpit announces instead. Recovery is unchanged (re-apply the trigger), but the label alone no longer implies motion, which is why `status` has to call these out explicitly: nothing else distinguishes one from an item that is genuinely mid-flight. **Session naming.** These sessions are long-lived and several run at once, so launch each with the issue number in its display name: @@ -189,7 +209,7 @@ claude -n "#503: operator config route" # then, in that session: /implement 50 **Always in a worktree.** `/implement` never works in the main checkout, for two reasons: editing `.claude/` from the session that is _using_ it mutates your live configuration mid-task, and the ticket may be editing the very agent file the session is following. In a worktree the session reads its instructions by absolute path from the main checkout while every edit lands on the worktree copy, so the committed behaviour holds for the whole run. Reclaim the worktree with `/worktree-clean` once the PR merges. -**Unverified:** whether the manual escape hatch `claude --agent impl-agent` carries the same restriction (its frontmatter forces `dontAsk` and worktree isolation). Untested — use `/implement` for config tickets regardless. If a future Claude Code version lifts the harness restriction, this whole route can be deleted; the sentinel and the label are the only things to unwind. +**Unverified:** whether the manual escape hatch `claude --agent impl-agent` carries the same restriction (its frontmatter forces `dontAsk` and worktree isolation). Untested — use `/implement` regardless. If a future Claude Code version lifts the harness restriction, this whole route can be deleted; the marker is the only thing to unwind. ## Pipeline output formats @@ -209,7 +229,7 @@ Every plan, review, summary, and comment is written for a human scanning fast: Appended below the ticket under a `---` then `## Implementation Plan`; revision mode replaces only that block. **Do not restate the ticket** — reference it. Fixed sections in this order; the conditional ones appear **only when they apply** (omit otherwise — no stub): -- **`ROUTE: operator session`** _(only when the plan touches `CLAUDE.md` or `.claude/**`)_ — a bare line before `## Overview`, exactly that text, plus one sentence naming why. It routes stages 2 and 4 to an operator session; see "Config tickets". +- **`SESSION REQUIRED` marker** _(only when the ticket can't be dispatched to an agent — today, when it touches `CLAUDE.md` or `.claude/**`)_ — the first line, before `## Overview`. Exact format and rules: "Session-required tickets". - **## Overview** — 2–4 sentences: what, why, the approach. - **## Changes** — files to create/modify, one bullet each: `` `path` — one-line reason ``. - **## Implementation** — ordered `- [ ]` checkboxes, one line each; fold validation / states / error-model notes into the step they belong to. @@ -311,7 +331,7 @@ Closing the cockpit session also halts dispatch (it is the only dispatcher) but | Labels manually changed on GitHub | Fine — labels are the source of truth | The next tick acts on whatever the labels say | | Stale worktrees / orphan `node_modules` dirs accumulating under `.claude/worktrees/` | Agents cut off mid-run; on Windows the harness leaves dirs git can't delete | Run **`/worktree-clean`** from the main checkout — it prunes registrations and force-deletes orphan dirs (`git worktree remove` alone fails with `Invalid argument` once `node_modules` exists). The cockpit reports these but never auto-deletes them. | | An agent stopped with `BLOCKED:` or hit `maxTurns` | Clean stop by design (not a crash) | Resolve the blocker (or widen scope/permissions), then `retry #N` | -| An issue sits at `plan approved`, or a PR at `needs revision`, and nothing dispatches | It carries `operator route` — a config ticket (`CLAUDE.md` / `.claude/**`) the cockpit never dispatches for | Open a named session and run it yourself: `claude -n "#N: "`, then `/implement `. See "Config tickets" | +| An issue sits at `plan approved`, or a PR at `needs revision`, and nothing dispatches | Its body carries the `SESSION REQUIRED` marker — the cockpit never dispatches for those | Open a named session and run it yourself: `claude -n "#N: "`, then `/implement `. See "Session-required tickets" | ## Reading current state without the cockpit diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index 415a4206..ce9bae39 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -1,6 +1,6 @@ --- name: implement -description: Run pipeline stage 2 or 4 yourself, in your own session, for a config ticket — one touching CLAUDE.md or .claude/**, which a dispatched agent cannot edit. Resolves the stage from the item's labels, works in a dedicated worktree, and follows the existing impl-agent / revise-agent definitions unchanged. Manual only. Usage: /implement +description: Run pipeline stage 2 or 4 yourself, in your own session, for a ticket marked SESSION REQUIRED — one that cannot be handed to a dispatched agent, today because it touches CLAUDE.md or .claude/**. Resolves the stage from the item's labels, works in a dedicated worktree, and follows the existing impl-agent / revise-agent definitions unchanged. Manual only. Usage: /implement disable-model-invocation: true allowed-tools: Read, Edit, Write, Glob, Grep, Bash, AskUserQuestion --- @@ -9,7 +9,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, AskUserQuestion **Trigger:** manual, in an operator's own session. **Input:** an issue or PR number (``). **Repo:** `SGAOperations/aplio`. -The harness denies `Edit`/`Write` under `.claude/` to **dispatched subagents**, and `settings.json` cannot grant it back — so `impl-agent` and `revise-agent` can't run for tickets that touch `CLAUDE.md` or `.claude/**`. Your session has no such restriction. This skill is a **thin wrapper**: it points you at the existing agent definition and overrides only the rules that exist because that agent is a subagent. Background and rationale: `.claude/docs/PIPELINE.md` → "Config tickets". +The harness denies `Edit`/`Write` under `.claude/` to **dispatched subagents**, and `settings.json` cannot grant it back — so `impl-agent` and `revise-agent` can't run for tickets that touch `CLAUDE.md` or `.claude/**`. Your session has no such restriction. Those tickets are marked **`SESSION REQUIRED`** in their body, and the cockpit announces them instead of dispatching. This skill is a **thin wrapper**: it points you at the existing agent definition and overrides only the rules that exist because that agent is a subagent. Background and rationale: `.claude/docs/PIPELINE.md` → "Session-required tickets". **The agent files are the workflow. Do not modify them, and do not reimplement them here.** @@ -46,7 +46,7 @@ gh pr view --repo SGAOperations/aplio --json labels,headRefName,baseRefName, - Anything else → stop, report the current labels, change nothing: `# is not awaiting an operator (labels: …). Nothing was changed.` -**If the item carries the trigger label but not `operator route`,** ask first (AskUserQuestion): _"#412 has no `operator route` label, so the cockpit may dispatch `impl-agent` for it too. Proceed anyway / Cancel."_ A double dispatch — cockpit and operator on the same item — is the hazard this guards. +**If the item carries the trigger label but its body has no `SESSION REQUIRED` marker,** ask first (AskUserQuestion): _"#412 isn't marked `SESSION REQUIRED`, so the cockpit will dispatch an agent for it too. Proceed anyway / Cancel."_ A double dispatch — cockpit and operator on the same item — is the hazard this guards. Then report the resolved mode, worktree path and branch before the slow steps, so the operator can see you picked the right stage. @@ -81,7 +81,15 @@ In revise mode, rebase onto the base branch from inside the worktree: `git rebas Read the resolved agent file from the **main checkout** and follow it end to end: label swaps, the plan checklist, the `.temp/commit-msg.txt` commit format, the three CI checks, push by refspec, PR body format, base `dev`, the issue's assignee, thread resolution, the revision note. Also read `.claude/docs/ENGINEERING.md` and its **Pre-PR self-check**, as the agent file requires. -**One addition to `gh pr create` (impl mode):** pass `--label "operator route"` so the marker travels to the PR — the same precedent as copying the assignee, and it makes every later cockpit decision a pure label check. +**Carry the marker into the PR (impl mode).** The cockpit re-reads it on the PR to decide stage 4, so the PR description must repeat it verbatim, directly under `Closes #N`: + +``` +Closes #503 + +> **SESSION REQUIRED:** touches `CLAUDE.md` / `.claude/**` — a dispatched agent can't edit those +``` + +Same literal string as the issue plan, same rendering — `.claude/docs/PIPELINE.md` → "Session-required tickets". **No label is involved on either surface.** Otherwise `gh pr create` is exactly as the agent file specifies: ```bash gh pr create --repo SGAOperations/aplio \ @@ -89,7 +97,6 @@ gh pr create --repo SGAOperations/aplio \ --title "# " \ --body-file .temp/pr-.md \ --assignee "" \ - --label "operator route" \ --head -ticket-name-in-kebab-case ``` diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md index ea3d2cb7..0863628e 100644 --- a/.claude/skills/pipeline/SKILL.md +++ b/.claude/skills/pipeline/SKILL.md @@ -18,7 +18,7 @@ You are the orchestrator of the agent pipeline in `.claude/docs/PIPELINE.md`. Th - Never act on issues/PRs that lack a pipeline **trigger** label — opt-in is human-initiated. - **Never act on an item assigned to another operator** — ownership transfers only through an explicit human take-over (see Ownership). - Never dispatch for an item with an **in-flight** label (`planning`, `in progress`, `reviewing`, `revising`, `refreshing`) — an agent owns it or a human paused it. -- **Never dispatch `impl-agent` or `revise-agent` for an item labelled `operator route`** — the harness blocks a dispatched agent from editing `CLAUDE.md` / `.claude/**`, so stages 2 and 4 run in the operator's own session. **Announce the command instead** (see Operator-route items). `review-agent` and refresh-mode dispatches are unaffected. +- **Never dispatch `impl-agent` or `revise-agent` for an item whose body carries the `SESSION REQUIRED` marker** — those tickets can't be handed to an agent (today: they touch `CLAUDE.md` / `.claude/**`, which the harness won't let a subagent edit). **Announce the command instead** (see Session-required items). `review-agent` and refresh-mode dispatches are never gated by it. - Every dispatch runs in the background (`run_in_background: true`). Worktree isolation, model, tool scope, and **permission mode (`dontAsk` — auto-denies anything not allow-listed)** all come from the subagent definition in `.claude/agents/` — you do not set them at the call site. (Since CC v2.1.186 a background subagent's prompts surface to you unless it runs `dontAsk` **and** this session is in default mode — see Model & permission mode.) - **Respect the draining flag:** while draining (see Stop controls), dispatch nothing new and schedule no wakeup; only report state and relay completions. @@ -36,9 +36,9 @@ On start and on every wakeup, run one polling pass: # Trigger labels → dispatch — this operator's items only gh issue list --repo SGAOperations/aplio --assignee "@me" --label "ready" --json number,title gh issue list --repo SGAOperations/aplio --assignee "@me" --label "plan changes requested" --json number,title -gh issue list --repo SGAOperations/aplio --assignee "@me" --label "plan approved" --json number,title +gh issue list --repo SGAOperations/aplio --assignee "@me" --label "plan approved" --json number,title,body gh pr list --repo SGAOperations/aplio --assignee "@me" --label "ready for review" --json number,title -gh pr list --repo SGAOperations/aplio --assignee "@me" --label "needs revision" --json number,title +gh pr list --repo SGAOperations/aplio --assignee "@me" --label "needs revision" --json number,title,body gh pr list --repo SGAOperations/aplio --assignee "@me" --label "refresh branch" --json number,title # Gates and announcements → talk to the human — this operator's items only @@ -46,12 +46,10 @@ gh issue list --repo SGAOperations/aplio --assignee "@me" --label "plan review" gh issue list --repo SGAOperations/aplio --assignee "@me" --label "blocked" --json number,title gh pr list --repo SGAOperations/aplio --assignee "@me" --label "approved" --json number,title gh pr list --repo SGAOperations/aplio --assignee "@me" --label "needs human" --json number,title -gh issue list --repo SGAOperations/aplio --assignee "@me" --label "operator route" --json number,title,labels -gh pr list --repo SGAOperations/aplio --assignee "@me" --label "operator route" --json number,title,labels # Unowned sweep → report only, never act (see Ownership above) -gh issue list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready" or . == "plan changes requested" or . == "plan approved" or . == "plan review" or . == "blocked" or . == "operator route"))]' -gh pr list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready for review" or . == "needs revision" or . == "approved" or . == "needs human" or . == "operator route"))]' +gh issue list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready" or . == "plan changes requested" or . == "plan approved" or . == "plan review" or . == "blocked"))]' +gh pr list --repo SGAOperations/aplio --search "no:assignee" --limit 100 --json number,title,labels --jq '[.[] | select(.labels | map(.name) | any(. == "ready for review" or . == "needs revision" or . == "approved" or . == "needs human"))]' # Ungated-PR sweep → report only, never act (see Ungated report below) gh pr list --repo SGAOperations/aplio --assignee "@me" --json number,title,labels --jq '[.[] | select((.labels | map(.name)) as $l | ($l | any(. == "ready for review" or . == "reviewing" or . == "needs revision" or . == "revising" or . == "approved" or . == "refresh branch" or . == "refreshing" or . == "needs human")) and ($l | index("claude") | not)) | {number, title}]' @@ -104,14 +102,16 @@ Agent({ Stage → trigger mapping: -| Trigger query result | subagent_type | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| Issue labeled `ready` | `plan-agent` (fresh plan) | -| Issue labeled `plan changes requested` | `plan-agent` (revision) | -| Issue labeled `plan approved` | `impl-agent` — **unless `operator route`: announce, never dispatch** | -| PR labeled `ready for review` | `review-agent` | -| PR labeled `needs revision` | `revise-agent` — **after the cycle-cap check**; **unless `operator route`: announce, never dispatch** | -| PR labeled `refresh branch` | `revise-agent` in **refresh mode** | +| Trigger query result | subagent_type | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| Issue labeled `ready` | `plan-agent` (fresh plan) | +| Issue labeled `plan changes requested` | `plan-agent` (revision) | +| Issue labeled `plan approved` | `impl-agent` — **unless `SESSION REQUIRED`: announce, never dispatch** | +| PR labeled `ready for review` | `review-agent` | +| PR labeled `needs revision` | `revise-agent` — **after the cycle-cap check**; **unless `SESSION REQUIRED`: announce, never dispatch** | +| PR labeled `refresh branch` | `revise-agent` in **refresh mode** | + +**Session-required items never dispatch.** Before dispatching `impl-agent` or `revise-agent`, check that item's `body` for the literal string `SESSION REQUIRED`. It is already in the trigger query's result (both queries request `body`), so this costs no extra call. Present → **announce, don't dispatch** (see Session-required items); absent → dispatch normally. For a refresh, say so in the prompt so the agent takes its Refresh mode path: `Run your pipeline stage for PR # in refresh mode (label: refresh branch).` @@ -136,38 +136,27 @@ then notify the human. For each issue labeled `plan review`: -- **Read the route first** — the plan declares whether stages 2/4 can be dispatched at all: - - ```bash - gh issue view --repo SGAOperations/aplio --json body --jq '.body | contains("ROUTE: operator session")' - ``` - - `true` means a **config ticket** (`CLAUDE.md` / `.claude/**`) — add `operator route` in the **same** label edit that approves the plan, and announce instead of dispatching (see Operator-route items). - -- **Without `auto plan`:** summarize the plan from the issue body in a few sentences, then ask (AskUserQuestion): **Approve** / **Request changes** / **Discuss**. - - Approve → - ```bash - gh issue edit --repo SGAOperations/aplio --remove-label "plan review" --add-label "plan approved" # route false - gh issue edit --repo SGAOperations/aplio --remove-label "plan review" --add-label "plan approved,operator route" # route true - ``` - Route `false` → impl dispatches this tick. Route `true` → dispatch nothing; announce the operator command. +- **Without `auto plan`:** summarize the plan from the issue body in a few sentences, then ask (AskUserQuestion): **Approve** / **Request changes** / **Discuss**. If the plan carries the `SESSION REQUIRED` marker, say so in the summary — the human should learn at the gate that they'll be running this one themselves. + - Approve → `gh issue edit --repo SGAOperations/aplio --remove-label "plan review" --add-label "plan approved"` (impl dispatches this tick — **unless** the plan is session-required, in which case this tick announces instead). - Request changes → write the human's feedback to `.temp/feedback-.md`, `gh issue comment --repo SGAOperations/aplio --body-file .temp/feedback-.md`, then `--remove-label "plan review" --add-label "plan changes requested"`. - Discuss → converse; finish with one of the two transitions above. -- **With `auto plan`:** run the same route check, swap `plan review` → `plan approved` (plus `operator route` when the route is `true`) immediately, no interaction — and dispatch impl this tick **only** when the route is `false`. +- **With `auto plan`:** swap `plan review` → `plan approved` immediately, no interaction, and dispatch impl this tick (same session-required exception). + +The gate applies **no special label** for a session-required plan — the marker is already in the issue body, and the dispatch step reads it from there. -### Operator-route items +### Session-required items -An item labelled `operator route` keeps its trigger label but is **never** dispatched (`.claude/docs/PIPELINE.md` → "Config tickets"). Announce it **once per session per item** — the same tracked-announcement pattern as `approved` PRs — and take no other action. Derive the session name from the issue title: `#: <2–5 lowercase words>`. +An item whose body carries the `SESSION REQUIRED` marker keeps its trigger label but is **never** dispatched (`.claude/docs/PIPELINE.md` → "Session-required tickets"). Announce it **once per session per item** — the same tracked-announcement pattern as `approved` PRs — and take no other action. Derive the session name from the **issue** title: `#: <2–5 lowercase words>`. -- **Issue at `plan approved` + `operator route`:** +- **Issue at `plan approved` + marker:** - > 🧰 #503 is a **config ticket** (touches `CLAUDE.md` / `.claude/**`) — dispatched agents can't edit those. Open a named session and run it yourself: + > 🧰 #503 needs its own session — its plan is marked `SESSION REQUIRED` (touches `CLAUDE.md` / `.claude/**`, which a dispatched agent can't edit). Run it yourself: > `claude -n "#503: operator config route"` then `/implement 503` > I'll pick it back up at review. -- **PR at `needs revision` + `operator route`** (announce **after** the cycle-cap check, which still runs and can still escalate to `needs human`): +- **PR at `needs revision` + marker** (announce **after** the cycle-cap check, which still runs and can still escalate to `needs human`): - > 🧰 PR #512 needs revision and is a config ticket — `claude -n "#503: operator config route"` then `/implement 512` in your own session. I'll review again once it's back at `ready for review`. (The session name carries the **issue** number; the command takes the PR number.) + > 🧰 PR #512 needs revision and is marked `SESSION REQUIRED` — `claude -n "#503: operator config route"` then `/implement 512` in your own session. I'll review again once it's back at `ready for review`. (The session name carries the **issue** number; the command takes the PR number.) ### Approved PRs @@ -203,7 +192,7 @@ Interpret intent, not literal syntax: Dispatch the plan agent the same tick. - **"scope out X" / "break down X"** — Stage 0 deserves a stronger model than haiku; suggest the human run `/scope` in their main session. -- **"status"** — re-run the tick queries **live** and build the table from them (never from session memory): each in-flight item + stage, each item waiting on the human, and each PR currently labeled `approved` (the live `gh pr list --assignee "@me" --label approved` result — a merged PR has already dropped out, so it must not appear). It **inherits the assignee filter**, so it reports only this operator's items; append the unowned sweep result as a separate **"unowned"** line, and the ungated-PR sweep result as an **"ungated"** line, so a stalled ticket or a PR with no approval gate is diagnosable from one command. List `operator route` items under the human-gated group with the commands to run, e.g. `#503 — plan approved · operator route → claude -n "#503: operator config route" · /implement 503`. +- **"status"** — re-run the tick queries **live** and build the table from them (never from session memory): each in-flight item + stage, each item waiting on the human, and each PR currently labeled `approved` (the live `gh pr list --assignee "@me" --label approved` result — a merged PR has already dropped out, so it must not appear). It **inherits the assignee filter**, so it reports only this operator's items; append the unowned sweep result as a separate **"unowned"** line, and the ungated-PR sweep result as an **"ungated"** line, so a stalled ticket or a PR with no approval gate is diagnosable from one command. List **session-required** items under the human-gated group with the commands to run, e.g. `#503 — plan approved · SESSION REQUIRED → claude -n "#503: operator config route" · /implement 503`. - **"pause #N"** — remove the item's current trigger label; confirm what was removed. Same ownership rule as opt-in: if the item belongs to **another operator**, say so and stop rather than touch its labels. - **"resume #N" / "retry #N"** — re-apply the trigger label for where it stalled (issue stuck in `planning` → `ready`; PR stuck in `revising` → `needs revision`; PR stuck in `refreshing` → `refresh branch`; etc.). Same ownership rule as opt-in: if the item is **unassigned**, add `--add-assignee "@me"` in the same command (re-applying a trigger to an unassigned item is a no-op for every cockpit); if it belongs to **another operator**, say so and stop rather than re-trigger. - **"refresh #N"** — apply `refresh branch` to that PR and dispatch it this tick, bypassing the per-merge cap. Use it to force a fresh preview deployment on a PR left quota-red. Same ownership rule as opt-in. @@ -231,4 +220,4 @@ Background-agent completions wake this session automatically; the scheduled wake ## Manual / recovery -Each stage is also runnable by hand without the cockpit — @-mention the subagent (e.g. `@agent-impl-agent implement #142`) or run a whole session as it via `claude --agent impl-agent`. All durable state is in labels, so `retry #N` (or re-applying the trigger label on GitHub) recovers any stalled item. **Exception:** an `operator route` item is never dispatched — run `/implement ` in your own named session, since no subagent can edit `CLAUDE.md` / `.claude/**`. +Each stage is also runnable by hand without the cockpit — @-mention the subagent (e.g. `@agent-impl-agent implement #142`) or run a whole session as it via `claude --agent impl-agent`. All durable state is in labels, so `retry #N` (or re-applying the trigger label on GitHub) recovers any stalled item. **Exception:** an item marked `SESSION REQUIRED` is never dispatched — run `/implement ` in your own named session. diff --git a/.claude/skills/worktree-clean/SKILL.md b/.claude/skills/worktree-clean/SKILL.md index c4efa899..6c9444b6 100644 --- a/.claude/skills/worktree-clean/SKILL.md +++ b/.claude/skills/worktree-clean/SKILL.md @@ -7,7 +7,7 @@ allowed-tools: Read, Bash # Clean up pipeline worktrees -Pipeline agents run in isolated worktrees under `.claude/worktrees/agent-*`; operator-run config tickets (`/implement`) add `.claude/worktrees/impl-*`. Both are in scope here. On Windows, once an agent has run `npm ci`, the harness often **de-registers** the worktree (removes its `.git` file) but **can't delete the directory** — the populated `node_modules` (and long/`(app)`-parenthesized paths) defeat `git worktree remove` (`Invalid argument`) and `git worktree prune` (which only clears registrations whose directory is already gone). These **orphan directories** then accumulate (hundreds of MB of `node_modules` each). The cockpit deliberately does **not** force-delete them in its autonomous loop; this skill does it, interactively, run by you from the **main checkout**. +Pipeline agents run in isolated worktrees under `.claude/worktrees/agent-*`; operator-run session-required tickets (`/implement`) add `.claude/worktrees/impl-*`. Both are in scope here. On Windows, once an agent has run `npm ci`, the harness often **de-registers** the worktree (removes its `.git` file) but **can't delete the directory** — the populated `node_modules` (and long/`(app)`-parenthesized paths) defeat `git worktree remove` (`Invalid argument`) and `git worktree prune` (which only clears registrations whose directory is already gone). These **orphan directories** then accumulate (hundreds of MB of `node_modules` each). The cockpit deliberately does **not** force-delete them in its autonomous loop; this skill does it, interactively, run by you from the **main checkout**. > **Scope guard:** this skill only ever deletes directories **directly under `.claude/worktrees/`**. Never delete anything outside that directory, never the main checkout, and never a worktree of an **in-flight** pipeline item (check `gh pr list`/the cockpit first if unsure). diff --git a/CLAUDE.md b/CLAUDE.md index de0f021a..4bebadda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ All tests live under `tests/`, never co-located with the source they cover: `tes ## Worktrees & local dev - Pipeline agents get their own isolated worktree automatically (`isolation: worktree`) — they handle setup; see `.claude/docs/PIPELINE.md`. Do not script worktree creation for them. -- **Config tickets are implemented by you, not by an agent** — anything touching `CLAUDE.md` or `.claude/**`, because the harness blocks a dispatched agent's `Edit` there. The plan declares it, the cockpit labels it `operator route` and announces the command: launch a named session (`claude -n "#XXX: "`) and run `/implement XXX`, which runs stage 2/4 in its own worktree. See `.claude/docs/PIPELINE.md` → "Config tickets". +- **Some tickets are implemented by you, not by an agent** — today, anything touching `CLAUDE.md` or `.claude/**`, because the harness blocks a dispatched agent's `Edit` there. The plan marks these `SESSION REQUIRED` and the cockpit announces instead of dispatching: launch a named session (`claude -n "#XXX: "`) and run `/implement XXX`, which runs stage 2/4 in its own worktree. See `.claude/docs/PIPELINE.md` → "Session-required tickets". - For manual local work in a worktree, install deps with `npm ci` (then `npm run prisma:generate`). **Do not `ln -s node_modules` — symlinks fall back to copies on Windows here.** Sync before resuming: `git fetch origin && git rebase origin/dev`. - **`npm ci` is what activates Git hooks** — it runs `prepare` (`husky && npm run hooks:check`), which regenerates the untracked `.husky/_` bootstrap dir, sets `core.hooksPath`, and fails `npm ci` itself if activation didn't take. Each worktree/clone needs its own `npm ci` for hooks to fire there. If hooks stop firing, re-run `npm ci` (or `npm run prepare`) and verify with `npm run hooks:check`. - **The same step sets `core.commentChar=';'`** — git's default `#` makes it strip the mandated `#XXX` subject as a comment every time it re-reads a message through the editor machinery (`git rebase --continue`, `git commit --amend`), silently promoting the first body line into the subject; the `commit-msg` hook does not run on that path. Side effect: git's own instructional lines in the commit editor are `;`-prefixed. This writes to the shared `.git/config`, so one worktree's `npm ci` fixes every worktree of that clone. It cannot be enforced across fresh clones or forks, which is why CI validates subjects too. From b15a8fa580b2b4723abbc5e99761af0b8d170044 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 19:03:07 -0400 Subject: [PATCH 31/47] #503 pass the claude label when /implement opens its PR #513 gated approval-check.yml on the claude label, so an operator-run PR opened without it would merge with no approval gate. Co-Authored-By: Claude Opus 5 --- .claude/skills/implement/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index ce9bae39..4cfbd131 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -89,7 +89,7 @@ Closes #503 > **SESSION REQUIRED:** touches `CLAUDE.md` / `.claude/**` — a dispatched agent can't edit those ``` -Same literal string as the issue plan, same rendering — `.claude/docs/PIPELINE.md` → "Session-required tickets". **No label is involved on either surface.** Otherwise `gh pr create` is exactly as the agent file specifies: +Same literal string as the issue plan, same rendering — `.claude/docs/PIPELINE.md` → "Session-required tickets". **The routing marker is never a label.** The one label `gh pr create` does pass is `claude`, exactly as the agent file specifies — it **activates the approval gate** (`approval-check.yml` runs only on PRs carrying it), so a PR opened without it merges with no gate at all: ```bash gh pr create --repo SGAOperations/aplio \ @@ -97,6 +97,7 @@ gh pr create --repo SGAOperations/aplio \ --title "# " \ --body-file .temp/pr-.md \ --assignee "" \ + --label "claude" \ --head -ticket-name-in-kebab-case ``` From caa1c2b4af53f0e25f878f9eace7858a3e516194 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 17:44:25 -0400 Subject: [PATCH 32/47] #391 pin node 24 across engines, nvmrc, and ci Reconciles the three-way local/CI/README disagreement so node-version-file: package.json in CI actually resolves to something. Co-Authored-By: Claude Sonnet 4.6 --- .github/dependabot.yml | 3 + .nvmrc | 1 + README.md | 2 +- package-lock.json | 171 +++++++++++++++++++++-------------------- package.json | 8 +- 5 files changed, 100 insertions(+), 85 deletions(-) create mode 100644 .nvmrc diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 201c4a87..f0a51810 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,6 @@ updates: radix-ui: patterns: - '@radix-ui/*' + ignore: + - dependency-name: '@types/node' + update-types: ['version-update:semver-major'] diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 9fc2abb9..9f283e9d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Aplio is an internal recruiting and application platform. Admins and managers cr ### Prerequisites -- **Node.js** 22+ LTS (see `@types/node` in `package.json`) +- **Node.js** 24.x (pinned in `engines`/`.nvmrc`; see `@types/node` in `package.json`) - **npm** (comes with Node) - **Docker** (for a local Postgres instance via `docker-compose.yml`) **or** a hosted Neon Postgres URL diff --git a/package-lock.json b/package-lock.json index e66dc02c..28b30ce4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/node": "^26", + "@types/node": "^24.13.3", "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -67,7 +67,12 @@ "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.10" + }, + "engines": { + "node": "24.x", + "npm": ">=10" } }, "node_modules/@alloc/quick-lru": { @@ -5265,12 +5270,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/pg": { @@ -5310,17 +5315,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", - "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/type-utils": "8.58.2", - "@typescript-eslint/utils": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5333,15 +5338,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.2", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -5349,16 +5354,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", - "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -5374,14 +5379,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", - "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.2", - "@typescript-eslint/types": "^8.58.2", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -5396,14 +5401,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", - "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5414,9 +5419,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", - "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -5431,15 +5436,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", - "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5456,9 +5461,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", - "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -5470,16 +5475,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", - "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.2", - "@typescript-eslint/tsconfig-utils": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/visitor-keys": "8.58.2", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5508,26 +5513,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -5537,9 +5542,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5550,16 +5555,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", - "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.2", - "@typescript-eslint/types": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5574,13 +5579,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", - "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -13504,16 +13509,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", - "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.2", - "@typescript-eslint/parser": "8.58.2", - "@typescript-eslint/typescript-estree": "8.58.2", - "@typescript-eslint/utils": "8.58.2" + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13556,9 +13561,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unified": { diff --git a/package.json b/package.json index 545cbb6a..cba25a08 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "aplio", "version": "1.6.0", "private": true, + "engines": { + "node": "24.x", + "npm": ">=10" + }, "scripts": { "dev": "next dev --turbopack", "build": "prisma generate && next build", @@ -14,6 +18,7 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:migrate:deploy": "prisma migrate deploy", + "prisma:migrate:status": "prisma migrate status", "prisma:seed": "npx tsx --env-file=.env prisma/seed.ts", "db:start": "docker compose up -d", "db:stop": "docker compose down", @@ -73,7 +78,7 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/node": "^26", + "@types/node": "^24.13.3", "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -87,6 +92,7 @@ "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", "typescript": "6.0.3", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.10" } } From 2a570da7d0afb8a5a3d925681c4a5bf1fb661484 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 17:48:00 -0400 Subject: [PATCH 33/47] #391 enable noUncheckedIndexedAccess and modernize tsconfig target target ES2017 -> ES2022; add noUncheckedIndexedAccess, noUnusedLocals, noUnusedParameters and fix every access site the flags surfaced. Co-Authored-By: Claude Sonnet 4.6 --- components/features/application-answers-list.tsx | 2 +- components/features/application-stepper.tsx | 2 +- components/features/markdown-field.tsx | 4 ++-- components/features/profile-question.tsx | 2 +- lib/auth/redirect.ts | 2 +- lib/constants.ts | 8 ++++---- lib/dates.ts | 11 +++++++++-- lib/utils.ts | 2 +- prisma/data/applications.ts | 5 ++--- prisma/seed/helpers.ts | 6 +++++- tests/helpers/fixtures.ts | 3 ++- tsconfig.json | 5 ++++- 12 files changed, 33 insertions(+), 19 deletions(-) diff --git a/components/features/application-answers-list.tsx b/components/features/application-answers-list.tsx index 025cd2be..3a43831f 100644 --- a/components/features/application-answers-list.tsx +++ b/components/features/application-answers-list.tsx @@ -33,7 +33,7 @@ export function ApplicationAnswersList({ questionId: answer.questionId, isGlobal: answer.isGlobal, }} - url={answer.value[0]} + url={answer.value[0] ?? ''} /> ) : answer.value.length === 1 ? (

{answer.value[0]}

diff --git a/components/features/application-stepper.tsx b/components/features/application-stepper.tsx index bd496e49..28b7713e 100644 --- a/components/features/application-stepper.tsx +++ b/components/features/application-stepper.tsx @@ -99,7 +99,7 @@ function ReadOnlyQuestionCard({ // Read-only shows the profile's own answer, so the target is profile-scoped. ) : question.type === 'multiple_choice' ? (
diff --git a/components/features/markdown-field.tsx b/components/features/markdown-field.tsx index 501099b6..fe40fd1e 100644 --- a/components/features/markdown-field.tsx +++ b/components/features/markdown-field.tsx @@ -153,8 +153,8 @@ function continueList(el: HTMLTextAreaElement): boolean { const match = ordered ?? unordered; if (!match) return false; - const indent = match[1]; - const rest = ordered ? ordered[5] : unordered ? unordered[4] : ''; + const indent = match[1] ?? ''; + const rest = (ordered ? ordered[5] : unordered ? unordered[4] : '') ?? ''; if (rest.trim() === '') { // Empty marker line — remove it and exit the list. diff --git a/components/features/profile-question.tsx b/components/features/profile-question.tsx index 2bf4a72a..51b30917 100644 --- a/components/features/profile-question.tsx +++ b/components/features/profile-question.tsx @@ -155,7 +155,7 @@ export function ProfileQuestion({ ) : question.type === 'file_upload' ? ( ) : question.type === 'multiple_choice' ? (
diff --git a/lib/auth/redirect.ts b/lib/auth/redirect.ts index a1d7279f..e81fec2b 100644 --- a/lib/auth/redirect.ts +++ b/lib/auth/redirect.ts @@ -27,7 +27,7 @@ export function sanitizeRedirectTo(value: unknown): string | null { if (typeof value !== 'string') return null; if (!SAFE_PATH.test(value)) return null; - const pathname = value.split(/[?#]/)[0]; + const pathname = value.split(/[?#]/)[0] ?? value; // Rejected rather than normalized — no in-app destination needs traversal. if (pathname.split('/').includes('..')) return null; if (pathname === '/login' || pathname.startsWith('/login/')) return null; diff --git a/lib/constants.ts b/lib/constants.ts index da7afa0f..f7f12c6a 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -233,21 +233,21 @@ export function getAnswerValueError( case 'short_answer': if (value.length > 1) return 'Only one answer is allowed for this question.'; - if (value[0].length > ANSWER_SHORT_MAX_LENGTH) + if ((value[0] ?? '').length > ANSWER_SHORT_MAX_LENGTH) return `Answer must be ${ANSWER_SHORT_MAX_LENGTH} characters or fewer.`; return null; case 'long_answer': if (value.length > 1) return 'Only one answer is allowed for this question.'; - if (value[0].length > ANSWER_LONG_MAX_LENGTH) + if ((value[0] ?? '').length > ANSWER_LONG_MAX_LENGTH) return `Answer must be ${ANSWER_LONG_MAX_LENGTH} characters or fewer.`; return null; case 'single_choice': { if (value.length > 1) return 'Only one answer is allowed for this question.'; - const entry = value[0]; + const entry = value[0] ?? ''; if (question.options.includes(entry)) return null; // Not a current option — no free text allowed, or this is the "Other" entry. if (!question.allowOther) @@ -273,7 +273,7 @@ export function getAnswerValueError( return 'Only one "Other" answer is allowed.'; if ( nonOptionEntries.length === 1 && - nonOptionEntries[0].length > ANSWER_OTHER_MAX_LENGTH + (nonOptionEntries[0] ?? '').length > ANSWER_OTHER_MAX_LENGTH ) return `Answer must be ${ANSWER_OTHER_MAX_LENGTH} characters or fewer.`; return null; diff --git a/lib/dates.ts b/lib/dates.ts index 11de7e2c..4092230b 100644 --- a/lib/dates.ts +++ b/lib/dates.ts @@ -29,6 +29,13 @@ function zoneOffsetMs(timeZone: string, at: Date): number { return asUTC - at.getTime(); } +function parseOrgDay(day: string): [number, number, number] { + const [year, month, date] = day.split('-').map(Number); + if (year === undefined || month === undefined || date === undefined) + throw new Error(`Invalid org day: ${day}`); + return [year, month, date]; +} + // `naive` is org-local wall time encoded as UTC fields; resolves it to the real // UTC instant. Two-pass since the offset itself depends on the instant (DST). function resolveOrgWallClock(naive: Date): Date { @@ -40,7 +47,7 @@ function resolveOrgWallClock(naive: Date): Date { /** `YYYY-MM-DD` (org-local calendar day) → the UTC instant of that day's start. */ export function orgDayStart(day: string): Date { - const [year, month, date] = day.split('-').map(Number); + const [year, month, date] = parseOrgDay(day); return resolveOrgWallClock( new Date(Date.UTC(year, month - 1, date, 0, 0, 0, 0)), ); @@ -49,7 +56,7 @@ export function orgDayStart(day: string): Date { // `YYYY-MM-DD` (org-local calendar day) → the UTC instant of that day's end (23:59:59.999). // Resolves the whole second first — zoneOffsetMs's Date.UTC always carries ms=0. export function orgDayEnd(day: string): Date { - const [year, month, date] = day.split('-').map(Number); + const [year, month, date] = parseOrgDay(day); const wholeSecond = resolveOrgWallClock( new Date(Date.UTC(year, month - 1, date, 23, 59, 59, 0)), ); diff --git a/lib/utils.ts b/lib/utils.ts index 628248b9..3a386770 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -100,7 +100,7 @@ export function partitionAnswerValue( const fittedIndex = value.findIndex((v) => question.options.includes(v)); if (fittedIndex === -1) return { fitted: [], orphaned: value }; return { - fitted: [value[fittedIndex]], + fitted: [value[fittedIndex] as string], orphaned: [ ...value.slice(0, fittedIndex), ...value.slice(fittedIndex + 1), diff --git a/prisma/data/applications.ts b/prisma/data/applications.ts index 419a35da..e749740e 100644 --- a/prisma/data/applications.ts +++ b/prisma/data/applications.ts @@ -276,9 +276,8 @@ export async function getApplications( ); const yearPart = parts.find((p) => /^\d{4}$/.test(p)); if (monthIdx !== -1 && yearPart) { - const monthNum = MONTH_NAMES.findIndex((m) => - parts[monthIdx].startsWith(m), - ); + const monthPart = parts[monthIdx] as string; + const monthNum = MONTH_NAMES.findIndex((m) => monthPart.startsWith(m)); const y = parseInt(yearPart, 10); dateWhere = { submittedAt: { diff --git a/prisma/seed/helpers.ts b/prisma/seed/helpers.ts index 92224984..982947d1 100644 --- a/prisma/seed/helpers.ts +++ b/prisma/seed/helpers.ts @@ -27,7 +27,11 @@ export function utcDayOffset(now: Date, days: number): Date { * would land on the wrong org-local day depending on the season. */ export function orgDayOffset(now: Date, days: number): string { - const [year, month, date] = toOrgDayString(now).split('-').map(Number); + const [year, month, date] = toOrgDayString(now).split('-').map(Number) as [ + number, + number, + number, + ]; const shifted = new Date(Date.UTC(year, month - 1, date + days)); return shifted.toISOString().slice(0, 10); } diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts index 0893e939..72e977bb 100644 --- a/tests/helpers/fixtures.ts +++ b/tests/helpers/fixtures.ts @@ -127,7 +127,8 @@ export async function answerAllRequiredGlobalQuestions( await Promise.all( questions.map((q) => { - const value = q.options.length > 0 ? [q.options[0]] : ['placeholder']; + const value = + q.options.length > 0 ? [q.options[0] as string] : ['placeholder']; return prisma.globalAnswer.upsert({ where: { userId_globalQuestionId: { userId: user.id, globalQuestionId: q.id }, diff --git a/tsconfig.json b/tsconfig.json index a3b019e1..7f62f3e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,13 @@ { "compilerOptions": { - "target": "ES2017", + "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", From 9dd1e0330e0a5e3993d89d73a9322c118d8058a3 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 19 Aug 2026 18:03:18 -0400 Subject: [PATCH 34/47] #391 enable type-aware eslint rules and fix every violation Adds no-floating-promises, no-misused-promises, no-unnecessary-type-assertion, no-unnecessary-condition, switch-exhaustiveness-check, and a no-restricted-imports guard on @/lib/prisma reaching non-data/action code. lib/prisma.ts gets import 'server-only' as the guard's runtime twin. Co-Authored-By: Claude Sonnet 4.6 --- components/features/activity-feed.tsx | 3 +- components/features/answer-file-link.tsx | 2 +- components/features/application-question.tsx | 18 +++--- components/features/application-stepper.tsx | 7 ++- components/features/applications-table.tsx | 2 +- .../features/global-question-dialog.tsx | 4 +- .../features/global-questions-table.tsx | 2 +- components/features/login-view.tsx | 6 +- components/features/name-field.tsx | 2 +- components/features/pipeline-summary.tsx | 3 +- components/features/position-card.tsx | 3 +- components/features/position-details-form.tsx | 2 +- .../features/position-question-dialog.tsx | 4 +- components/features/profile-question.tsx | 16 +++--- components/features/question-file-field.tsx | 6 -- components/features/status-badge.tsx | 4 +- components/ui/form-dialog.tsx | 2 +- components/ui/form.tsx | 6 +- components/ui/input-otp.tsx | 2 +- eslint.config.mjs | 55 ++++++++++++++++++- lib/auth/errors.ts | 4 ++ lib/prisma.ts | 4 +- lib/use-sortable-table.ts | 7 +-- prisma/actions/profile.ts | 2 +- prisma/data/applications.ts | 2 +- 25 files changed, 104 insertions(+), 64 deletions(-) diff --git a/components/features/activity-feed.tsx b/components/features/activity-feed.tsx index 1e08758c..edd60ce4 100644 --- a/components/features/activity-feed.tsx +++ b/components/features/activity-feed.tsx @@ -48,8 +48,7 @@ function ActivityFeedList({ items, emptyDescription }: ActivityFeedListProps) {
    {items.map((item) => { const dotClass = - STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant] ?? - 'bg-muted-foreground'; + STATUS_BADGE_VARIANT_TO_DOT[item.statusVariant]; return (
  1. void handleDownload()} disabled={isPending} aria-label={`Download ${filename}`} > diff --git a/components/features/application-question.tsx b/components/features/application-question.tsx index df2baae8..c307b8d9 100644 --- a/components/features/application-question.tsx +++ b/components/features/application-question.tsx @@ -142,7 +142,7 @@ export function ApplicationQuestion({ onChange={(e) => field.onChange(e.target.value ? [e.target.value] : []) } - onBlur={handleBlur} + onBlur={() => void handleBlur()} maxLength={ANSWER_SHORT_MAX_LENGTH} aria-required={question.required} aria-invalid={!!error} @@ -158,7 +158,7 @@ export function ApplicationQuestion({ onChange={(e) => field.onChange(e.target.value ? [e.target.value] : []) } - onBlur={handleBlur} + onBlur={() => void handleBlur()} className="min-h-[120px]" maxLength={ANSWER_LONG_MAX_LENGTH} aria-required={question.required} @@ -206,7 +206,7 @@ export function ApplicationQuestion({ setOtherSelected(false); setOtherText(''); field.onChange([option]); - save([option]); + void save([option]); }} className="accent-primary size-4" /> @@ -226,7 +226,7 @@ export function ApplicationQuestion({ setOtherSelected(true); const next = otherText ? [otherText] : []; field.onChange(next); - save(next); + void save(next); }} className="accent-primary size-4" /> @@ -252,7 +252,7 @@ export function ApplicationQuestion({ setOtherText(e.target.value); field.onChange(e.target.value ? [e.target.value] : []); }} - onBlur={handleBlur} + onBlur={() => void handleBlur()} aria-labelledby={`${labelId} ${question.id}-other-label`} maxLength={ANSWER_OTHER_MAX_LENGTH} /> @@ -282,7 +282,7 @@ export function ApplicationQuestion({ ? [...fitted, option] : fitted.filter((v) => v !== option); field.onChange(next); - save(next); + void save(next); }} /> {option} @@ -304,12 +304,12 @@ export function ApplicationQuestion({ ? [...checkedOptions, otherText] : checkedOptions; field.onChange(next); - save(next); + void save(next); } else { // Drops the typed text immediately, so it isn't resubmitted. setOtherText(''); field.onChange(checkedOptions); - save(checkedOptions); + void save(checkedOptions); } }} /> @@ -342,7 +342,7 @@ export function ApplicationQuestion({ : checkedOptions, ); }} - onBlur={handleBlur} + onBlur={() => void handleBlur()} aria-labelledby={`${labelId} ${question.id}-other-label`} maxLength={ANSWER_OTHER_MAX_LENGTH} /> diff --git a/components/features/application-stepper.tsx b/components/features/application-stepper.tsx index 28b7713e..2fd4bb88 100644 --- a/components/features/application-stepper.tsx +++ b/components/features/application-stepper.tsx @@ -501,7 +501,7 @@ export function ApplicationStepper({ variant={isCustomizing ? 'default' : 'outline'} size="sm" className="mt-0.5 shrink-0" - onClick={handleToggleCustomize} + onClick={() => void handleToggleCustomize()} disabled={isReverting} > {isCustomizing @@ -603,7 +603,10 @@ export function ApplicationStepper({ > Back -