src/lib/background.ts
Rama main · réplica de lectura del repositorio original.
/**
* A role's background, read as sections rather than as one blob.
*
* `Role.background` is and stays ONE markdown string. That is deliberate:
* `update_role` replaces it wholesale, the workspace export round-trips it as a
* single field, and the schema has promised "unlimited markdown" since the
* first commit. Columns would cost a migration and every round-trip path.
*
* What was missing is that a background holds four different KINDS of thing,
* and only one of them belongs on a resume:
*
* evidence what they did. Raw material. This is what gets mined.
* rules how to write about this role. Binding on whoever writes.
* caveats positioning and interview prep. NEVER resume content.
* open something they have not settled yet — a date they are unsure of,
* a number they need to check. Not usable until it is resolved.
*
* Before this, all of it was the same undifferentiated text, so a tailoring
* conversation read "tenure is short, have the answer ready" with exactly the
* same status as "brought the work in-house". People were already signalling
* the difference by shouting in capitals; this makes the app understand it.
*
* DEFAULT IS EVIDENCE. A section is only reclassified when its heading matches
* the small list below, or a paragraph opens with one of the shouted markers
* after it, so ordinary prose means precisely what it meant yesterday. Nothing is silently excluded from a
* resume; a section has to ask to be.
*
* Pure, and not in src/lib/data/: it touches no database and takes no userId,
* the same way resume-schema.ts and letter-kinds.ts do not.
*/
export type SectionKind = "evidence" | "rules" | "caveats" | "open";
export type BackgroundSection = {
kind: SectionKind;
/**
* The heading text without its hashes. Empty for the opening paragraph, and
* for evidence that carries on under a heading after a marked paragraph.
* For a marked paragraph it is the marker as written, e.g. "NAMING RULE".
*/
heading: string;
/** Everything under the heading, trimmed. May be empty. */
body: string;
/** Where this section starts in the original string, for an editor to seek. */
offset: number;
/**
* True when a marker at the start of a paragraph opened this section rather
* than a `##` heading. It runs to the next blank line — or, for a marked
* bullet, to the next bullet — and then whatever the heading above held
* resumes.
*/
inline?: boolean;
};
/**
* Headings that mean something, lowercased.
*
* Kept SHORT on purpose. Every alias is a chance to classify something as a
* rule that a person meant as evidence — which silently drops real material
* out of their resumes, and is the failure this list must not cause. Two
* spellings and one synonym each is the whole budget; the editor inserts the
* canonical heading so nobody has to remember them, and the reader shows every
* section's kind so a miss is visible rather than silent.
*/
const RESERVED: Record<string, SectionKind> = {
rule: "rules",
rules: "rules",
"naming rule": "rules",
"naming rules": "rules",
"writing rules": "rules",
constraints: "rules",
caveat: "caveats",
caveats: "caveats",
positioning: "caveats",
"interview prep": "caveats",
open: "open",
"open questions": "open",
unresolved: "open",
"to confirm": "open",
};
/** The spellings the editor writes and the tool descriptions teach. */
export const CANONICAL: Record<Exclude<SectionKind, "evidence">, string> = {
rules: "Rules",
caveats: "Caveats",
open: "Open questions",
};
/**
* Markers written in CAPITALS, inside the text rather than as a heading.
*
* This is how people — and the assistants writing for them — actually mark
* things: "NAMING RULE: describe it by function", "⚠️ OPEN: confirm the start
* month", "## Post-departure signal — INTERVIEW ONLY". Before this was read,
* every one of those was evidence, so an interview-only line went straight into
* resumeEvidence with a warning sign on it.
*
* The capitals are the whole safety argument. A lowercase "open source:" or
* "Positioning:" at the start of a paragraph is prose and stays evidence; only
* a label that is SHOUTED, and that matches one of these exactly, is read as a
* marker. "OPEN SOURCE:" does not match OPEN, because these are whole-label
* matches, not substrings.
*/
const MARKERS: Array<[RegExp, Exclude<SectionKind, "evidence">]> = [
[/^(?:(?:[A-Z]+ ){0,3}(?:RULES?|GUARDRAILS?)|NAMING|FRAMING|WORDING)$/, "rules"],
[/^(?:CAVEATS?|INTERVIEW ONLY|INTERVIEW PREP|POSITIONING|NOT FOR (?:THE )?RESUMES?|NEVER ON A RESUME)$/, "caveats"],
[/^(?:OPEN|OPEN QUESTIONS?|UNRESOLVED|TO CONFIRM|UNCONFIRMED|NEEDS CONFIRMING|TBD|TODO)$/, "open"],
];
/** What a shouted label means, or null when it is not one of the markers. */
function markerKind(label: string): Exclude<SectionKind, "evidence"> | null {
const bare = label.replace(/\([^)]*\)/g, " ").replace(/\s+/g, " ").trim();
// Shouting is the signal. A label with any lowercase letter is prose.
if (!/[A-Z]/.test(bare) || bare !== bare.toUpperCase()) return null;
for (const [pattern, kind] of MARKERS) if (pattern.test(bare)) return kind;
return null;
}
/**
* A marker at the start of a line: an optional bullet, optional bold, an
* optional warning sign or other symbol, the CAPITAL label with an optional
* parenthetical, then a colon. Group 1 is the bullet, 2 the label, 3 the rest.
*/
const INLINE_MARKER =
/^(\s*(?:[-*+]|\d+[.)])\s+)?(?:\*\*)?\s*(?:[^\w\s(*#]{1,4}\s*)?([A-Z][A-Z '&/]*[A-Z](?:\s*\([^)]*\))?)\s*(?:\*\*)?\s*:\s*(?:\*\*)?\s*(.*)$/;
function inlineMarker(line: string) {
const match = INLINE_MARKER.exec(line);
if (!match) return null;
const kind = markerKind(match[2]);
if (!kind) return null;
const rest = match[3];
// Where the marked text stops. A marked bullet, a marked line indented
// under somebody else's bullet ("- Pokitomik …\n FRAMING RULE: …"), or a
// marker that states its rule on the same line all end at the next bullet:
// the list carries on, and the next bullet is evidence again. Only a bare
// label — "RULES:" and nothing after it, or text ending in a colon — owns
// the bullets that follow, because that is what a label over a list means.
const indented = !match[1] && /^\s+\S/.test(line);
const endsAtBullet = Boolean(match[1]) || indented || (rest.trim() !== "" && !/:\s*$/.test(rest));
return { kind, bullet: Boolean(match[1]), endsAtBullet, label: match[2].trim(), rest };
}
/**
* What a heading means.
*
* A trailing colon and a parenthetical are stripped before matching, because
* people date their own decisions — "Naming rule (resolved 2026-08-17)" is a
* naming rule, and failing to see that would be the app being pedantic about
* punctuation at the cost of getting the answer wrong.
*/
export function sectionKind(heading: string): SectionKind {
const bare = heading
.replace(/\([^)]*\)/g, " ")
.replace(/[:•\-–—]+\s*$/, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
const reserved = RESERVED[bare];
if (reserved) return reserved;
// A shouted suffix after a dash: "Post-departure signal — INTERVIEW ONLY".
// The heading keeps its name; the suffix says what kind of thing it is.
const suffix = /\s[—–-]+\s*([^—–-]+?)\s*$/.exec(heading.trim());
if (suffix) {
const kind = markerKind(suffix[1].replace(/^[^\w(]+/, ""));
if (kind) return kind;
}
// Or a shouted prefix: "## ⚠️ OPEN: follower count".
const prefix = inlineMarker(heading.trim());
if (prefix && !prefix.bullet) return prefix.kind;
return "evidence";
}
/**
* Split a background into its sections.
*
* Only `##` opens a headed section. A `###` belongs to the section above it, so
* a writer can subdivide without changing what anything MEANS — and so a stray
* third hash cannot quietly turn a caveat back into evidence.
*
* Inside an EVIDENCE section, a paragraph that opens with a shouted marker
* ("RULE:", "⚠️ OPEN:", "**INTERVIEW ONLY:**") becomes a section of its own
* for as long as that paragraph runs — to the next blank line, or to the next
* bullet unless the marker is a bare label over a list (see inlineMarker) —
* and the evidence around it carries on under no
* heading, so resumeEvidence reassembles it without repeating the one above.
* Inside a rules, caveats or open section markers change nothing: the heading
* already said what everything under it is, and a marker cannot promote a
* caveat back to evidence.
*
* Text before the first heading becomes one untitled evidence section, which is
* where the opening line of most backgrounds lives.
*/
export function parseBackground(text: string): BackgroundSection[] {
if (!text.trim()) return [];
const sections: BackgroundSection[] = [];
const lines = text.split("\n");
let heading = "";
let kind: SectionKind = "evidence";
let inline = false;
let buffer: string[] = [];
let offset = 0;
let cursor = 0;
// The kind of the `##` section we are inside, which a marked paragraph
// interrupts and then hands back to.
let headedKind: SectionKind = "evidence";
let endsAtBullet = false;
const flush = () => {
const body = buffer.join("\n").trim();
if (heading || body) {
sections.push(inline ? { kind, heading, body, offset, inline } : { kind, heading, body, offset });
}
buffer = [];
};
/** Close a marked paragraph and return to plain evidence under no heading. */
const resume = (at: number) => {
flush();
heading = "";
kind = headedKind;
inline = false;
offset = at;
};
for (const line of lines) {
const match = /^##(?!#)\s*(.*)$/.exec(line);
if (match) {
flush();
heading = match[1].trim();
kind = headedKind = sectionKind(heading);
inline = false;
offset = cursor;
} else if (inline && (!line.trim() || (endsAtBullet && /^\s*(?:[-*+]|\d+[.)])\s+/.test(line)))) {
// The marked paragraph is over: a blank line ends it, and so does the
// next bullet unless the marker was a bare label over a list.
resume(cursor);
if (line.trim()) {
const marker = inlineMarker(line);
if (marker && headedKind === "evidence") {
flush();
heading = marker.label;
kind = marker.kind;
inline = true;
endsAtBullet = marker.endsAtBullet;
offset = cursor;
buffer.push(marker.rest);
} else {
buffer.push(line);
}
}
} else {
const marker = !inline && headedKind === "evidence" ? inlineMarker(line) : null;
if (marker) {
flush();
heading = marker.label;
kind = marker.kind;
inline = true;
endsAtBullet = marker.endsAtBullet;
offset = cursor;
buffer.push(marker.rest);
} else {
buffer.push(line);
}
}
cursor += line.length + 1;
}
flush();
return sections;
}
/** Sections of one kind, in the order they appear. */
export function sectionsOfKind(text: string, kind: SectionKind): BackgroundSection[] {
return parseBackground(text).filter((section) => section.kind === kind);
}
/**
* The part of a background a resume may be written from.
*
* This is the whole point of the file. Anything that reaches a document — a
* resume bullet, a cover letter line — comes from here and never from the
* string itself.
*/
export function resumeEvidence(text: string): string {
return sectionsOfKind(text, "evidence")
.map((section) => (section.heading ? `## ${section.heading}\n${section.body}` : section.body))
.join("\n\n")
.trim();
}
/**
* The rules, caveats and open questions, as lines, for a writer to obey
* rather than quote.
*
* Bullets are unwrapped so a caller gets statements rather than markdown; a
* section with no bullets comes back as its paragraphs. A marked paragraph
* comes back as one line, because "NAMING RULE: by function only. No product
* names." is one rule however many sentences it takes.
*/
export function writingGuidance(text: string): {
rules: string[];
caveats: string[];
open: string[];
} {
const linesOf = (kind: SectionKind) =>
sectionsOfKind(text, kind)
.flatMap((section) => {
const lines = section.body.split("\n");
const listed = lines.some((line) => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line));
return section.inline && !listed ? [lines.map((line) => line.trim()).join(" ")] : lines;
})
.map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim())
.filter(Boolean);
return { rules: linesOf("rules"), caveats: linesOf("caveats"), open: linesOf("open") };
}
/** True when this background carries anything a writer has to obey. */
export function hasGuidance(text: string): boolean {
const { rules, caveats, open } = writingGuidance(text);
return rules.length > 0 || caveats.length > 0 || open.length > 0;
}
/**
* Add text to a background, merging into a section that already has that
* heading rather than opening a second one with the same name.
*
* Appending twice under "Rules" used to leave two `## Rules` blocks, which then
* read as two unrelated rules lists and rendered as a stutter. Merging is what
* somebody means by "file this under Rules", and it keeps the reserved sections
* to one each without a special case for them.
*
* The merge SPLICES rather than rebuilding the document from its sections.
* Rebuilding was a line shorter and normalised everybody's blank lines on the
* way past, so appending one rule rewrote every byte of the file — which the
* version history would then show as "the whole background changed", and undo
* would take them back past edits they never made. Everything outside the
* target section comes out byte-identical.
*
* Pure: it returns the new string and writes nothing.
*/
export function appendToBackground(text: string, addition: string, heading?: string): string {
const body = addition.trim();
if (!body) return text;
const name = heading?.trim();
if (!name) return appendEvidence(text, body);
// A reserved heading matches by KIND, not by spelling. Somebody whose rules
// live under "Naming rule (resolved 2026-08-17)" gets the next one filed
// there, rather than a bare "## Rules" opening beside it — which is the
// stutter this function exists to prevent, and which an assistant would cause
// every single time, because the tool description teaches it to pass "Rules".
// Anything else matches by name, where an exact heading is a literal request.
// Only `##` sections are candidates: a marked paragraph is somebody's single
// flagged line, and filing the next rule inside it would glue two rules into
// one paragraph.
const kind = sectionKind(name);
const sections = parseBackground(text);
const headed = (section: BackgroundSection) => Boolean(section.heading) && !section.inline;
const index =
kind === "evidence"
? sections.findIndex(
(section) => headed(section) && section.heading.toLowerCase() === name.toLowerCase(),
)
: sections.findIndex((section) => headed(section) && section.kind === kind);
if (index < 0) return `${text.trim()}\n\n## ${name}\n${body}`.trim();
// The section runs to the next `##`, or to the end of the text — past any
// marked paragraphs and the evidence resuming after them, which are all
// still under its heading.
const next = sections.findIndex((section, i) => i > index && headed(section));
const end = next >= 0 ? sections[next].offset : text.length;
const head = text.slice(0, end).replace(/\s+$/, "");
return `${head}\n${body}${text.slice(end) ? `\n\n${text.slice(end).replace(/^\s+/, "")}` : ""}`;
}
/**
* A one-line preview with the markdown taken off.
*
* From the EVIDENCE only, so a list of roles never previews somebody's note to
* themselves about how short their tenure looks.
*/
export function backgroundExcerpt(text: string, max = 240): string {
// Headings go entirely rather than losing their hashes: "Scope — LEAD WITH
// THIS" is a note to a writer, not a preview of the work.
const flat = resumeEvidence(text)
.replace(/^#{1,6}\s.*$/gm, "")
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "")
.replace(/[*_`]/g, "")
.replace(/\s+/g, " ")
.trim();
return flat.length > max ? `${flat.slice(0, max).trimEnd()}…` : flat;
}
/**
* Add evidence with no heading, and make sure it stays evidence.
*
* Appending to the end is right almost always. It is wrong when the document
* ENDS in a reserved section: text added after "## Caveats" is read as a
* caveat, so "file this" would quietly have filed a real achievement where no
* document could use it. In that case the text goes in just before the run of
* reserved sections at the bottom — still under whatever evidence came last,
* which is where it would have gone if the caveats had not been there.
*/
function appendEvidence(text: string, body: string): string {
const trimmed = text.trim();
if (!trimmed) return body;
const candidate = `${trimmed}\n\n${body}`;
// The section the new text falls inside: the last one starting at or before it.
const at0 = trimmed.length + 2;
const landed = parseBackground(candidate).filter((section) => section.offset <= at0).pop();
if (!landed || landed.kind === "evidence") return candidate;
// Walk back over the trailing `##` sections that are not evidence.
const sections = parseBackground(trimmed);
let at = -1;
for (let i = sections.length - 1; i >= 0; i -= 1) {
const section = sections[i];
if (section.kind === "evidence") break;
if (section.heading && !section.inline) at = section.offset;
}
if (at <= 0) return `${body}\n\n${trimmed}`;
const head = trimmed.slice(0, at).replace(/\s+$/, "");
return `${head}\n\n${body}\n\n${trimmed.slice(at)}`;
}
/** Where a section ends: the start of the next one, or the end of the text. */
function sectionEnd(sections: BackgroundSection[], index: number, text: string) {
return index + 1 < sections.length ? sections[index + 1].offset : text.length;
}
const BULLET = /^(\s*(?:[-*+]|\d+[.)])\s+)/;
/** Whitespace-insensitive, for matching a question the way it was listed. */
function same(a: string, b: string) {
const norm = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase();
return norm(a) === norm(b);
}
/**
* Settle one open question: take it out, and put the answer in as evidence.
*
* `question` is the text as writingGuidance / list_open_questions return it.
* Where the answer goes depends on where the question was:
*
* - a marked line inside evidence ("- ⚠️ OPEN: was it $40K or $45K?") is
* REPLACED in place by the answer, bullet kept, so the settled fact sits
* exactly where the doubt was — next to the work it is about;
* - a line in an "Open questions" section is removed, the heading goes too
* when nothing is left under it, and the answer is added as evidence —
* under `heading` when one is given, otherwise the way appendEvidence adds
* anything.
*
* No answer means the question is simply dropped, for "that no longer
* matters". Everything outside the touched lines comes back byte-identical.
* Resolving the same question twice fails the second time rather than doing
* something else, because by then it is not there.
* Pure: throws when the question is not there, writes nothing.
*/
export function resolveOpen(
text: string,
question: string,
answer?: string,
heading?: string,
): string {
const sections = parseBackground(text);
const reply = answer?.trim() ?? "";
for (let i = 0; i < sections.length; i += 1) {
const section = sections[i];
if (section.kind !== "open") continue;
const start = section.offset;
const end = sectionEnd(sections, i, text);
const raw = text.slice(start, end);
if (section.inline) {
const lines = section.body.split("\n");
const listed = lines.some((line) => BULLET.test(line));
const joined = lines.map((line) => line.trim()).join(" ");
if (!(listed ? lines.some((line) => same(line.replace(BULLET, ""), question)) : same(joined, question))) {
continue;
}
// The whole marked paragraph or bullet goes; the answer takes its place.
const bullet = BULLET.exec(raw)?.[1] ?? "";
const tail = /\s*$/.exec(raw)?.[0] ?? "";
if (reply) return `${text.slice(0, start)}${bullet}${reply}${tail}${text.slice(end)}`;
// A dropped bullet leaves its list intact; a dropped paragraph leaves one
// blank line between its neighbours rather than three.
return bullet ? `${text.slice(0, start)}${text.slice(end)}` : splice(text, start, end);
}
// A line inside an "Open questions" section. Find it by its own text, with
// any continuation lines that belong to the same bullet.
const lines = raw.split("\n");
const index = lines.findIndex(
(line, n) => n > 0 && same(line.replace(BULLET, ""), question),
);
if (index < 0) continue;
let stop = index + 1;
while (stop < lines.length && /^\s{2,}\S/.test(lines[stop]) && !BULLET.test(lines[stop])) stop += 1;
const kept = [...lines.slice(0, index), ...lines.slice(stop)];
// The heading goes with its last question: an empty "## Open questions"
// is a to-do list that says there is something to do.
const without = kept.slice(1).every((line) => !line.trim())
? splice(text, start, end)
: `${text.slice(0, start)}${kept.join("\n")}${text.slice(end)}`;
return reply ? appendToBackground(without, reply, heading) : without;
}
throw new Error(
"That open question is not in this background. Call list_open_questions and pass the question exactly as it came back.",
);
}
/**
* Cut [start, end) out and close the gap with exactly one blank line, touching
* nothing else — so the version history shows the one change that was made.
*/
function splice(text: string, start: number, end: number) {
const before = text.slice(0, start).replace(/\s+$/, "");
const after = text.slice(end).replace(/^\s+/, "");
if (!before) return after;
if (!after) return before;
return `${before}\n\n${after}`;
}