@loopops/mcp-server 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -0
- package/dist/tools/_csv-input.d.ts +27 -0
- package/dist/tools/_csv-input.js +56 -0
- package/dist/tools/account-master.js +10 -3
- package/dist/tools/people-master.d.ts +22 -0
- package/dist/tools/people-master.js +166 -0
- package/dist/tools/sfdc-sync.js +12 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerEngTools } from "./tools/eng.js";
|
|
|
13
13
|
import { registerCrmTools } from "./tools/crm.js";
|
|
14
14
|
import { registerEngageTools } from "./tools/engage.js";
|
|
15
15
|
import { registerAccountMasterTools } from "./tools/account-master.js";
|
|
16
|
+
import { registerPeopleMasterTools } from "./tools/people-master.js";
|
|
16
17
|
import { registerScoringTools } from "./tools/scoring.js";
|
|
17
18
|
import { registerSfdcSyncTools } from "./tools/sfdc-sync.js";
|
|
18
19
|
import { registerTalTools } from "./tools/tal.js";
|
|
@@ -58,6 +59,7 @@ registerEngTools(server, allowedSkills);
|
|
|
58
59
|
registerCrmTools(server, allowedSkills);
|
|
59
60
|
registerEngageTools(server, allowedSkills);
|
|
60
61
|
registerAccountMasterTools(server, allowedSkills);
|
|
62
|
+
registerPeopleMasterTools(server, allowedSkills);
|
|
61
63
|
registerTalTools(server, allowedSkills);
|
|
62
64
|
registerScoringTools(server, allowedSkills);
|
|
63
65
|
registerSfdcSyncTools(server, allowedSkills);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper for tools that accept "either CSV content as text, or a path to
|
|
3
|
+
* a CSV file." The MCP server runs locally as a subprocess on the user's
|
|
4
|
+
* machine, so file IO is available; this lets operators say "process the
|
|
5
|
+
* file at ~/Downloads/foo.csv" without a separate Read step in chat.
|
|
6
|
+
*
|
|
7
|
+
* Both `import_accounts_csv` and `process_approvals_csv` use this. The
|
|
8
|
+
* tRPC procedures behind them still take CSV content — this is purely
|
|
9
|
+
* a wrapper convenience.
|
|
10
|
+
*/
|
|
11
|
+
export type CsvInput = {
|
|
12
|
+
/** CSV content as a string (when operator pastes it inline). */
|
|
13
|
+
csv?: string;
|
|
14
|
+
/** Filesystem path (absolute, ~-prefixed, or relative to cwd). */
|
|
15
|
+
csvPath?: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Resolve to CSV content. Throws with operator-friendly messages on
|
|
19
|
+
* misuse (both unset, both set, file missing, file empty).
|
|
20
|
+
*/
|
|
21
|
+
export declare function resolveCsvInput(input: CsvInput): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* Common Zod-friendly description for the csvPath field. Tool authors
|
|
24
|
+
* pass this as the `.describe()` argument so the wording stays
|
|
25
|
+
* consistent across tools.
|
|
26
|
+
*/
|
|
27
|
+
export declare const CSV_PATH_DESCRIPTION = "Path to a CSV file on the operator's machine. Absolute, ~-prefixed (e.g. `~/Downloads/foo.csv`), or relative to the MCP subprocess's working directory. Either `csv` or `csvPath` is required (not both).";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper for tools that accept "either CSV content as text, or a path to
|
|
3
|
+
* a CSV file." The MCP server runs locally as a subprocess on the user's
|
|
4
|
+
* machine, so file IO is available; this lets operators say "process the
|
|
5
|
+
* file at ~/Downloads/foo.csv" without a separate Read step in chat.
|
|
6
|
+
*
|
|
7
|
+
* Both `import_accounts_csv` and `process_approvals_csv` use this. The
|
|
8
|
+
* tRPC procedures behind them still take CSV content — this is purely
|
|
9
|
+
* a wrapper convenience.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { isAbsolute, resolve } from "node:path";
|
|
14
|
+
/**
|
|
15
|
+
* Resolve to CSV content. Throws with operator-friendly messages on
|
|
16
|
+
* misuse (both unset, both set, file missing, file empty).
|
|
17
|
+
*/
|
|
18
|
+
export async function resolveCsvInput(input) {
|
|
19
|
+
const hasContent = input.csv !== undefined && input.csv.length > 0;
|
|
20
|
+
const hasPath = input.csvPath !== undefined && input.csvPath.length > 0;
|
|
21
|
+
if (!hasContent && !hasPath) {
|
|
22
|
+
throw new Error("Provide either `csv` (the CSV content as text) or `csvPath` (a path to a CSV file).");
|
|
23
|
+
}
|
|
24
|
+
if (hasContent && hasPath) {
|
|
25
|
+
throw new Error("Provide either `csv` or `csvPath`, not both. Pick one — content for paste-in, path for an on-disk file.");
|
|
26
|
+
}
|
|
27
|
+
if (hasContent)
|
|
28
|
+
return input.csv;
|
|
29
|
+
// Path branch. Expand `~` to $HOME, resolve relative paths from
|
|
30
|
+
// the current working directory of the MCP subprocess.
|
|
31
|
+
let path = input.csvPath;
|
|
32
|
+
if (path.startsWith("~/")) {
|
|
33
|
+
path = resolve(homedir(), path.slice(2));
|
|
34
|
+
}
|
|
35
|
+
else if (!isAbsolute(path)) {
|
|
36
|
+
path = resolve(process.cwd(), path);
|
|
37
|
+
}
|
|
38
|
+
let content;
|
|
39
|
+
try {
|
|
40
|
+
content = await readFile(path, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
44
|
+
throw new Error(`Could not read CSV file at \`${path}\`: ${reason}`);
|
|
45
|
+
}
|
|
46
|
+
if (content.length === 0) {
|
|
47
|
+
throw new Error(`File at \`${path}\` is empty.`);
|
|
48
|
+
}
|
|
49
|
+
return content;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Common Zod-friendly description for the csvPath field. Tool authors
|
|
53
|
+
* pass this as the `.describe()` argument so the wording stays
|
|
54
|
+
* consistent across tools.
|
|
55
|
+
*/
|
|
56
|
+
export const CSV_PATH_DESCRIPTION = "Path to a CSV file on the operator's machine. Absolute, ~-prefixed (e.g. `~/Downloads/foo.csv`), or relative to the MCP subprocess's working directory. Either `csv` or `csvPath` is required (not both).";
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { trpcMutation, trpcQuery } from "../api-client.js";
|
|
19
|
+
import { CSV_PATH_DESCRIPTION, resolveCsvInput } from "./_csv-input.js";
|
|
19
20
|
import { safeTool } from "./_helpers.js";
|
|
20
21
|
export function registerAccountMasterTools(server, allowed) {
|
|
21
22
|
if (allowed.has("account_lookup")) {
|
|
@@ -82,8 +83,9 @@ export function registerAccountMasterTools(server, allowed) {
|
|
|
82
83
|
].join(" "), {
|
|
83
84
|
csv: z
|
|
84
85
|
.string()
|
|
85
|
-
.
|
|
86
|
-
.describe("CSV content as text. First row must be the header. Accepts the same header aliases as the account-master-csv-import.mjs script."),
|
|
86
|
+
.optional()
|
|
87
|
+
.describe("CSV content as text. First row must be the header. Accepts the same header aliases as the account-master-csv-import.mjs script. Provide this OR csvPath, not both."),
|
|
88
|
+
csvPath: z.string().optional().describe(CSV_PATH_DESCRIPTION),
|
|
87
89
|
source: z
|
|
88
90
|
.string()
|
|
89
91
|
.min(1)
|
|
@@ -99,7 +101,12 @@ export function registerAccountMasterTools(server, allowed) {
|
|
|
99
101
|
.boolean()
|
|
100
102
|
.optional()
|
|
101
103
|
.describe("If true, drain the queue via the matcher immediately after insert."),
|
|
102
|
-
}, safeTool(async (input) =>
|
|
104
|
+
}, safeTool(async (input) => {
|
|
105
|
+
const csv = await resolveCsvInput(input);
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
107
|
+
const { csv: _csv, csvPath: _csvPath, ...rest } = input;
|
|
108
|
+
return trpcMutation("mcp.importAccountsCsv", { csv, ...rest });
|
|
109
|
+
}));
|
|
103
110
|
}
|
|
104
111
|
if (allowed.has("override_account_attribute")) {
|
|
105
112
|
server.tool("override_account_attribute", [
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* People Master — MCP tool wrappers (ops+).
|
|
3
|
+
*
|
|
4
|
+
* Read-only tools (Phase 1.3):
|
|
5
|
+
* person_lookup — find people by email / employee_id /
|
|
6
|
+
* person_id / fullName (+ managerEmail to
|
|
7
|
+
* scope a name lookup)
|
|
8
|
+
* person_show — full detail for one person
|
|
9
|
+
* list_pending_people_matches — review queue for inbound candidates
|
|
10
|
+
* walk_hierarchy — preview a hierarchy walk's membership
|
|
11
|
+
*
|
|
12
|
+
* Mutating tools:
|
|
13
|
+
* import_people_csv — parse a Workday-shaped CSV upload,
|
|
14
|
+
* write inbound candidates
|
|
15
|
+
* sync_people_master_resolution_rules — apply YAML → DB and re-resolve
|
|
16
|
+
*
|
|
17
|
+
* See packages/api/src/routers/mcp.ts for the tRPC procedure
|
|
18
|
+
* implementations and packages/api/src/routers/mcp-schemas.ts for the
|
|
19
|
+
* input schemas.
|
|
20
|
+
*/
|
|
21
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
export declare function registerPeopleMasterTools(server: McpServer, allowed: Set<string>): void;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* People Master — MCP tool wrappers (ops+).
|
|
3
|
+
*
|
|
4
|
+
* Read-only tools (Phase 1.3):
|
|
5
|
+
* person_lookup — find people by email / employee_id /
|
|
6
|
+
* person_id / fullName (+ managerEmail to
|
|
7
|
+
* scope a name lookup)
|
|
8
|
+
* person_show — full detail for one person
|
|
9
|
+
* list_pending_people_matches — review queue for inbound candidates
|
|
10
|
+
* walk_hierarchy — preview a hierarchy walk's membership
|
|
11
|
+
*
|
|
12
|
+
* Mutating tools:
|
|
13
|
+
* import_people_csv — parse a Workday-shaped CSV upload,
|
|
14
|
+
* write inbound candidates
|
|
15
|
+
* sync_people_master_resolution_rules — apply YAML → DB and re-resolve
|
|
16
|
+
*
|
|
17
|
+
* See packages/api/src/routers/mcp.ts for the tRPC procedure
|
|
18
|
+
* implementations and packages/api/src/routers/mcp-schemas.ts for the
|
|
19
|
+
* input schemas.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { trpcMutation, trpcQuery } from "../api-client.js";
|
|
23
|
+
import { CSV_PATH_DESCRIPTION, resolveCsvInput } from "./_csv-input.js";
|
|
24
|
+
import { safeTool } from "./_helpers.js";
|
|
25
|
+
export function registerPeopleMasterTools(server, allowed) {
|
|
26
|
+
if (allowed.has("person_lookup")) {
|
|
27
|
+
server.tool("person_lookup", "Find People Master persons by email, employee_id, person_id, or fullName. Provide at least one. Multiple keys combine as OR — returns the union. Pair fullName with managerEmail when possible to avoid ambiguous name matches.", {
|
|
28
|
+
email: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Work email. Matches both `person.primary_email` and email aliases in `person_identifier`."),
|
|
33
|
+
employeeId: z
|
|
34
|
+
.string()
|
|
35
|
+
.min(1)
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Workday employee_id (e.g. 'W00123'). Case-sensitive."),
|
|
38
|
+
personId: z
|
|
39
|
+
.string()
|
|
40
|
+
.uuid()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("People Master person_id (UUID)."),
|
|
43
|
+
fullName: z
|
|
44
|
+
.string()
|
|
45
|
+
.min(1)
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Full name. Matched after normalization (case-insensitive, punctuation collapsed; apostrophes/hyphens/accents preserved). Returns multiple matches if name is ambiguous — pair with managerEmail."),
|
|
48
|
+
managerEmail: z
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Manager's work email. Scopes a fullName lookup to that manager's direct reports — recommended whenever you only have a name."),
|
|
53
|
+
}, safeTool(async (input) => trpcQuery("mcp.personLookup", input)));
|
|
54
|
+
}
|
|
55
|
+
if (allowed.has("person_show")) {
|
|
56
|
+
server.tool("person_show", "Show full detail for one person: identifiers, manager + direct reports, lifecycle states (ingestion / activity_capture), resolved attributes (the golden record with provenance), and recent lifecycle events. Use person_lookup first if you only have an email or name.", {
|
|
57
|
+
personId: z
|
|
58
|
+
.string()
|
|
59
|
+
.uuid()
|
|
60
|
+
.describe("person_id (UUID). Use person_lookup if you don't have it yet."),
|
|
61
|
+
}, safeTool(async ({ personId }) => trpcQuery("mcp.personShow", { personId })));
|
|
62
|
+
}
|
|
63
|
+
if (allowed.has("list_pending_people_matches")) {
|
|
64
|
+
server.tool("list_pending_people_matches", "Show inbound person candidates currently in `match_status='needs_review'` — rows the matcher couldn't confidently resolve. Each entry includes the source, ingestion time, and the candidate's email/employee_id so you can investigate via `person_lookup`.", {
|
|
65
|
+
limit: z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.positive()
|
|
69
|
+
.max(200)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Max candidates to return (1–200). Default: 50."),
|
|
72
|
+
source: z
|
|
73
|
+
.string()
|
|
74
|
+
.min(1)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("Filter to a single inbound source (e.g. 'workday_csv'). Omit to see all."),
|
|
77
|
+
}, safeTool(async (input) => trpcQuery("mcp.listPendingPeopleMatches", input)));
|
|
78
|
+
}
|
|
79
|
+
if (allowed.has("walk_hierarchy")) {
|
|
80
|
+
server.tool("walk_hierarchy", [
|
|
81
|
+
"Preview a hierarchy walk's membership. Reads `config/people_master/hierarchy_walks.yaml`",
|
|
82
|
+
"from the repo branch (default `main`), runs the evaluator against the live DB, and",
|
|
83
|
+
"returns the member list plus filter diagnostics (how many people were filtered by each",
|
|
84
|
+
"rule). Pass `walkId` to evaluate a specific walk; omit it to list all defined walks.",
|
|
85
|
+
"Use this to sanity-check a walk before wiring it into Activity Capture, or to see who",
|
|
86
|
+
"would be in scope after editing the YAML on a feature branch (pass `branch:` to point",
|
|
87
|
+
"at it).",
|
|
88
|
+
].join(" "), {
|
|
89
|
+
walkId: z
|
|
90
|
+
.string()
|
|
91
|
+
.min(1)
|
|
92
|
+
.optional()
|
|
93
|
+
.describe("walk_id from hierarchy_walks.yaml (e.g. 'cro_org'). Omit to list all defined walks."),
|
|
94
|
+
branch: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe("Branch to read YAML from. Default: 'main'."),
|
|
98
|
+
limit: z
|
|
99
|
+
.number()
|
|
100
|
+
.int()
|
|
101
|
+
.positive()
|
|
102
|
+
.max(500)
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Max members to render in the response. Default: 100. Stats reflect the full walk regardless."),
|
|
105
|
+
}, safeTool(async (input) => trpcQuery("mcp.walkHierarchy", input)));
|
|
106
|
+
}
|
|
107
|
+
if (allowed.has("import_people_csv")) {
|
|
108
|
+
server.tool("import_people_csv", [
|
|
109
|
+
"Import a Workday-shaped CSV of people into the People Master. Headers are case- and",
|
|
110
|
+
"punctuation-tolerant — `Worker ID`, `Employee ID`, `Work Email`, `Job Title`, `Manager",
|
|
111
|
+
"Email`, etc. all map to canonical fields. Rows without `work_email` AND without",
|
|
112
|
+
"(`employee_id` + `full_name`) are skipped. Each accepted row becomes an",
|
|
113
|
+
"`inbound_person_candidate` with the given source; with `process: true` they drain",
|
|
114
|
+
"through the matcher immediately, otherwise they wait for the next",
|
|
115
|
+
"`people_master_match_orchestrator` cron tick. Use `dryRun: true` to preview without",
|
|
116
|
+
"writing — recommended on the first import from any new source.",
|
|
117
|
+
].join(" "), {
|
|
118
|
+
csv: z
|
|
119
|
+
.string()
|
|
120
|
+
.optional()
|
|
121
|
+
.describe("CSV content as text. First row must be the header. Provide this OR csvPath, not both."),
|
|
122
|
+
csvPath: z.string().optional().describe(CSV_PATH_DESCRIPTION),
|
|
123
|
+
source: z
|
|
124
|
+
.string()
|
|
125
|
+
.min(1)
|
|
126
|
+
.max(64)
|
|
127
|
+
.regex(/^[a-z0-9_]+$/)
|
|
128
|
+
.optional()
|
|
129
|
+
.describe("Slug stored in `inbound_person_candidate.source` (snake_case). Defaults to 'workday_csv'."),
|
|
130
|
+
dryRun: z
|
|
131
|
+
.boolean()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("If true, parse + normalize + summarize but write nothing."),
|
|
134
|
+
process: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("If true, drain the matcher orchestrator immediately after insert."),
|
|
138
|
+
}, safeTool(async (input) => {
|
|
139
|
+
const csv = await resolveCsvInput(input);
|
|
140
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
141
|
+
const { csv: _csv, csvPath: _csvPath, ...rest } = input;
|
|
142
|
+
return trpcMutation("mcp.importPeopleCsv", { csv, ...rest });
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
if (allowed.has("sync_people_master_resolution_rules")) {
|
|
146
|
+
server.tool("sync_people_master_resolution_rules", [
|
|
147
|
+
"Apply the committed config/people_master/resolution_rules.yaml to the DB.",
|
|
148
|
+
"Server-side equivalent of `node scripts/people-master-sync-resolution-rules.mjs --resolve`.",
|
|
149
|
+
"Idempotent — re-running with no YAML changes is a no-op. Resolver backfills golden",
|
|
150
|
+
"records by default (pass `resolve: false` to skip).",
|
|
151
|
+
].join(" "), {
|
|
152
|
+
branch: z
|
|
153
|
+
.string()
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("Branch to read YAML from. Default: main. Useful for testing a feature branch's YAML before merging."),
|
|
156
|
+
resolve: z
|
|
157
|
+
.boolean()
|
|
158
|
+
.optional()
|
|
159
|
+
.describe("Re-run the resolver across affected persons after applying rule changes so golden records update. Default: true."),
|
|
160
|
+
dryRun: z
|
|
161
|
+
.boolean()
|
|
162
|
+
.optional()
|
|
163
|
+
.describe("Read + validate + diff against DB without writing. Useful for previewing."),
|
|
164
|
+
}, safeTool(async (input) => trpcMutation("mcp.syncPeopleMasterResolutionRules", input)));
|
|
165
|
+
}
|
|
166
|
+
}
|
package/dist/tools/sfdc-sync.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { trpcMutation, trpcQuery } from "../api-client.js";
|
|
14
|
+
import { CSV_PATH_DESCRIPTION, resolveCsvInput } from "./_csv-input.js";
|
|
14
15
|
import { safeTool } from "./_helpers.js";
|
|
15
16
|
const accountIdentifierShape = {
|
|
16
17
|
accountId: z
|
|
@@ -88,6 +89,10 @@ export function registerSfdcSyncTools(server, allowed) {
|
|
|
88
89
|
"writes the same lifecycle event (with reason + snapshot payload) as a single-account",
|
|
89
90
|
"approval would.",
|
|
90
91
|
"",
|
|
92
|
+
"Provide EITHER `csv` (paste content inline) OR `csvPath` (path to a file on the",
|
|
93
|
+
"operator's machine; absolute or `~/`-prefixed). The MCP subprocess reads files locally,",
|
|
94
|
+
"so `csvPath` works for both Claude Code and Claude Desktop.",
|
|
95
|
+
"",
|
|
91
96
|
"Validation:",
|
|
92
97
|
" - account_id must currently be in pending_initial_deployment_review (else row fails)",
|
|
93
98
|
" - reason must be ≥3 chars on approve=yes rows (else row fails)",
|
|
@@ -101,9 +106,13 @@ export function registerSfdcSyncTools(server, allowed) {
|
|
|
101
106
|
].join("\n"), {
|
|
102
107
|
csv: z
|
|
103
108
|
.string()
|
|
104
|
-
.
|
|
105
|
-
.describe("CSV content (header row + data rows). Required headers: account_id, approve, reason. Other columns from export_pending_approvals are tolerated."),
|
|
106
|
-
|
|
109
|
+
.optional()
|
|
110
|
+
.describe("CSV content (header row + data rows). Required headers: account_id, approve, reason. Other columns from export_pending_approvals are tolerated. Provide this OR csvPath, not both."),
|
|
111
|
+
csvPath: z.string().optional().describe(CSV_PATH_DESCRIPTION),
|
|
112
|
+
}, safeTool(async (input) => {
|
|
113
|
+
const csv = await resolveCsvInput(input);
|
|
114
|
+
return trpcMutation("mcp.processApprovalsCsv", { csv });
|
|
115
|
+
}));
|
|
107
116
|
}
|
|
108
117
|
if (allowed.has("deployment_status")) {
|
|
109
118
|
server.tool("deployment_status", "Top-line counts of accounts in each deployment lifecycle state (pending review, pending update, deployed, deployment_failed, sync_drift). Includes a sample of the initial-review queue so ops can spot-check what's waiting for approval.", {
|