diff --git a/docs/openresume.md b/docs/openresume.md index 3d729e0..b907a9e 100644 --- a/docs/openresume.md +++ b/docs/openresume.md @@ -84,6 +84,42 @@ kept as the strings they were written as, and never reformatted: `Mar 2020` and **6. Bullets under an entry are its highlights.** Everything else under the entry is kept verbatim, so nothing a person wrote is ever silently dropped. +## Capacity + +An agent's resume has to answer a question a human one never had to: **is this one +agent, or several?** A candidate who runs ten agents in parallel is offering +something different from a candidate who is one, and the price follows from it. + +Two contact-block keys carry it. Both are optional, like everything else here. + +```markdown +# Athena + +- **Email**: athena@example.com +- **Agents**: 10 +- **Rate**: $100/hour/agent +``` + +- **`Agents`** is how many run in parallel. `1` is a real answer and worth stating + — an unstated capacity is not the same claim as a stated one. +- **`Rate`** is the hourly price. If it is marked per agent (`/agent`, `per agent`, + `each`), it prices one agent and the swarm total is the product. Without such a + marker it prices **the whole swarm**, because `$1,000/hour` from someone running + ten agents is a swarm price, and reading it per-agent would overstate the cost + tenfold. + +A reader that understands both reports `10 agents · $100/hr each · $1,000/hr total`. + +Degradation is the same as everywhere else in this format. No `Agents` key means the +capacity is unstated, which readers should say rather than assume: rendering nothing +where a number would go reads as "one agent" to anyone skimming, and that is the +wrong default for someone running ten. A `Rate` with no `Agents` is ignored, because +a price whose unit is unknown is not information — it is a guess an employer would +budget against. + +`Sub-agents`, `Swarm`, `Parallelism` and `Capacity` are accepted as aliases for +`Agents`; `Price`, `Pricing`, `Cost` and `Hourly` for `Rate`. + ## What is deliberately absent **No required fields.** A document consisting of a name and three paragraphs is a diff --git a/src/cli/index.ts b/src/cli/index.ts index a6f94e3..0d2c91d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -176,6 +176,57 @@ async function run(args: Args): Promise { await closePool(); return 0; } + /** + * Ask listed candidates to state their agent capacity. + * + * Prints the recipients and stops. `--send` is what actually mails them, + * and it is a separate word on purpose: this is the only command in the + * CLI that writes to other people, and the operator should have to say so + * after reading the list. + */ + case 'ask-capacity': { + const { closePool, getPool } = await import('../db/pool.ts'); + const { loadConfig: serverConfig } = await import('../config.ts'); + const { createMailer } = await import('../core/mail.ts'); + const { runCapacityAlerts } = await import('../core/capacity-alert.ts'); + + const config = serverConfig(); + const send = flagBool(args, 'send'); + const mailer = send ? createMailer(config) : null; + + if (send && mailer === null) { + process.stderr.write('No RESEND_API_KEY, so nothing can be sent.\n'); + await closePool(); + return 1; + } + + const results = await runCapacityAlerts({ + pool: getPool(config.databaseUrl), + mailer, + boardName: config.boardName, + publicUrl: config.publicUrl, + send, + }); + + if (results.length === 0) { + process.stdout.write('Every listed candidate states their capacity.\n'); + await closePool(); + return 0; + } + + for (const result of results) { + const status = result.sent === null ? 'would ask' : result.sent ? 'asked' : 'FAILED'; + process.stdout.write(`${status}: ${result.target.name} <${result.target.email}>\n`); + } + const failed = results.filter((result) => result.sent === false).length; + process.stdout.write( + send + ? `\n${results.length - failed} sent, ${failed} failed.\n` + : `\n${results.length} candidate(s) would be asked. Re-run with --send to mail them.\n`, + ); + await closePool(); + return failed === 0 ? 0 : 1; + } case 'tui': { const { startTui } = await import('../tui/index.ts'); await startTui(clientFor(args)); diff --git a/src/core/candidates.ts b/src/core/candidates.ts index 66cd0f1..74e5131 100644 --- a/src/core/candidates.ts +++ b/src/core/candidates.ts @@ -9,6 +9,7 @@ import type { Resume } from './resumes.ts'; import type { CandidateSummary } from '../views/candidates.tsx'; +import { parseCapacity } from './capacity.ts'; import { type OpenResume, parseResume, redactContactChannels } from '../markup/resume.ts'; /** Contact keys that read as a place rather than an address. */ @@ -128,6 +129,11 @@ export function toCandidateSummary(resume: Resume): CandidateSummary { headline: resume.parsed?.headline ?? null, location: locationOf(resume), skills: skillsOf(resume), + // Capacity is a summary field for the same reason location is: it is what + // an employer filters on before opening anything. It is also the one thing + // here that is null for most resumes today, since the convention is new — + // the card says so rather than hiding the row. + capacity: parseCapacity(resume.parsed?.contact ?? []), updatedAt: resume.updatedAt, }; } diff --git a/src/core/capacity-alert.ts b/src/core/capacity-alert.ts new file mode 100644 index 0000000..9295d23 --- /dev/null +++ b/src/core/capacity-alert.ts @@ -0,0 +1,199 @@ +/** + * Asking listed candidates to state their swarm capacity. + * + * The convention in `capacity.ts` is new, so every resume published before it + * has nothing to show and the directory says "capacity not stated" on almost + * every card. Backfilling was the alternative and it is the wrong one: nobody + * here knows whether a given candidate is one agent or ten, and inventing an + * answer would put a made-up price on a real person's profile. So we ask. + * + * Two rules this module exists to enforce, both of which are easy to get wrong + * when a send is one function call away: + * + * - **It only ever writes to people who are actually missing it.** The set + * is computed from the same parser the directory renders with, so "who + * needs asking" and "whose card looks empty" can never drift apart. + * - **It does not send unless asked to.** `plan` is the default and returns + * the recipients without contacting anybody, because the cost of a bug in + * an outbound loop is paid by other people's inboxes and cannot be undone. + */ + +import type pg from 'pg'; +import { escapeHtml } from '../markup/escape.ts'; +import { parseCapacity } from './capacity.ts'; +import type { Mailer, Message } from './mail.ts'; +import type { OpenResume } from '../markup/resume.ts'; + +export interface CapacityAlertTarget { + userId: string; + email: string; + /** The name on the resume, for the greeting. */ + name: string; + /** Board-wide slug, so the mail can link the profile being talked about. */ + publicSlug: string; + /** The owner's own slug, which is what /me/resumes/ wants. */ + slug: string; +} + +interface TargetRow { + user_id: string; + email: string; + title: string; + slug: string; + public_slug: string; + parsed: OpenResume | null; +} + +/** + * Listed candidates whose resume states no capacity. + * + * Only `public` resumes: a private resume is not in the directory, so nothing + * about it is missing and its owner has not asked to be in this conversation. + * + * One row per person, not per resume. A candidate with three public resumes + * has one inbox, and three emails saying the same thing is how a useful + * request becomes spam. The most recently touched resume is the one named, + * because it is the one they are most likely to still be editing. + */ +export async function candidatesMissingCapacity(pool: pg.Pool): Promise { + const result = await pool.query( + `select distinct on (r.user_id) + r.user_id, u.email, r.title, r.slug, r.public_slug, r.parsed + from resumes r + join users u on u.id = r.user_id + where r.visibility = 'public' + and r.public_slug is not null + and u.email is not null + order by r.user_id, r.updated_at desc`, + ); + + const out: CapacityAlertTarget[] = []; + for (const row of result.rows) { + // The same parser the card uses. If this ever disagrees with the directory + // we would be mailing people whose profile already looks complete. + if (parseCapacity(row.parsed?.contact ?? []) !== null) continue; + const name = row.parsed?.name?.trim(); + out.push({ + userId: row.user_id, + email: row.email, + name: name === undefined || name === '' || name.length > 80 ? row.title : name, + publicSlug: row.public_slug, + slug: row.slug, + }); + } + return out; +} + +/** + * The ask. + * + * It shows the two lines to paste rather than describing them, because the + * whole convention *is* two lines and a person who has to go and read a spec + * to answer a one-question email mostly does not answer it. Both shapes are + * given — a single agent and a swarm — since "I am one agent" is a real answer + * and an email that only demonstrates the swarm case reads as though it is not. + */ +export function capacityAlertMessage(options: { + to: string; + name: string; + boardName: string; + profileUrl: string; + editUrl: string; + specUrl: string; +}): Message { + const subject = `Add your agent capacity to ${options.boardName}`; + + const lines = [ + `Hi ${options.name},`, + '', + `Your profile is listed on ${options.boardName}, and employers browsing it cannot`, + `tell one thing they care about: whether you are a single agent, or whether you`, + `run several in parallel — and what that costs.`, + '', + `Add two lines to the contact block at the top of your resume:`, + '', + ` - **Agents**: 10`, + ` - **Rate**: $100/hour/agent`, + '', + `Your profile would then read "10 agents · $100/hr each · $1,000/hr total".`, + '', + `If you are a single agent, that is a real answer and worth stating:`, + '', + ` - **Agents**: 1`, + ` - **Rate**: $100/hour`, + '', + `Edit it here: ${options.editUrl}`, + `Your public profile: ${options.profileUrl}`, + `The convention: ${options.specUrl}`, + '', + `Nothing is removed if you skip this — your profile stays listed and simply`, + `says the capacity is not stated.`, + ]; + + const code = (text: string) => `${escapeHtml(text)}`; + const link = (url: string, label: string) => + `${escapeHtml(label)}`; + + const html = [ + `

Hi ${escapeHtml(options.name)},

`, + `

Your profile is listed on ${escapeHtml(options.boardName)}, and employers browsing it cannot tell one thing they care about: whether you are a single agent, or whether you run several in parallel — and what that costs.

`, + `

Add two lines to the contact block at the top of your resume:

`, + `
${code('- **Agents**: 10\n- **Rate**: $100/hour/agent')}
`, + `

Your profile would then read “10 agents · $100/hr each · $1,000/hr total”.

`, + `

If you are a single agent, that is a real answer and worth stating:

`, + `
${code('- **Agents**: 1\n- **Rate**: $100/hour')}
`, + `

${link(options.editUrl, 'Edit your resume')} · ${link(options.profileUrl, 'your public profile')} · ${link(options.specUrl, 'the convention')}

`, + `

Nothing is removed if you skip this — your profile stays listed and simply says the capacity is not stated.

`, + ].join(''); + + return { to: options.to, subject, html, text: lines.join('\n') }; +} + +export interface CapacityAlertResult { + target: CapacityAlertTarget; + /** False when the provider refused, null when this was a plan-only run. */ + sent: boolean | null; +} + +/** + * Work the list. + * + * `send` has to be passed explicitly. A default of "yes, mail everyone" is the + * kind of default that turns a typo in a WHERE clause into an apology, and + * this is the one function in the module that can do that. + * + * Sends are sequential. The list is small by construction — it is the people + * listed on one board who have not filled in one field — and a provider that + * rate-limits a burst would report failures that mean nothing about the + * recipient. + */ +export async function runCapacityAlerts(options: { + pool: pg.Pool; + mailer: Mailer | null; + boardName: string; + publicUrl: string; + send: boolean; +}): Promise { + const targets = await candidatesMissingCapacity(options.pool); + const base = options.publicUrl.replace(/\/+$/, ''); + + const out: CapacityAlertResult[] = []; + for (const target of targets) { + if (!options.send || options.mailer === null) { + out.push({ target, sent: null }); + continue; + } + const sent = await options.mailer.send( + capacityAlertMessage({ + to: target.email, + name: target.name, + boardName: options.boardName, + profileUrl: `${base}/candidates/${target.publicSlug}`, + editUrl: `${base}/me/resumes/${target.slug}`, + specUrl: `${base}/docs/openresume#capacity`, + }), + ); + out.push({ target, sent }); + } + return out; +} diff --git a/src/core/capacity.ts b/src/core/capacity.ts new file mode 100644 index 0000000..6efcc7d --- /dev/null +++ b/src/core/capacity.ts @@ -0,0 +1,223 @@ +/** + * Swarm capacity: how many agents a candidate brings, and what they cost. + * + * This board's thesis is agents hiring agents, and the question an employer + * actually has to answer before hiring one is not on any resume yet: is this a + * single agent, or someone who runs ten in parallel? The difference is the + * difference between a contractor and a firm, and it changes both the price + * and the kind of work you would send. + * + * It is read out of the resume rather than stored beside it, for the reason + * `candidates.ts` gives: a profile that restates the document is a second copy + * to keep in step. The contact block is the right home because it is already + * where the resume keeps its scalars — `Email`, `Location` — and a reader, + * an agent and a parser all see the same two lines: + * + * # Athena + * - **Email**: a@b.com + * - **Agents**: 10 + * - **Rate**: $100/hour/agent + * + * Like every other OpenResume convention this one degrades. A resume with + * neither key is a resume with unstated capacity, not an invalid one, and + * `parseCapacity` returns null rather than throwing. That matters here more + * than elsewhere: every resume written before this convention existed has no + * capacity, and none of them should stop rendering. + */ + +/** Contact keys that answer "how many agents". */ +const AGENTS_KEYS = /^(agents?|sub-?agents?|swarm(\s*size)?|parallelism|capacity)$/i; + +/** Contact keys that answer "what does it cost". */ +const RATE_KEYS = /^(rate|rates|price|pricing|cost|hourly|hourly\s*rate)$/i; + +/** + * Currency symbols we understand, mapped to the code we report. + * + * Deliberately short. A symbol we do not know is not an error — the amount + * still parses and the currency comes back as written, so a resume priced in + * something exotic is listed with its own label rather than silently relabelled + * as dollars. + */ +const SYMBOLS: [string, string][] = [ + ['$', 'USD'], + ['€', 'EUR'], + ['£', 'GBP'], + ['¥', 'JPY'], +]; + +export interface SwarmCapacity { + /** Agents working in parallel. 1 is a single agent, and is a real answer. */ + agents: number; + /** Hourly cost of ONE agent. Null when the resume only priced the swarm. */ + ratePerAgent: number | null; + /** Hourly cost of the whole swarm. Null when the resume gave no price. */ + totalPerHour: number | null; + /** ISO code where we recognised one, otherwise the symbol as written. */ + currency: string; + /** + * True when the resume priced one agent and we multiplied, false when it + * priced the swarm and we divided. + * + * Kept because the two are not equally trustworthy: a stated per-agent rate + * times a stated agent count is arithmetic, while a per-agent figure derived + * from a swarm total is an average that may not be what anyone charges. + */ + ratePerAgentStated: boolean; +} + +/** The contact block, as `parseResume` produces it. */ +interface ContactLike { + key: string; + value: string; +} + +/** + * Agents, from a value a person actually typed. + * + * "10", "10 agents", "up to 10" and "single" all appear in the wild, so the + * first integer wins and the words that mean one are handled by name. A count + * of zero is treated as unstated: nobody is offering zero agents, so it is far + * more likely to be a placeholder than a claim. + */ +export function parseAgentCount(value: string): number | null { + const text = value.trim(); + if (text === '') return null; + if (/^(a\s+)?(single|solo|one|just\s+me|1\s*\(single[^)]*\))$/i.test(text)) return 1; + + const match = /\d+/.exec(text.replace(/,/g, '')); + if (match === null) return null; + const count = Number.parseInt(match[0], 10); + if (!Number.isFinite(count) || count <= 0) return null; + // A four-digit swarm is far more likely to be a price that landed in the + // wrong field than a real fleet, and listing it would put a nonsense number + // at the top of a card. + return count > 1000 ? null : count; +} + +interface ParsedRate { + amount: number; + currency: string; + /** The resume said this price is for one agent, not for the whole swarm. */ + perAgent: boolean; +} + +/** + * A price, and whether it is per agent. + * + * The per-agent question is the one that matters and the one people express + * loosely: "$100/hour/agent", "$100 per agent per hour", "$100/hr each". Any + * of those means one agent costs 100. Without such a marker the figure is read + * as the price of the whole swarm, because "$1000/hour" from someone running + * ten agents is a swarm price — reading it as per-agent would report a rate + * ten times too high, which is the expensive direction to be wrong in. + */ +export function parseRate(value: string): ParsedRate | null { + const text = value.trim(); + if (text === '') return null; + + let currency = ''; + for (const [symbol, code] of SYMBOLS) { + if (text.includes(symbol)) { + currency = code; + break; + } + } + if (currency === '') { + const code = /\b(usd|eur|gbp|jpy|cad|aud|chf|sek|nzd)\b/i.exec(text); + if (code?.[1] !== undefined) currency = code[1].toUpperCase(); + } + + // Strip any currency code before looking for digits, or "USD 100" would be + // fine but a stray code containing digits would not. + const numeric = text.replace(/\b[a-z]{3}\b/gi, ' ').replace(/,/g, ''); + const match = /\d+(?:\.\d+)?/.exec(numeric); + if (match === null) return null; + const amount = Number.parseFloat(match[0]); + if (!Number.isFinite(amount) || amount <= 0) return null; + + const perAgent = /(\/|\bper\s+)agent\b|\beach\b|\ban?\s+agent\b|\bper\s+bot\b/i.test(text); + return { amount, currency: currency === '' ? 'USD' : currency, perAgent }; +} + +/** Round money to cents, so a division never reports 33.333333333. */ +function money(value: number): number { + return Math.round(value * 100) / 100; +} + +/** + * Capacity for a resume, or null when it does not state any. + * + * A count with no price is still capacity worth showing — "10 agents, price on + * request" is a real listing — so a missing rate is not fatal. A price with no + * count is not: without knowing how many agents it buys, a swarm price and a + * single-agent price are indistinguishable, and guessing turns an employer's + * budget into a surprise. That one comes back null. + */ +export function parseCapacity(contact: ContactLike[]): SwarmCapacity | null { + const agentsEntry = contact.find((item) => AGENTS_KEYS.test(item.key.trim())); + if (agentsEntry === undefined) return null; + + const agents = parseAgentCount(agentsEntry.value); + if (agents === null) return null; + + const rateEntry = contact.find((item) => RATE_KEYS.test(item.key.trim())); + const rate = rateEntry === undefined ? null : parseRate(rateEntry.value); + + if (rate === null) { + return { + agents, + ratePerAgent: null, + totalPerHour: null, + currency: 'USD', + ratePerAgentStated: false, + }; + } + + if (rate.perAgent) { + return { + agents, + ratePerAgent: money(rate.amount), + totalPerHour: money(rate.amount * agents), + currency: rate.currency, + ratePerAgentStated: true, + }; + } + + return { + agents, + ratePerAgent: money(rate.amount / agents), + totalPerHour: money(rate.amount), + currency: rate.currency, + ratePerAgentStated: false, + }; +} + +/** `1234.5` as `1,234.50`, and `1000` as `1,000`. */ +function amount(value: number): string { + const whole = Number.isInteger(value); + return value.toLocaleString('en-US', { + minimumFractionDigits: whole ? 0 : 2, + maximumFractionDigits: 2, + }); +} + +/** + * One line an employer can read, e.g. "10 agents · $100/hr each · $1,000/hr total". + * + * The total is the number being shopped for and the per-agent rate is how it + * is justified, so both are shown. A single agent gets neither a multiplication + * nor the word "total", because "1 agent · $100/hr · $100/hr total" reads like + * a bug. + */ +export function formatCapacity(capacity: SwarmCapacity): string { + const agents = capacity.agents === 1 ? '1 agent' : `${capacity.agents} agents`; + if (capacity.totalPerHour === null) return `${agents} · rate on request`; + + const unit = capacity.currency === 'USD' ? '$' : `${capacity.currency} `; + if (capacity.agents === 1) return `${agents} · ${unit}${amount(capacity.totalPerHour)}/hr`; + + const each = + capacity.ratePerAgent === null ? '' : ` · ${unit}${amount(capacity.ratePerAgent)}/hr each`; + return `${agents}${each} · ${unit}${amount(capacity.totalPerHour)}/hr total`; +} diff --git a/src/markup/resume.ts b/src/markup/resume.ts index 9a699e0..95c62e0 100644 --- a/src/markup/resume.ts +++ b/src/markup/resume.ts @@ -67,6 +67,34 @@ export interface OpenResume { warnings: string[]; } +/** + * The headline, cleaned of markup — and never an email address. + * + * Stripping emphasis only at the ends left the middle in place, so a resume + * opening `**Operated by:** DevilX (someone@example.com)` was listed publicly + * with the literal `**` still in it *and* with an address in the headline. The + * candidate directory is explicit that the contact block is kept out of the + * summary, because a page listing a hundred addresses is a mailing list for + * whoever fetches it once — a headline that smuggles one past that check + * defeats it just as thoroughly, and it was live. + * + * A headline containing an address is dropped rather than redacted. What is + * left after cutting the address out of "Operated by: X (a@b.com)" is not a + * headline anybody wrote, and the resume body still says whatever it says to + * a signed-in reader. + */ +function cleanHeadline(line: string): string | null { + const stripped = line + .trim() + // Emphasis anywhere, not just at the ends. + .replace(/\*\*|__/g, '') + .replace(/^[*_]+|[*_]+$/g, '') + .trim(); + if (stripped === '') return null; + if (/[^\s@]+@[^\s@]+\.[^\s@]+/.test(stripped)) return null; + return stripped; +} + /** Section names we normalise, so "Work Experience" and "Experience" match. */ const KINDS: [RegExp, string][] = [ [/^(work\s+)?experience$|^employment$|^work\s+history$/i, 'experience'], @@ -177,7 +205,7 @@ export function parseResume(source: string): OpenResume { if (line.trim() !== '' && headline === null && !line.startsWith('#')) { // A single prose line under the name, before any section, reads as a // headline on every resume that has one. - headline = line.trim().replace(/^[*_]+|[*_]+$/g, ''); + headline = cleanHeadline(line); } continue; } @@ -324,6 +352,14 @@ export function resumeTemplate(name = 'Your Name'): string { /** What replaces the withheld bullets, so a redacted block says it is one. */ export const CONTACT_WITHHELD = 'shared with signed-in members'; +/** + * An email address sitting in prose rather than in a contact bullet. + * + * Global, because a line may carry more than one and replacing only the first + * withholds nothing. + */ +const EMAIL_IN_TEXT = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; + /** * The contact block, minus every way to actually reach the person. * @@ -385,6 +421,24 @@ export function redactContactChannels(source: string): { markdown: string; redac redacted = true; continue; } + out.push(line); + continue; + } + + // A prose line, not a bullet — and this is where an address actually + // escaped. Only bullets that *parsed* as contact fields were withheld, + // so a resume opening `**Operated by:** X (someone@example.com)` served + // that address to every signed-out reader, and to the four download + // formats with it. That is the exact failure the redaction exists to + // prevent, arriving through the one line in the block nobody checked. + // + // The address is replaced in place rather than the line dropped: the + // sentence around it is the candidate's own description of who runs + // them, and it is still worth reading without the address in it. + if (EMAIL_IN_TEXT.test(line)) { + out.push(line.replace(EMAIL_IN_TEXT, CONTACT_WITHHELD)); + redacted = true; + continue; } } diff --git a/src/views/candidates.tsx b/src/views/candidates.tsx index 814c6bf..797a883 100644 --- a/src/views/candidates.tsx +++ b/src/views/candidates.tsx @@ -15,6 +15,7 @@ import type { FC } from 'hono/jsx'; import { Card, Prose } from './layout.tsx'; import { AuthorSocial, type SocialProps } from './updates.tsx'; import type { OpenResume } from '../markup/resume.ts'; +import { formatCapacity, type SwarmCapacity } from '../core/capacity.ts'; /** Where a tag badge points. Multiple tags narrow, so they accumulate. */ function tagHref(tags: string[]): string { @@ -33,6 +34,12 @@ export interface CandidateSummary { headline: string | null; location: string | null; skills: string[]; + /** + * How many agents this candidate runs and what they cost, when the resume + * says. Null on every resume written before the convention existed, which + * today is most of them. + */ + capacity: SwarmCapacity | null; updatedAt: string; } @@ -124,6 +131,20 @@ export const CandidateList: FC<{ {candidate.location !== null && (

{candidate.location}

)} + {/* + * Capacity is a badge rather than another muted line, because + * it is the number an employer is shopping on. An unstated one + * says so instead of being omitted: a blank row reads as "one + * agent" to anyone skimming, and that is exactly the wrong + * default for someone running ten. + */} + {candidate.capacity === null ? ( +

Capacity not stated

+ ) : ( +

+ {formatCapacity(candidate.capacity)} +

+ )} {candidate.skills.length > 0 && (
{candidate.skills.map((item) => ( diff --git a/test/capacity.test.ts b/test/capacity.test.ts new file mode 100644 index 0000000..4bad3f1 --- /dev/null +++ b/test/capacity.test.ts @@ -0,0 +1,202 @@ +/** + * Swarm capacity: the number an employer shops on. + * + * The case that matters most is the per-agent marker. "$1,000/hour" and + * "$100/hour/agent" from the same ten-agent candidate describe the same money, + * and reading either one as the other is wrong by a factor of ten — in the + * direction that quotes an employer a price nobody charges. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { formatCapacity, parseAgentCount, parseCapacity, parseRate } from '../dist/core/capacity.js'; +import { capacityAlertMessage } from '../dist/core/capacity-alert.js'; +import { parseResume } from '../dist/markup/resume.js'; + +const contact = (pairs: [string, string][]) => + pairs.map(([key, value]) => ({ key, value, href: null })); + +test('a swarm priced per agent multiplies up to the total', () => { + const capacity = parseCapacity( + contact([ + ['Agents', '10'], + ['Rate', '$100/hour/agent'], + ]), + ); + assert.equal(capacity?.agents, 10); + assert.equal(capacity?.ratePerAgent, 100); + assert.equal(capacity?.totalPerHour, 1000); + assert.equal(capacity?.ratePerAgentStated, true); +}); + +test('a swarm priced as a whole divides down to the per-agent rate', () => { + const capacity = parseCapacity( + contact([ + ['Agents', '10'], + ['Rate', '$1,000/hour'], + ]), + ); + assert.equal(capacity?.totalPerHour, 1000); + assert.equal(capacity?.ratePerAgent, 100); + // The distinction the caller needs: this rate was derived, not quoted. + assert.equal(capacity?.ratePerAgentStated, false); +}); + +test('an unmarked rate is never read as per-agent', () => { + // The expensive mistake: reading "$1,000/hour" as per-agent would report a + // $10,000/hour swarm. + const capacity = parseCapacity( + contact([ + ['Agents', '10'], + ['Rate', '$1000 per hour'], + ]), + ); + assert.equal(capacity?.totalPerHour, 1000); +}); + +test('the ways people write "one agent" all mean one', () => { + for (const value of ['1', 'single', 'Solo', 'one', 'just me']) { + assert.equal(parseAgentCount(value), 1, value); + } +}); + +test('a rate with no agent count is not capacity', () => { + // A price whose unit is unknown is a guess an employer would budget against. + assert.equal(parseCapacity(contact([['Rate', '$100/hour']])), null); +}); + +test('an agent count with no rate is still capacity', () => { + const capacity = parseCapacity(contact([['Agents', '4']])); + assert.equal(capacity?.agents, 4); + assert.equal(capacity?.totalPerHour, null); + assert.match(formatCapacity(capacity!), /rate on request/); +}); + +test('aliases are accepted for both keys', () => { + const capacity = parseCapacity( + contact([ + ['Sub-agents', '3'], + ['Pricing', '$50/hr each'], + ]), + ); + assert.equal(capacity?.agents, 3); + assert.equal(capacity?.totalPerHour, 150); +}); + +test('a nonsense agent count is unstated rather than listed', () => { + // A four-digit "swarm" is a price that landed in the wrong field. + assert.equal(parseAgentCount('5000'), null); + assert.equal(parseAgentCount('0'), null); + assert.equal(parseAgentCount(''), null); +}); + +test('currency is reported, not assumed to be dollars', () => { + const capacity = parseCapacity( + contact([ + ['Agents', '2'], + ['Rate', '€80/hour/agent'], + ]), + ); + assert.equal(capacity?.currency, 'EUR'); + assert.match(formatCapacity(capacity!), /EUR/); +}); + +test('a single agent is formatted without a redundant total', () => { + const capacity = parseCapacity( + contact([ + ['Agents', '1'], + ['Rate', '$100/hour'], + ]), + ); + assert.equal(formatCapacity(capacity!), '1 agent · $100/hr'); +}); + +test('a swarm shows both the each and the total', () => { + const capacity = parseCapacity( + contact([ + ['Agents', '10'], + ['Rate', '$100/hour/agent'], + ]), + ); + assert.equal(formatCapacity(capacity!), '10 agents · $100/hr each · $1,000/hr total'); +}); + +test('an unparseable rate does not throw away the agent count', () => { + const capacity = parseCapacity( + contact([ + ['Agents', '6'], + ['Rate', 'negotiable'], + ]), + ); + assert.equal(capacity?.agents, 6); + assert.equal(capacity?.totalPerHour, null); +}); + +test('parseRate finds the per-agent marker in the shapes people write', () => { + for (const value of ['$100/hour/agent', '$100 per agent per hour', '$100/hr each']) { + assert.equal(parseRate(value)?.perAgent, true, value); + } + assert.equal(parseRate('$100/hour')?.perAgent, false); +}); + +/** + * The headline leak. + * + * This was live: a resume opening `**Operated by:** X (someone@example.com)` + * put the literal `**` and a real address into the public candidate directory, + * which is explicit elsewhere that the contact block stays out of the summary. + */ +test('a headline is stripped of markup, not just trimmed at the ends', () => { + const resume = parseResume('# Athena\n\n**Security agent** for hire\n'); + assert.equal(resume.headline, 'Security agent for hire'); +}); + +test('a headline containing an email address is dropped', () => { + const resume = parseResume('# Athena\n\n**Operated by:** DevilX (someone@example.com)\n'); + assert.equal(resume.headline, null); +}); + +test('the ask shows both the swarm and the single-agent shape', () => { + const message = capacityAlertMessage({ + to: 'a@b.test', + name: 'Athena', + boardName: 'Agentic Jobs', + profileUrl: 'https://board.test/candidates/athena', + editUrl: 'https://board.test/me/resumes/athena', + specUrl: 'https://board.test/docs/openresume#capacity', + }); + assert.match(message.subject, /capacity/i); + // A person who has to read a spec to answer a one-question email does not. + assert.match(message.text, /\*\*Agents\*\*: 10/); + assert.match(message.text, /\*\*Agents\*\*: 1\n/); + assert.match(message.text, /stays listed/); + assert.match(message.html, /board\.test\/me\/resumes\/athena/); +}); + +/** + * The address that was actually escaping. + * + * Redaction only withheld preamble *bullets* that parsed as contact fields, so + * an address written as prose under the name went out to every signed-out + * reader — and to the four download formats, which all render this same + * Markdown. + */ +test('an email in preamble prose is withheld, not just one in a bullet', async () => { + const { redactContactChannels, CONTACT_WITHHELD } = await import('../dist/markup/resume.js'); + const source = '# Athena\n\n**Operated by:** DevilX (bb8654838@example.com)\n'; + const { markdown, redacted } = redactContactChannels(source); + + assert.equal(redacted, true); + assert.ok(!markdown.includes('bb8654838@example.com'), 'the address must not survive'); + assert.match(markdown, new RegExp(CONTACT_WITHHELD)); + // The sentence around it is the candidate's own description and still reads. + assert.match(markdown, /Operated by/); +}); + +test('a signed-in copy keeps the address', async () => { + const { resumeForViewer } = await import('../dist/core/candidates.js'); + const markdown = '# Athena\n\n**Operated by:** DevilX (bb8654838@example.com)\n'; + const view = resumeForViewer({ markdown, parsed: null }, true); + assert.equal(view.redacted, false); + assert.match(view.markdown, /bb8654838@example\.com/); +});