feat(roster-assistant): catalog snapshot collector + GET /roster/assistant/catalog #1218
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1218
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "code-lead/1211"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements the catalog producer the Agents Roster AI Assistant feature uses to constrain its proposals to skills, MCPs, tools, and model tiers that actually exist in the deployment.
collectCatalog()(domain/roster-assistant/catalog.ts) enumerates every section through the existing registries —getSkillLibrary()for skills,resolveMcpServers()at the global ladder layer for configured MCPs,config/recommended-catalog.jsonfor known-but-unconfigured MCPs withsetup_required,FORGE_TOOLS_ALLOWLIST+ built-in SDK tools (Bash/Write/Edit flagged sensitive per spec), and the sharedMODEL_TIERSconst.GET /roster/assistant/catalogreturns the snapshot as JSON. Read-only, session-gated (same gate as the other/api/*GETs).afterMutationwhenkind === 'mcp') and from the skill-library cache reset.hashis a SHA-256 of the snapshot with the hash field itself excluded. Arrays are sorted and object literals are built with a fixed key order soJSON.stringifyis deterministic — the FE drift detector at accept time gets a stable identifier.Closes #1211
Test plan
just qaclean (typecheck + lint + format + 3382 tests + sql-layer-check + paraglide-check + i18n-string-check + flow-schema-check all pass)catalog.test.tscover: shape (every section + sensitive-tool flagging + every FORGE allowlist entry + penpot wildcard only when configured + recommended merging + missing file → empty recommended set), hash stability (no-op call, MCP added, configured set changed, order-independent), caching + invalidation (TTL memoisation, invalidate forces rebuild, override bypasses cache).roster-assistant.test.tsdrives the route throughhandleRequestend-to-end (status, content type, body shape, cache HIT).🤖 Generated with Claude Code
Code Review — PR #1218: catalog snapshot collector +
GET /roster/assistant/catalogOverall this is a well-designed, thoroughly documented, and well-tested piece of work. The hash-stability guarantee, cache-invalidation contract, and multi-source MCP enumeration are all done correctly. Two issues need addressing before merge.
❌ 1. Circular dependency:
catalog.ts↔skill-library.ts(must fix)catalog.tsimportsgetSkillLibraryfromskill-library.ts:And
skill-library.tsnow imports back into the feature that depends on it:This creates a circular module graph. ESM live-bindings mean it works at runtime today, but it's an architectural layering violation (a utility domain module importing from a higher-level feature module), creates hidden initialization-order sensitivity, and will confuse any future reader who tries to understand the skills layer in isolation.
The fix is simple — the coupling is unnecessary.
catalog.test.tsalready calls both resets explicitly inbeforeEach:That's the right place for it.
resetSkillLibraryCacheForTestshould only reset the skill library. Remove theinvalidateCatalogSnapshot()call from it (and the import). The PR comment itself acknowledges "Production never calls this" — there's no production justification for the coupling.❌ 2. No negative auth test on
GET /roster/assistant/catalog(must fix)roster-assistant.test.tsonly exercises the happy path. There is no test verifying that a request without a valid session cookie is rejected. Every other session-gated endpoint in the test suite includes a401/ redirect case. Add:The
reqhelper already has thewithSessionopt, so this is a one-liner to add.⚠️ 3. Silent Zod validation failure in
readRecommendedMcps(minor, consider fixing)When
recommendedCatalogSchema.safeParsefails, the function silently returns[]:An operator who ships a malformed
config/recommended-catalog.json(e.g. missing a required field after an upgrade) will see the recommended section silently empty with no indication why. Aconsole.warnwithresult.error.messagewould make this much easier to diagnose. TheENOENTpath correctly returns[]without logging (expected); schema failures deserve a warning.ℹ️ 4.
BUILTIN_TOOLSdrift risk (informational)The comment says the list is "kept in lockstep with
DefaultMcpRegistry.allowedTools" but there's no automated enforcement. If a future SDK update adds a new built-in tool, the catalog will silently omit it. Consider a// biome-ignoreexemption + a reference comment to the source file path, or a lint check, so the coupling is at least visible in diffs. Not blocking.✅ What's done well
catalog.test.ts(including the insertion-order determinism test) give genuine confidence.afterMutation(kind guard), skill-library reset, and TTL fallback all covered.recommendedCatalogPathoverride bypasses the production cache — tests can iterate fixtures without poking module state. Clean design.resolveMcpServers({ type: "", instance_id: null })for the global ladder view is consistent with the existing pattern rather than re-deriving defaults.handleRequestend-to-end.server.tswiring is correct: route registered before the SPA catch-all,/roster/added to the 404-bypass predicate, noguardMutatingneeded for a read-only endpoint.Please fix items 1 and 2; 3 is strongly encouraged.
cd231f27e6fe1d28a732Review — PR #1218: catalog snapshot collector +
GET /roster/assistant/catalogCI ✅ green. Approving with a few non-blocking observations; one of them (the circular import) should be resolved before the next ticket adds more members to the cycle.
✅ What's solid
handleRequeststack. Every spec bullet from #1211 has a regression lock.hashfield is excluded from its own input. TherecommendedCatalogPathoverride bypasses the cache cleanly so test fixtures can iterate.readRecommendedMcpsis well-scoped.ENOENT→ empty list; JSON parse failure → empty list; other I/O errors propagate. That's exactly the right distinction.afterMutationgates onkind === 'mcp'; skill-library reset busts both caches; TTL fallback is 5 min as specced.app.use("*", makeSessionGateMiddleware())at line 2437 ofmain.tscovers this route. The comment "same gate as the other/api/*GETs" is accurate —guardMutatingis only needed for per-route mutation enforcement on top of the global middleware.⚠️ Circular module dependency — address before next ticket
catalog.ts→skill-library.ts→catalog.ts(via the newinvalidateCatalogSnapshotimport).Both consumed values are called inside function bodies (not at module-init time), so there's no TDZ crash today and the tests pass. But the dependency is upside-down — the skills domain shouldn't know about the roster-assistant catalog — and adding one module-level use of either export in a future refactor would produce a silent
undefinedthat only manifests at runtime.Suggested fix (one small file): extract the invalidation signal to a neutral
domain/roster-assistant/catalog-invalidation.tsthat exports justinvalidateCatalogSnapshotand thecachedmutable slot. Bothcatalog.tsandskill-library.tsimport from that single location; the cycle disappears.🔸
model_tiersis the only array not sortedThe module header promises "arrays are sorted"; every section sorts defensively — except
model_tiers:MODEL_TIERSis a stable const so this won't flip today, but it breaks the stated invariant and will silently invalidate every cached hash if the shared const ever gets reordered. One-line fix:🔸 Integration test has no unauthenticated-request case
roster-assistant.test.tsverifies the 200 path but never sends a request without a session cookie. Given that the only auth wall is the global middleware, a regression that accidentally exempts/roster/assistant/catalogfrommakeSessionGateMiddlewarewould go undetected. A quick addition:🔹
extractDescriptionwill return heading text for most skillsIf a skill's
*.mdstarts with# init(or# review, etc.) the first non-empty, non-blank line is the heading itself, sodescriptionwill be"init"— the name, not a sentence explaining what the skill does. For an AI assistant that embeds these descriptions in a system prompt, that's low-signal.Not a blocker — depends entirely on how skill files are structured — but worth verifying that the downstream prompt-builder produces coherent descriptions before the assistant feature lands. If headings are the dominant pattern, consider skipping lines that are purely heading markers and pulling the first prose paragraph instead.
Summary
catalog ↔ skill-librarymodel_tiersnot sortedextractDescriptionheading behaviourGood work on the test depth and the invalidation contracts. The circular import is the one thing to resolve proactively.