From 09460fb2258b7e8f198c27727b2b611d08ce61b7 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Wed, 30 Sep 2026 15:13:41 +0200 Subject: [PATCH 1/5] Give the buddy a map of the app: get_app_guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The buddy could only answer "how do I create a project?" or "where do I set up roles?" from search_docs, which holds the project's material and has never heard of Sprintstart's own pages. - AppGuide: a hand-written catalog of the app's pages (route, where to find it, what it is for, how-tos in the UI's own labels, who may open it), mirroring the frontend's accessPolicy. - BuddyAppGuideTools: renders the guide for the caller only — filtered by their permission group and, for a PM, by the projects they manage — and lists what somebody else has to do (e.g. only an admin creates a project). Names the page the caller is on when the client sends it. - Mounted in both the hire's buddy and team mode. - SendBuddyMessageRequest.currentPage, threaded through to the tool. - UserApi.getPermissionGroup: the highest role, as the frontend sees it. Co-Authored-By: Claude Opus 5.5 --- .../onboarding/controller/BuddyController.kt | 8 +- .../request/buddy/SendBuddyMessageRequest.kt | 7 + .../onboarding/service/AppGuide.kt | 348 ++++++++++++++++++ .../onboarding/service/BuddyAppGuideTools.kt | 184 +++++++++ .../onboarding/service/BuddyService.kt | 6 +- .../onboarding/service/BuddyTeamService.kt | 8 +- .../onboarding/service/BuddyTeamTools.kt | 5 + .../onboarding/service/BuddyToolExecutor.kt | 12 +- .../onboarding/service/TeamAreaTools.kt | 2 + .../user/external/UserApi.kt | 13 + .../user/service/UserApiService.kt | 9 + .../controller/BuddyControllerTest.kt | 20 + .../onboarding/service/AppGuideTest.kt | 70 ++++ .../service/BuddyAppGuideToolsTest.kt | 144 ++++++++ .../onboarding/service/BuddyTeamToolsTest.kt | 17 + .../service/BuddyToolExecutorTest.kt | 16 + 16 files changed, 863 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt create mode 100644 src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt create mode 100644 src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt index 34db08d25..fdcdbbac5 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt @@ -217,13 +217,19 @@ class BuddyController( ): Flow { val teamProjectId = request.teamProjectId return if (teamProjectId == null) { - buddyService.sendMessageForMe(jwt.subject, request.content, request.capabilitiesEnabled) + buddyService.sendMessageForMe( + jwt.subject, + request.content, + request.capabilitiesEnabled, + request.currentPage, + ) } else { buddyTeamService.sendMessageForMe( jwt.subject, teamProjectId, request.content, request.capabilitiesEnabled, + request.currentPage, ) } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt index 33c3beebd..fd50ad468 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt @@ -27,4 +27,11 @@ data class SendBuddyMessageRequest( * field names the project, it never authorises anything. */ val teamProjectId: UUID? = null, + /** + * The app path the sender was on (`/team-management`, `/team/…`), so "where is this on the page + * I'm on?" can be answered. Only ever matched against the app guide, never echoed; absent from + * older clients. Not validated: an odd value must cost the guide a line, never the hire their + * message. + */ + val currentPage: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt new file mode 100644 index 000000000..addcbcee5 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt @@ -0,0 +1,348 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.user.external.enums.Role + +/** + * One thing a person can do on a page, and who may do it. + * + * @property task What it achieves, in the words somebody would ask for it ("create a project"). + * @property steps Where to click, in the UI's own labels. + * @property doers Who may do it, when that is narrower than who may open the page. A page HR can + * open can still hold a button only an admin may press, and a guide that ignores the difference + * sends HR to a button that refuses them. + */ +data class AppGuideHowTo( + val task: String, + val steps: String, + val doers: Set? = null, +) + +/** + * One page of the Sprintstart app, described for the buddy. + * + * @property path The route, which is also how the buddy links to the page. Always root-relative, + * so the chat renders it as an in-app link rather than a new tab. A `{placeholder}` marks an id the + * buddy has to fill in from another tool before linking. + * @property matches Prefixes of detail routes that belong to this page (`/team/` for one member), + * so the page somebody is looking at can be named even when its URL carries an id. + * @property roles Who may open it — mirrors the frontend's `accessPolicy.ts` `routePermissions`. + * @property managerScoped Whether a PM additionally has to manage the selected project — mirrors + * `MANAGER_ASSIGNMENT_ROUTES` there. HR and admins are gated by role alone. + */ +data class AppGuidePage( + val name: String, + val path: String, + val whereToFind: String, + val purpose: String, + val howTos: List = emptyList(), + val roles: Set = EVERYONE, + val managerScoped: Boolean = false, + val matches: List = emptyList(), +) { + /** Whether [path] needs an id filled in before it can be linked (`/team/{member_id}`). */ + val needsId: Boolean get() = '{' in path +} + +private val EVERYONE = setOf(Role.USER, Role.PM, Role.HR, Role.ADMIN) +private val MANAGERS = setOf(Role.PM, Role.HR, Role.ADMIN) +private val ADMIN_HR = setOf(Role.HR, Role.ADMIN) +private val PM_ADMIN = setOf(Role.PM, Role.ADMIN) +private val ADMIN_ONLY = setOf(Role.ADMIN) + +/** + * The map of the app the buddy reads from, so "where do I…" is answered from the app rather than + * from the project's documentation — which describes the *project*, and has never heard of a page + * called Team Management. + * + * ### Keeping it true + * + * This is a hand-written copy of what the frontend shows, and it goes stale the moment a page is + * renamed there. Two things keep that visible rather than silent: the labels are the UI's own + * (sidebar entries, tab names, button text — quoted, so a rename is a grep away), and + * `AppGuideTest` pins the route list, so adding or removing a page here is a deliberate edit. + * The roles mirror `sprintstart-frontend/src/auth/accessPolicy.ts`; when that file changes, this + * one has to follow. + * + * Deliberately no page for the login screen, the skill wizard or the 404: nobody asks the buddy + * how to reach them, and every entry costs prompt space on every question that does. + */ +object AppGuide { + /** Not a page: the project selector every project-scoped page follows. */ + const val PROJECT_SWITCHER = + "Most pages show the project selected in the account menu at the bottom of the sidebar " + + "(click your avatar, then \"Switch project\"). A page that looks empty or shows the " + + "wrong team is usually showing a different project." + + /** Not a page either: the buddy itself, which lives on every page. */ + const val BUDDY_EVERYWHERE = + "The buddy is the floating window in the corner of every page (drag it to another " + + "corner), and also a full page at /buddy next to the Chat." + + val pages: List = listOf( + AppGuidePage( + name = "Dashboard", + path = "/", + whereToFind = "sidebar, \"Dashboard\" (the start page)", + purpose = "Personal overview: greeting, next step, onboarding progress, skills, " + + "recent chats and project widgets.", + howTos = listOf( + AppGuideHowTo( + "change which widgets show", + "add or remove widgets with the widget picker; \"Reset your dashboard\" " + + "restores the default layout", + ), + ), + ), + AppGuidePage( + name = "Board", + path = "/board", + whereToFind = "sidebar, \"Board\"", + purpose = "The hire's own board: the current task, path steps, notes, checklists and " + + "links (many placed by the buddy), arranged in stages and areas.", + ), + AppGuidePage( + name = "Chat", + path = "/chat", + whereToFind = "sidebar, \"Chat\"", + purpose = "Ask the AI questions about the project's documentation and code; answers " + + "cite their sources. Separate from the buddy, which also knows the person's " + + "own onboarding.", + matches = listOf("/chat/"), + ), + AppGuidePage( + name = "Buddy", + path = "/buddy", + whereToFind = "the Buddy tab next to Chat, or the floating buddy on any page", + purpose = "The onboarding buddy as a full page.", + ), + AppGuidePage( + name = "Knowledge Base", + path = "/knowledge-base", + whereToFind = "sidebar, \"Knowledge Base\"", + purpose = "Browse the project's ingested documentation, code runbooks and artifacts.", + ), + AppGuidePage( + name = "OnBoarding", + path = "/onboarding", + whereToFind = "sidebar, \"OnBoarding\" (shown until their onboarding is finished)", + purpose = "The person's onboarding path: phases, steps and knowledge-check questions, " + + "in the order they unlock.", + howTos = listOf( + AppGuideHowTo( + "work through a step", + "open the step in the current phase, start it, and mark it done when finished; " + + "\"Ask the buddy\" on a step or question brings it into this chat", + ), + ), + matches = listOf("/onboarding/"), + ), + AppGuidePage( + name = "Settings", + path = "/settings", + whereToFind = "the gear / account menu at the bottom of the sidebar, \"Settings\"", + purpose = "Tabs \"User Profile\" (name, profile icon, GitHub username), \"Appearance\" " + + "(theme) and \"Access Tokens\".", + howTos = listOf( + AppGuideHowTo( + "set a GitHub username so pull requests are found", + "Settings → \"User Profile\"", + ), + ), + ), + AppGuidePage( + name = "PM Dashboard", + path = "/pm-dashboard", + whereToFind = "sidebar, \"PM Dashboard\" (the project manager section)", + purpose = "The selected project at a glance: ingestion status, \"Team overview\", and " + + "\"Insights\" (recurring questions, knowledge gaps, onboarding metrics).", + howTos = listOf( + AppGuideHowTo( + "see how the team's onboarding is going", + "the \"Team overview\" card; click it for Team Management", + ), + ), + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Team Management", + path = "/team-management", + whereToFind = "PM Dashboard → the \"Team overview\" card (it has no sidebar entry)", + purpose = "Tabs \"User Management\" (every member and their onboarding progress) and " + + "\"Role Management\" (the project's roles and the skills each needs).", + howTos = listOf( + AppGuideHowTo( + "create a project role", + "\"Role Management\" tab → \"Create role\" (name and what it is responsible for)", + ), + AppGuideHowTo( + "give a role its skills", + "\"Role Management\" → pick the role → \"Skills\": add one, or \"Suggest skills\"", + ), + AppGuideHowTo( + "assign members to a role", + "\"Role Management\" → pick the role → \"Members\"; \"Without a role\" lists who " + + "has none yet", + ), + AppGuideHowTo( + "open one member", + "\"User Management\" tab → click the person", + ), + ), + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Team member", + path = "/team/{member_id}", + whereToFind = "Team Management → \"User Management\" → click a person", + purpose = "One member: their roles, current step, onboarding path, feedback and skip " + + "requests, and skill and knowledge gaps.", + howTos = listOf( + AppGuideHowTo( + "choose or change a member's role", + "click their role chip (or \"Choose role\" when they have none) under their name " + + "→ \"Manage Roles\"", + ), + AppGuideHowTo( + "rebuild a member's onboarding path with AI", + "the rebuild button in the member's header", + doers = PM_ADMIN, + ), + ), + roles = MANAGERS, + managerScoped = true, + matches = listOf("/team/"), + ), + AppGuidePage( + name = "Data Ingestion", + path = "/data-ingestion", + whereToFind = "sidebar, \"Data Ingestion\" (the project manager section)", + purpose = "The material the AI and the buddy answer from, for the selected project, " + + "and whether it is ingested yet.", + howTos = listOf( + AppGuideHowTo( + "connect GitHub, Jira, Confluence or upload documents", + "\"Add sources\" → pick the kind → \"Connect\"", + ), + ), + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Blueprints", + path = "/blueprints", + whereToFind = "sidebar, \"Blueprints\" (the project manager section)", + purpose = "Reusable onboarding path templates (\"Global blueprints\" and \"Project " + + "blueprints\"). Editing one never changes a hire's live path.", + howTos = listOf( + AppGuideHowTo( + "create an onboarding blueprint", + "\"New blueprint path\", then add phases and steps in the \"Graph\" or \"Outline\" " + + "view", + ), + AppGuideHowTo( + "see earlier versions of a blueprint", + "open it → \"Version history\"", + ), + ), + roles = MANAGERS, + managerScoped = true, + matches = listOf("/blueprints/"), + ), + AppGuidePage( + name = "Hire Setup", + path = "/hire-setup", + whereToFind = "sidebar, \"Hire Setup\" (the project manager section)", + purpose = "What a new hire needs before they start and the first work waiting for them: " + + "tabs \"Arrival steps\" (/hire-setup?tab=arrival) and \"Starter work\" " + + "(/hire-setup?tab=starter).", + howTos = listOf( + AppGuideHowTo( + "write the arrival checklist (accounts, access, setup)", + "Hire Setup → \"Arrival steps\" tab", + doers = PM_ADMIN, + ), + AppGuideHowTo( + "review the first tasks offered to hires", + "Hire Setup → \"Starter work\" tab", + doers = PM_ADMIN, + ), + ), + roles = MANAGERS, + ), + AppGuidePage( + name = "Escalation Inbox", + path = "/insights/knowledge-requests", + whereToFind = "sidebar, \"Escalation Inbox\" (the project manager section)", + purpose = "Questions hires flagged because the buddy could not answer them.", + howTos = listOf( + AppGuideHowTo( + "answer a hire's escalated question", + "open it in the inbox and answer; the answer becomes team knowledge the buddy " + + "uses from then on", + doers = PM_ADMIN, + ), + ), + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "FAQ", + path = "/insights/faq", + whereToFind = "PM Dashboard → \"Insights\" → the FAQ widget", + purpose = "The questions the team asks most, grouped.", + roles = MANAGERS, + managerScoped = true, + matches = listOf("/insights/faq/"), + ), + AppGuidePage( + name = "Knowledge gaps", + path = "/insights/knowledge-gaps", + whereToFind = "PM Dashboard → \"Insights\" → the knowledge gaps widget", + purpose = "Topics the documentation does not cover well enough, from what people asked.", + roles = MANAGERS, + managerScoped = true, + matches = listOf("/insights/knowledge-gaps/"), + ), + AppGuidePage( + name = "Onboarding metrics", + path = "/insights/onboarding", + whereToFind = "PM Dashboard → \"Insights\" → the onboarding metrics widget", + purpose = "Contribution metrics per hire: pull requests, review waits, stalls.", + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Access Management", + path = "/admin", + whereToFind = "sidebar, \"Access Management\"", + purpose = "Tabs \"Users\", \"Projects\" and \"Tokens\" for the whole organisation.", + howTos = listOf( + AppGuideHowTo( + "create a project", + "\"Projects\" tab → \"New Project\": Details (name, description, industry, " + + "project manager) → Members → Sources → Review", + doers = ADMIN_ONLY, + ), + AppGuideHowTo( + "make somebody a project's manager, or add people to a project", + "\"Projects\" tab → open the project", + doers = ADMIN_ONLY, + ), + AppGuideHowTo( + "change somebody's permission group (member, PM, HR, admin)", + "\"Users\" tab → open the user", + doers = ADMIN_ONLY, + ), + ), + roles = ADMIN_HR, + ), + ) + + /** The page [pathname] belongs to, or null for a route this guide does not describe. */ + fun pageAt(pathname: String): AppGuidePage? = + // Detail routes first: `/team/…` must not be read as Team Management's own path. + pages.firstOrNull { page -> page.matches.any { pathname.startsWith(it) } } + ?: pages.firstOrNull { it.path == pathname } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt new file mode 100644 index 000000000..a7ef34ddf --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt @@ -0,0 +1,184 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto +import com.sprintstart.sprintstartbackend.user.external.UserApi +import com.sprintstart.sprintstartbackend.user.external.enums.Role +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * The buddy's knowledge of the app itself: which pages the caller can open, what is on them, how to + * do things there, and who to ask for what they cannot do. + * + * Before this the buddy had one place to look for "how do I create a project?" — `search_docs` — + * and that corpus is the *project's* material, which has never heard of Sprintstart's pages. It + * either said nothing useful or described a screen that does not exist. + * + * Mounted for everybody, in both the hire's buddy and team mode: everybody uses the app, and what + * changes with the reader is what the guide says, not whether it exists. + * + * ### Read for the caller, never for somebody else + * + * The guide is filtered by the caller's own permission group and, for a PM, by the projects they + * actually manage — the same two facts the frontend's route guard decides on. A page the caller + * cannot open is never offered as a place to go; what happens there is listed as somebody else's + * job, with who that somebody is. Telling a PM "go to Access Management → New Project" sends them to + * a page their sidebar does not have. + * + * This is guidance, not authorisation: every page still enforces its own access, so a guide that + * drifted from the frontend would mislead, never let anybody in. + */ +@Component +class BuddyAppGuideTools( + private val userApi: UserApi, +) { + /** + * The guide for [userId], naming the page they are on when [currentPage] is known. + * + * @param currentPage The path the caller's browser was on when they spoke (query and fragment + * are ignored). Whatever the client sent, so it is only ever matched against the guide, never + * echoed back verbatim. + */ + fun guideFor(userId: UUID, currentPage: String?): String { + val role = userApi.getPermissionGroup(userId) ?: Role.USER + val managed = if (role == Role.PM) managedProjectNames(userId) else emptyList() + val reader = Reader(role, managesAProject = role != Role.PM || managed.isNotEmpty()) + + val openable = AppGuide.pages.filter { reader.opens(it) } + val elsewhere = AppGuide.pages.flatMap { page -> + page.howTos + .filter { !reader.opens(page) || (it.doers != null && role !in it.doers) } + .map { page to it } + } + + return buildString { + appendLine(whoIsAsking(role, managed)) + here(currentPage)?.let { appendLine(it) } + appendLine() + appendLine("Pages they can open (link each as [Name](path)):") + openable.forEach { page -> appendPage(page, role) } + if (elsewhere.isNotEmpty()) { + appendLine() + appendLine("Only somebody else can do these — say who to ask, never send them to the page:") + elsewhere.forEach { (page, howTo) -> + val who = describeDoers(howTo.doers ?: page.roles) + appendLine("- ${howTo.task}: $who, on ${page.name} (${howTo.steps})") + } + } + if (reader.blockedFromManagerPages()) { + appendLine() + appendLine( + "They hold the PM role but manage no project, so the manager pages (PM Dashboard, " + + "Team Management, Data Ingestion, Blueprints, the insights) stay hidden. An admin " + + "makes somebody a project's manager in Access Management → Projects.", + ) + } + appendLine() + appendLine("Anywhere in the app:") + appendLine("- ${AppGuide.PROJECT_SWITCHER}") + append("- ${AppGuide.BUDDY_EVERYWHERE}") + } + } + + private fun StringBuilder.appendPage(page: AppGuidePage, role: Role) { + val link = if (page.needsId) "${page.path} — fill the id in from another tool" else page.path + appendLine("- ${page.name} ($link) — ${page.whereToFind}") + appendLine(" ${page.purpose}") + page.howTos + .filter { it.doers == null || role in it.doers } + .forEach { appendLine(" · to ${it.task}: ${it.steps}") } + } + + /** The names of the caller's projects that they manage — the PM-only gate on manager pages. */ + private fun managedProjectNames(userId: UUID): List { + val authId = userApi.getAuthIdByUserId(userId).orElse(null) ?: return emptyList() + return userApi + .getUsersByIds(listOf(userId)) + .firstOrNull() + ?.projects + .orEmpty() + .filter { userApi.canManageProject(authId, it.projectId) } + .map { it.name } + } + + private fun whoIsAsking(role: Role, managed: List): String { + val base = "Who is asking: ${ROLE_NAMES.getValue(role)}." + return when { + role != Role.PM -> base + managed.isEmpty() -> "$base They manage no project yet." + else -> "$base They manage: ${managed.joinToString(", ")}." + } + } + + /** The page the caller is looking at, or null when nothing usable was sent. */ + private fun here(currentPage: String?): String? { + val pathname = currentPage + ?.substringBefore('?') + ?.substringBefore('#') + ?.takeIf { it.startsWith("/") && it.length <= MAX_PATH_LENGTH } + ?: return null + val page = AppGuide.pageAt(pathname) + ?: return "They are on a page this guide does not describe." + return "They are looking at: ${page.name} (${page.path}). \"This page\" or \"here\" means this one." + } + + private fun describeDoers(doers: Set): String { + val names = DOER_ORDER.filter { it in doers }.map { DOER_NAMES.getValue(it) } + if (names.size <= 1) return names.joinToString() + return names.dropLast(1).joinToString(", ") + " or " + names.last() + } + + /** The two facts the frontend's route guard decides a page on. */ + private class Reader( + val role: Role, + val managesAProject: Boolean, + ) { + fun opens(page: AppGuidePage): Boolean = + role in page.roles && (!page.managerScoped || role != Role.PM || managesAProject) + + fun blockedFromManagerPages(): Boolean = role == Role.PM && !managesAProject + } + + companion object { + const val GET_APP_GUIDE = "get_app_guide" + + // A path is a few segments and an id; anything longer is not a route of this app. + private const val MAX_PATH_LENGTH = 200 + + private val ROLE_NAMES = mapOf( + Role.USER to "a team member", + Role.PM to "a project manager (PM)", + Role.HR to "HR", + Role.ADMIN to "an admin", + ) + + // Who does it, named the way somebody would go and find them. A PM does manager work for the + // projects they manage, so "the project's manager" is the person to chase, not "a PM". + private val DOER_ORDER = listOf(Role.PM, Role.HR, Role.ADMIN, Role.USER) + private val DOER_NAMES = mapOf( + Role.PM to "the project's manager", + Role.HR to "HR", + Role.ADMIN to "an admin", + Role.USER to "anybody", + ) + + val GET_APP_GUIDE_SPEC = BuddyToolSpecDto( + name = GET_APP_GUIDE, + description = "How the Sprintstart app itself works for the person you are talking to: the " + + "pages they can open, where each one is (the sidebar entry or the card that leads " + + "there), what it is for and how to do things on it — create a project role, choose a " + + "member's role, connect a source, write arrival steps, set a GitHub username — plus " + + "who to ask for what they cannot do themselves, and which page they are looking at " + + "right now. Call it for any 'where do I…', 'how do I … here', 'where is …' or " + + "'what is this page' question about the app. `search_docs` describes the project the " + + "team works on, not this app, so never answer these from it. Takes no arguments — it " + + "always reads the caller.", + parameters = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { }) + }, + ) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt index d72ffcbd9..14876b50b 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt @@ -233,6 +233,7 @@ class BuddyService( authId: String, content: String, capabilitiesEnabled: Boolean = true, + currentPage: String? = null, ): Flow { val userId = resolveUserId(authId) val session = getOrCreateSession(userId) @@ -287,7 +288,7 @@ class BuddyService( next.add( BuddyAgentMessageDto( role = "tool", - content = runToolCall(call, userId), + content = runToolCall(call, userId, currentPage), toolCallId = call.id, ), ) @@ -375,6 +376,7 @@ class BuddyService( private suspend fun FlowCollector.runToolCall( call: BuddyToolCallDto, userId: UUID, + currentPage: String?, ): String = if (buddyActionService.isAction(call.name)) { val outcome = buddyActionService.propose(call, userId) @@ -405,7 +407,7 @@ class BuddyService( outcome.toolResult } else { emit(BuddyStreamEvent(type = "tool_use", name = call.name, kind = "tool")) - buddyToolExecutor.execute(call, userId) + buddyToolExecutor.execute(call, userId, currentPage) } private fun resolveUserId(authId: String): UUID = diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt index 0b5946d6e..143729003 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt @@ -139,6 +139,7 @@ class BuddyTeamService( projectId: UUID, content: String, capabilitiesEnabled: Boolean = true, + currentPage: String? = null, ): Flow { val userId = authorize(authId, projectId) val session = getOrCreateSession(userId, projectId) @@ -153,7 +154,12 @@ class BuddyTeamService( BuddyTeamMessage(session = session, role = BuddyMessageRole.USER, content = content), ) - val context = TeamToolContext(userId = userId, authId = authId, projectId = projectId) + val context = TeamToolContext( + userId = userId, + authId = authId, + projectId = projectId, + currentPage = currentPage, + ) return flow { var messages = history + BuddyAgentMessageDto(role = "user", content = content) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt index aa8aa5941..ba99f6017 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt @@ -42,6 +42,7 @@ class BuddyTeamTools( private val projectMembershipApi: ProjectMembershipApi, private val areaToolsProvider: ObjectProvider, private val buddyProposalService: BuddyProposalService, + private val buddyAppGuideTools: BuddyAppGuideTools, ) { // Resolved lazily: an area's tools may themselves depend on services that depend on this one. private val areaTools: Map by lazy { @@ -63,6 +64,9 @@ class BuddyTeamTools( add(GET_TEAM_ATTENTION_SPEC) add(FIND_MEMBER_SPEC) add(GET_MEMBER_PROGRESS_SPEC) + // A read about the app, not about the team, so outside the areas: "where do I set up + // roles?" has to be answerable before anything is opened. + add(BuddyAppGuideTools.GET_APP_GUIDE_SPEC) if (openableAreas().isNotEmpty()) { add(openAreaSpec()) } @@ -111,6 +115,7 @@ class BuddyTeamTools( GET_TEAM_ATTENTION -> teamAttention(context.projectId) FIND_MEMBER -> findMember(context.projectId, call.stringArg("query")) GET_MEMBER_PROGRESS -> memberProgress(context.projectId, call.stringArg("member_id")) + BuddyAppGuideTools.GET_APP_GUIDE -> buddyAppGuideTools.guideFor(context.userId, context.currentPage) else -> areaTools.values.firstOrNull { it.handles(call.name) }?.execute(call, context) ?: "Unknown tool: ${call.name}." } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt index 62f7745bd..e6571931a 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt @@ -42,6 +42,7 @@ class BuddyToolExecutor( private val projectMembershipApi: ProjectMembershipApi, private val arrivalStepService: ArrivalStepService, private val competencyPlacementService: CompetencyPlacementService, + private val buddyAppGuideTools: BuddyAppGuideTools, ) { /** * The backend tools the AI reasoner is told it may call, for this hire. @@ -86,6 +87,8 @@ class BuddyToolExecutor( add(GET_SUGGESTED_TASKS_SPEC) add(SEARCH_CANONICAL_ANSWERS_SPEC) add(GET_TEAMMATES_SPEC) + // Always mounted: everybody uses the app, and the guide says what this caller can open. + add(BuddyAppGuideTools.GET_APP_GUIDE_SPEC) addAll(buddyBoardTools.toolSpecs()) } @@ -129,8 +132,12 @@ class BuddyToolExecutor( return "Never placed on (a short chat would settle these, and you may offer one): $named" } - /** Executes [call] on behalf of [userId], returning a plain-text result for the model. */ - fun execute(call: BuddyToolCallDto, userId: UUID): String = + /** + * Executes [call] on behalf of [userId], returning a plain-text result for the model. + * + * @param currentPage The app path the caller was on when they spoke, for the app guide. + */ + fun execute(call: BuddyToolCallDto, userId: UUID, currentPage: String? = null): String = when { buddyBoardTools.handles(call.name) -> buddyBoardTools.execute(call, userId) else -> when (call.name) { @@ -142,6 +149,7 @@ class BuddyToolExecutor( GET_SUGGESTED_TASKS -> getSuggestedTasks(userId) SEARCH_CANONICAL_ANSWERS -> searchCanonicalAnswers(userId, call.stringArg("query")) GET_TEAMMATES -> getTeammates(userId) + BuddyAppGuideTools.GET_APP_GUIDE -> buddyAppGuideTools.guideFor(userId, currentPage) else -> "Unknown tool: ${call.name}." } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt index cdb705f17..8e24f4d32 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt @@ -19,6 +19,8 @@ data class TeamToolContext( val userId: UUID, val authId: String, val projectId: UUID, + /** The app path the manager was on when they spoke, for the app guide; null when not sent. */ + val currentPage: String? = null, ) /** diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt index aff2ff7dd..3676b2f70 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt @@ -2,6 +2,7 @@ package com.sprintstart.sprintstartbackend.user.external import com.sprintstart.sprintstartbackend.user.external.dto.UserDto import com.sprintstart.sprintstartbackend.user.external.enums.GithubLoginVerification +import com.sprintstart.sprintstartbackend.user.external.enums.Role import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import java.time.Instant @@ -92,6 +93,18 @@ interface UserApi { */ fun isAdmin(authId: String): Boolean + /** + * Returns the one permission group a user's pages are gated by: the highest of their roles. + * + * The same collapse the user response's `permissionGroup` makes, and the value the frontend's + * access policy decides every route on — so a module describing the app to somebody can describe + * exactly the pages they can open. + * + * @param userId Internal SprintStart user identifier. + * @return The highest role the user holds, or `null` when the user does not exist. + */ + fun getPermissionGroup(userId: UUID): Role? + /** * Returns the GitHub account a user contributes as, if they have declared one. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt index 78b138ea7..60697f4e0 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt @@ -14,6 +14,7 @@ import com.sprintstart.sprintstartbackend.user.model.entity.Project import com.sprintstart.sprintstartbackend.user.model.entity.ProjectRole import com.sprintstart.sprintstartbackend.user.model.entity.ProjectUserAssignment import com.sprintstart.sprintstartbackend.user.model.entity.User +import com.sprintstart.sprintstartbackend.user.model.mapper.effectivePermissionGroup import com.sprintstart.sprintstartbackend.user.model.mapper.toUserApiDto import com.sprintstart.sprintstartbackend.user.repository.ProjectRepository import com.sprintstart.sprintstartbackend.user.repository.UserRepository @@ -277,6 +278,14 @@ class UserApiService( .map { user -> Role.ADMIN in user.roles } .orElse(false) + @Transactional(readOnly = true) + @Tracked("Reading user permission group") + override fun getPermissionGroup(userId: UUID): Role? = + userRepository + .findById(userId) + .map { it.effectivePermissionGroup() } + .orElse(null) + override fun canManageProject(authId: String, projectId: UUID): Boolean { val user = userRepository.findByAuthId(authId).orElse(null) ?: return false diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt index d91c355a9..0ffc648b5 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt @@ -217,6 +217,26 @@ class BuddyControllerTest( coVerify { buddyService.sendMessageForMe(authId, "hi", true) } } + /** The page the hire was on reaches the buddy, so "where is this here?" has a "here". */ + @Test + fun `sendMessageForMe passes the current page through`() { + coEvery { buddyService.sendMessageForMe(authId, "where are roles?", true, "/team-management") } returns + flowOf(BuddyStreamEvent(type = "done")) + + val asyncResult = mockMvc + .perform( + post("/api/v1/onboarding/me/buddy/messages") + .with(userJwt) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"content":"where are roles?","currentPage":"/team-management"}"""), + ).andExpect(request().asyncStarted()) + .andReturn() + + mockMvc.perform(asyncDispatch(asyncResult)).andExpect(status().isOk) + + coVerify { buddyService.sendMessageForMe(authId, "where are roles?", true, "/team-management") } + } + @Test fun `sendMessageForMe should stream tokens and done`() { val events = listOf( diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt new file mode 100644 index 000000000..98b8cbd0b --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt @@ -0,0 +1,70 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.user.external.enums.Role +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class AppGuideTest { + /** + * Pinned on purpose. The guide is a hand-written copy of the frontend's routes, so adding, + * renaming or removing a page has to be a deliberate edit here — check the frontend's + * `AppRouter.tsx` and `accessPolicy.ts` when this fails, not just the expected list. + */ + @Test + fun `describes exactly these routes`() { + assertThat(AppGuide.pages.map { it.path }).containsExactly( + "/", + "/board", + "/chat", + "/buddy", + "/knowledge-base", + "/onboarding", + "/settings", + "/pm-dashboard", + "/team-management", + "/team/{member_id}", + "/data-ingestion", + "/blueprints", + "/hire-setup", + "/insights/knowledge-requests", + "/insights/faq", + "/insights/knowledge-gaps", + "/insights/onboarding", + "/admin", + ) + } + + /** Anything else would render as a new tab to somebody else's site, or not as a link at all. */ + @Test + fun `every path is root-relative`() { + AppGuide.pages.forEach { page -> + assertThat(page.path).startsWith("/").doesNotStartWith("//") + } + } + + /** The frontend only narrows PM access on pages that PM, HR and admins can all open. */ + @Test + fun `a manager-scoped page is open to PM, HR and admins`() { + AppGuide.pages.filter { it.managerScoped }.forEach { page -> + assertThat(page.roles).containsExactlyInAnyOrder(Role.PM, Role.HR, Role.ADMIN) + } + } + + @Test + fun `names the page behind a detail route`() { + assertThat(AppGuide.pageAt("/team/1234")?.name).isEqualTo("Team member") + assertThat(AppGuide.pageAt("/chat/abc")?.name).isEqualTo("Chat") + assertThat(AppGuide.pageAt("/blueprints/abc")?.name).isEqualTo("Blueprints") + } + + /** `/team-management` starts with `/team`, but not with the member page's `/team/`. */ + @Test + fun `does not mistake team management for a member page`() { + assertThat(AppGuide.pageAt("/team-management")?.name).isEqualTo("Team Management") + } + + @Test + fun `knows nothing about a route it does not describe`() { + assertThat(AppGuide.pageAt("/nowhere")).isNull() + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt new file mode 100644 index 000000000..3c345811a --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt @@ -0,0 +1,144 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.user.external.UserApi +import com.sprintstart.sprintstartbackend.user.external.dto.ProjectDto +import com.sprintstart.sprintstartbackend.user.external.dto.UserDto +import com.sprintstart.sprintstartbackend.user.external.enums.Role +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.Optional +import java.util.UUID + +class BuddyAppGuideToolsTest { + private val userId = UUID.randomUUID() + private val managedProject = ProjectDto(projectId = UUID.randomUUID(), name = "Apollo", description = null) + private val otherProject = ProjectDto(projectId = UUID.randomUUID(), name = "Gemini", description = null) + + private val userApi: UserApi = mockk { + every { getAuthIdByUserId(userId) } returns Optional.of("auth|me") + every { getUsersByIds(listOf(userId)) } returns listOf( + UserDto( + id = userId, + username = "me", + firstname = "Sam", + lastname = "Lee", + avatarUrl = null, + profileIcon = null, + projects = setOf(managedProject, otherProject), + projectRoles = emptyList(), + ), + ) + every { canManageProject("auth|me", any()) } returns false + } + + private val tools = BuddyAppGuideTools(userApi) + + private fun guideAs(role: Role?, currentPage: String? = null): String { + every { userApi.getPermissionGroup(userId) } returns role + return tools.guideFor(userId, currentPage) + } + + /** The part of the guide that lists pages the caller may be sent to. */ + private fun openable(guide: String): String = + guide.substringAfter("Pages they can open").substringBefore("Only somebody else") + + /** The part that lists what somebody else has to do. */ + private fun elsewhere(guide: String): String = + guide.substringAfter("Only somebody else", missingDelimiterValue = "") + + @Test + fun `offers a hire their own pages and never a manager page`() { + val guide = guideAs(Role.USER) + + assertThat(openable(guide)).contains("Board (/board)", "OnBoarding (/onboarding)") + assertThat(openable(guide)).doesNotContain("/pm-dashboard", "/team-management", "/admin") + } + + /** The question this tool exists for: a PM asking how to create a project. */ + @Test + fun `tells a PM that only an admin creates a project`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + val guide = guideAs(Role.PM) + + assertThat(openable(guide)).doesNotContain("/admin", "New Project") + assertThat(elsewhere(guide)).contains("create a project: an admin, on Access Management") + } + + @Test + fun `shows a managing PM where roles are set up and chosen`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + val guide = guideAs(Role.PM) + + assertThat(guide).contains("They manage: Apollo.") + assertThat(guide).doesNotContain("Gemini") + assertThat(openable(guide)) + .contains("Team Management (/team-management) — PM Dashboard") + .contains("to create a project role: \"Role Management\" tab") + .contains("to choose or change a member's role") + } + + /** The frontend hides every manager page from a PM who manages nothing; so must the guide. */ + @Test + fun `keeps manager pages from a PM who manages no project, and says why`() { + val guide = guideAs(Role.PM) + + assertThat(openable(guide)).doesNotContain("/pm-dashboard", "/team-management") + assertThat(guide).contains("They manage no project yet.") + assertThat(guide).contains("manage no project, so the manager pages") + } + + /** HR opens Access Management but may not press "New Project" — the backend is admin-only. */ + @Test + fun `sends HR to an admin for what only an admin may do on a page HR can open`() { + val guide = guideAs(Role.HR) + + assertThat(openable(guide)).contains("Access Management (/admin)").doesNotContain("New Project") + assertThat(elsewhere(guide)).contains("create a project: an admin") + } + + @Test + fun `gives an admin everything and nothing to ask anybody else`() { + val guide = guideAs(Role.ADMIN) + + assertThat(openable(guide)).contains("to create a project: \"Projects\" tab → \"New Project\"") + assertThat(elsewhere(guide)).isEmpty() + } + + @Test + fun `reads an unknown user as a plain member`() { + val guide = guideAs(null) + + assertThat(guide).contains("Who is asking: a team member.") + assertThat(openable(guide)).doesNotContain("/pm-dashboard") + } + + @Test + fun `names the page the caller is on, ignoring the query`() { + val guide = guideAs(Role.ADMIN, currentPage = "/team/42?tab=path#top") + + assertThat(guide).contains("They are looking at: Team member (/team/{member_id}).") + } + + @Test + fun `admits a page it does not describe`() { + assertThat(guideAs(Role.USER, currentPage = "/skill-wizard")) + .contains("They are on a page this guide does not describe.") + } + + /** The value is the client's word; anything that is not an app path is dropped, never echoed. */ + @Test + fun `drops a current page that is not an app path`() { + val guide = guideAs(Role.USER, currentPage = "javascript:alert(1)") + + assertThat(guide).doesNotContain("javascript", "They are looking at", "They are on") + } + + @Test + fun `says nothing about the current page when none was sent`() { + assertThat(guideAs(Role.USER)).doesNotContain("They are looking at", "They are on") + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt index 1b64851ae..bf8825c3c 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt @@ -34,6 +34,7 @@ class BuddyTeamToolsTest { private val projectMembershipApi: ProjectMembershipApi = mockk() private val areaToolsProvider: ObjectProvider = mockk() private val buddyProposalService: BuddyProposalService = mockk() + private val buddyAppGuideTools: BuddyAppGuideTools = mockk() private val projectId = UUID.randomUUID() private val memberId = UUID.randomUUID() @@ -57,6 +58,7 @@ class BuddyTeamToolsTest { projectMembershipApi, areaToolsProvider, buddyProposalService, + buddyAppGuideTools, ) } @@ -111,9 +113,24 @@ class BuddyTeamToolsTest { BuddyTeamTools.GET_TEAM_ATTENTION, BuddyTeamTools.FIND_MEMBER, BuddyTeamTools.GET_MEMBER_PROGRESS, + BuddyAppGuideTools.GET_APP_GUIDE, ) } + @Test + fun `answers the app guide for the manager and the page they are on`() { + every { buddyAppGuideTools.guideFor(context.userId, "/pm-dashboard") } returns "the guide" + val tools = tools() + + val result = tools.execute( + call(BuddyAppGuideTools.GET_APP_GUIDE), + context.copy(currentPage = "/pm-dashboard"), + readNames(tools), + ) + + assertThat(result).isEqualTo("the guide") + } + @Test fun `mounts open_area once an area has tools, and the area's tools only after it is opened`() { val tools = tools(knowledgeArea) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt index e01245030..75ff8b6f8 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt @@ -39,6 +39,7 @@ class BuddyToolExecutorTest { every { getGithubLoginByUserId(any()) } returns "sam" } private val buddyBoardTools: BuddyBoardTools = mockk(relaxed = true) + private val buddyAppGuideTools: BuddyAppGuideTools = mockk() private val artifactIngestionApi: ArtifactIngestionApi = mockk() private val projectMembershipApi: ProjectMembershipApi = mockk() @@ -67,6 +68,7 @@ class BuddyToolExecutorTest { projectMembershipApi, arrivalStepService, competencyPlacementService, + buddyAppGuideTools, ) private val userId = UUID.randomUUID() @@ -221,9 +223,23 @@ class BuddyToolExecutorTest { "get_suggested_tasks", "search_canonical_answers", "get_teammates", + "get_app_guide", ) } + @Test + fun `answers the app guide for the caller and the page they are on`() { + every { buddyAppGuideTools.guideFor(userId, "/team-management") } returns "the guide" + + val result = executor.execute( + BuddyToolCallDto(id = "c1", name = "get_app_guide"), + userId, + currentPage = "/team-management", + ) + + assertThat(result).isEqualTo("the guide") + } + /** * Asserted on the real mounting rather than a stubbed tool list. Every other test of what the * buddy offers a hire goes through a mocked spec list, so the one thing that decides whether a From 78d2ae66e7bf3126ea842998f6605630cb0e4178 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Wed, 30 Sep 2026 15:30:32 +0200 Subject: [PATCH 2/5] Offer a "What can I do here?" chip; match guide labels to the UI The chip is gated on get_app_guide like every other chip on its tool. The dashboard's button is "Edit dashboard", and the onboarding page on dev has "Start step" / "Mark as complete" but no "Ask the buddy" yet. Co-Authored-By: Claude Opus 5.5 --- .../sprintstartbackend/onboarding/service/AppGuide.kt | 8 ++++---- .../onboarding/service/BuddySuggestionService.kt | 6 ++++++ .../onboarding/service/BuddySuggestionServiceTest.kt | 7 +++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt index addcbcee5..7e0ebad45 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt @@ -88,8 +88,8 @@ object AppGuide { howTos = listOf( AppGuideHowTo( "change which widgets show", - "add or remove widgets with the widget picker; \"Reset your dashboard\" " + - "restores the default layout", + "\"Edit dashboard\" → add or remove widgets (\"Your widgets\"), then \"Done\"; " + + "resetting restores the default layout", ), ), ), @@ -130,8 +130,8 @@ object AppGuide { howTos = listOf( AppGuideHowTo( "work through a step", - "open the step in the current phase, start it, and mark it done when finished; " + - "\"Ask the buddy\" on a step or question brings it into this chat", + "open the step in the current phase → \"Start step\", and \"Mark as complete\" " + + "when finished", ), ), matches = listOf("/onboarding/"), diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt index ea7d03334..07349f4f1 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt @@ -89,6 +89,12 @@ class BuddySuggestionService( label = "Who's on my team?", question = "Who else is on my project?", ), + // Last, because it is about the app rather than the hire's onboarding. "This page" is + // the one the chip was pressed on: the client sends it with the message. + BuddyAppGuideTools.GET_APP_GUIDE to BuddySuggestionResponse( + label = "What can I do here?", + question = "What can I do on this page?", + ), ) } } diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt index 648dbe8f8..704e20d80 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt @@ -36,6 +36,13 @@ class BuddySuggestionServiceTest { .containsExactly("How am I doing?", "What should I work on?") } + @Test + fun `offers to explain the page the hire is on`() { + mounted(BuddyAppGuideTools.GET_APP_GUIDE) + + assertThat(service.forHire(userId).single().question).isEqualTo("What can I do on this page?") + } + /** * The whole point of deriving rather than listing: a chip appears exactly when its tool does. * Unlike the tool, a chip is something the hire *sees*, so getting this wrong is louder. From 0aed91341e3f7690485c3b4fc6324bfeaf932936 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 3 Oct 2026 15:46:06 +0200 Subject: [PATCH 3/5] Rewrite the app guide for the PM workspace and add tab deep links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev turned the PM area into one page with a tab bar (Overview, Team with Members/Roles, Onboarding, Questions, Knowledge gaps, Escalations with Open/Durable answers). The guide now describes those tabs where they are, and its how-tos carry deep links that open the right tab (/team-management?tab=roles, /hire-setup?tab=arrival, /admin?tab=projects, /insights/knowledge-requests?view=answered) — only parameters the frontend reads, pinned by a test to stay on their own page. Also: Hire Setup's tabs are "Arrival"/"Starter work", the member page's role picker is "Choose role"/"Add role", "Rebuild path", and the onboarding page has "Ask your buddy about this". AGENTS.md says to keep AppGuide.kt in step with the frontend. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 + .../onboarding/service/AppGuide.kt | 194 ++++++++++-------- .../onboarding/service/BuddyAppGuideTools.kt | 14 +- .../onboarding/service/AppGuideTest.kt | 23 ++- .../service/BuddyAppGuideToolsTest.kt | 14 +- 5 files changed, 158 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3d9a7e53..e7f8a3e9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,12 @@ Narrate what you are doing as you go. Do not work silently and present a result * Keep validation errors user-facing and predictable. * For PATCH requests, nullable fields are acceptable when omitted values should remain unchanged. +## Buddy app guide + +* `onboarding/service/AppGuide.kt` is the buddy's map of the frontend: every page, where it is, its tabs and their deep links, and who may open it. It is hand-written and mirrors `sprintstart-frontend/src/auth/accessPolicy.ts` and the UI's own labels. +* When a frontend page, tab, tab URL parameter or quoted label changes, update `AppGuide.kt` (and the route list in `AppGuideTest`) in the same piece of work, and the frontend's `src/features/buddy/appGuideRoutes.ts` with it. +* A how-to's `link` may only use a URL parameter the frontend actually reads. + ## Do not * Do not add new production dependencies without explaining why and confirming it with me. diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt index 7e0ebad45..c0d37d76e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt @@ -10,11 +10,15 @@ import com.sprintstart.sprintstartbackend.user.external.enums.Role * @property doers Who may do it, when that is narrower than who may open the page. A page HR can * open can still hold a button only an admin may press, and a guide that ignores the difference * sends HR to a button that refuses them. + * @property link A deep link that lands on the right tab already (`/team-management?tab=roles`), + * when the page keeps its tab in the URL. Only ever a parameter the frontend actually reads: a + * link that lands on the wrong tab is worse than a plain one. */ data class AppGuideHowTo( val task: String, val steps: String, val doers: Set? = null, + val link: String? = null, ) /** @@ -49,17 +53,27 @@ private val ADMIN_HR = setOf(Role.HR, Role.ADMIN) private val PM_ADMIN = setOf(Role.PM, Role.ADMIN) private val ADMIN_ONLY = setOf(Role.ADMIN) +// The PM area is one page with a tab bar (`PmWorkspace` in the frontend); six of the routes below +// are its tabs. Named once so every entry says where the tab bar is the same way. +private const val PM_TAB = "PM Dashboard (sidebar) → the" + /** * The map of the app the buddy reads from, so "where do I…" is answered from the app rather than * from the project's documentation — which describes the *project*, and has never heard of a page - * called Team Management. + * called PM Dashboard. * * ### Keeping it true * * This is a hand-written copy of what the frontend shows, and it goes stale the moment a page is - * renamed there. Two things keep that visible rather than silent: the labels are the UI's own - * (sidebar entries, tab names, button text — quoted, so a rename is a grep away), and - * `AppGuideTest` pins the route list, so adding or removing a page here is a deliberate edit. + * renamed there. What keeps that visible rather than silent: + * + * - The labels are the UI's own (sidebar entries, tab names, button text — quoted, so a rename is + * a grep away). + * - `AppGuideTest` pins the route list here, and the frontend's `appGuideRoutes.test.ts` fails + * when `accessPolicy.ts` gains a route that neither this guide describes nor is excluded on + * purpose — so a new page breaks CI in the repo it was added in. + * - Both repos' `AGENTS.md` say to update this file with any page, tab or label change. + * * The roles mirror `sprintstart-frontend/src/auth/accessPolicy.ts`; when that file changes, this * one has to follow. * @@ -133,6 +147,10 @@ object AppGuide { "open the step in the current phase → \"Start step\", and \"Mark as complete\" " + "when finished", ), + AppGuideHowTo( + "get help with a step or a question", + "\"Ask your buddy about this\" on it brings it into the buddy chat", + ), ), matches = listOf("/onboarding/"), ), @@ -141,7 +159,7 @@ object AppGuide { path = "/settings", whereToFind = "the gear / account menu at the bottom of the sidebar, \"Settings\"", purpose = "Tabs \"User Profile\" (name, profile icon, GitHub username), \"Appearance\" " + - "(theme) and \"Access Tokens\".", + "(theme) and, for project managers, HR and admins, \"Access Tokens\".", howTos = listOf( AppGuideHowTo( "set a GitHub username so pull requests are found", @@ -152,41 +170,41 @@ object AppGuide { AppGuidePage( name = "PM Dashboard", path = "/pm-dashboard", - whereToFind = "sidebar, \"PM Dashboard\" (the project manager section)", - purpose = "The selected project at a glance: ingestion status, \"Team overview\", and " + - "\"Insights\" (recurring questions, knowledge gaps, onboarding metrics).", - howTos = listOf( - AppGuideHowTo( - "see how the team's onboarding is going", - "the \"Team overview\" card; click it for Team Management", - ), - ), + whereToFind = "sidebar, \"PM Dashboard\" — one page with the tabs \"Overview\", \"Team\" " + + "(Members, Roles), \"Onboarding\", \"Questions\", \"Knowledge gaps\" and \"Escalations\"", + purpose = "The selected project at a glance (the \"Overview\" tab): who needs attention, " + + "ingestion status and the project analysis. The other tabs are listed separately.", roles = MANAGERS, managerScoped = true, ), AppGuidePage( - name = "Team Management", + name = "Team", path = "/team-management", - whereToFind = "PM Dashboard → the \"Team overview\" card (it has no sidebar entry)", - purpose = "Tabs \"User Management\" (every member and their onboarding progress) and " + - "\"Role Management\" (the project's roles and the skills each needs).", + whereToFind = "$PM_TAB \"Team\" tab, with the views \"Members\" and \"Roles\"", + purpose = "\"Members\": everybody on the project and their onboarding progress. \"Roles\": " + + "the project's roles and the skills each needs.", howTos = listOf( AppGuideHowTo( "create a project role", - "\"Role Management\" tab → \"Create role\" (name and what it is responsible for)", + "Team → \"Roles\" → \"Create role\" (name and what it is responsible for)", + link = "/team-management?tab=roles", ), AppGuideHowTo( "give a role its skills", - "\"Role Management\" → pick the role → \"Skills\": add one, or \"Suggest skills\"", + "Team → \"Roles\" → pick the role → \"Skills\": add one, or \"Suggest skills\"", + link = "/team-management?tab=roles", ), AppGuideHowTo( "assign members to a role", - "\"Role Management\" → pick the role → \"Members\"; \"Without a role\" lists who " + - "has none yet", + "Team → \"Roles\" → pick the role → \"Members\"; \"Without a role\" lists who has " + + "none yet", + link = "/team-management?tab=roles", ), AppGuideHowTo( - "open one member", - "\"User Management\" tab → click the person", + "look at one member", + "Team → \"Members\" → click the person for a side panel, \"Full profile\" for " + + "their page", + link = "/team-management", ), ), roles = MANAGERS, @@ -195,18 +213,18 @@ object AppGuide { AppGuidePage( name = "Team member", path = "/team/{member_id}", - whereToFind = "Team Management → \"User Management\" → click a person", - purpose = "One member: their roles, current step, onboarding path, feedback and skip " + - "requests, and skill and knowledge gaps.", + whereToFind = "$PM_TAB \"Team\" tab → \"Members\" → a person → \"Full profile\"", + purpose = "One member's full profile: their roles, current step, onboarding path, " + + "feedback and skip requests, and skill and knowledge gaps.", howTos = listOf( AppGuideHowTo( "choose or change a member's role", - "click their role chip (or \"Choose role\" when they have none) under their name " + - "→ \"Manage Roles\"", + "under their name: the \"Choose role\" / \"Add role\" picker adds one, the × on a " + + "role removes it", ), AppGuideHowTo( "rebuild a member's onboarding path with AI", - "the rebuild button in the member's header", + "\"Rebuild path\" in the member's header", doers = PM_ADMIN, ), ), @@ -214,6 +232,55 @@ object AppGuide { managerScoped = true, matches = listOf("/team/"), ), + AppGuidePage( + name = "Onboarding (PM)", + path = "/insights/onboarding", + whereToFind = "$PM_TAB \"Onboarding\" tab", + purpose = "Contribution metrics per hire: pull requests, review waits, stalls.", + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Questions", + path = "/insights/faq", + whereToFind = "$PM_TAB \"Questions\" tab", + purpose = "The questions the team asks most, grouped.", + roles = MANAGERS, + managerScoped = true, + matches = listOf("/insights/faq/"), + ), + AppGuidePage( + name = "Knowledge gaps", + path = "/insights/knowledge-gaps", + whereToFind = "$PM_TAB \"Knowledge gaps\" tab", + purpose = "Topics the documentation does not cover well enough, from what people asked.", + roles = MANAGERS, + managerScoped = true, + matches = listOf("/insights/knowledge-gaps/"), + ), + AppGuidePage( + name = "Escalations", + path = "/insights/knowledge-requests", + whereToFind = "$PM_TAB \"Escalations\" tab, with the views \"Open\" and \"Durable answers\"", + purpose = "Questions hires flagged because the buddy could not answer them, and the " + + "answers already written for them.", + howTos = listOf( + AppGuideHowTo( + "answer a hire's escalated question", + "Escalations → \"Open\" → open it and answer; the answer becomes team knowledge " + + "the buddy uses from then on", + doers = PM_ADMIN, + link = "/insights/knowledge-requests", + ), + AppGuideHowTo( + "see answers already written", + "Escalations → \"Durable answers\"", + link = "/insights/knowledge-requests?view=answered", + ), + ), + roles = MANAGERS, + managerScoped = true, + ), AppGuidePage( name = "Data Ingestion", path = "/data-ingestion", @@ -233,8 +300,8 @@ object AppGuide { name = "Blueprints", path = "/blueprints", whereToFind = "sidebar, \"Blueprints\" (the project manager section)", - purpose = "Reusable onboarding path templates (\"Global blueprints\" and \"Project " + - "blueprints\"). Editing one never changes a hire's live path.", + purpose = "Reusable onboarding path templates (\"Project blueprints\", and for admins " + + "\"Global blueprints\"). Editing one never changes a hire's live path.", howTos = listOf( AppGuideHowTo( "create an onboarding blueprint", @@ -253,87 +320,50 @@ object AppGuide { AppGuidePage( name = "Hire Setup", path = "/hire-setup", - whereToFind = "sidebar, \"Hire Setup\" (the project manager section)", - purpose = "What a new hire needs before they start and the first work waiting for them: " + - "tabs \"Arrival steps\" (/hire-setup?tab=arrival) and \"Starter work\" " + - "(/hire-setup?tab=starter).", + whereToFind = "sidebar, \"Hire Setup\" (the project manager section), tabs \"Arrival\" " + + "and \"Starter work\"", + purpose = "What a new hire needs before they start (accounts, access, setup) and the " + + "first work waiting for them.", howTos = listOf( AppGuideHowTo( "write the arrival checklist (accounts, access, setup)", - "Hire Setup → \"Arrival steps\" tab", + "Hire Setup → \"Arrival\" tab", doers = PM_ADMIN, + link = "/hire-setup?tab=arrival", ), AppGuideHowTo( "review the first tasks offered to hires", "Hire Setup → \"Starter work\" tab", doers = PM_ADMIN, + link = "/hire-setup?tab=starter", ), ), roles = MANAGERS, ), - AppGuidePage( - name = "Escalation Inbox", - path = "/insights/knowledge-requests", - whereToFind = "sidebar, \"Escalation Inbox\" (the project manager section)", - purpose = "Questions hires flagged because the buddy could not answer them.", - howTos = listOf( - AppGuideHowTo( - "answer a hire's escalated question", - "open it in the inbox and answer; the answer becomes team knowledge the buddy " + - "uses from then on", - doers = PM_ADMIN, - ), - ), - roles = MANAGERS, - managerScoped = true, - ), - AppGuidePage( - name = "FAQ", - path = "/insights/faq", - whereToFind = "PM Dashboard → \"Insights\" → the FAQ widget", - purpose = "The questions the team asks most, grouped.", - roles = MANAGERS, - managerScoped = true, - matches = listOf("/insights/faq/"), - ), - AppGuidePage( - name = "Knowledge gaps", - path = "/insights/knowledge-gaps", - whereToFind = "PM Dashboard → \"Insights\" → the knowledge gaps widget", - purpose = "Topics the documentation does not cover well enough, from what people asked.", - roles = MANAGERS, - managerScoped = true, - matches = listOf("/insights/knowledge-gaps/"), - ), - AppGuidePage( - name = "Onboarding metrics", - path = "/insights/onboarding", - whereToFind = "PM Dashboard → \"Insights\" → the onboarding metrics widget", - purpose = "Contribution metrics per hire: pull requests, review waits, stalls.", - roles = MANAGERS, - managerScoped = true, - ), AppGuidePage( name = "Access Management", path = "/admin", - whereToFind = "sidebar, \"Access Management\"", - purpose = "Tabs \"Users\", \"Projects\" and \"Tokens\" for the whole organisation.", + whereToFind = "sidebar, \"Access Management\", tabs \"Users\", \"Projects\" and \"Tokens\"", + purpose = "Users, projects and access tokens for the whole organisation.", howTos = listOf( AppGuideHowTo( "create a project", "\"Projects\" tab → \"New Project\": Details (name, description, industry, " + "project manager) → Members → Sources → Review", doers = ADMIN_ONLY, + link = "/admin?tab=projects", ), AppGuideHowTo( "make somebody a project's manager, or add people to a project", "\"Projects\" tab → open the project", doers = ADMIN_ONLY, + link = "/admin?tab=projects", ), AppGuideHowTo( "change somebody's permission group (member, PM, HR, admin)", "\"Users\" tab → open the user", doers = ADMIN_ONLY, + link = "/admin?tab=users", ), ), roles = ADMIN_HR, @@ -342,7 +372,7 @@ object AppGuide { /** The page [pathname] belongs to, or null for a route this guide does not describe. */ fun pageAt(pathname: String): AppGuidePage? = - // Detail routes first: `/team/…` must not be read as Team Management's own path. + // Detail routes first: `/team/…` must not be read as Team's own path. pages.firstOrNull { page -> page.matches.any { pathname.startsWith(it) } } ?: pages.firstOrNull { it.path == pathname } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt index a7ef34ddf..1905b7424 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt @@ -57,7 +57,10 @@ class BuddyAppGuideTools( appendLine(whoIsAsking(role, managed)) here(currentPage)?.let { appendLine(it) } appendLine() - appendLine("Pages they can open (link each as [Name](path)):") + appendLine( + "Pages they can open (link each as [Name](path); where a how-to has its own link, " + + "use that one — it opens the right tab):", + ) openable.forEach { page -> appendPage(page, role) } if (elsewhere.isNotEmpty()) { appendLine() @@ -70,8 +73,8 @@ class BuddyAppGuideTools( if (reader.blockedFromManagerPages()) { appendLine() appendLine( - "They hold the PM role but manage no project, so the manager pages (PM Dashboard, " + - "Team Management, Data Ingestion, Blueprints, the insights) stay hidden. An admin " + + "They hold the PM role but manage no project, so the manager pages (PM Dashboard " + + "and its tabs, Data Ingestion, Blueprints) stay hidden. An admin " + "makes somebody a project's manager in Access Management → Projects.", ) } @@ -88,7 +91,10 @@ class BuddyAppGuideTools( appendLine(" ${page.purpose}") page.howTos .filter { it.doers == null || role in it.doers } - .forEach { appendLine(" · to ${it.task}: ${it.steps}") } + .forEach { howTo -> + val link = howTo.link?.let { " (link: $it)" }.orEmpty() + appendLine(" · to ${howTo.task}: ${howTo.steps}$link") + } } /** The names of the caller's projects that they manage — the PM-only gate on manager pages. */ diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt index 98b8cbd0b..9fd443cf4 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt @@ -23,13 +23,13 @@ class AppGuideTest { "/pm-dashboard", "/team-management", "/team/{member_id}", + "/insights/onboarding", + "/insights/faq", + "/insights/knowledge-gaps", + "/insights/knowledge-requests", "/data-ingestion", "/blueprints", "/hire-setup", - "/insights/knowledge-requests", - "/insights/faq", - "/insights/knowledge-gaps", - "/insights/onboarding", "/admin", ) } @@ -42,6 +42,19 @@ class AppGuideTest { } } + /** + * A how-to's deep link must land on its own page — a link to another page's tab would send + * somebody to a page the guide never checked they can open. + */ + @Test + fun `every deep link stays on its own page`() { + AppGuide.pages.forEach { page -> + page.howTos.mapNotNull { it.link }.forEach { link -> + assertThat(link.substringBefore('?')).isEqualTo(page.path) + } + } + } + /** The frontend only narrows PM access on pages that PM, HR and admins can all open. */ @Test fun `a manager-scoped page is open to PM, HR and admins`() { @@ -60,7 +73,7 @@ class AppGuideTest { /** `/team-management` starts with `/team`, but not with the member page's `/team/`. */ @Test fun `does not mistake team management for a member page`() { - assertThat(AppGuide.pageAt("/team-management")?.name).isEqualTo("Team Management") + assertThat(AppGuide.pageAt("/team-management")?.name).isEqualTo("Team") } @Test diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt index 3c345811a..b88918868 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt @@ -76,11 +76,21 @@ class BuddyAppGuideToolsTest { assertThat(guide).contains("They manage: Apollo.") assertThat(guide).doesNotContain("Gemini") assertThat(openable(guide)) - .contains("Team Management (/team-management) — PM Dashboard") - .contains("to create a project role: \"Role Management\" tab") + .contains("Team (/team-management) — PM Dashboard (sidebar) → the \"Team\" tab") + .contains("to create a project role: Team → \"Roles\"") .contains("to choose or change a member's role") } + /** The link opens the tab the steps talk about, not the page's default one. */ + @Test + fun `hands over the deep link that opens the right tab`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + assertThat(openable(guideAs(Role.PM))) + .contains("\"Create role\" (name and what it is responsible for) (link: /team-management?tab=roles)") + .contains("Hire Setup → \"Arrival\" tab (link: /hire-setup?tab=arrival)") + } + /** The frontend hides every manager page from a PM who manages nothing; so must the guide. */ @Test fun `keeps manager pages from a PM who manages no project, and says why`() { From 0401d869090a82567cab600b86ff678cdb727d8d Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 3 Oct 2026 17:17:01 +0200 Subject: [PATCH 4/5] Tell a managing PM that manager pages follow the selected project The frontend shows the manager pages only while a project the PM manages is selected, not whenever they manage one. Without saying so, the guide sent a PM looking at a project they only belong to to a sidebar entry that is not there. Co-Authored-By: Claude Opus 5.5 --- .../onboarding/service/BuddyAppGuideTools.kt | 12 +++++++++--- .../onboarding/service/BuddyAppGuideToolsTest.kt | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt index 1905b7424..48ed505ad 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt @@ -73,8 +73,7 @@ class BuddyAppGuideTools( if (reader.blockedFromManagerPages()) { appendLine() appendLine( - "They hold the PM role but manage no project, so the manager pages (PM Dashboard " + - "and its tabs, Data Ingestion, Blueprints) stay hidden. An admin " + + "They hold the PM role but manage no project, so the $MANAGER_PAGES stay hidden. An admin " + "makes somebody a project's manager in Access Management → Projects.", ) } @@ -114,7 +113,11 @@ class BuddyAppGuideTools( return when { role != Role.PM -> base managed.isEmpty() -> "$base They manage no project yet." - else -> "$base They manage: ${managed.joinToString(", ")}." + // The frontend gates the manager pages on the *selected* project, not on managing any + // one; without this a PM looking at a project they only belong to is sent to a sidebar + // entry that is not there. + else -> "$base They manage: ${managed.joinToString(", ")}. The $MANAGER_PAGES show only " + + "while one of these is the selected project." } } @@ -153,6 +156,9 @@ class BuddyAppGuideTools( // A path is a few segments and an id; anything longer is not a route of this app. private const val MAX_PATH_LENGTH = 200 + // The pages `AppGuidePage.managerScoped` marks, as somebody would name them. + private const val MANAGER_PAGES = "manager pages (PM Dashboard and its tabs, Data Ingestion, Blueprints)" + private val ROLE_NAMES = mapOf( Role.USER to "a team member", Role.PM to "a project manager (PM)", diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt index b88918868..7d5a6327d 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt @@ -81,6 +81,17 @@ class BuddyAppGuideToolsTest { .contains("to choose or change a member's role") } + /** The frontend gates manager pages on the selected project, so "manages one" is not enough. */ + @Test + fun `tells a managing PM that manager pages follow the selected project`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + assertThat(guideAs(Role.PM)).contains( + "They manage: Apollo. The manager pages (PM Dashboard and its tabs, Data Ingestion, " + + "Blueprints) show only while one of these is the selected project.", + ) + } + /** The link opens the tab the steps talk about, not the page's default one. */ @Test fun `hands over the deep link that opens the right tab`() { From adaedcf11ee5c843e7a4153bcc2687d8f90bf120 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 3 Oct 2026 17:40:02 +0200 Subject: [PATCH 5/5] Describe the Skills tab of Access Management in the app guide The frontend gained an admin-only Skills tab for the organisation-wide skill pool. HR is now told an admin does that, and an admin gets the deep link. Co-Authored-By: Claude Opus 5.5 --- .../onboarding/service/AppGuide.kt | 12 ++++++++++-- .../onboarding/service/BuddyAppGuideToolsTest.kt | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt index c0d37d76e..33e35bb51 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt @@ -343,8 +343,9 @@ object AppGuide { AppGuidePage( name = "Access Management", path = "/admin", - whereToFind = "sidebar, \"Access Management\", tabs \"Users\", \"Projects\" and \"Tokens\"", - purpose = "Users, projects and access tokens for the whole organisation.", + whereToFind = "sidebar, \"Access Management\", tabs \"Users\", \"Projects\", \"Skills\" " + + "(admins only) and \"Tokens\"", + purpose = "Users, projects, the organisation-wide skill pool and access tokens.", howTos = listOf( AppGuideHowTo( "create a project", @@ -365,6 +366,13 @@ object AppGuide { doers = ADMIN_ONLY, link = "/admin?tab=users", ), + AppGuideHowTo( + "add, edit or retire a skill in the organisation-wide pool", + "\"Skills\" tab → \"New skill\", or open a skill to edit it or \"Retire skill\"; " + + "taking a skill off one role is done in Team → \"Roles\" instead", + doers = ADMIN_ONLY, + link = "/admin?tab=skills", + ), ), roles = ADMIN_HR, ), diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt index 7d5a6327d..75a6943fa 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt @@ -119,6 +119,9 @@ class BuddyAppGuideToolsTest { assertThat(openable(guide)).contains("Access Management (/admin)").doesNotContain("New Project") assertThat(elsewhere(guide)).contains("create a project: an admin") + // The Skills tab is hidden from HR in the frontend, and every skill-pool write 403s for them. + assertThat(openable(guide)).doesNotContain("New skill") + assertThat(elsewhere(guide)).contains("retire a skill in the organisation-wide pool: an admin") } @Test