From f16790b015c25824ffd0594fdba2d03f1a165885 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Sun, 7 Jun 2026 18:17:44 +0200 Subject: [PATCH 01/13] feat(schemas): add Zod contract package Shared, validated contract for CV Builder surfaces: Resume, JobDescription, Archetype, Issue, Claim, and EvalResult (with required rubric/archetype versions). Closes #47 --- packages/schemas/README.md | 17 +++ packages/schemas/package.json | 36 +++++++ .../schemas/src/__tests__/schemas.test.ts | 102 ++++++++++++++++++ packages/schemas/src/archetype.ts | 25 +++++ packages/schemas/src/evaluation.ts | 51 +++++++++ packages/schemas/src/index.ts | 37 +++++++ packages/schemas/src/job-description.ts | 11 ++ packages/schemas/src/resume.ts | 52 +++++++++ packages/schemas/tsconfig.json | 10 ++ packages/schemas/vitest.config.ts | 8 ++ pnpm-lock.yaml | 21 ++++ 11 files changed, 370 insertions(+) create mode 100644 packages/schemas/README.md create mode 100644 packages/schemas/package.json create mode 100644 packages/schemas/src/__tests__/schemas.test.ts create mode 100644 packages/schemas/src/archetype.ts create mode 100644 packages/schemas/src/evaluation.ts create mode 100644 packages/schemas/src/index.ts create mode 100644 packages/schemas/src/job-description.ts create mode 100644 packages/schemas/src/resume.ts create mode 100644 packages/schemas/tsconfig.json create mode 100644 packages/schemas/vitest.config.ts diff --git a/packages/schemas/README.md b/packages/schemas/README.md new file mode 100644 index 0000000..c0793fa --- /dev/null +++ b/packages/schemas/README.md @@ -0,0 +1,17 @@ +# @cv-builder/schemas + +[Zod](https://zod.dev) schemas shared across CV Builder surfaces. Schemas are +the source of truth; TypeScript types are inferred from them. + +Phase 1 requires that no LLM response is used without passing Zod validation, +and that every `EvalResult` carries a `rubricVersion` and `archetypeVersion`. + +```ts +import { EvalResultSchema, type EvalResult } from "@cv-builder/schemas"; + +const result: EvalResult = EvalResultSchema.parse(rawModelOutput); +``` + +Exports: `Resume` / `ResumeSource`, `JobDescription`, `Archetype`, +`EvaluationWeights`, `EvaluationDimension`, `Issue`, `Claim`, `EvalResult` +(each with its `*Schema`). Evaluation types only — tailoring is Phase 2. diff --git a/packages/schemas/package.json b/packages/schemas/package.json new file mode 100644 index 0000000..1ead620 --- /dev/null +++ b/packages/schemas/package.json @@ -0,0 +1,36 @@ +{ + "name": "@cv-builder/schemas", + "version": "0.1.0", + "description": "Zod schemas and inferred types — the validated contract shared across CV Builder", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "keywords": [ + "cv", + "resume", + "zod", + "schema", + "types" + ], + "license": "MIT" +} diff --git a/packages/schemas/src/__tests__/schemas.test.ts b/packages/schemas/src/__tests__/schemas.test.ts new file mode 100644 index 0000000..848f937 --- /dev/null +++ b/packages/schemas/src/__tests__/schemas.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + ArchetypeSchema, + EvalResultSchema, + ResumeSchema, +} from "../index.js"; + +const validEvalResult = { + rubricVersion: "1.0.0", + archetypeVersion: "1.0.0", + archetypeId: "software-engineer", + archetypeName: "Software Engineer", + score: 3.4, + dimensions: [ + { + name: "Shipped Evidence", + weight: 0.3, + score: 4, + maxScore: 5, + feedback: "Strong production work with named outcomes.", + }, + ], + strengths: ["Clear quantified impact"], + issues: [ + { + element: "Summary", + quote: "Passionate team player", + why: "Generic phrasing with no evidence.", + fix: "Replace with a concrete, measurable achievement.", + severity: "major", + }, + ], + claims: [ + { + text: "Scaled system to 1M users", + category: "metric", + supported: false, + reason: "No corroborating detail elsewhere in the resume.", + }, + ], + atsCompatible: true, +}; + +describe("EvalResultSchema", () => { + it("parses a well-formed result", () => { + expect(() => EvalResultSchema.parse(validEvalResult)).not.toThrow(); + }); + + it("rejects a malformed result", () => { + const bad = { ...validEvalResult, score: "high" }; + expect(EvalResultSchema.safeParse(bad).success).toBe(false); + }); + + it("requires rubricVersion and archetypeVersion", () => { + for (const field of ["rubricVersion", "archetypeVersion"]) { + const partial = { ...validEvalResult }; + delete (partial as Record)[field]; + const result = EvalResultSchema.safeParse(partial); + expect(result.success, `${field} should be required`).toBe(false); + } + }); + + it("rejects an out-of-range dimension score", () => { + const bad = { + ...validEvalResult, + dimensions: [{ ...validEvalResult.dimensions[0], score: 7 }], + }; + expect(EvalResultSchema.safeParse(bad).success).toBe(false); + }); +}); + +describe("ResumeSchema", () => { + it("applies array/object defaults from rawText alone", () => { + const resume = ResumeSchema.parse({ rawText: "Jane Doe — Engineer" }); + expect(resume.links).toEqual([]); + expect(resume.skills).toEqual([]); + expect(resume.contact).toEqual({}); + }); +}); + +describe("ArchetypeSchema", () => { + it("requires at least one keyword", () => { + const archetype = { + id: "software-engineer", + name: "Software Engineer", + description: "Builds and ships software", + keywords: [], + evaluationWeights: { + shippedEvidence: 0.3, + quantifiedImpact: 0.2, + toolingVisibility: 0.2, + atsCompatibility: 0.1, + keywordMatch: 0.1, + publicProof: 0.1, + }, + actionVerbs: ["Built"], + antiPatterns: ["familiar with"], + version: "1.0.0", + }; + expect(ArchetypeSchema.safeParse(archetype).success).toBe(false); + }); +}); diff --git a/packages/schemas/src/archetype.ts b/packages/schemas/src/archetype.ts new file mode 100644 index 0000000..b26a011 --- /dev/null +++ b/packages/schemas/src/archetype.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const EvaluationWeightsSchema = z.object({ + shippedEvidence: z.number().min(0).max(1), + quantifiedImpact: z.number().min(0).max(1), + toolingVisibility: z.number().min(0).max(1), + atsCompatibility: z.number().min(0).max(1), + keywordMatch: z.number().min(0).max(1), + publicProof: z.number().min(0).max(1), +}); +export type EvaluationWeights = z.infer; + +// Weights are expected to sum to ~1.0, but that's enforced in the intelligence +// layer so a half-edited archetype still parses here. +export const ArchetypeSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + description: z.string(), + keywords: z.array(z.string()).min(1), + evaluationWeights: EvaluationWeightsSchema, + actionVerbs: z.array(z.string()), + antiPatterns: z.array(z.string()), + version: z.string(), +}); +export type Archetype = z.infer; diff --git a/packages/schemas/src/evaluation.ts b/packages/schemas/src/evaluation.ts new file mode 100644 index 0000000..36b01ed --- /dev/null +++ b/packages/schemas/src/evaluation.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +export const EvaluationDimensionSchema = z.object({ + name: z.string(), + weight: z.number().min(0).max(1), + score: z.number().int().min(0).max(5), + maxScore: z.number().int().positive().default(5), + feedback: z.string(), +}); +export type EvaluationDimension = z.infer; + +export const IssueSchema = z.object({ + element: z.string(), + quote: z.string().optional(), + why: z.string(), + fix: z.string(), + severity: z.enum(["critical", "major", "minor"]), +}); +export type Issue = z.infer; + +export const ClaimSchema = z.object({ + text: z.string(), + category: z.enum([ + "tool", + "technology", + "metric", + "experience", + "education", + "other", + ]), + supported: z.boolean(), + reason: z.string(), +}); +export type Claim = z.infer; + +// Versions are required so old results stay reproducible when the rubric or an +// archetype changes later. +export const EvalResultSchema = z.object({ + rubricVersion: z.string(), + archetypeVersion: z.string(), + archetypeId: z.string(), + archetypeName: z.string(), + score: z.number().min(0).max(5), + dimensions: z.array(EvaluationDimensionSchema), + strengths: z.array(z.string()).default([]), + issues: z.array(IssueSchema).default([]), + claims: z.array(ClaimSchema).default([]), + atsCompatible: z.boolean(), + locale: z.string().optional(), +}); +export type EvalResult = z.infer; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts new file mode 100644 index 0000000..87b220e --- /dev/null +++ b/packages/schemas/src/index.ts @@ -0,0 +1,37 @@ +// Zod schemas are the source of truth; types are inferred from them. +// Phase 1 ships evaluation types only — tailoring/rewrite is Phase 2. + +export { + EvaluationWeightsSchema, + ArchetypeSchema, + type EvaluationWeights, + type Archetype, +} from "./archetype.js"; + +export { + ResumeSourceSchema, + ResumeLinkSchema, + ResumeContactSchema, + ResumeExperienceSchema, + ResumeEducationSchema, + ResumeSchema, + type ResumeSource, + type ResumeLink, + type ResumeContact, + type ResumeExperience, + type ResumeEducation, + type Resume, +} from "./resume.js"; + +export { JobDescriptionSchema, type JobDescription } from "./job-description.js"; + +export { + EvaluationDimensionSchema, + IssueSchema, + ClaimSchema, + EvalResultSchema, + type EvaluationDimension, + type Issue, + type Claim, + type EvalResult, +} from "./evaluation.js"; diff --git a/packages/schemas/src/job-description.ts b/packages/schemas/src/job-description.ts new file mode 100644 index 0000000..4def655 --- /dev/null +++ b/packages/schemas/src/job-description.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +// Phase 1 uses the JD only as keyword-match context; fit-scoring is Phase 2. +export const JobDescriptionSchema = z.object({ + content: z.string(), + url: z.string().optional(), + company: z.string().optional(), + title: z.string().optional(), + keywords: z.array(z.string()).default([]), +}); +export type JobDescription = z.infer; diff --git a/packages/schemas/src/resume.ts b/packages/schemas/src/resume.ts new file mode 100644 index 0000000..4482faa --- /dev/null +++ b/packages/schemas/src/resume.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +// Raw input as it arrives from a surface, before extraction. +export const ResumeSourceSchema = z.object({ + content: z.string(), + format: z.enum(["pdf", "markdown", "plaintext", "html"]), +}); +export type ResumeSource = z.infer; + +export const ResumeLinkSchema = z.object({ + type: z.enum(["github", "linkedin", "portfolio", "blog", "website", "other"]), + url: z.string(), +}); +export type ResumeLink = z.infer; + +export const ResumeContactSchema = z.object({ + email: z.string().optional(), + phone: z.string().optional(), + location: z.string().optional(), +}); +export type ResumeContact = z.infer; + +export const ResumeExperienceSchema = z.object({ + company: z.string(), + role: z.string(), + startDate: z.string().optional(), + endDate: z.string().optional(), + bullets: z.array(z.string()).default([]), +}); +export type ResumeExperience = z.infer; + +export const ResumeEducationSchema = z.object({ + institution: z.string(), + degree: z.string().optional(), + field: z.string().optional(), + year: z.string().optional(), +}); +export type ResumeEducation = z.infer; + +export const ResumeSchema = z.object({ + name: z.string().optional(), + headline: z.string().optional(), + summary: z.string().optional(), + contact: ResumeContactSchema.default({}), + links: z.array(ResumeLinkSchema).default([]), + experience: z.array(ResumeExperienceSchema).default([]), + education: z.array(ResumeEducationSchema).default([]), + skills: z.array(z.string()).default([]), + // Original document, kept so downstream steps can quote exact source text. + rawText: z.string(), +}); +export type Resume = z.infer; diff --git a/packages/schemas/tsconfig.json b/packages/schemas/tsconfig.json new file mode 100644 index 0000000..d080397 --- /dev/null +++ b/packages/schemas/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/__tests__/**"] +} diff --git a/packages/schemas/vitest.config.ts b/packages/schemas/vitest.config.ts new file mode 100644 index 0000000..4ed8031 --- /dev/null +++ b/packages/schemas/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 889aaad..2c05a4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,22 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/schemas: + dependencies: + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages: '@alloc/quick-lru@5.2.0': @@ -1175,6 +1191,9 @@ packages: engines: {node: '>=8'} hasBin: true + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2053,3 +2072,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + zod@3.25.76: {} From eee572db4614656cb71cd81e5ba81832043157e3 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Sun, 7 Jun 2026 18:59:56 +0200 Subject: [PATCH 02/13] feat(intelligence): add rubric v1, archetypes, and validators Scoring brain the prompts reference: rubric v1 (six weighted dimensions with 0-5 anchors), three role archetypes (Software Engineer, Product Manager, Data & ML Engineer), keyword-based detectArchetype, and the ATS and claim validator specs. Closes #63 --- packages/intelligence/README.md | 16 +++++ packages/intelligence/package.json | 37 ++++++++++ .../src/__tests__/intelligence.test.ts | 58 ++++++++++++++++ .../src/archetypes/data-ml-engineer.ts | 52 ++++++++++++++ packages/intelligence/src/archetypes/index.ts | 24 +++++++ .../src/archetypes/product-manager.ts | 53 ++++++++++++++ .../src/archetypes/software-engineer.ts | 54 +++++++++++++++ packages/intelligence/src/detect.ts | 27 ++++++++ packages/intelligence/src/index.ts | 18 +++++ packages/intelligence/src/rubric.ts | 69 +++++++++++++++++++ packages/intelligence/src/validators/ats.ts | 43 ++++++++++++ .../intelligence/src/validators/claims.ts | 20 ++++++ packages/intelligence/tsconfig.json | 10 +++ packages/intelligence/vitest.config.ts | 8 +++ pnpm-lock.yaml | 19 +++++ 15 files changed, 508 insertions(+) create mode 100644 packages/intelligence/README.md create mode 100644 packages/intelligence/package.json create mode 100644 packages/intelligence/src/__tests__/intelligence.test.ts create mode 100644 packages/intelligence/src/archetypes/data-ml-engineer.ts create mode 100644 packages/intelligence/src/archetypes/index.ts create mode 100644 packages/intelligence/src/archetypes/product-manager.ts create mode 100644 packages/intelligence/src/archetypes/software-engineer.ts create mode 100644 packages/intelligence/src/detect.ts create mode 100644 packages/intelligence/src/index.ts create mode 100644 packages/intelligence/src/rubric.ts create mode 100644 packages/intelligence/src/validators/ats.ts create mode 100644 packages/intelligence/src/validators/claims.ts create mode 100644 packages/intelligence/tsconfig.json create mode 100644 packages/intelligence/vitest.config.ts diff --git a/packages/intelligence/README.md b/packages/intelligence/README.md new file mode 100644 index 0000000..0998cd6 --- /dev/null +++ b/packages/intelligence/README.md @@ -0,0 +1,16 @@ +# @cv-builder/intelligence + +The scoring brain the prompts and skill reference: the fixed rubric, the role +archetypes, and the validator specs. + +- **Rubric v1** — six dimensions (Shipped Evidence, Quantified Impact, Tech/Tool + Visibility, ATS Compatibility, Keyword Match, Public Proof) with 0–5 anchors, + tagged `RUBRIC_VERSION`. +- **Archetypes** — role config (keywords, dimension weights, action verbs, + anti-patterns). Ships Software Engineer, Product Manager, Data & ML Engineer. + Add one by dropping a file in `src/archetypes/` and registering it. +- **Validators** — `checkAtsCompatibility()` plus the ATS and claim rule sets + the prompts encode. + +`detectArchetype(resumeText)` picks the best-matching archetype, falling back to +Software Engineer when there's no signal. diff --git a/packages/intelligence/package.json b/packages/intelligence/package.json new file mode 100644 index 0000000..f11ac92 --- /dev/null +++ b/packages/intelligence/package.json @@ -0,0 +1,37 @@ +{ + "name": "@cv-builder/intelligence", + "version": "0.1.0", + "description": "Scoring rubric, role archetypes, and validators for CV evaluation", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cv-builder/schemas": "workspace:*", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "keywords": [ + "cv", + "resume", + "rubric", + "archetypes", + "ats" + ], + "license": "MIT" +} diff --git a/packages/intelligence/src/__tests__/intelligence.test.ts b/packages/intelligence/src/__tests__/intelligence.test.ts new file mode 100644 index 0000000..16522ac --- /dev/null +++ b/packages/intelligence/src/__tests__/intelligence.test.ts @@ -0,0 +1,58 @@ +import { ArchetypeSchema } from "@cv-builder/schemas"; +import { describe, expect, it } from "vitest"; +import { + ARCHETYPES, + RUBRIC, + checkAtsCompatibility, + detectArchetype, +} from "../index.js"; + +describe("archetypes", () => { + it("ships at least 3, each valid against the schema", () => { + expect(ARCHETYPES.length).toBeGreaterThanOrEqual(3); + for (const archetype of ARCHETYPES) { + expect(ArchetypeSchema.safeParse(archetype).success, archetype.id).toBe(true); + } + }); + + it("has weights summing to 1.0", () => { + for (const { id, evaluationWeights } of ARCHETYPES) { + const sum = Object.values(evaluationWeights).reduce((a, b) => a + b, 0); + expect(sum, id).toBeCloseTo(1, 5); + } + }); + + it("covers every rubric dimension with a weight", () => { + const keys = RUBRIC.dimensions.map((d) => d.key).sort(); + for (const { id, evaluationWeights } of ARCHETYPES) { + expect(Object.keys(evaluationWeights).sort(), id).toEqual(keys); + } + }); +}); + +describe("detectArchetype", () => { + it("detects a software engineer resume", () => { + const text = "Built React and Node services, scaled Postgres, deployed on Kubernetes."; + expect(detectArchetype(text).id).toBe("software-engineer"); + }); + + it("detects a product manager resume", () => { + const text = "Owned the roadmap, ran A/B testing and user research, grew retention."; + expect(detectArchetype(text).id).toBe("product-manager"); + }); + + it("falls back to the default on no signal", () => { + expect(detectArchetype("hello world").id).toBe("software-engineer"); + }); +}); + +describe("checkAtsCompatibility", () => { + it("flags a table layout", () => { + expect(checkAtsCompatibility("| Skills | Years |\n| React | 5 |").compatible).toBe(false); + }); + + it("passes clean single-column text", () => { + const text = "Experience\nBuilt things at Acme.\nContact: jane@example.com"; + expect(checkAtsCompatibility(text).compatible).toBe(true); + }); +}); diff --git a/packages/intelligence/src/archetypes/data-ml-engineer.ts b/packages/intelligence/src/archetypes/data-ml-engineer.ts new file mode 100644 index 0000000..a0d1b5e --- /dev/null +++ b/packages/intelligence/src/archetypes/data-ml-engineer.ts @@ -0,0 +1,52 @@ +import type { Archetype } from "@cv-builder/schemas"; + +export const dataMlEngineer: Archetype = { + id: "data-ml-engineer", + name: "Data & ML Engineer", + description: "Engineers building data pipelines and machine learning systems", + keywords: [ + "python", + "sql", + "spark", + "airflow", + "dbt", + "pandas", + "pytorch", + "tensorflow", + "scikit-learn", + "feature engineering", + "etl", + "data pipeline", + "warehouse", + "snowflake", + "bigquery", + "mlops", + "model serving", + "experiment tracking", + ], + evaluationWeights: { + shippedEvidence: 0.25, + quantifiedImpact: 0.2, + toolingVisibility: 0.25, + atsCompatibility: 0.1, + keywordMatch: 0.1, + publicProof: 0.1, + }, + actionVerbs: [ + "Built", + "Trained", + "Deployed", + "Productionized", + "Optimized", + "Automated", + "Modeled", + "Scaled", + ], + antiPatterns: [ + "familiar with machine learning", + "exposure to data science", + "worked with big data", + "passionate about AI", + ], + version: "1.0.0", +}; diff --git a/packages/intelligence/src/archetypes/index.ts b/packages/intelligence/src/archetypes/index.ts new file mode 100644 index 0000000..de35c50 --- /dev/null +++ b/packages/intelligence/src/archetypes/index.ts @@ -0,0 +1,24 @@ +import type { Archetype } from "@cv-builder/schemas"; +import { dataMlEngineer } from "./data-ml-engineer.js"; +import { productManager } from "./product-manager.js"; +import { softwareEngineer } from "./software-engineer.js"; + +// Register new archetypes here. Software Engineer is the fallback when nothing +// else clearly matches. +export const ARCHETYPES: Archetype[] = [ + softwareEngineer, + productManager, + dataMlEngineer, +]; + +export const DEFAULT_ARCHETYPE = softwareEngineer; + +export function getArchetype(id: string): Archetype | undefined { + return ARCHETYPES.find((a) => a.id === id); +} + +export function listArchetypes(): Archetype[] { + return ARCHETYPES; +} + +export { softwareEngineer, productManager, dataMlEngineer }; diff --git a/packages/intelligence/src/archetypes/product-manager.ts b/packages/intelligence/src/archetypes/product-manager.ts new file mode 100644 index 0000000..5d14bae --- /dev/null +++ b/packages/intelligence/src/archetypes/product-manager.ts @@ -0,0 +1,53 @@ +import type { Archetype } from "@cv-builder/schemas"; + +export const productManager: Archetype = { + id: "product-manager", + name: "Product Manager", + description: "Product managers owning discovery, roadmap, and delivery", + keywords: [ + "roadmap", + "discovery", + "user research", + "a/b testing", + "experiment", + "okrs", + "kpis", + "retention", + "activation", + "funnel", + "churn", + "stakeholder", + "prioritization", + "agile", + "amplitude", + "mixpanel", + "go-to-market", + "pricing", + ], + evaluationWeights: { + shippedEvidence: 0.25, + quantifiedImpact: 0.25, + toolingVisibility: 0.1, + atsCompatibility: 0.15, + keywordMatch: 0.15, + publicProof: 0.1, + }, + actionVerbs: [ + "Launched", + "Owned", + "Drove", + "Grew", + "Defined", + "Prioritized", + "Increased", + "Reduced", + ], + antiPatterns: [ + "passionate about products", + "leveraged synergies", + "aligned stakeholders", + "wore many hats", + "helped with", + ], + version: "1.0.0", +}; diff --git a/packages/intelligence/src/archetypes/software-engineer.ts b/packages/intelligence/src/archetypes/software-engineer.ts new file mode 100644 index 0000000..2dbc0ab --- /dev/null +++ b/packages/intelligence/src/archetypes/software-engineer.ts @@ -0,0 +1,54 @@ +import type { Archetype } from "@cv-builder/schemas"; + +export const softwareEngineer: Archetype = { + id: "software-engineer", + name: "Software Engineer", + description: "Frontend, backend, and full-stack engineers building software", + keywords: [ + "typescript", + "javascript", + "python", + "go", + "java", + "react", + "node", + "postgres", + "redis", + "kafka", + "docker", + "kubernetes", + "aws", + "ci/cd", + "microservices", + "rest", + "graphql", + "testing", + "system design", + ], + evaluationWeights: { + shippedEvidence: 0.3, + quantifiedImpact: 0.2, + toolingVisibility: 0.2, + atsCompatibility: 0.1, + keywordMatch: 0.1, + publicProof: 0.1, + }, + actionVerbs: [ + "Built", + "Shipped", + "Designed", + "Scaled", + "Optimized", + "Migrated", + "Automated", + "Refactored", + ], + antiPatterns: [ + "familiar with", + "exposure to", + "team player", + "fast learner", + "responsible for various tasks", + ], + version: "1.0.0", +}; diff --git a/packages/intelligence/src/detect.ts b/packages/intelligence/src/detect.ts new file mode 100644 index 0000000..1c19dfa --- /dev/null +++ b/packages/intelligence/src/detect.ts @@ -0,0 +1,27 @@ +import type { Archetype } from "@cv-builder/schemas"; +import { ARCHETYPES, DEFAULT_ARCHETYPE } from "./archetypes/index.js"; + +function countMatches(text: string, keywords: string[]): number { + return keywords.reduce((n, kw) => (text.includes(kw.toLowerCase()) ? n + 1 : n), 0); +} + +// Picks the archetype whose keywords appear most in the resume. Falls back to +// the default when there's no signal, so detection never returns nothing. +export function detectArchetype( + resumeText: string, + archetypes: Archetype[] = ARCHETYPES, +): Archetype { + const text = resumeText.toLowerCase(); + let best = DEFAULT_ARCHETYPE; + let bestScore = 0; + + for (const archetype of archetypes) { + const score = countMatches(text, archetype.keywords); + if (score > bestScore) { + best = archetype; + bestScore = score; + } + } + + return best; +} diff --git a/packages/intelligence/src/index.ts b/packages/intelligence/src/index.ts new file mode 100644 index 0000000..528f328 --- /dev/null +++ b/packages/intelligence/src/index.ts @@ -0,0 +1,18 @@ +// Rubric v1, role archetypes, and the validator specs the prompts encode. + +export { RUBRIC, RUBRIC_VERSION, RUBRIC_DIMENSIONS, type RubricDimension } from "./rubric.js"; + +export { + ARCHETYPES, + DEFAULT_ARCHETYPE, + getArchetype, + listArchetypes, + softwareEngineer, + productManager, + dataMlEngineer, +} from "./archetypes/index.js"; + +export { detectArchetype } from "./detect.js"; + +export { ATS_RULES, checkAtsCompatibility, type AtsCheck } from "./validators/ats.js"; +export { CLAIM_RULES } from "./validators/claims.js"; diff --git a/packages/intelligence/src/rubric.ts b/packages/intelligence/src/rubric.ts new file mode 100644 index 0000000..3494ef4 --- /dev/null +++ b/packages/intelligence/src/rubric.ts @@ -0,0 +1,69 @@ +export const RUBRIC_VERSION = "1.0.0"; + +// `key` matches a field in EvaluationWeights so a dimension and its archetype +// weight always line up. +export interface RubricDimension { + key: + | "shippedEvidence" + | "quantifiedImpact" + | "toolingVisibility" + | "atsCompatibility" + | "keywordMatch" + | "publicProof"; + name: string; + description: string; + // What a 0 vs a 5 looks like, used by the score prompt as anchors. + low: string; + high: string; +} + +export const RUBRIC_DIMENSIONS: RubricDimension[] = [ + { + key: "shippedEvidence", + name: "Shipped Evidence", + description: "Real work that reached production, with named tools and outcomes.", + low: "Responsibilities only; nothing shows it shipped.", + high: "Every role names what was built, for whom, and the result.", + }, + { + key: "quantifiedImpact", + name: "Quantified Impact", + description: "Numbers that frame scope, speed, adoption, or savings.", + low: "No metrics anywhere.", + high: "Most bullets carry a concrete, credible number.", + }, + { + key: "toolingVisibility", + name: "Tech/Tool Visibility", + description: "Named technologies that match the target role.", + low: "Vague or no tech named.", + high: "Relevant stack is explicit and used in context, not just listed.", + }, + { + key: "atsCompatibility", + name: "ATS Compatibility", + description: "Single column, standard headings, parseable formatting.", + low: "Tables, columns, or graphics that parsers mangle.", + high: "Clean single-column text an ATS reads without loss.", + }, + { + key: "keywordMatch", + name: "Keyword Match", + description: + "Overlap with the JD when provided, else the role-family keyword set.", + low: "Misses the terms the role screens for.", + high: "Naturally covers the role's expected keywords.", + }, + { + key: "publicProof", + name: "Public Proof Surface", + description: "GitHub, portfolio, blog, or other verifiable presence.", + low: "No links or external proof.", + high: "Working links that back up the claims.", + }, +]; + +export const RUBRIC = { + version: RUBRIC_VERSION, + dimensions: RUBRIC_DIMENSIONS, +} as const; diff --git a/packages/intelligence/src/validators/ats.ts b/packages/intelligence/src/validators/ats.ts new file mode 100644 index 0000000..86de78a --- /dev/null +++ b/packages/intelligence/src/validators/ats.ts @@ -0,0 +1,43 @@ +// Rules the score prompt and the deterministic check below both follow. +export const ATS_RULES = [ + { id: "single-column", label: "Single-column layout, no side-by-side text" }, + { id: "standard-headings", label: "Standard section headings" }, + { id: "no-tables", label: "No tables or text laid out in columns" }, + { id: "no-graphics", label: "No images, icons, or text inside graphics" }, + { id: "contact-parseable", label: "Email and basic contact info in plain text" }, +] as const; + +const STANDARD_HEADINGS = [ + "experience", + "work experience", + "employment", + "education", + "skills", + "projects", + "summary", + "certifications", +]; + +export interface AtsCheck { + compatible: boolean; + findings: string[]; +} + +// Catches the obvious, deterministic ATS problems from raw text. Subtler layout +// issues are left to the score prompt. +export function checkAtsCompatibility(resumeText: string): AtsCheck { + const findings: string[] = []; + const text = resumeText.toLowerCase(); + + if (/\|.*\|/.test(resumeText) || /\t.*\t/.test(resumeText)) { + findings.push("Looks like a table or columns — flatten to single-column text."); + } + if (!/[\w.+-]+@[\w-]+\.[\w.-]+/.test(resumeText)) { + findings.push("No plain-text email found."); + } + if (!STANDARD_HEADINGS.some((h) => text.includes(h))) { + findings.push("No standard section headings detected."); + } + + return { compatible: findings.length === 0, findings }; +} diff --git a/packages/intelligence/src/validators/claims.ts b/packages/intelligence/src/validators/claims.ts new file mode 100644 index 0000000..aadb14b --- /dev/null +++ b/packages/intelligence/src/validators/claims.ts @@ -0,0 +1,20 @@ +// What the validate-claims prompt treats as an unsupported claim. The check is +// LLM-driven; this is the shared spec it encodes. +export const CLAIM_RULES = [ + { + id: "metric-without-context", + label: "A number with no scope, baseline, or how it was achieved", + }, + { + id: "tool-never-used", + label: "A tool or technology listed but never shown in any bullet", + }, + { + id: "seniority-without-scope", + label: "A leadership or scale claim with no team size, budget, or reach", + }, + { + id: "vague-superlative", + label: "Superlatives ('world-class', 'expert') with nothing backing them", + }, +] as const; diff --git a/packages/intelligence/tsconfig.json b/packages/intelligence/tsconfig.json new file mode 100644 index 0000000..d080397 --- /dev/null +++ b/packages/intelligence/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/__tests__/**"] +} diff --git a/packages/intelligence/vitest.config.ts b/packages/intelligence/vitest.config.ts new file mode 100644 index 0000000..4ed8031 --- /dev/null +++ b/packages/intelligence/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c05a4e..625b637 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,25 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/intelligence: + dependencies: + '@cv-builder/schemas': + specifier: workspace:* + version: link:../schemas + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/schemas: dependencies: zod: From 7b098450bb3fda41c3b1b21ae049119130b143eb Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Sun, 7 Jun 2026 19:13:24 +0200 Subject: [PATCH 03/13] feat(prompts): add extract, score, and validate-claims pack The three Phase 1 prompts a power user's agent runs. Markdown templates are the source of truth; renderers inject the live rubric, archetype weights, and claim rules so each assembled prompt is self-contained and stays in sync with the intelligence package. Every prompt asks for JSON matching its schema. Closes #64 --- packages/prompts/README.md | 21 ++++++ packages/prompts/package.json | 40 ++++++++++++ packages/prompts/prompts/extract.md | 41 ++++++++++++ packages/prompts/prompts/score.md | 64 +++++++++++++++++++ packages/prompts/prompts/validate-claims.md | 32 ++++++++++ .../prompts/src/__tests__/prompts.test.ts | 44 +++++++++++++ packages/prompts/src/index.ts | 58 +++++++++++++++++ packages/prompts/tsconfig.json | 10 +++ packages/prompts/vitest.config.ts | 8 +++ pnpm-lock.yaml | 19 ++++++ 10 files changed, 337 insertions(+) create mode 100644 packages/prompts/README.md create mode 100644 packages/prompts/package.json create mode 100644 packages/prompts/prompts/extract.md create mode 100644 packages/prompts/prompts/score.md create mode 100644 packages/prompts/prompts/validate-claims.md create mode 100644 packages/prompts/src/__tests__/prompts.test.ts create mode 100644 packages/prompts/src/index.ts create mode 100644 packages/prompts/tsconfig.json create mode 100644 packages/prompts/vitest.config.ts diff --git a/packages/prompts/README.md b/packages/prompts/README.md new file mode 100644 index 0000000..2e5b34c --- /dev/null +++ b/packages/prompts/README.md @@ -0,0 +1,21 @@ +# @cv-builder/prompts + +The three Phase 1 prompts a power user's agent runs, in `prompts/`: + +- **extract** — raw resume text → structured `Resume` +- **score** — `Resume` (+ optional JD keywords) + rubric + archetype → `EvalResult` +- **validate-claims** — `Resume` → `Claim[]`, flagging unsupported claims + +The `.md` files are the source of truth (readable, what the skill surfaces). The +renderers inject the live rubric and archetype data so the assembled prompt is +self-contained and stays in sync with `@cv-builder/intelligence`. + +```ts +import { renderScorePrompt } from "@cv-builder/prompts"; +import { softwareEngineer } from "@cv-builder/intelligence"; + +const prompt = renderScorePrompt({ archetype: softwareEngineer, jdKeywords }); +``` + +Each prompt asks for JSON matching its schema in `@cv-builder/schemas`; callers +validate that output before using it. diff --git a/packages/prompts/package.json b/packages/prompts/package.json new file mode 100644 index 0000000..efed7bb --- /dev/null +++ b/packages/prompts/package.json @@ -0,0 +1,40 @@ +{ + "name": "@cv-builder/prompts", + "version": "0.1.0", + "description": "Prompt pack — extract, score, and validate-claims templates", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "prompts" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cv-builder/intelligence": "workspace:*", + "@cv-builder/schemas": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "keywords": [ + "cv", + "resume", + "prompts", + "llm" + ], + "license": "MIT" +} diff --git a/packages/prompts/prompts/extract.md b/packages/prompts/prompts/extract.md new file mode 100644 index 0000000..1284997 --- /dev/null +++ b/packages/prompts/prompts/extract.md @@ -0,0 +1,41 @@ +# Extract Resume + +You are given the raw text of a resume. Convert it into a structured object. + +## Rules + +- Use only what's in the text. Do not invent employers, dates, metrics, or links. +- Leave a field empty (or its array `[]`) when the text doesn't contain it. +- Put the full, unmodified source text in `rawText` so later steps can quote it. +- Normalize obvious formatting noise (stray bullets, page numbers) out of fields, + but never change the wording of achievements. + +## Output + +Return a single JSON object matching the `Resume` schema: + +```json +{ + "name": "string | omit", + "headline": "string | omit", + "summary": "string | omit", + "contact": { "email": "string?", "phone": "string?", "location": "string?" }, + "links": [{ "type": "github|linkedin|portfolio|blog|website|other", "url": "string" }], + "experience": [ + { + "company": "string", + "role": "string", + "startDate": "string?", + "endDate": "string?", + "bullets": ["string"] + } + ], + "education": [ + { "institution": "string", "degree": "string?", "field": "string?", "year": "string?" } + ], + "skills": ["string"], + "rawText": "string" +} +``` + +Output only the JSON. No prose, no code fence. diff --git a/packages/prompts/prompts/score.md b/packages/prompts/prompts/score.md new file mode 100644 index 0000000..e324f9f --- /dev/null +++ b/packages/prompts/prompts/score.md @@ -0,0 +1,64 @@ +# Score Resume + +Score a structured resume against the rubric for a target role. Be specific and +honest — generic praise is useless to the candidate. + +## Target role + +- Archetype: {{ARCHETYPE_NAME}} (`{{ARCHETYPE_ID}}`, v{{ARCHETYPE_VERSION}}) +- Rubric version: {{RUBRIC_VERSION}} + +### Dimension weights + +{{WEIGHTS}} + +## Rubric + +Score each dimension 0–5 using these anchors: + +{{RUBRIC}} + +## Keyword context + +{{JD_KEYWORDS}} + +## Instructions + +1. Score every dimension 0–5 with one sentence of grounded feedback. +2. Overall `score` = sum(dimension score × weight), kept on the 0–5 scale. +3. List concrete `strengths`. +4. Surface **at least 3** `issues`. Each must quote the exact resume text it + refers to (`quote`) and give a concrete `fix`. +5. Set `atsCompatible` from the ATS dimension. +6. Echo back `rubricVersion`, `archetypeVersion`, `archetypeId`, `archetypeName`. + +## Output + +Return a single JSON object matching the `EvalResult` schema: + +```json +{ + "rubricVersion": "{{RUBRIC_VERSION}}", + "archetypeVersion": "{{ARCHETYPE_VERSION}}", + "archetypeId": "{{ARCHETYPE_ID}}", + "archetypeName": "{{ARCHETYPE_NAME}}", + "score": 0, + "dimensions": [ + { "name": "string", "weight": 0, "score": 0, "maxScore": 5, "feedback": "string" } + ], + "strengths": ["string"], + "issues": [ + { + "element": "string", + "quote": "string", + "why": "string", + "fix": "string", + "severity": "critical|major|minor" + } + ], + "claims": [], + "atsCompatible": true +} +``` + +Output only the JSON. No prose, no code fence. diff --git a/packages/prompts/prompts/validate-claims.md b/packages/prompts/prompts/validate-claims.md new file mode 100644 index 0000000..979254b --- /dev/null +++ b/packages/prompts/prompts/validate-claims.md @@ -0,0 +1,32 @@ +# Validate Claims + +Read a structured resume and check whether its claims are supported by the rest +of the document. Flag the ones that aren't — this is how the candidate learns +what a recruiter will quietly distrust. + +## A claim is unsupported when + +{{CLAIM_RULES}} + +## Instructions + +- Pull out the concrete claims (metrics, named tools/tech, scope/seniority). +- Mark each `supported: true` only when something elsewhere backs it up. +- For `supported: false`, say in `reason` what's missing, not just that it's weak. + +## Output + +Return a JSON array of `Claim` objects: + +```json +[ + { + "text": "string", + "category": "tool|technology|metric|experience|education|other", + "supported": true, + "reason": "string" + } +] +``` + +Output only the JSON array. No prose, no code fence. diff --git a/packages/prompts/src/__tests__/prompts.test.ts b/packages/prompts/src/__tests__/prompts.test.ts new file mode 100644 index 0000000..6c12910 --- /dev/null +++ b/packages/prompts/src/__tests__/prompts.test.ts @@ -0,0 +1,44 @@ +import { RUBRIC_VERSION, softwareEngineer } from "@cv-builder/intelligence"; +import { describe, expect, it } from "vitest"; +import { + PROMPT_NAMES, + renderExtractPrompt, + renderScorePrompt, + renderValidateClaimsPrompt, +} from "../index.js"; + +describe("prompt pack", () => { + it("ships the three Phase 1 prompts", () => { + expect(PROMPT_NAMES).toEqual(["extract", "score", "validate-claims"]); + }); + + it("each prompt names its output schema", () => { + expect(renderExtractPrompt()).toContain("Resume"); + expect(renderScorePrompt({ archetype: softwareEngineer })).toContain("EvalResult"); + expect(renderValidateClaimsPrompt()).toContain("Claim"); + }); + + it("score prompt embeds the current rubric version", () => { + const prompt = renderScorePrompt({ archetype: softwareEngineer }); + expect(prompt).toContain(RUBRIC_VERSION); + expect(prompt).not.toContain("{{"); + }); + + it("score prompt injects archetype weights and JD keywords", () => { + const withJd = renderScorePrompt({ + archetype: softwareEngineer, + jdKeywords: ["kubernetes", "graphql"], + }); + expect(withJd).toContain("shippedEvidence:"); + expect(withJd).toContain("kubernetes, graphql"); + + const withoutJd = renderScorePrompt({ archetype: softwareEngineer }); + expect(withoutJd).toContain("No JD provided"); + }); + + it("validate-claims prompt injects the claim rules", () => { + const prompt = renderValidateClaimsPrompt(); + expect(prompt).not.toContain("{{CLAIM_RULES}}"); + expect(prompt).toContain("tool or technology listed but never shown"); + }); +}); diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts new file mode 100644 index 0000000..21a609c --- /dev/null +++ b/packages/prompts/src/index.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + CLAIM_RULES, + RUBRIC, + RUBRIC_VERSION, +} from "@cv-builder/intelligence"; +import type { Archetype } from "@cv-builder/schemas"; + +export const PROMPT_NAMES = ["extract", "score", "validate-claims"] as const; +export type PromptName = (typeof PROMPT_NAMES)[number]; + +// .md lives next to dist/ and src/ alike, so this resolves in both. +function loadTemplate(name: PromptName): string { + const path = fileURLToPath(new URL(`../prompts/${name}.md`, import.meta.url)); + return readFileSync(path, "utf-8"); +} + +export function renderExtractPrompt(): string { + return loadTemplate("extract"); +} + +export function renderValidateClaimsPrompt(): string { + const rules = CLAIM_RULES.map((r) => `- ${r.label}`).join("\n"); + return loadTemplate("validate-claims").replace("{{CLAIM_RULES}}", rules); +} + +export interface ScorePromptOptions { + archetype: Archetype; + jdKeywords?: string[]; +} + +export function renderScorePrompt({ + archetype, + jdKeywords, +}: ScorePromptOptions): string { + const weights = Object.entries(archetype.evaluationWeights) + .map(([key, value]) => `- ${key}: ${value}`) + .join("\n"); + + const rubric = RUBRIC.dimensions + .map((d) => `- **${d.name}** — ${d.description}\n - 0: ${d.low}\n - 5: ${d.high}`) + .join("\n"); + + const keywords = + jdKeywords && jdKeywords.length > 0 + ? `Match against these JD keywords: ${jdKeywords.join(", ")}.` + : `No JD provided — match against the role's own keyword set: ${archetype.keywords.join(", ")}.`; + + return loadTemplate("score") + .replaceAll("{{ARCHETYPE_NAME}}", archetype.name) + .replaceAll("{{ARCHETYPE_ID}}", archetype.id) + .replaceAll("{{ARCHETYPE_VERSION}}", archetype.version) + .replaceAll("{{RUBRIC_VERSION}}", RUBRIC_VERSION) + .replace("{{WEIGHTS}}", weights) + .replace("{{RUBRIC}}", rubric) + .replace("{{JD_KEYWORDS}}", keywords); +} diff --git a/packages/prompts/tsconfig.json b/packages/prompts/tsconfig.json new file mode 100644 index 0000000..d080397 --- /dev/null +++ b/packages/prompts/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["src/__tests__/**"] +} diff --git a/packages/prompts/vitest.config.ts b/packages/prompts/vitest.config.ts new file mode 100644 index 0000000..4ed8031 --- /dev/null +++ b/packages/prompts/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 625b637..1e6bd1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,25 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/prompts: + dependencies: + '@cv-builder/intelligence': + specifier: workspace:* + version: link:../intelligence + '@cv-builder/schemas': + specifier: workspace:* + version: link:../schemas + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/schemas: dependencies: zod: From 131083ae7f87270b609f3e77dbc3687368596407 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Sun, 7 Jun 2026 19:58:31 +0200 Subject: [PATCH 04/13] =?UTF-8?q?feat(cli):=20add=20power-user=20pack=20?= =?UTF-8?q?=E2=80=94=20Claude=20Code=20skill,=20commands,=20welcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clone the repo, open Claude Code, run /evaluate-cv: the cv-evaluation skill runs extract -> detect -> score -> validate-claims locally, reading the prompts and rubric straight from the repo (no build). A SessionStart hook greets the user with the available commands on open. Skill subfiles point at the package sources to avoid drift. Closes #65 --- .claude/commands/evaluate-cv.md | 16 +++++ .claude/commands/setup-profile.md | 15 +++++ .claude/settings.json | 17 ++++++ .claude/skills/cv-evaluation/SKILL.md | 39 ++++++++++++ .claude/skills/cv-evaluation/archetypes.md | 10 ++++ .../skills/cv-evaluation/claim-validation.md | 9 +++ .claude/skills/cv-evaluation/rubric.md | 8 +++ .claude/skills/cv-evaluation/scoring.md | 12 ++++ .claude/welcome.sh | 19 ++++++ CLAUDE.md | 25 ++++++++ apps/cli/README.md | 60 +++++++++++++++++++ apps/cli/package.json | 6 ++ pnpm-lock.yaml | 2 + 13 files changed, 238 insertions(+) create mode 100644 .claude/commands/evaluate-cv.md create mode 100644 .claude/commands/setup-profile.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/cv-evaluation/SKILL.md create mode 100644 .claude/skills/cv-evaluation/archetypes.md create mode 100644 .claude/skills/cv-evaluation/claim-validation.md create mode 100644 .claude/skills/cv-evaluation/rubric.md create mode 100644 .claude/skills/cv-evaluation/scoring.md create mode 100755 .claude/welcome.sh create mode 100644 CLAUDE.md create mode 100644 apps/cli/README.md create mode 100644 apps/cli/package.json diff --git a/.claude/commands/evaluate-cv.md b/.claude/commands/evaluate-cv.md new file mode 100644 index 0000000..6e14ee7 --- /dev/null +++ b/.claude/commands/evaluate-cv.md @@ -0,0 +1,16 @@ +--- +description: Score a resume against the CV Builder rubric, fully locally +argument-hint: [--jd ] +--- + +Evaluate the resume using the `cv-evaluation` skill. + +Arguments: $ARGUMENTS + +- The first argument is the path to the resume (PDF, markdown, or text). +- An optional `--jd ` adds a job description as keyword context only. + +Read the file(s), run the extract → detect → score → validate-claims pipeline, +validate the result against `EvalResultSchema`, then present the overall score, +the per-dimension breakdown, the top issues with fixes, and any unsupported +claims. Nothing leaves this machine. diff --git a/.claude/commands/setup-profile.md b/.claude/commands/setup-profile.md new file mode 100644 index 0000000..2eca1cd --- /dev/null +++ b/.claude/commands/setup-profile.md @@ -0,0 +1,15 @@ +--- +description: Gather a resume to evaluate, three ways +argument-hint: [path-to-file] +--- + +Help the user get a resume ready for `/evaluate-cv`. Pick the path that fits: + +1. **File** — if `$ARGUMENTS` names a file, read it and confirm what you found. +2. **Paste** — if they pasted resume text, use it directly. +3. **Interview** — if they have nothing prepared, ask a short, focused set of + questions (roles, dates, what they built, measurable outcomes, links) and + assemble a plain-text resume from their answers. + +Once you have the resume text, save it to a file the user chooses and tell them +to run `/evaluate-cv `. Don't invent details they didn't give you. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8d1b317 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": ["Read", "Glob", "Grep"] + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/welcome.sh\"" + } + ] + } + ] + } +} diff --git a/.claude/skills/cv-evaluation/SKILL.md b/.claude/skills/cv-evaluation/SKILL.md new file mode 100644 index 0000000..53fa570 --- /dev/null +++ b/.claude/skills/cv-evaluation/SKILL.md @@ -0,0 +1,39 @@ +--- +name: cv-evaluation +description: Evaluate a resume against the CV Builder rubric and return a scored, schema-valid EvalResult. Use when the user wants their CV or resume scored, critiqued, or checked for ATS issues and unsupported claims. +--- + +# CV Evaluation + +Run a resume through the same three-step evaluation the hosted product uses, +entirely locally. Everything you need lives in this repo — read the source, don't +guess. + +## Inputs + +- A resume file (PDF, markdown, or plain text). For PDF, read the text content. +- Optionally a job description, used only as keyword context (Phase 1 does not + fit-score against a JD). + +## Pipeline + +1. **Extract.** Apply `packages/prompts/prompts/extract.md` to the raw resume + text to produce a structured `Resume`. +2. **Detect archetype.** Match the resume against the role archetypes in + `packages/intelligence/src/archetypes/`. Pick the best fit; default to + Software Engineer when there's no clear signal. See [archetypes](./archetypes.md). +3. **Score.** Apply `packages/prompts/prompts/score.md` with the detected + archetype's weights and the rubric. See [rubric](./rubric.md) and + [scoring](./scoring.md). Surface **at least 3** issues, each quoting the exact + resume text and giving a concrete fix. +4. **Validate claims.** Apply `packages/prompts/prompts/validate-claims.md` to + flag unsupported claims. See [claim-validation](./claim-validation.md). + +## Output + +Produce one `EvalResult` (schema: `packages/schemas/src/evaluation.ts`). It must +carry `rubricVersion` and `archetypeVersion`. Validate the object against +`EvalResultSchema` before presenting it — never show an unvalidated result. + +Then summarize for the user: overall score, the per-dimension breakdown, the top +issues with their fixes, and any unsupported claims. diff --git a/.claude/skills/cv-evaluation/archetypes.md b/.claude/skills/cv-evaluation/archetypes.md new file mode 100644 index 0000000..a5646cd --- /dev/null +++ b/.claude/skills/cv-evaluation/archetypes.md @@ -0,0 +1,10 @@ +# Archetypes + +Each role archetype (keywords, dimension weights, action verbs, anti-patterns) +is one file in `packages/intelligence/src/archetypes/`. Read the relevant file — +the weights there drive the overall score, so use them exactly. + +To detect the archetype: count how many of each archetype's keywords appear in +the resume and pick the highest. The same heuristic is implemented in +`packages/intelligence/src/detect.ts` (`detectArchetype`). When nothing clearly +matches, use Software Engineer. diff --git a/.claude/skills/cv-evaluation/claim-validation.md b/.claude/skills/cv-evaluation/claim-validation.md new file mode 100644 index 0000000..aa83af1 --- /dev/null +++ b/.claude/skills/cv-evaluation/claim-validation.md @@ -0,0 +1,9 @@ +# Claim Validation + +The prompt and the rules for what counts as an unsupported claim are in +`packages/prompts/prompts/validate-claims.md` (rules sourced from +`packages/intelligence/src/validators/claims.ts`). + +Goal: catch what a recruiter would quietly distrust — a metric with no scope, a +tool listed but never used in a bullet, a seniority claim with no team size or +reach. For each flagged claim, say what's missing, not just that it's weak. diff --git a/.claude/skills/cv-evaluation/rubric.md b/.claude/skills/cv-evaluation/rubric.md new file mode 100644 index 0000000..487b690 --- /dev/null +++ b/.claude/skills/cv-evaluation/rubric.md @@ -0,0 +1,8 @@ +# Rubric + +The six dimensions, their 0–5 anchors, and the current `RUBRIC_VERSION` are +defined in `packages/intelligence/src/rubric.ts`. Read that file — it is the +source of truth. Don't reproduce the anchors from memory. + +Score each dimension on its own 0–5 scale, then weight by the archetype (see +[archetypes](./archetypes.md)) to get the overall 0–5 score. diff --git a/.claude/skills/cv-evaluation/scoring.md b/.claude/skills/cv-evaluation/scoring.md new file mode 100644 index 0000000..cca6563 --- /dev/null +++ b/.claude/skills/cv-evaluation/scoring.md @@ -0,0 +1,12 @@ +# Scoring + +The scoring instructions and exact output shape are in +`packages/prompts/prompts/score.md`. Fill its `{{...}}` placeholders from the +detected archetype's weights and the rubric. + +Hold the line on quality: + +- Every issue quotes the exact resume text it's about — no paraphrasing. +- Every issue has a concrete fix, not "consider improving this". +- Overall score = sum(dimension score × weight), on the 0–5 scale. +- Be honest. A weak resume should score low; inflating it doesn't help anyone. diff --git a/.claude/welcome.sh b/.claude/welcome.sh new file mode 100755 index 0000000..75a590d --- /dev/null +++ b/.claude/welcome.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Emits the CV Builder menu as a SessionStart hook systemMessage, shown to the +# user when they open Claude Code at the repo root. + +# No jq → skip the banner rather than break the session start. +command -v jq >/dev/null 2>&1 || exit 0 + +read -r -d '' MENU <<'EOF' +CV Builder — evaluate your resume locally and privately. Nothing leaves your machine. + + /evaluate-cv [--jd ] Score a resume against the rubric + /setup-profile Assemble a resume if you don't have a file yet + +No build or install needed to evaluate. + +What would you like to do? +EOF + +jq -n --arg msg "$MENU" '{systemMessage: $msg}' diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eae9328 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# CV Builder + +Open source, privacy-first resume evaluator. This repo doubles as a **power-user +pack**: clone it, open Claude Code here, and evaluate your resume locally with +your own agent — nothing leaves your machine. + +## Power-user commands + +- `/evaluate-cv [--jd ]` — score a resume against + the rubric and surface issues + unsupported claims. +- `/setup-profile` — assemble a resume if you don't have a file ready. + +## No build needed for evaluation + +The `cv-evaluation` skill (`.claude/skills/cv-evaluation/`) reads the prompts in +`packages/prompts/prompts/` and the rubric/archetypes in +`packages/intelligence/src/` straight from the repo, and returns an `EvalResult` +validated against `packages/schemas`. `pnpm install` is only needed to run the +package tests or the web app — not for the power-user flow. + +## Repo shape + +pnpm + turbo monorepo. `packages/schemas` (contract) → `packages/intelligence` +(rubric + archetypes) → `packages/prompts` (the prompt pack) → the skill ties +them together. diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..d89fa98 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,60 @@ +# Power User — evaluate your resume locally + +Run the full CV evaluation on your own machine with your own +[Claude Code](https://claude.com/claude-code). Your resume never leaves your +computer — no server, no account, no API keys in this repo. + +## Quickstart + +```bash +git clone https://github.com/TechImmigrants/cv-builder.git +cd cv-builder +claude # open Claude Code at the repo root +``` + +No build or install is needed to evaluate — the skill reads the prompts and +rubric straight from the repo. (`pnpm install` is only for running the package +tests or the web app.) + +Then, inside Claude Code: + +``` +/evaluate-cv ./my-resume.pdf +``` + +Optionally add a job description as keyword context: + +``` +/evaluate-cv ./my-resume.pdf --jd ./job.md +``` + +No resume handy? Run `/setup-profile` and it'll help you assemble one. + +## What you get + +An honest, role-adaptive score (0–5) with: + +- a per-dimension breakdown across the six rubric dimensions, +- at least three specific issues, each quoting the exact line and giving a fix, +- any unsupported claims a recruiter would distrust, +- an ATS-compatibility read. + +## How it works + +The `/evaluate-cv` command drives the **cv-evaluation** skill +(`.claude/skills/cv-evaluation/`), which runs four steps against this repo: + +| Step | Source | +|---|---| +| Extract structured resume | `packages/prompts/prompts/extract.md` | +| Detect role archetype | `packages/intelligence/src/archetypes/` | +| Score against the rubric | `packages/prompts/prompts/score.md` + `packages/intelligence/src/rubric.ts` | +| Flag unsupported claims | `packages/prompts/prompts/validate-claims.md` | + +The result is validated against `EvalResultSchema` +(`packages/schemas`) before you see it. + +## Privacy + +Everything runs through your local Claude Code. This pack makes no network calls +of its own and stores nothing. diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..4ee4ad1 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,6 @@ +{ + "name": "@cv-builder/power-user", + "version": "0.1.0", + "description": "Power-user pack: evaluate your resume locally with your own Claude Code", + "private": true +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e6bd1b..8885d7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,8 @@ importers: specifier: ^5.7.0 version: 5.9.3 + apps/cli: {} + apps/web-ui: dependencies: '@cv-builder/core': From c44edf18684b4677517e23706ca392f16c7ee9d2 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Sun, 7 Jun 2026 20:11:24 +0200 Subject: [PATCH 05/13] test(eval): add golden fixtures and pnpm eval harness Five fixtures across the three archetypes (including an ATS-hostile resume) guard the deterministic layer: for each, the detected archetype and ATS read must match expectations and the resume must parse against the schema. No LLM involved. Wired as a pnpm eval task and a CI step. Closes #66 --- .github/workflows/ci.yml | 2 + package.json | 1 + packages/eval/README.md | 17 +++++ .../fixtures/data-ml-strong/expected.json | 5 ++ .../eval/fixtures/data-ml-strong/resume.md | 15 ++++ .../eval/fixtures/pm-strong/expected.json | 5 ++ packages/eval/fixtures/pm-strong/resume.md | 15 ++++ .../eval/fixtures/swe-strong/expected.json | 5 ++ packages/eval/fixtures/swe-strong/resume.md | 15 ++++ .../fixtures/swe-weak-table/expected.json | 5 ++ .../eval/fixtures/swe-weak-table/resume.md | 9 +++ .../eval/fixtures/swe-with-jd/expected.json | 5 ++ packages/eval/fixtures/swe-with-jd/jd.md | 4 ++ packages/eval/fixtures/swe-with-jd/resume.md | 15 ++++ packages/eval/package.json | 22 ++++++ packages/eval/src/__tests__/fixtures.test.ts | 69 +++++++++++++++++++ packages/eval/tsconfig.json | 9 +++ packages/eval/vitest.config.ts | 8 +++ pnpm-lock.yaml | 19 +++++ turbo.json | 3 + 20 files changed, 248 insertions(+) create mode 100644 packages/eval/README.md create mode 100644 packages/eval/fixtures/data-ml-strong/expected.json create mode 100644 packages/eval/fixtures/data-ml-strong/resume.md create mode 100644 packages/eval/fixtures/pm-strong/expected.json create mode 100644 packages/eval/fixtures/pm-strong/resume.md create mode 100644 packages/eval/fixtures/swe-strong/expected.json create mode 100644 packages/eval/fixtures/swe-strong/resume.md create mode 100644 packages/eval/fixtures/swe-weak-table/expected.json create mode 100644 packages/eval/fixtures/swe-weak-table/resume.md create mode 100644 packages/eval/fixtures/swe-with-jd/expected.json create mode 100644 packages/eval/fixtures/swe-with-jd/jd.md create mode 100644 packages/eval/fixtures/swe-with-jd/resume.md create mode 100644 packages/eval/package.json create mode 100644 packages/eval/src/__tests__/fixtures.test.ts create mode 100644 packages/eval/tsconfig.json create mode 100644 packages/eval/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b80541..5935c96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,6 @@ jobs: - run: pnpm test + - run: pnpm eval + - run: pnpm build diff --git a/package.json b/package.json index bd0c486..d8dbb9a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "turbo dev", "lint": "turbo lint", "test": "turbo test", + "eval": "turbo eval", "format": "prettier --write \"**/*.{ts,tsx,md,json}\"", "format:check": "prettier --check \"**/*.{ts,tsx,md,json}\"" }, diff --git a/packages/eval/README.md b/packages/eval/README.md new file mode 100644 index 0000000..109036b --- /dev/null +++ b/packages/eval/README.md @@ -0,0 +1,17 @@ +# @cv-builder/eval + +Golden fixtures that guard the deterministic intelligence layer. Run with +`pnpm eval` (and in CI). + +Each fixture is a folder under `fixtures/`: + +- `resume.md` — the input resume +- `jd.md` — optional job description (keyword context) +- `expected.json` — `{ archetype, atsCompatible }` + +The harness asserts, without any LLM, that for every fixture the detected +archetype and ATS read match what's expected, and that the resume parses against +the schema. A change that breaks archetype detection or the ATS check fails here. + +Scoring *quality* (the LLM-produced `EvalResult`) is out of scope — that needs a +provider adapter, which is a separate surface. diff --git a/packages/eval/fixtures/data-ml-strong/expected.json b/packages/eval/fixtures/data-ml-strong/expected.json new file mode 100644 index 0000000..b3ab9e0 --- /dev/null +++ b/packages/eval/fixtures/data-ml-strong/expected.json @@ -0,0 +1,5 @@ +{ + "description": "Strong data & ML engineer resume", + "archetype": "data-ml-engineer", + "atsCompatible": true +} diff --git a/packages/eval/fixtures/data-ml-strong/resume.md b/packages/eval/fixtures/data-ml-strong/resume.md new file mode 100644 index 0000000..c8e873b --- /dev/null +++ b/packages/eval/fixtures/data-ml-strong/resume.md @@ -0,0 +1,15 @@ +# Dev Patel + +Data & ML Engineer — dev@example.com — github.com/devpatel + +## Experience + +### DataCo — Machine Learning Engineer (2021–2024) + +- Built data pipelines with Spark, Airflow, and dbt; productionized batch and streaming ETL. +- Trained and deployed PyTorch models with model serving and experiment tracking. +- Optimized feature engineering on Snowflake and BigQuery, cutting training time 3x. + +## Skills + +Python, SQL, Spark, Airflow, dbt, PyTorch, scikit-learn, MLOps, Snowflake, BigQuery diff --git a/packages/eval/fixtures/pm-strong/expected.json b/packages/eval/fixtures/pm-strong/expected.json new file mode 100644 index 0000000..88c4be4 --- /dev/null +++ b/packages/eval/fixtures/pm-strong/expected.json @@ -0,0 +1,5 @@ +{ + "description": "Strong product manager resume", + "archetype": "product-manager", + "atsCompatible": true +} diff --git a/packages/eval/fixtures/pm-strong/resume.md b/packages/eval/fixtures/pm-strong/resume.md new file mode 100644 index 0000000..61b843a --- /dev/null +++ b/packages/eval/fixtures/pm-strong/resume.md @@ -0,0 +1,15 @@ +# Sara Lee + +Product Manager — sara@example.com — linkedin.com/in/saralee + +## Experience + +### FinCo — Senior Product Manager (2020–2024) + +- Owned the roadmap and discovery for a B2B SaaS; grew retention 25%. +- Ran A/B testing and user research; reduced churn and lifted activation. +- Defined OKRs and KPIs; drove the onboarding funnel with the growth team. + +## Skills + +Roadmap, Discovery, Prioritization, Amplitude, Mixpanel, Stakeholder management, Agile diff --git a/packages/eval/fixtures/swe-strong/expected.json b/packages/eval/fixtures/swe-strong/expected.json new file mode 100644 index 0000000..540d774 --- /dev/null +++ b/packages/eval/fixtures/swe-strong/expected.json @@ -0,0 +1,5 @@ +{ + "description": "Strong, ATS-clean software engineer resume", + "archetype": "software-engineer", + "atsCompatible": true +} diff --git a/packages/eval/fixtures/swe-strong/resume.md b/packages/eval/fixtures/swe-strong/resume.md new file mode 100644 index 0000000..26d1a9f --- /dev/null +++ b/packages/eval/fixtures/swe-strong/resume.md @@ -0,0 +1,15 @@ +# Jane Doe + +Software Engineer — jane@example.com — github.com/janedoe + +## Experience + +### Acme — Senior Software Engineer (2021–2024) + +- Built a React and Node.js checkout serving 2M users; cut p95 latency 40%. +- Migrated Postgres to a sharded cluster, scaling writes 5x. +- Automated CI/CD with Docker and Kubernetes, halving deploy time. + +## Skills + +TypeScript, React, Node, Postgres, Redis, Kafka, Docker, Kubernetes, AWS, GraphQL diff --git a/packages/eval/fixtures/swe-weak-table/expected.json b/packages/eval/fixtures/swe-weak-table/expected.json new file mode 100644 index 0000000..e901024 --- /dev/null +++ b/packages/eval/fixtures/swe-weak-table/expected.json @@ -0,0 +1,5 @@ +{ + "description": "Software engineer skills but a table layout and no email — ATS-hostile", + "archetype": "software-engineer", + "atsCompatible": false +} diff --git a/packages/eval/fixtures/swe-weak-table/resume.md b/packages/eval/fixtures/swe-weak-table/resume.md new file mode 100644 index 0000000..7c396f2 --- /dev/null +++ b/packages/eval/fixtures/swe-weak-table/resume.md @@ -0,0 +1,9 @@ +John Smith — Developer + +| Skill | Level | +| ------ | ------ | +| react | senior | +| node | mid | +| docker | basic | + +Worked on various TypeScript projects. Familiar with Kubernetes and AWS. diff --git a/packages/eval/fixtures/swe-with-jd/expected.json b/packages/eval/fixtures/swe-with-jd/expected.json new file mode 100644 index 0000000..bde48d0 --- /dev/null +++ b/packages/eval/fixtures/swe-with-jd/expected.json @@ -0,0 +1,5 @@ +{ + "description": "Backend engineer evaluated with a matching JD as keyword context", + "archetype": "software-engineer", + "atsCompatible": true +} diff --git a/packages/eval/fixtures/swe-with-jd/jd.md b/packages/eval/fixtures/swe-with-jd/jd.md new file mode 100644 index 0000000..87b006f --- /dev/null +++ b/packages/eval/fixtures/swe-with-jd/jd.md @@ -0,0 +1,4 @@ +# Backend Engineer + +We're hiring a backend engineer to scale our payments platform. You'll work with +Go, Kubernetes, Kafka, GraphQL, and Postgres at high throughput. diff --git a/packages/eval/fixtures/swe-with-jd/resume.md b/packages/eval/fixtures/swe-with-jd/resume.md new file mode 100644 index 0000000..7200380 --- /dev/null +++ b/packages/eval/fixtures/swe-with-jd/resume.md @@ -0,0 +1,15 @@ +# Miguel Santos + +Backend Engineer — miguel@example.com — github.com/miguels + +## Experience + +### Payments Inc — Backend Engineer (2019–2024) + +- Designed Go and Node services behind a GraphQL gateway handling 10k req/s. +- Scaled Postgres and Kafka pipelines; cut order-processing time 60%. +- Ran the platform on Kubernetes with automated CI/CD. + +## Skills + +Go, Node, TypeScript, GraphQL, Postgres, Kafka, Kubernetes, Docker, AWS diff --git a/packages/eval/package.json b/packages/eval/package.json new file mode 100644 index 0000000..db5962a --- /dev/null +++ b/packages/eval/package.json @@ -0,0 +1,22 @@ +{ + "name": "@cv-builder/eval", + "version": "0.1.0", + "description": "Golden fixtures and eval harness for the deterministic intelligence layer", + "type": "module", + "private": true, + "scripts": { + "lint": "tsc --noEmit", + "eval": "vitest run", + "dev": "vitest" + }, + "dependencies": { + "@cv-builder/intelligence": "workspace:*", + "@cv-builder/schemas": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "license": "MIT" +} diff --git a/packages/eval/src/__tests__/fixtures.test.ts b/packages/eval/src/__tests__/fixtures.test.ts new file mode 100644 index 0000000..32a1e17 --- /dev/null +++ b/packages/eval/src/__tests__/fixtures.test.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { + checkAtsCompatibility, + detectArchetype, + getArchetype, +} from "@cv-builder/intelligence"; +import { ResumeSchema } from "@cv-builder/schemas"; +import { describe, expect, it } from "vitest"; + +const fixturesDir = fileURLToPath(new URL("../../fixtures", import.meta.url)); + +interface Expected { + archetype: string; + atsCompatible: boolean; + description?: string; +} + +interface Fixture { + name: string; + resume: string; + jd?: string; + expected: Expected; +} + +function loadFixtures(): Fixture[] { + return readdirSync(fixturesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => { + const dir = `${fixturesDir}/${d.name}`; + const jdPath = `${dir}/jd.md`; + return { + name: d.name, + resume: readFileSync(`${dir}/resume.md`, "utf-8"), + jd: existsSync(jdPath) ? readFileSync(jdPath, "utf-8") : undefined, + expected: JSON.parse(readFileSync(`${dir}/expected.json`, "utf-8")) as Expected, + }; + }); +} + +const fixtures = loadFixtures(); + +describe("golden fixtures", () => { + it("covers at least 5 fixtures across at least 3 archetypes", () => { + expect(fixtures.length).toBeGreaterThanOrEqual(5); + const archetypes = new Set(fixtures.map((f) => f.expected.archetype)); + expect(archetypes.size).toBeGreaterThanOrEqual(3); + }); + + for (const f of fixtures) { + describe(f.name, () => { + it("expects a real archetype", () => { + expect(getArchetype(f.expected.archetype), f.expected.archetype).toBeDefined(); + }); + + it("detects the expected archetype", () => { + expect(detectArchetype(f.resume).id).toBe(f.expected.archetype); + }); + + it("matches expected ATS compatibility", () => { + expect(checkAtsCompatibility(f.resume).compatible).toBe(f.expected.atsCompatible); + }); + + it("parses as a Resume", () => { + expect(ResumeSchema.safeParse({ rawText: f.resume }).success).toBe(true); + }); + }); + } +}); diff --git a/packages/eval/tsconfig.json b/packages/eval/tsconfig.json new file mode 100644 index 0000000..66d8880 --- /dev/null +++ b/packages/eval/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/packages/eval/vitest.config.ts b/packages/eval/vitest.config.ts new file mode 100644 index 0000000..4ed8031 --- /dev/null +++ b/packages/eval/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8885d7c..8ee5510 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,25 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/eval: + dependencies: + '@cv-builder/intelligence': + specifier: workspace:* + version: link:../intelligence + '@cv-builder/schemas': + specifier: workspace:* + version: link:../schemas + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0) + packages/intelligence: dependencies: '@cv-builder/schemas': diff --git a/turbo.json b/turbo.json index 56b1698..81d0046 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,9 @@ }, "test": { "dependsOn": ["build"] + }, + "eval": { + "dependsOn": ["^build"] } } } From 6748d7ee3a4fc6f7e7783d99fcb5eaa4f53231a7 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 13:33:00 +0200 Subject: [PATCH 06/13] refactor(schemas): drop maxScore, rubric scale is fixed 0-5 --- packages/schemas/src/__tests__/schemas.test.ts | 1 - packages/schemas/src/evaluation.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/schemas/src/__tests__/schemas.test.ts b/packages/schemas/src/__tests__/schemas.test.ts index 848f937..08e1265 100644 --- a/packages/schemas/src/__tests__/schemas.test.ts +++ b/packages/schemas/src/__tests__/schemas.test.ts @@ -16,7 +16,6 @@ const validEvalResult = { name: "Shipped Evidence", weight: 0.3, score: 4, - maxScore: 5, feedback: "Strong production work with named outcomes.", }, ], diff --git a/packages/schemas/src/evaluation.ts b/packages/schemas/src/evaluation.ts index 36b01ed..4038d1c 100644 --- a/packages/schemas/src/evaluation.ts +++ b/packages/schemas/src/evaluation.ts @@ -4,7 +4,6 @@ export const EvaluationDimensionSchema = z.object({ name: z.string(), weight: z.number().min(0).max(1), score: z.number().int().min(0).max(5), - maxScore: z.number().int().positive().default(5), feedback: z.string(), }); export type EvaluationDimension = z.infer; From ab36770119f16928c07a861db44b0ee21478f4e0 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 13:42:47 +0200 Subject: [PATCH 07/13] chore(schemas): apply biome formatting --- .../schemas/src/__tests__/schemas.test.ts | 6 +-- packages/schemas/src/evaluation.ts | 9 +--- packages/schemas/src/index.ts | 44 +++++++++---------- 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/packages/schemas/src/__tests__/schemas.test.ts b/packages/schemas/src/__tests__/schemas.test.ts index 08e1265..7faa566 100644 --- a/packages/schemas/src/__tests__/schemas.test.ts +++ b/packages/schemas/src/__tests__/schemas.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - ArchetypeSchema, - EvalResultSchema, - ResumeSchema, -} from "../index.js"; +import { ArchetypeSchema, EvalResultSchema, ResumeSchema } from "../index.js"; const validEvalResult = { rubricVersion: "1.0.0", diff --git a/packages/schemas/src/evaluation.ts b/packages/schemas/src/evaluation.ts index 4038d1c..4ab78b3 100644 --- a/packages/schemas/src/evaluation.ts +++ b/packages/schemas/src/evaluation.ts @@ -19,14 +19,7 @@ export type Issue = z.infer; export const ClaimSchema = z.object({ text: z.string(), - category: z.enum([ - "tool", - "technology", - "metric", - "experience", - "education", - "other", - ]), + category: z.enum(["tool", "technology", "metric", "experience", "education", "other"]), supported: z.boolean(), reason: z.string(), }); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 87b220e..ffff15a 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -2,36 +2,34 @@ // Phase 1 ships evaluation types only — tailoring/rewrite is Phase 2. export { - EvaluationWeightsSchema, + type Archetype, ArchetypeSchema, type EvaluationWeights, - type Archetype, + EvaluationWeightsSchema, } from "./archetype.js"; +export { + type Claim, + ClaimSchema, + type EvalResult, + EvalResultSchema, + type EvaluationDimension, + EvaluationDimensionSchema, + type Issue, + IssueSchema, +} from "./evaluation.js"; +export { type JobDescription, JobDescriptionSchema } from "./job-description.js"; export { - ResumeSourceSchema, - ResumeLinkSchema, + type Resume, + type ResumeContact, ResumeContactSchema, - ResumeExperienceSchema, + type ResumeEducation, ResumeEducationSchema, + type ResumeExperience, + ResumeExperienceSchema, + type ResumeLink, + ResumeLinkSchema, ResumeSchema, type ResumeSource, - type ResumeLink, - type ResumeContact, - type ResumeExperience, - type ResumeEducation, - type Resume, + ResumeSourceSchema, } from "./resume.js"; - -export { JobDescriptionSchema, type JobDescription } from "./job-description.js"; - -export { - EvaluationDimensionSchema, - IssueSchema, - ClaimSchema, - EvalResultSchema, - type EvaluationDimension, - type Issue, - type Claim, - type EvalResult, -} from "./evaluation.js"; From d438cc7bf1e1059af670d859166cec31c6aad7ed Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 14:09:37 +0200 Subject: [PATCH 08/13] fix(intelligence): match keywords on word boundaries, drop unused zod dep --- packages/intelligence/package.json | 3 +-- .../src/__tests__/intelligence.test.ts | 20 +++++++++++-------- packages/intelligence/src/archetypes/index.ts | 8 ++------ packages/intelligence/src/detect.ts | 10 ++++++++-- packages/intelligence/src/index.ts | 15 ++++++++------ packages/intelligence/src/rubric.ts | 3 +-- pnpm-lock.yaml | 3 --- 7 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/intelligence/package.json b/packages/intelligence/package.json index f11ac92..b59f64c 100644 --- a/packages/intelligence/package.json +++ b/packages/intelligence/package.json @@ -18,8 +18,7 @@ "test": "vitest run" }, "dependencies": { - "@cv-builder/schemas": "workspace:*", - "zod": "^3.24.0" + "@cv-builder/schemas": "workspace:*" }, "devDependencies": { "@types/node": "^25.6.2", diff --git a/packages/intelligence/src/__tests__/intelligence.test.ts b/packages/intelligence/src/__tests__/intelligence.test.ts index 16522ac..849b8fa 100644 --- a/packages/intelligence/src/__tests__/intelligence.test.ts +++ b/packages/intelligence/src/__tests__/intelligence.test.ts @@ -1,11 +1,6 @@ import { ArchetypeSchema } from "@cv-builder/schemas"; import { describe, expect, it } from "vitest"; -import { - ARCHETYPES, - RUBRIC, - checkAtsCompatibility, - detectArchetype, -} from "../index.js"; +import { ARCHETYPES, checkAtsCompatibility, detectArchetype, RUBRIC } from "../index.js"; describe("archetypes", () => { it("ships at least 3, each valid against the schema", () => { @@ -32,7 +27,8 @@ describe("archetypes", () => { describe("detectArchetype", () => { it("detects a software engineer resume", () => { - const text = "Built React and Node services, scaled Postgres, deployed on Kubernetes."; + const text = + "Built React and Node services, scaled Postgres, deployed on Kubernetes."; expect(detectArchetype(text).id).toBe("software-engineer"); }); @@ -44,11 +40,19 @@ describe("detectArchetype", () => { it("falls back to the default on no signal", () => { expect(detectArchetype("hello world").id).toBe("software-engineer"); }); + + it("matches whole words only, not substrings", () => { + const text = + "Drove go-to-market with MongoDB-backed analytics, owned the roadmap and funnel."; + expect(detectArchetype(text).id).toBe("product-manager"); + }); }); describe("checkAtsCompatibility", () => { it("flags a table layout", () => { - expect(checkAtsCompatibility("| Skills | Years |\n| React | 5 |").compatible).toBe(false); + expect(checkAtsCompatibility("| Skills | Years |\n| React | 5 |").compatible).toBe( + false + ); }); it("passes clean single-column text", () => { diff --git a/packages/intelligence/src/archetypes/index.ts b/packages/intelligence/src/archetypes/index.ts index de35c50..5cfcd87 100644 --- a/packages/intelligence/src/archetypes/index.ts +++ b/packages/intelligence/src/archetypes/index.ts @@ -5,11 +5,7 @@ import { softwareEngineer } from "./software-engineer.js"; // Register new archetypes here. Software Engineer is the fallback when nothing // else clearly matches. -export const ARCHETYPES: Archetype[] = [ - softwareEngineer, - productManager, - dataMlEngineer, -]; +export const ARCHETYPES: Archetype[] = [softwareEngineer, productManager, dataMlEngineer]; export const DEFAULT_ARCHETYPE = softwareEngineer; @@ -21,4 +17,4 @@ export function listArchetypes(): Archetype[] { return ARCHETYPES; } -export { softwareEngineer, productManager, dataMlEngineer }; +export { dataMlEngineer, productManager, softwareEngineer }; diff --git a/packages/intelligence/src/detect.ts b/packages/intelligence/src/detect.ts index 1c19dfa..5e90407 100644 --- a/packages/intelligence/src/detect.ts +++ b/packages/intelligence/src/detect.ts @@ -1,15 +1,21 @@ import type { Archetype } from "@cv-builder/schemas"; import { ARCHETYPES, DEFAULT_ARCHETYPE } from "./archetypes/index.js"; +function hasKeyword(text: string, keyword: string): boolean { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // hyphen counts as a word char so "go" doesn't match "go-to-market" + return new RegExp(`(? (text.includes(kw.toLowerCase()) ? n + 1 : n), 0); + return keywords.reduce((n, kw) => (hasKeyword(text, kw.toLowerCase()) ? n + 1 : n), 0); } // Picks the archetype whose keywords appear most in the resume. Falls back to // the default when there's no signal, so detection never returns nothing. export function detectArchetype( resumeText: string, - archetypes: Archetype[] = ARCHETYPES, + archetypes: Archetype[] = ARCHETYPES ): Archetype { const text = resumeText.toLowerCase(); let best = DEFAULT_ARCHETYPE; diff --git a/packages/intelligence/src/index.ts b/packages/intelligence/src/index.ts index 528f328..fb085be 100644 --- a/packages/intelligence/src/index.ts +++ b/packages/intelligence/src/index.ts @@ -1,18 +1,21 @@ // Rubric v1, role archetypes, and the validator specs the prompts encode. -export { RUBRIC, RUBRIC_VERSION, RUBRIC_DIMENSIONS, type RubricDimension } from "./rubric.js"; - export { ARCHETYPES, DEFAULT_ARCHETYPE, + dataMlEngineer, getArchetype, listArchetypes, - softwareEngineer, productManager, - dataMlEngineer, + softwareEngineer, } from "./archetypes/index.js"; - export { detectArchetype } from "./detect.js"; +export { + RUBRIC, + RUBRIC_DIMENSIONS, + RUBRIC_VERSION, + type RubricDimension, +} from "./rubric.js"; -export { ATS_RULES, checkAtsCompatibility, type AtsCheck } from "./validators/ats.js"; +export { ATS_RULES, type AtsCheck, checkAtsCompatibility } from "./validators/ats.js"; export { CLAIM_RULES } from "./validators/claims.js"; diff --git a/packages/intelligence/src/rubric.ts b/packages/intelligence/src/rubric.ts index 3494ef4..8498065 100644 --- a/packages/intelligence/src/rubric.ts +++ b/packages/intelligence/src/rubric.ts @@ -49,8 +49,7 @@ export const RUBRIC_DIMENSIONS: RubricDimension[] = [ { key: "keywordMatch", name: "Keyword Match", - description: - "Overlap with the JD when provided, else the role-family keyword set.", + description: "Overlap with the JD when provided, else the role-family keyword set.", low: "Misses the terms the role screens for.", high: "Naturally covers the role's expected keywords.", }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7084c52..003467f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,9 +91,6 @@ importers: '@cv-builder/schemas': specifier: workspace:* version: link:../schemas - zod: - specifier: ^3.24.0 - version: 3.25.76 devDependencies: '@types/node': specifier: ^25.6.2 From f7201ca329a01878cedd86f2bdc4d49b5ff9e13e Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 15:25:47 +0200 Subject: [PATCH 09/13] docs(prompts): consistent optional notation in extract prompt, runnable readme example --- packages/prompts/README.md | 5 ++++- packages/prompts/prompts/extract.md | 6 +++--- packages/prompts/src/index.ts | 11 ++--------- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/prompts/README.md b/packages/prompts/README.md index 2e5b34c..2621768 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -14,7 +14,10 @@ self-contained and stays in sync with `@cv-builder/intelligence`. import { renderScorePrompt } from "@cv-builder/prompts"; import { softwareEngineer } from "@cv-builder/intelligence"; -const prompt = renderScorePrompt({ archetype: softwareEngineer, jdKeywords }); +const prompt = renderScorePrompt({ + archetype: softwareEngineer, + jdKeywords: ["kubernetes", "graphql"], +}); ``` Each prompt asks for JSON matching its schema in `@cv-builder/schemas`; callers diff --git a/packages/prompts/prompts/extract.md b/packages/prompts/prompts/extract.md index 1284997..149bc86 100644 --- a/packages/prompts/prompts/extract.md +++ b/packages/prompts/prompts/extract.md @@ -16,9 +16,9 @@ Return a single JSON object matching the `Resume` schema: ```json { - "name": "string | omit", - "headline": "string | omit", - "summary": "string | omit", + "name": "string?", + "headline": "string?", + "summary": "string?", "contact": { "email": "string?", "phone": "string?", "location": "string?" }, "links": [{ "type": "github|linkedin|portfolio|blog|website|other", "url": "string" }], "experience": [ diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 21a609c..e0d69c3 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -1,10 +1,6 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { - CLAIM_RULES, - RUBRIC, - RUBRIC_VERSION, -} from "@cv-builder/intelligence"; +import { CLAIM_RULES, RUBRIC, RUBRIC_VERSION } from "@cv-builder/intelligence"; import type { Archetype } from "@cv-builder/schemas"; export const PROMPT_NAMES = ["extract", "score", "validate-claims"] as const; @@ -30,10 +26,7 @@ export interface ScorePromptOptions { jdKeywords?: string[]; } -export function renderScorePrompt({ - archetype, - jdKeywords, -}: ScorePromptOptions): string { +export function renderScorePrompt({ archetype, jdKeywords }: ScorePromptOptions): string { const weights = Object.entries(archetype.evaluationWeights) .map(([key, value]) => `- ${key}: ${value}`) .join("\n"); From 8590a1737d0965148d83a6b119321fe1bf435b33 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 15:36:50 +0200 Subject: [PATCH 10/13] docs(cli): add language to bare code fences --- apps/cli/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index d89fa98..2ab0fc7 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -18,13 +18,13 @@ tests or the web app.) Then, inside Claude Code: -``` +```text /evaluate-cv ./my-resume.pdf ``` Optionally add a job description as keyword context: -``` +```text /evaluate-cv ./my-resume.pdf --jd ./job.md ``` From 9b4891f1b9794686938e96c3517e2d562f1121dd Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 15:43:36 +0200 Subject: [PATCH 11/13] fix(intelligence): flag embedded images in ats check, correct skill step count --- .claude/skills/cv-evaluation/SKILL.md | 2 +- packages/eval/src/__tests__/fixtures.test.ts | 2 +- packages/intelligence/src/__tests__/intelligence.test.ts | 5 +++++ packages/intelligence/src/validators/ats.ts | 3 +++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.claude/skills/cv-evaluation/SKILL.md b/.claude/skills/cv-evaluation/SKILL.md index 53fa570..8d8073d 100644 --- a/.claude/skills/cv-evaluation/SKILL.md +++ b/.claude/skills/cv-evaluation/SKILL.md @@ -5,7 +5,7 @@ description: Evaluate a resume against the CV Builder rubric and return a scored # CV Evaluation -Run a resume through the same three-step evaluation the hosted product uses, +Run a resume through the same four-step evaluation the hosted product uses, entirely locally. Everything you need lives in this repo — read the source, don't guess. diff --git a/packages/eval/src/__tests__/fixtures.test.ts b/packages/eval/src/__tests__/fixtures.test.ts index 32a1e17..fdd7cce 100644 --- a/packages/eval/src/__tests__/fixtures.test.ts +++ b/packages/eval/src/__tests__/fixtures.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { checkAtsCompatibility, diff --git a/packages/intelligence/src/__tests__/intelligence.test.ts b/packages/intelligence/src/__tests__/intelligence.test.ts index 849b8fa..f5bce41 100644 --- a/packages/intelligence/src/__tests__/intelligence.test.ts +++ b/packages/intelligence/src/__tests__/intelligence.test.ts @@ -59,4 +59,9 @@ describe("checkAtsCompatibility", () => { const text = "Experience\nBuilt things at Acme.\nContact: jane@example.com"; expect(checkAtsCompatibility(text).compatible).toBe(true); }); + + it("flags embedded images", () => { + const text = "Experience\njane@example.com\n![headshot](./me.png)"; + expect(checkAtsCompatibility(text).compatible).toBe(false); + }); }); diff --git a/packages/intelligence/src/validators/ats.ts b/packages/intelligence/src/validators/ats.ts index 86de78a..8be2889 100644 --- a/packages/intelligence/src/validators/ats.ts +++ b/packages/intelligence/src/validators/ats.ts @@ -32,6 +32,9 @@ export function checkAtsCompatibility(resumeText: string): AtsCheck { if (/\|.*\|/.test(resumeText) || /\t.*\t/.test(resumeText)) { findings.push("Looks like a table or columns — flatten to single-column text."); } + if (/!\[[^\]]*\]\([^)]+\)/.test(resumeText) || / Date: Fri, 12 Jun 2026 17:43:13 +0200 Subject: [PATCH 12/13] docs(skill): say where to run schema validation and add a no-install fallback --- .claude/skills/cv-evaluation/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.claude/skills/cv-evaluation/SKILL.md b/.claude/skills/cv-evaluation/SKILL.md index 53fa570..72c54d7 100644 --- a/.claude/skills/cv-evaluation/SKILL.md +++ b/.claude/skills/cv-evaluation/SKILL.md @@ -35,5 +35,10 @@ Produce one `EvalResult` (schema: `packages/schemas/src/evaluation.ts`). It must carry `rubricVersion` and `archetypeVersion`. Validate the object against `EvalResultSchema` before presenting it — never show an unvalidated result. +Run the validation from inside `packages/schemas` (zod only resolves there under +pnpm's layout). If dependencies aren't installed, don't error out: check the +object field by field against `evaluation.ts` instead, and tell the user that +running `pnpm install` enables strict validation. + Then summarize for the user: overall score, the per-dimension breakdown, the top issues with their fixes, and any unsupported claims. From 5b606c0723e11bdf6e93f0f4db606d7c60d34186 Mon Sep 17 00:00:00 2001 From: AmirBahador Bahadori Date: Fri, 12 Jun 2026 17:59:22 +0200 Subject: [PATCH 13/13] docs(skill): validate over stdin so resume data never hits the working tree --- .claude/skills/cv-evaluation/SKILL.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/skills/cv-evaluation/SKILL.md b/.claude/skills/cv-evaluation/SKILL.md index 72c54d7..26526cf 100644 --- a/.claude/skills/cv-evaluation/SKILL.md +++ b/.claude/skills/cv-evaluation/SKILL.md @@ -36,9 +36,11 @@ carry `rubricVersion` and `archetypeVersion`. Validate the object against `EvalResultSchema` before presenting it — never show an unvalidated result. Run the validation from inside `packages/schemas` (zod only resolves there under -pnpm's layout). If dependencies aren't installed, don't error out: check the -object field by field against `evaluation.ts` instead, and tell the user that -running `pnpm install` enables strict validation. +pnpm's layout). Pipe the JSON to node over stdin rather than writing it to a +file, so nothing derived from the resume touches the working tree. If +dependencies aren't installed, don't error out: check the object field by field +against `evaluation.ts` instead, and tell the user that running `pnpm install` +enables strict validation. Then summarize for the user: overall score, the per-dimension breakdown, the top issues with their fixes, and any unsupported claims.