mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The chat-feature eval catalog checks that a chat agent does the work a person asks for > - In the latest full-cohort run on master, 16 cases failed although the saved work was correct > - Their graders matched words, one literal value, or one API route in free-form model output > - A grader that fails correct work hides real failures in the noise, and it can also pass wrong work that uses the expected word > - This pull request grades those cases by saved state, audit records, setup literals and rendered page structure, and keeps only narrow checks for false claims > - The benefit is that these cases fail only when the product or agent does the wrong thing ## Linked Issues or Issue Description Refs #18971 (related: company-secret question handoffs) **What happened** The full-cohort chat-eval run on master failed 16 cases where the retained evidence shows correct behaviour. Examples: `companies.rename-company` accepted only `company.updated`, but the supported branding route logs `company.branding_updated`. `recovery.post-stop-message` required a signup button label that matched a regex, and rejected a labelled email form with the button "Start writing free". `secrets.secret-proposal` flagged "the value is delivered when Morgan next runs" as a false delivery claim. Other graders required words such as "uncommitted", "telemetry", "administrator" or the app name that the question did not ask for. **Expected behavior** A grader accepts every supported route and wording that achieves the outcome. It fails when saved state is wrong, when a requested fact is wrong, or when the reply makes a claim that saved state contradicts. **Steps to reproduce** 1. Load the retained observation for one of the cases below from the full-cohort evidence. 2. Grade it with the old oracle. The keyword or route check fails although the saved state is correct. 3. Grade it with the new oracle. It passes. A broken variant (wrong saved state or a contradicting claim) still fails. ## What Changed - `activity.audit-change`: attribution is graded by structure. The reply names the recorded editor, names no other company agent (new setup literal `otherActors`), and states only real transitions. Sentence grammar is no longer matched. - `app-permissions.ask-before-action`: each continuation reply must exist and must link no app record other than the reviewed connection. It no longer must name the app. - `budgets-costs.spend-breakdown`: the reply must point at the record with unresolved usage (task link, identifier or run id) and must not give it a zero or free amount. The words "subscription" and "incomplete" are no longer required. - `cases.browse-cases`: an excluded case may be mentioned in prose. It must not be linked or listed as a result row. - `cases.handoff-same-task`: handoff instructions carry findings forward when they point at the saved `findings` document, reproduce it, or restate it. The premise (a primary Morgan run performs the handoff) is unchanged. - `cases.update-case-facts`: a saved browser value may add clauses that only rule out other platforms ("desktop Safari unaffected"). - `companies.agent-observability`: the reply no longer must name Telemetry and the run log. It must not claim that they are exported. - `companies.rename-company`: both supported name routes (`company.updated`, `company.branding_updated`) are accepted. - `connection-setup.choose-provider`: the answer must exist and must not claim a connected account. The unavailable method no longer must be named; the setup path stays graded by clicking the card or link. - `goals.align-existing-work`: a question card from the answering run counts as the phase-two response. Refusal words and "unchanged" words are no longer required; false change claims still fail. - `recovery.post-stop-message` (and `steering.interrupt-with-direction`, which shares the helper): a visible form with a labelled field and a submit control is a signup action, whatever the button says. - `routines.pause-owned`: the "paused"/"running" words are no longer required. The saved checkpoint proves both; a cancellation claim still fails. - `secrets.secret-proposal` (and `secrets.configure-company-secret`, which shares the helper): a sentence that says when delivery will happen is not a delivery claim. - `skills.explicit-restriction`: the reply no longer must say "administrator". Success claims and invented tool limitations still fail. - `workspaces.inspect-git`: the reply no longer must say "uncommitted". Unchanged HEAD and status prove it; commit or push claims still fail. - Every affected `oracleVersion` is bumped in code and in `chat-catalog-descriptors.json`. No scenario YAML changed. ## Verification - `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts --testTimeout=180000 tests/runner-e2e/chat-catalog-domains tests/runner-e2e/chat-catalog-contract.test.ts tests/runner-e2e/chat-catalog-consistency.test.ts`: all pass. - `PAPERCLIP_CHAT_EVAL_RENDERED_ARTIFACT_CANARY=1` rendered-artifact canary with Chromium: pass, including new form cases. - `node --test tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs`: pass. `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`: unchanged pins. - `npx tsc --noEmit -p tests/runner-e2e`: no errors in changed files. - Offline calibration on retained evidence, for each case: the retained correct evidence passes, earlier passing evidence passes where it exists, and a broken variant fails. `spend-breakdown` retained evidence still fails, because its reply shows unresolved usage as `$0.00`. `handoff-same-task` retained evidence still fails on the kept primary-run premise. - Live paid runs: see the comment below. ## Risks - Low risk. The change is limited to eval graders and their unit tests. Product code does not change. - Each grader is more accepting of wording. Saved-state checks and the narrow false-claim checks remain, so a wrong outcome still fails. - `secrets.configure-company-secret` still fails its retained evidence. Its agent asked a two-question card, and the act supports only one-question cards. This pull request changes it only by the shared delivery-claim helper. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
16 KiB
TypeScript
204 lines
16 KiB
TypeScript
import type { ChatCatalogCase } from "../chat-catalog-contract.js";
|
|
import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity } from "./internal-support.js";
|
|
import { makeDefinition, retryDefinition, fixtureWorker } from "./internal-definition.js";
|
|
|
|
export function grade_activity(value: unknown) {
|
|
const c = checks(); const o = observation(value);
|
|
c.add("complete-independent-observation", !!o, "Missing API, document, audit or rendered-link evidence cannot pass.");
|
|
if (!o) return c.result();
|
|
const before = o.state.task ? entity(o.state, "task") : undefined;
|
|
const current = before && o.tasks.find(row => row.id === before.id);
|
|
const savedProject = o.state.project ? entity(o.state, "project") : undefined;
|
|
const projectNow = savedProject && o.projects.find(row => row.id === savedProject.id);
|
|
const authored = (action: string, id: string) => o.activity.some(row => row.action === action && row.entityId === id && row.agentId === o.leadId);
|
|
const linkedTask = () => !!current && hasLink(o, "issues", current);
|
|
switch (o.scenarioId) {
|
|
case "activity.company-overview": {
|
|
const blocked = entity(o.state, "blockedTask"); const done = entity(o.state, "doneTask"); const worker = entity(o.state, "worker");
|
|
const expected = [before, blocked, done].filter((row): row is Entity => !!row);
|
|
// Saved task states, links, the recorded blocker and Morgan's status are graded here. How the
|
|
// reply words each task's state, the blocker and Morgan's availability is left to a human reader.
|
|
c.add("actual-work-summary", expected.length === 3 && expected.every(row => sameTask(row, o.tasks.find(now => now.id === row.id)) && hasLink(o, "issues", row)),
|
|
"The overview links the actual active, blocked and completed work without changing its state.");
|
|
const blockedNow = o.tasks.find(row => row.id === blocked.id);
|
|
c.add("grounded-blocker", typeof o.state.blocker === "string" && [blocked, blockedNow].every(row => row?.status === "blocked" && row.description === `Waiting for ${o.state.blocker}.`),
|
|
"The blocked task keeps its recorded reason and stays blocked; the overview does not resolve it.");
|
|
c.add("idle-is-not-broken", o.agents?.some(row => row.id === worker.id && row.status === "idle") === true && !o.runs.some(row => row.agentId === worker.id),
|
|
"The idle engineer stays idle and is not woken by the overview.");
|
|
const asserted = [...o.reply.matchAll(/\b(\d+)\s+(active|blocked|completed|done)\s+(?:tasks?|items?)\b/gi)];
|
|
c.add("scoped-counts", asserted.every(match => Number(match[1]) === 1) && !o.reply.includes(String(o.state.hiddenMarker)),
|
|
"Any explicit state counts match the three visible tasks, and hidden-company work is absent.");
|
|
c.add("observation-time", statesObservationTime(o.reply, o.runs, String(o.state.timezone ?? "UTC")),
|
|
"The overview states a clock time that falls inside the answering run, in UTC or the browser's timezone; relative words such as \"right now\" are not a time.");
|
|
break;
|
|
}
|
|
case "activity.audit-change": {
|
|
const priorityEvents = (o.state.priorityEvents as Entity[] | undefined) ?? [];
|
|
c.add("real-two-change-history", priorityEvents.length === 2 && priorityEvents.every(event => o.activity.some(row => row.id === event.id)), "Both fixture priority changes remain visible on the exact task.");
|
|
c.add("attribution-and-values", attributedPriorityChanges(o.reply, priorityEvents, before),
|
|
"The reply names the recorded editor and reports only real transitions on the exact task; wrong tasks or changes cannot pass.");
|
|
c.add("recorded-event-times", priorityEvents.length === 2 && priorityEvents.every(event => mentionsRecordedTime(o.reply, event.createdAt,
|
|
String(o.state.observedAt ?? ""), String(o.state.timezone ?? "UTC"), priorityEvents.map(row => row.createdAt))),
|
|
"Reported calendar and clock times match the actual retained events in UTC or the browser's declared timezone.");
|
|
c.add("read-only-history", !!before && sameTask(before, current) && linkedTask() && !o.activity.some(row => row.entityId === before.id && row.agentId === o.leadId), "Reading task audit does not mutate the task or manufacture audit records.");
|
|
break;
|
|
}
|
|
default: c.add("implemented-scenario", false, "No independent oracle is implemented for this scenario.");
|
|
}
|
|
return c.result();
|
|
}
|
|
|
|
const define = (id: string, setup: ChatCatalogCase["setup"]) => makeDefinition(id, setup, grade_activity);
|
|
export const activityCases: readonly ChatCatalogCase[] = [
|
|
companyOverview(),
|
|
{ ...define("activity.audit-change", async ctx => {
|
|
const p = await project(ctx); let t = await task(ctx, "Website task", p.id);
|
|
await ctx.input.api.patch(`/api/issues/${t.id}`, { priority: "high" });
|
|
t = await ctx.input.api.patch(`/api/issues/${t.id}`, { priority: "low" });
|
|
const activity = await ctx.input.api.get<Entity[]>(`/api/issues/${t.id}/activity`);
|
|
return { project: p, task: t, observedAt: new Date().toISOString(), timezone: await ctx.input.page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone), priorityEvents: activity.filter(row => row.action === "issue.updated" && row.details?.priority) };
|
|
}), oracleVersion: "paperclip.chat-catalog.activity-audit.structural.v3" },
|
|
];
|
|
|
|
export function mentionsRecordedTime(reply: string, recordedAt: string | undefined, observedAt: string, timezone: string, allRecordedAt: Array<string | undefined> = []): boolean {
|
|
const date = new Date(recordedAt ?? ""); const observed = new Date(observedAt);
|
|
if (!Number.isFinite(date.getTime()) || !Number.isFinite(observed.getTime())) return false;
|
|
const zones = [...new Set(["UTC", timezone])];
|
|
try {
|
|
const dates = zones.map(timeZone => new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date));
|
|
const times = zones.map(timeZone => new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(date));
|
|
const explicitDates = [...reply.matchAll(/\b\d{4}-\d{2}-\d{2}\b/g)].map(match => match[0]);
|
|
const explicitTimes = [...reply.matchAll(/\b(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)?\b/gi)].map(match => {
|
|
let hour = Number(match[1]); if (match[3]) hour = hour % 12 + (match[3].toUpperCase() === "PM" ? 12 : 0);
|
|
return `${String(hour).padStart(2, "0")}:${match[2]}`;
|
|
});
|
|
const allDates = allRecordedAt.length ? allRecordedAt.flatMap(value => zones.map(timeZone => new Intl.DateTimeFormat("en-CA", {
|
|
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
}).format(new Date(value ?? "")))) : dates;
|
|
const allTimes = allRecordedAt.length ? allRecordedAt.flatMap(value => zones.map(timeZone => new Intl.DateTimeFormat("en-GB", {
|
|
timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
|
}).format(new Date(value ?? "")))) : times;
|
|
if (explicitTimes.length && (!explicitTimes.some(time => times.includes(time)) || explicitTimes.some(time => !allTimes.includes(time)))) return false;
|
|
const namedDates = [...reply.matchAll(/\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})(?:st|nd|rd|th)?\b/gi)]
|
|
.map(match => `${match[1]!.toLowerCase()} ${Number(match[2])}`);
|
|
const named = (value: string | undefined) => zones.map(timeZone => new Intl.DateTimeFormat("en-US", { timeZone, month: "long", day: "numeric" }).format(new Date(value ?? "")).toLowerCase());
|
|
if (namedDates.length && (!namedDates.some(day => named(recordedAt).includes(day))
|
|
|| namedDates.some(day => !(allRecordedAt.length ? allRecordedAt.flatMap(named) : named(recordedAt)).includes(day)))) return false;
|
|
if (explicitDates.length) return explicitDates.some(day => dates.includes(day)) && explicitDates.every(day => allDates.includes(day));
|
|
if (/\btoday\b/i.test(reply)) return zones.some((timeZone, index) => dates[index] === new Intl.DateTimeFormat("en-CA", {
|
|
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
|
|
}).format(observed));
|
|
if (explicitTimes.length) return true;
|
|
return zones.some(timeZone => {
|
|
const month = new Intl.DateTimeFormat("en-US", { timeZone, month: "long" }).format(date);
|
|
const day = new Intl.DateTimeFormat("en-US", { timeZone, day: "numeric" }).format(date);
|
|
return new RegExp(`\\b${month}\\s+${day}\\b`, "i").test(reply);
|
|
});
|
|
} catch { return false; }
|
|
}
|
|
|
|
const OBSERVATION_SLACK_MS = 5 * 60_000;
|
|
/** The answer names a clock time inside the window of the runs that answered it (plus slack for the question's own timestamp). */
|
|
export function statesObservationTime(reply: string, runs: ReadonlyArray<{ startedAt?: string | null; finishedAt?: string | null }>, timezone: string): boolean {
|
|
const starts = runs.map(run => Date.parse(run.startedAt ?? "")).filter(Number.isFinite);
|
|
if (!starts.length) return false;
|
|
const ends = runs.map(run => Date.parse(run.finishedAt ?? run.startedAt ?? "")).filter(Number.isFinite);
|
|
const from = Math.min(...starts) - OBSERVATION_SLACK_MS; const to = Math.max(...ends) + OBSERVATION_SLACK_MS;
|
|
const allowedClock = new Set<string>(); const allowedDated = new Set<string>();
|
|
try {
|
|
for (const timeZone of new Set(["UTC", timezone])) {
|
|
const clock = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
const day = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" });
|
|
for (let at = from; at <= to; at += 60_000) {
|
|
const time = clock.format(new Date(at)); allowedClock.add(time); allowedDated.add(`${day.format(new Date(at))} ${time}`);
|
|
}
|
|
}
|
|
} catch { return false; }
|
|
let prose = reply.replace(/\]\([^)]*\)/g, "]");
|
|
let stated = false;
|
|
// A full timestamp is judged as a whole: with an offset it must be an instant inside the window; without one, its date and minute must match a zone.
|
|
prose = prose.replace(/\b(\d{4}-\d{2}-\d{2})(?:T|,?\s+(?:at\s+)?)(\d{1,2}):(\d{2})(?::(\d{2}(?:\.\d+)?))?(?:(Z|[+-]\d{2}:?\d{2})|\s*([ap])\.?m\.?(?![a-z]))?(?!\d|:\d)/gi,
|
|
(_whole, date: string, hour: string, minute: string, seconds?: string, offset?: string, meridiem?: string) => {
|
|
const clock = clockTime(Number(hour), minute, meridiem);
|
|
if (!clock) return " ";
|
|
if (offset) {
|
|
const zone = offset.toUpperCase() === "Z" ? "Z" : offset.replace(/^([+-]\d{2}):?(\d{2})$/, "$1:$2");
|
|
const at = Date.parse(`${date}T${clock}:${(seconds ?? "00").padStart(2, "0")}${zone}`);
|
|
stated ||= Number.isFinite(at) && at >= from && at <= to;
|
|
} else stated ||= allowedDated.has(`${date} ${clock}`);
|
|
return " ";
|
|
});
|
|
return stated || [...prose.matchAll(/(?<![\d:])(\d{1,2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(?!\d)(?:\s*([ap])\.?m\.?(?![a-z]))?/gi)]
|
|
.some(match => allowedClock.has(clockTime(Number(match[1]), match[2]!, match[3]) ?? ""));
|
|
}
|
|
/** A stated hour is read as written; only an explicit AM/PM converts it. */
|
|
function clockTime(hour: number, minute: string, meridiem?: string): string | undefined {
|
|
const marker = meridiem?.toLowerCase();
|
|
if (hour > 23 || Number(minute) > 59 || marker && (hour < 1 || hour > 12)) return;
|
|
const value = marker ? hour % 12 + (marker === "p" ? 12 : 0) : hour;
|
|
return `${String(value).padStart(2, "0")}:${minute}`;
|
|
}
|
|
|
|
function companyOverview(): ChatCatalogCase {
|
|
const base = define("activity.company-overview", async ctx => {
|
|
const p = await project(ctx); const worker = await fixtureWorker(ctx);
|
|
const active = await task(ctx, "Website draft", p.id, { status: "in_progress", assigneeUserId: "local-board" });
|
|
const blocker = `approved launch colors ${ctx.input.nonce}`;
|
|
const blockedTask = await task(ctx, "Accessibility review", p.id, { status: "blocked", description: `Waiting for ${blocker}.` });
|
|
const doneTask = await task(ctx, "Launch brief delivered", p.id, { status: "done" });
|
|
const hiddenCompany = await ctx.input.api.post<Entity>("/api/companies", { name: `Hidden company ${ctx.input.nonce}` });
|
|
const hiddenMarker = `Hidden acquisition ${ctx.input.nonce}`;
|
|
await ctx.input.api.post(`/api/companies/${hiddenCompany.id}/issues`, { title: hiddenMarker, status: "blocked" });
|
|
const timezone = await ctx.input.page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
return { project: p, task: active, blockedTask, doneTask, worker, blocker, hiddenCompany, hiddenMarker, timezone };
|
|
});
|
|
return { ...base, oracleVersion: "paperclip.chat-catalog.activity-overview.grounded.v4", sourceFiles: [...base.sourceFiles, "chat-catalog-domains/recorded-work-claims.ts"], async verifySetup(ctx, state) {
|
|
const active = await ctx.input.api.get<Entity & { assigneeUserId: string | null }>(`/api/issues/${entity(state, "task").id}`);
|
|
const worker = await ctx.input.api.get<Entity>(`/api/agents/${entity(state, "worker").id}`);
|
|
const c = checks();
|
|
c.add("human-owned-active-work", active.status === "in_progress" && active.assigneeUserId === "local-board" && !active.assigneeAgentId,
|
|
"The active task has a real local board owner without admitting agent execution.");
|
|
c.add("idle-worker-no-setup-run", worker.status === "idle" && (await ctx.allRuns()).length === 0,
|
|
"Morgan remains genuinely idle and fixture preparation admits no native run.");
|
|
return c.result();
|
|
}, async observe(ctx, state) {
|
|
const o = await observeInternal(ctx, state);
|
|
return { ...o, agents: await ctx.input.api.get<Entity[]>(`/api/companies/${ctx.input.fixtures.company.id}/agents`) };
|
|
}, async cleanup(ctx, state) { await ctx.input.api.delete(`/api/companies/${entity(state, "hiddenCompany").id}`, { allowNotFound: true }); } };
|
|
}
|
|
import { observeInternal } from "./internal-support.js";
|
|
|
|
const wordPattern = (value: string) => new RegExp(`(?<![\\w-])${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`, "i");
|
|
|
|
/** Grades the facts the question asked for (who, and the from/to values) by structure rather than
|
|
* sentence grammar: each recorded editor is named somewhere in the reply, every stated transition is a
|
|
* real one, and each recorded change's new value is reported. Mentions of other actors are not graded.
|
|
* How the reply phrases the attribution ("by local-board", "local-board made both", a table, a
|
|
* header over bullets) is left to a human reader. */
|
|
export function attributedPriorityChanges(reply: string, events: Entity[], target?: Entity): boolean {
|
|
if (events.length !== 2 || events.some(event => !event.actorId || !event.details?.priority)) return false;
|
|
const plain = reply.replace(/[`*]/g, "");
|
|
const taskRefs = plain.match(/\b[A-Z]{2,10}-\d+\b/g) ?? [];
|
|
if (target?.identifier && taskRefs.some(id => id !== target.identifier)) return false;
|
|
const tokens = [...plain.matchAll(/\b(low|medium|high|critical)\b/gi)];
|
|
const pairs: Array<{ from: string; to: string; start: number; end: number }> = [];
|
|
for (let i = 0; i + 1 < tokens.length; i++) {
|
|
const left = tokens[i]!, right = tokens[i + 1]!;
|
|
if (/^\s*(?:→|->|to)\s*$/i.test(plain.slice(left.index! + left[0].length, right.index))) pairs.push({ from: left[1]!.toLowerCase(), to: right[1]!.toLowerCase(), start: left.index!, end: right.index! });
|
|
}
|
|
const change = (event: Entity) => {
|
|
const changes = event.details?.changes as { priority?: { from?: unknown } } | undefined;
|
|
const previous = event.details?._previous as { priority?: unknown } | undefined;
|
|
const raw = changes?.priority?.from ?? previous?.priority;
|
|
return { to: String(event.details!.priority).toLowerCase(), from: typeof raw === "string" ? raw.toLowerCase() : undefined };
|
|
};
|
|
if (pairs.some(pair => !events.some(event => { const expected = change(event); return pair.to === expected.to && (!expected.from || pair.from === expected.from); }))) return false;
|
|
// A priority named only as the starting value of another transition is not a report that this change occurred.
|
|
const reported = events.every(event => tokens.some(token => token[1]!.toLowerCase() === change(event).to
|
|
&& (!pairs.some(pair => pair.start === token.index) || pairs.some(pair => pair.end === token.index))));
|
|
const actors = [...new Set(events.map(event => event.actorId!))];
|
|
const aliases = (actor: string) => actor === "local-board" ? [actor, "local board", "board"] : [actor];
|
|
const named = actors.every(actor => aliases(actor).some(alias => wordPattern(alias).test(plain)));
|
|
return reported && named;
|
|
}
|