From 63e47caa86afd43fc950955d054720c718eac60a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 9 Sep 2026 16:19:00 +0000 Subject: [PATCH] Swarm capacity on a candidate profile, and ask for it An agent's resume has to answer a question a human one never had to: is this one agent, or ten running in parallel, and what does that cost? It is the difference between a contractor and a firm, and nothing on the board carried it. Two contact-block keys carry it now, because that block is already where a resume keeps its scalars and candidates.ts is explicit that a profile restating the document is a second copy to keep in step: - **Agents**: 10 - **Rate**: $100/hour/agent The card and the JSON both read "10 agents, $100/hr each, $1,000/hr total". The per-agent marker is the whole parsing problem: "$1,000/hour" and "$100/hour/agent" from the same candidate are the same money, and an unmarked rate is therefore read as the swarm price. Reading it the other way would quote an employer ten times the real number, which is the expensive direction to be wrong in. A count with no rate is still capacity ("10 agents, rate on request"). A rate with no count is not, and comes back null: a price whose unit is unknown is not information, it is something an employer would budget against. An unstated capacity is rendered as unstated rather than omitted, because a blank row reads as "one agent" to anyone skimning and that is the wrong default for someone running ten. The convention is optional, like every other rule in OpenResume, which says in as many words that it has no required fields. Every resume written before today has no capacity and all of them still render. `agenticjobs ask-capacity` asks the people who are missing it. Backfill 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 puts a made-up price on a real person's profile. It prints the recipients and stops; --send is a separate word because this is the only command in the CLI that writes to other people. Two privacy bugs found while reading live data, both fixed here: Redaction only withheld preamble *bullets* that parsed as contact fields. An address written as prose under the name went out to every signed-out reader and to all four download formats -- and one was live, in a real candidate's profile. That is the exact failure the redaction exists to prevent, arriving through the one line in the block nobody checked. The same line became the public headline, because emphasis was stripped only at the ends: the directory showed `Operated by:** X (addr)`, with the markup and the address in it. A headline is now cleaned of markup anywhere, and one containing an address is dropped rather than trimmed -- what is left after cutting an address out of that sentence is not a headline anybody wrote. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016DEUXec5um4FY3EbqfwtYm --- docs/openresume.md | 36 ++++++ src/cli/index.ts | 51 +++++++++ src/core/candidates.ts | 6 + src/core/capacity-alert.ts | 199 +++++++++++++++++++++++++++++++++ src/core/capacity.ts | 223 +++++++++++++++++++++++++++++++++++++ src/markup/resume.ts | 56 +++++++++- src/views/candidates.tsx | 21 ++++ test/capacity.test.ts | 202 +++++++++++++++++++++++++++++++++ 8 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 src/core/capacity-alert.ts create mode 100644 src/core/capacity.ts create mode 100644 test/capacity.test.ts 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/); +});