Skip to Content
PlansStep 3.1 — Tag-Vocab Bootstrap Implementation Plan

Step 3.1 — Tag-Vocab Bootstrap Implementation Plan

Source: plans/step-3.1-vocab-bootstrap-impl.md · synced 2026-06-12

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Step coverage: Step 3, Step 3.1 — Tag-vocab bootstrap from CV↔JD pilot match. Design: step-3.1-vocab-bootstrap-design.md Repo: calibre-app/ (separate repo, branch feat/vocab-bootstrap cut from master) Outputs: calibre-app/config/tag-vocab.candidate.json (Stage 4) → calibre-app/config/tag-vocab.json + tag-vocab.audit.json (Stage 5).

Goal: Produce a locked, recruiter-ratified tag vocabulary keyed by (sub_function, sub_vertical) — the controlled vocab that unblocks Step 3.2 (JD normaliser), Step 5.2 (CV normaliser), and Step 6.1 (Layer 1 rubric loader).

Architecture: Five sequential stages. Stage 1 extracts categorical terms from every JD (new Claude calls) and every parsed CV (pure JSON reads — no Claude). Stage 2 buckets terms by (sub_function, sub_vertical). Stage 3 clusters and names tags per cell with one Claude tool-use call per cell (~20 cells). Stage 4 writes the candidate JSON. Stage 5 is a TTY ratification CLI that the Calibre team drives — approve / rename / merge / reject per tag, with full audit log.

Tech Stack: TypeScript 5 (strict) · Next.js 16 / Node 20 · @anthropic-ai/sdk 0.52 · pdf-parse / mammoth / word-extractor (reused from cv-parser/extractor) · Vitest 3.1 · tsx 4.19.

Cost & scale: ~41 JD calls + ~20 cell-clustering calls + ~1 sanity-check ≈ ~62 Claude calls total. Estimate USD 5–10 at Sonnet. Total wall-clock ≈ 1.5–2 days build + 1 ratification session with the Calibre team.


File Structure

calibre-app/ ├── src/lib/vocab-bootstrap/ │ ├── types.ts # JDTerms · CellTermBundle · CellVocabCandidate · LockedVocab · AuditEntry │ ├── jd-extractor.ts # JD file (PDF/DOCX/DOC) → raw text (reuses cv-parser/extractor pattern) │ ├── jd-term-extractor.ts # Claude tool-use: JD text → JDTerms │ ├── cv-term-extractor.ts # Pure JSON: parsed-cv JSON → CVTerms (no Claude) │ ├── bucketer.ts # Group all terms → cells keyed by (sub_function, sub_vertical) │ ├── clusterer.ts # Claude tool-use per cell: CellTermBundle → CellVocabCandidate │ ├── locked-vocab.ts # Apply ratification actions → LockedVocab + AuditEntry[] │ └── index.ts # Public API: bootstrapVocab() runs Stages 1–4 ├── scripts/ │ ├── run-vocab-bootstrap.ts # Stages 1–4 orchestrator → writes tag-vocab.candidate.json │ └── review-vocab-candidate.ts # Stage 5: TTY ratification CLI → writes tag-vocab.json + tag-vocab.audit.json ├── config/ │ ├── tag-vocab.candidate.json # Output of Stages 1–4 (regeneratable) │ ├── tag-vocab.json # LOCKED — read by Step 3.2/5.2/6.1 │ └── tag-vocab.audit.json # Ratification history per tag (who/when/why) └── tests/lib/vocab-bootstrap/ ├── cv-term-extractor.test.ts # Pure-function tests against fixture parsed-cv JSONs ├── bucketer.test.ts # (sub_function, sub_vertical) cell assignment ├── clusterer.test.ts # Schema validation; synonym-merge fixture ├── jd-term-extractor.test.ts # Schema validation against canned Claude response └── locked-vocab.test.ts # Apply each ratification action; audit entries written

The two Stage-5 CLI scripts have no automated tests — they’re interactive and small. Coverage is via Task 11’s end-to-end validation script that reads tag-vocab.json + tag-vocab.audit.json and asserts invariants.


Conventions (read once, follow throughout)

  • Strict TypeScript. No any. No as casts except for narrowing Anthropic SDK union types.
  • Test framework: Vitest. npm test runs vitest run. Each test file imports from vitest: import { describe, it, expect } from 'vitest'.
  • No new dependencies. Everything ships with what’s already in package.json.
  • Imports use the @/ alias that points at src/. E.g. import { extractText } from '@/lib/cv-parser/extractor'.
  • Claude tool-use schema follows the cv-parser shape. See src/lib/cv-parser/parser.ts for the canonical pattern (Anthropic.Tool literal · input_schema with explicit required arrays · response handled via content.find(b => b.type === 'tool_use')).
  • Sub-function + sub-vertical IDs: locked in SOURCE-OF-TRUTH.md §3a/§3b. Use the IDs from there verbatim (cargo-ops, ship-ops, risk-analytics, finance-fo, ops-back-office).
  • Sub-vertical inference from path is mechanical:
    • JD folders nested by sub-vertical (Operations- cargo, commodities brokerage/Agri/) → take the deepest folder name, slugify.
    • JD folders flat (Risk/, Finance- RM.../, Operations - banks.../) → parse the filename: "JD for Credit Risk.pdf" → credit.
    • Map: see pathToSubVertical() in Task 2.
  • Frequent commits. Each task ends with a commit. Don’t squash; the audit trail matters.
  • Branch: feat/vocab-bootstrap. Already cut from master in calibre-app/ (2026-06-03). All commits in this plan land here.
  • .env.local must exist at calibre-app/.env.local with ANTHROPIC_API_KEY=.... Scripts load it in the same pattern as scripts/batch-parse.ts lines 4–12.

Task 1 — Types module (single source of truth for the bootstrap pipeline)

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/types.ts

  • Test: none (pure type module)

  • Step 1: Create types module

Create calibre-app/src/lib/vocab-bootstrap/types.ts:

/** * Sub-function IDs. Locked in SOURCE-OF-TRUTH.md §3a. * Keep this list in sync with the canonical taxonomy. */ export type SubFunction = | 'cargo-ops' | 'ship-ops' | 'risk-analytics' | 'finance-fo' | 'ops-back-office' /** * Sub-vertical ID — free-form slug derived from JD folder/filename. * Examples: 'agri' · 'energy' · 'metals' · 'tanker' · 'credit' · 'rm' * Locked draft set lives in SOURCE-OF-TRUTH.md §3b. */ export type SubVertical = string /** * Stage 1 — raw term inventory pulled from one document. * For a CV this is built by cv-term-extractor.ts (pure JSON read from parsed-cv). * For a JD this is the Claude tool-use response from jd-term-extractor.ts. */ export interface DocTerms { source_file: string // relative path from calibre-app/, e.g. 'outputs/parsed-cvs/Adrian Tan (Risk, Oil).json' source_kind: 'cv' | 'jd' sub_function: SubFunction sub_verticals: SubVertical[] // CVs can belong to multiple; JDs always one categorical_terms: string[] // specialisations: 'LNG', 'Crude Oil', 'Veg Oil' tools_and_systems: string[] certifications: string[] geo_signals: string[] seniority_indicators: string[] technical_knowledge: string[] } /** * Stage 2 — terms grouped by (sub_function, sub_vertical) cell. * Each term carries provenance: which docs surfaced it, and a count. */ export interface CellTermBundle { sub_function: SubFunction sub_vertical: SubVertical terms: Array<{ term: string count: number source_docs: string[] // doc paths }> total_unique_terms_observed: number } /** * Stage 3 — clustered candidate vocab for one cell. * Output of one Claude tool-use call per cell. */ export interface CellVocabCandidate { sub_function: SubFunction sub_vertical: SubVertical tags: Array<{ slug: string // 'lng' · 'crude-oil' · 'mas-compliance' — hyphen-case, ≤24 chars display: string // 'LNG' · 'Crude Oil' · 'MAS Compliance' synonyms: string[] // raw terms that map to this tag source_docs: string[] // docs where any synonym appeared confidence: number // 0–1, Claude's cluster-coherence confidence }> total_unique_terms_observed: number rationale: string // 1–2 sentences: choices made coverage_low: boolean // true when total source_docs across cell ≤ 2 } /** * Stage 4 — full candidate file. Output of run-vocab-bootstrap.ts. */ export interface CandidateVocab { version: string // 'candidate-2026-06-03' format generated_at: string // ISO 8601 UTC cells: CellVocabCandidate[] } /** * Stage 5 — locked vocab. Output of review-vocab-candidate.ts. */ export interface LockedVocab { version: string // 'locked-2026-06-XX' locked_at: string // ISO 8601 UTC approved_by: string // 'calibre-team' (collective) cells: Array<{ sub_function: SubFunction sub_vertical: SubVertical tags: Array<{ slug: string display: string synonyms: string[] }> }> } /** * Stage 5 — audit entry per ratification action. */ export interface AuditEntry { timestamp: string // ISO 8601 UTC sub_function: SubFunction sub_vertical: SubVertical action: 'approve' | 'rename' | 'merge' | 'reject' | 'add' candidate_slug: string | null // null when action is 'add' resolved_slug: string // final slug after action reason: string // free-text from reviewer reviewer: string // 'calibre-team' or specific name typed at CLI } export interface AuditLog { version: string entries: AuditEntry[] }
  • Step 2: Verify it compiles

Run: npx tsc --noEmit Expected: PASS (no errors).

  • Step 3: Commit
git add src/lib/vocab-bootstrap/types.ts git commit -m "feat(vocab-bootstrap): add type module (Step 3.1 Task 1)"

Task 2 — CV term extractor (pure JSON; no Claude)

CVs are already parsed. Reuse outputs/parsed-cvs/<name>.json. This module turns each parsed CV into a DocTerms. Pure function. Fast tests.

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/cv-term-extractor.ts

  • Create: calibre-app/tests/lib/vocab-bootstrap/cv-term-extractor.test.ts

  • Create: calibre-app/tests/lib/vocab-bootstrap/fixtures/cv-adrian-tan.json (minimal parsed-cv fixture; see Step 1 below)

  • Step 1: Create the fixture

Create calibre-app/tests/lib/vocab-bootstrap/fixtures/cv-adrian-tan.json:

{ "identity": { "name": { "value": "Adrian Tan", "confidence": 0.99 }, "location": { "value": "Singapore", "confidence": 0.95 } }, "current_role": { "raw_title": { "value": "Senior Risk Analyst", "confidence": 0.95 }, "employer": { "value": "Acme Trading", "confidence": 0.95 } }, "employment_history": [ { "raw_title": { "value": "Risk Analyst", "confidence": 0.95 }, "employer": { "value": "Beta Corp", "confidence": 0.95 } } ], "signals": { "commodity_specialisations": { "value": ["LNG", "Crude Oil"], "confidence": 0.9 }, "vertical_signals": { "value": ["Energy", "Oil & Gas"], "confidence": 0.85 }, "tools_and_systems": { "value": ["Murex", "Excel VBA"], "confidence": 0.9 }, "certifications": { "value": ["FRM"], "confidence": 0.95 } } }
  • Step 2: Write the failing test

Create calibre-app/tests/lib/vocab-bootstrap/cv-term-extractor.test.ts:

import { describe, it, expect } from 'vitest' import path from 'path' import fs from 'fs' import { cvToDocTerms } from '@/lib/vocab-bootstrap/cv-term-extractor' const FIXTURE_DIR = path.resolve(__dirname, 'fixtures') describe('cvToDocTerms', () => { it('extracts categorical, tool, cert, geo, and seniority terms from a parsed CV', () => { const fixture = JSON.parse( fs.readFileSync(path.join(FIXTURE_DIR, 'cv-adrian-tan.json'), 'utf-8') ) const result = cvToDocTerms( fixture, 'outputs/parsed-cvs/Adrian Tan (Risk, Oil).json', 'risk-analytics', ['market'] ) expect(result.source_kind).toBe('cv') expect(result.sub_function).toBe('risk-analytics') expect(result.sub_verticals).toEqual(['market']) expect(result.categorical_terms).toEqual(expect.arrayContaining(['LNG', 'Crude Oil', 'Energy', 'Oil & Gas'])) expect(result.tools_and_systems).toEqual(expect.arrayContaining(['Murex', 'Excel VBA'])) expect(result.certifications).toEqual(['FRM']) expect(result.geo_signals).toEqual(['Singapore']) expect(result.seniority_indicators).toEqual(expect.arrayContaining(['Senior'])) }) it('skips fields whose confidence is below 0.5', () => { const lowConf = { identity: { location: { value: 'Singapore', confidence: 0.3 } }, signals: { commodity_specialisations: { value: ['Speculative Tag'], confidence: 0.2 }, vertical_signals: { value: [], confidence: 1.0 }, tools_and_systems: { value: [], confidence: 1.0 }, certifications: { value: [], confidence: 1.0 }, }, current_role: { raw_title: { value: 'IC Analyst', confidence: 0.95 } }, employment_history: [], } const result = cvToDocTerms(lowConf, 'test.json', 'risk-analytics', ['credit']) expect(result.categorical_terms).not.toContain('Speculative Tag') expect(result.geo_signals).not.toContain('Singapore') }) })
  • Step 3: Run the test to verify it fails

Run: npx vitest run tests/lib/vocab-bootstrap/cv-term-extractor.test.ts Expected: FAIL with Cannot find module '@/lib/vocab-bootstrap/cv-term-extractor'.

  • Step 4: Implement the module

Create calibre-app/src/lib/vocab-bootstrap/cv-term-extractor.ts:

import type { DocTerms, SubFunction, SubVertical } from './types' const CONFIDENCE_FLOOR = 0.5 type ConfField<T> = { value: T; confidence: number | null } | null | undefined type ParsedCV = { identity?: { location?: ConfField<string> } current_role?: { raw_title?: ConfField<string> } employment_history?: Array<{ raw_title?: ConfField<string> }> signals?: { commodity_specialisations?: ConfField<string[]> vertical_signals?: ConfField<string[]> tools_and_systems?: ConfField<string[]> certifications?: ConfField<string[]> } } const SENIORITY_PATTERNS = [ /\b(senior|lead|head of|director|vp|vice president|chief|c-suite|principal|managing)\b/i, ] function readArray(f: ConfField<string[]>): string[] { if (!f || f.value == null) return [] if (f.confidence != null && f.confidence < CONFIDENCE_FLOOR) return [] return f.value } function readString(f: ConfField<string>): string | null { if (!f || f.value == null) return null if (f.confidence != null && f.confidence < CONFIDENCE_FLOOR) return null return f.value } function senioritySignalsFromTitle(title: string): string[] { const out: string[] = [] for (const re of SENIORITY_PATTERNS) { const m = title.match(re) if (m) out.push(m[1][0].toUpperCase() + m[1].slice(1).toLowerCase()) } return out } /** * Pure function: parsed-CV JSON → DocTerms. * No Claude. No I/O. Reads only fields whose confidence ≥ CONFIDENCE_FLOOR. */ export function cvToDocTerms( parsed: ParsedCV, sourceFile: string, subFunction: SubFunction, subVerticals: SubVertical[] ): DocTerms { const sigs = parsed.signals ?? {} const categorical = [ ...readArray(sigs.commodity_specialisations), ...readArray(sigs.vertical_signals), ] const tools = readArray(sigs.tools_and_systems) const certs = readArray(sigs.certifications) const loc = readString(parsed.identity?.location) const geo = loc ? [loc] : [] const titles: string[] = [] const cur = readString(parsed.current_role?.raw_title) if (cur) titles.push(cur) for (const h of parsed.employment_history ?? []) { const t = readString(h.raw_title) if (t) titles.push(t) } const seniority = Array.from( new Set(titles.flatMap(senioritySignalsFromTitle)) ) return { source_file: sourceFile, source_kind: 'cv', sub_function: subFunction, sub_verticals: subVerticals, categorical_terms: categorical, tools_and_systems: tools, certifications: certs, geo_signals: geo, seniority_indicators: seniority, technical_knowledge: [], // CVs don't have a clean technical_knowledge field — left empty here, JDs fill this } }
  • Step 5: Run the test to verify it passes

Run: npx vitest run tests/lib/vocab-bootstrap/cv-term-extractor.test.ts Expected: PASS, 2/2 tests green.

  • Step 6: Commit
git add src/lib/vocab-bootstrap/cv-term-extractor.ts tests/lib/vocab-bootstrap/cv-term-extractor.test.ts tests/lib/vocab-bootstrap/fixtures/cv-adrian-tan.json git commit -m "feat(vocab-bootstrap): CV term extractor + tests (Step 3.1 Task 2)"

Task 3 — JD text extractor (PDF/DOCX/DOC → raw text + path parsing)

Reuse the cv-parser/extractor pattern. JDs sit in ../reference/JD/.... Two responsibilities: (a) extract the raw text, (b) derive (sub_function, sub_vertical) from the path.

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/jd-extractor.ts

  • Create: calibre-app/tests/lib/vocab-bootstrap/jd-extractor.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/vocab-bootstrap/jd-extractor.test.ts:

import { describe, it, expect } from 'vitest' import { pathToSubFunction, pathToSubVertical } from '@/lib/vocab-bootstrap/jd-extractor' describe('pathToSubFunction', () => { it('maps each top-level JD folder name to the right sub_function ID', () => { expect(pathToSubFunction('Operations- cargo, commodities brokerage/Agri/JD for Agri Operator.pdf')).toBe('cargo-ops') expect(pathToSubFunction('Operations- ship, ship brokerage/Tanker/Broker Tanker.pdf')).toBe('ship-ops') expect(pathToSubFunction('Risk/JD for Credit Risk.pdf')).toBe('risk-analytics') expect(pathToSubFunction('Finance- RM, wealth management, FA, institutional sales/JD for RM.pdf')).toBe('finance-fo') expect(pathToSubFunction('Operations - banks, finance brokerage, funds, asset management/JD for AML.pdf')).toBe('ops-back-office') }) it('throws on an unrecognised top folder', () => { expect(() => pathToSubFunction('Random Folder/JD for Something.pdf')).toThrow(/unknown sub_function folder/i) }) }) describe('pathToSubVertical', () => { it('returns the deepest folder name as the sub-vertical for nested JDs', () => { expect(pathToSubVertical('Operations- cargo, commodities brokerage/Agri/JD for Agri Operator.pdf')).toBe('agri') expect(pathToSubVertical('Operations- cargo, commodities brokerage/Energy/JD for LNG Operator.pdf')).toBe('energy') expect(pathToSubVertical('Operations- ship, ship brokerage/Tanker/Broker Tanker.pdf')).toBe('tanker') }) it('parses sub-vertical out of the filename for flat folders', () => { expect(pathToSubVertical('Risk/JD for Credit Risk.pdf')).toBe('credit') expect(pathToSubVertical('Risk/JD for Market Risk.pdf')).toBe('market') expect(pathToSubVertical('Risk/JD for Operational Risk.pdf')).toBe('operational') expect(pathToSubVertical('Risk/JD for Product Controller.pdf')).toBe('product-controller') expect(pathToSubVertical('Risk/JD for Trading Analytics Risk.pdf')).toBe('trading-analytics') expect(pathToSubVertical('Finance- RM, wealth management, FA, institutional sales/JD for RM.pdf')).toBe('rm') expect(pathToSubVertical('Finance- RM, wealth management, FA, institutional sales/JD for Wealth Management.pdf')).toBe('wealth-mgmt') expect(pathToSubVertical('Operations - banks, finance brokerage, funds, asset management/JD for AML.pdf')).toBe('aml') }) })
  • Step 2: Run test, watch it fail

Run: npx vitest run tests/lib/vocab-bootstrap/jd-extractor.test.ts Expected: FAIL with Cannot find module '@/lib/vocab-bootstrap/jd-extractor'.

  • Step 3: Implement the module

Create calibre-app/src/lib/vocab-bootstrap/jd-extractor.ts:

import fs from 'fs/promises' import path from 'path' import type { SubFunction, SubVertical } from './types' import { extractText } from '@/lib/cv-parser/extractor' const TOP_FOLDER_TO_SF: Record<string, SubFunction> = { 'Operations- cargo, commodities brokerage': 'cargo-ops', 'Operations- ship, ship brokerage': 'ship-ops', 'Risk': 'risk-analytics', 'Finance- RM, wealth management, FA, institutional sales': 'finance-fo', 'Operations - banks, finance brokerage, funds, asset management': 'ops-back-office', } const FLAT_FOLDER_FILENAME_TO_SV: Record<string, Record<string, SubVertical>> = { 'Risk': { 'JD for Credit Risk': 'credit', 'JD for Market Risk': 'market', 'JD for Operational Risk': 'operational', 'JD for Product Controller': 'product-controller', 'JD for Trading Analytics Risk': 'trading-analytics', }, 'Finance- RM, wealth management, FA, institutional sales': { 'JD for RM': 'rm', 'JD for Wealth Management': 'wealth-mgmt', 'JD for Institutional Sales': 'institutional-sales', 'JD for Portfolio Management': 'portfolio-mgmt', 'JD for Trading': 'trading', }, 'Operations - banks, finance brokerage, funds, asset management': { 'JD for AML': 'aml', 'JD for Compliance': 'compliance', 'JD for Assistant RM': 'assistant-rm', 'JD for Finance Ops': 'finance-ops', }, } function slugify(s: string): string { return s.toLowerCase().replace(/[^\w]+/g, '-').replace(/^-+|-+$/g, '') } export function pathToSubFunction(relPath: string): SubFunction { const parts = relPath.split(/[\\/]/) const sf = TOP_FOLDER_TO_SF[parts[0]] if (!sf) throw new Error(`unknown sub_function folder: '${parts[0]}'`) return sf } export function pathToSubVertical(relPath: string): SubVertical { const parts = relPath.split(/[\\/]/) const top = parts[0] const filename = path.basename(parts[parts.length - 1], path.extname(parts[parts.length - 1])) // Nested-folder case: deepest folder is the sub-vertical. if (parts.length > 2) { const deep = parts[parts.length - 2] return slugify(deep) } // Flat-folder case: look up filename in the per-folder map. const fileMap = FLAT_FOLDER_FILENAME_TO_SV[top] if (fileMap && fileMap[filename]) return fileMap[filename] // Fallback: slugify the filename's content after "JD for ". const m = filename.match(/^JD for\s+(.+?)(\s+Risk)?$/i) if (m) return slugify(m[1]) return slugify(filename) } export async function extractJDText(absPath: string): Promise<string> { const buf = await fs.readFile(absPath) const ext = path.extname(absPath).toLowerCase() const kind = ext === '.pdf' ? 'pdf' : ext === '.docx' ? 'docx' : ext === '.doc' ? 'doc' : null if (!kind) throw new Error(`unsupported JD extension: ${ext}`) return extractText(buf, kind) }
  • Step 4: Verify the cv-parser/extractor exposes extractText with the signature used above

Run: grep -n "export" src/lib/cv-parser/extractor.ts | head Expected: see export async function extractText(buf: Buffer, kind: 'pdf' | 'docx' | 'doc'): Promise<string>.

If the signature differs, adjust extractJDText above to match — extractText is the existing source of truth.

  • Step 5: Run the test, watch it pass

Run: npx vitest run tests/lib/vocab-bootstrap/jd-extractor.test.ts Expected: PASS, both describe blocks green.

  • Step 6: Commit
git add src/lib/vocab-bootstrap/jd-extractor.ts tests/lib/vocab-bootstrap/jd-extractor.test.ts git commit -m "feat(vocab-bootstrap): JD text extractor + path-to-(SF,SV) resolver (Step 3.1 Task 3)"

Task 4 — JD term extractor (Claude tool-use)

One Claude call per JD. Tool-use schema mirrors the cv-parser pattern.

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/jd-term-extractor.ts

  • Create: calibre-app/tests/lib/vocab-bootstrap/jd-term-extractor.test.ts

  • Step 1: Write the failing test (mock Anthropic client)

Create calibre-app/tests/lib/vocab-bootstrap/jd-term-extractor.test.ts:

import { describe, it, expect, vi } from 'vitest' import { jdToDocTerms } from '@/lib/vocab-bootstrap/jd-term-extractor' describe('jdToDocTerms', () => { it('returns DocTerms with source_kind=jd and the supplied (SF, SV)', async () => { const fakeClient = { messages: { create: vi.fn().mockResolvedValue({ content: [ { type: 'tool_use', name: 'extract_jd_terms', input: { categorical_terms: ['LNG', 'Crude Oil', 'Charter Party'], tools_and_systems: ['SAP', 'Bloomberg'], certifications: ['HSSE'], geo_signals: ['Singapore', 'Asia-Pacific'], seniority_indicators: ['Senior'], technical_knowledge: ['vessel chartering', 'demurrage'], }, }, ], }), }, } as unknown as import('@anthropic-ai/sdk').default const result = await jdToDocTerms( fakeClient, 'Sample JD text describing an LNG operator in Singapore.', 'reference/JD/Operations- cargo, commodities brokerage/Energy/JD for LNG Operator.pdf', 'cargo-ops', 'energy' ) expect(result.source_kind).toBe('jd') expect(result.sub_function).toBe('cargo-ops') expect(result.sub_verticals).toEqual(['energy']) expect(result.categorical_terms).toEqual(expect.arrayContaining(['LNG', 'Crude Oil'])) expect(result.geo_signals).toEqual(expect.arrayContaining(['Singapore'])) expect(fakeClient.messages.create).toHaveBeenCalledOnce() }) it('throws when Claude response has no tool_use block', async () => { const fakeClient = { messages: { create: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'no tool call' }] }), }, } as unknown as import('@anthropic-ai/sdk').default await expect( jdToDocTerms(fakeClient, 'x', 'p', 'cargo-ops', 'energy') ).rejects.toThrow(/no tool_use/i) }) })
  • Step 2: Run, watch fail

Run: npx vitest run tests/lib/vocab-bootstrap/jd-term-extractor.test.ts Expected: FAIL — module missing.

  • Step 3: Implement

Create calibre-app/src/lib/vocab-bootstrap/jd-term-extractor.ts:

import type Anthropic from '@anthropic-ai/sdk' import type { DocTerms, SubFunction, SubVertical } from './types' const JD_TERM_TOOL: Anthropic.Tool = { name: 'extract_jd_terms', description: 'Extract every categorical term this job description uses to describe what the role needs. Be exhaustive — over-extract; clustering will dedupe synonyms downstream.', input_schema: { type: 'object' as const, required: [ 'categorical_terms', 'tools_and_systems', 'certifications', 'geo_signals', 'seniority_indicators', 'technical_knowledge', ], properties: { categorical_terms: { type: 'array', items: { type: 'string' }, description: 'Domain-specific specialisations: commodities, products, vessel classes, asset types. E.g. "LNG", "Crude Oil", "Drybulk", "AML".', }, tools_and_systems: { type: 'array', items: { type: 'string' }, description: 'Named software, platforms, or systems. E.g. "SAP", "Bloomberg", "Murex".', }, certifications: { type: 'array', items: { type: 'string' }, description: 'Required or preferred certifications. E.g. "FRM", "CFA", "HSSE", "MAS RNF".', }, geo_signals: { type: 'array', items: { type: 'string' }, description: 'Geographic markers: countries, regions, cities. E.g. "Singapore", "Asia-Pacific", "China".', }, seniority_indicators: { type: 'array', items: { type: 'string' }, description: 'Words indicating seniority. E.g. "Senior", "Lead", "Head of", "Director".', }, technical_knowledge: { type: 'array', items: { type: 'string' }, description: 'Domain technical concepts the role must understand. E.g. "vessel chartering", "demurrage", "AML", "KYC", "P&L attribution".', }, }, }, } const SYSTEM_PROMPT = `You are a recruitment-intelligence extractor. Read the job description and return every categorical term it uses. Over-extract — downstream clustering will merge synonyms. Do not paraphrase; return the terms verbatim as they appear in the JD.` export async function jdToDocTerms( client: Anthropic, jdText: string, sourceFile: string, subFunction: SubFunction, subVertical: SubVertical, model = 'claude-sonnet-4-5' ): Promise<DocTerms> { const response = await client.messages.create({ model, max_tokens: 4096, tools: [JD_TERM_TOOL], tool_choice: { type: 'tool', name: 'extract_jd_terms' }, system: SYSTEM_PROMPT, messages: [{ role: 'user', content: `JD source: ${sourceFile}\n\n--- JD TEXT ---\n${jdText}` }], }) const block = response.content.find(b => b.type === 'tool_use') if (!block || block.type !== 'tool_use') { throw new Error(`jdToDocTerms: no tool_use block in response for ${sourceFile}`) } const input = block.input as { categorical_terms: string[] tools_and_systems: string[] certifications: string[] geo_signals: string[] seniority_indicators: string[] technical_knowledge: string[] } return { source_file: sourceFile, source_kind: 'jd', sub_function: subFunction, sub_verticals: [subVertical], categorical_terms: input.categorical_terms, tools_and_systems: input.tools_and_systems, certifications: input.certifications, geo_signals: input.geo_signals, seniority_indicators: input.seniority_indicators, technical_knowledge: input.technical_knowledge, } }
  • Step 4: Run, watch pass

Run: npx vitest run tests/lib/vocab-bootstrap/jd-term-extractor.test.ts Expected: PASS, 2/2.

  • Step 5: Commit
git add src/lib/vocab-bootstrap/jd-term-extractor.ts tests/lib/vocab-bootstrap/jd-term-extractor.test.ts git commit -m "feat(vocab-bootstrap): JD term extractor via Claude tool-use (Step 3.1 Task 4)"

Task 5 — Bucketer (group DocTerms[] → CellTermBundle[])

Every term lands in its (sub_function, sub_vertical) cell. CVs can land in multiple cells.

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/bucketer.ts

  • Create: calibre-app/tests/lib/vocab-bootstrap/bucketer.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/vocab-bootstrap/bucketer.test.ts:

import { describe, it, expect } from 'vitest' import { bucketDocs } from '@/lib/vocab-bootstrap/bucketer' import type { DocTerms } from '@/lib/vocab-bootstrap/types' const cvA: DocTerms = { source_file: 'cv-a.json', source_kind: 'cv', sub_function: 'cargo-ops', sub_verticals: ['energy', 'agri'], categorical_terms: ['LNG', 'Sugar'], tools_and_systems: ['SAP'], certifications: [], geo_signals: ['Singapore'], seniority_indicators: ['Senior'], technical_knowledge: [], } const jdA: DocTerms = { source_file: 'jd-a.pdf', source_kind: 'jd', sub_function: 'cargo-ops', sub_verticals: ['energy'], categorical_terms: ['LNG', 'Crude Oil'], tools_and_systems: ['SAP', 'Bloomberg'], certifications: [], geo_signals: ['Singapore'], seniority_indicators: [], technical_knowledge: ['demurrage'], } describe('bucketDocs', () => { it('places terms into one cell per (SF, SV) the doc belongs to', () => { const cells = bucketDocs([cvA, jdA]) expect(cells).toHaveLength(2) const energyCell = cells.find(c => c.sub_vertical === 'energy')! const agriCell = cells.find(c => c.sub_vertical === 'agri')! expect(energyCell.sub_function).toBe('cargo-ops') expect(energyCell.terms.some(t => t.term === 'LNG' && t.count === 2)).toBe(true) expect(energyCell.terms.some(t => t.term === 'Crude Oil' && t.count === 1)).toBe(true) expect(energyCell.terms.some(t => t.term === 'Sugar')).toBe(true) // cvA's sugar lands in energy too because cvA contributes to energy expect(energyCell.terms.some(t => t.term === 'demurrage' && t.count === 1)).toBe(true) expect(agriCell.terms.some(t => t.term === 'Sugar')).toBe(true) expect(agriCell.terms.some(t => t.term === 'LNG')).toBe(true) // cvA still contributes LNG to agri (CVs are multi-vertical) }) it('records provenance for each term', () => { const cells = bucketDocs([cvA, jdA]) const lng = cells.find(c => c.sub_vertical === 'energy')!.terms.find(t => t.term === 'LNG')! expect(lng.source_docs.sort()).toEqual(['cv-a.json', 'jd-a.pdf']) }) it('reports total_unique_terms_observed per cell', () => { const cells = bucketDocs([cvA, jdA]) const energyCell = cells.find(c => c.sub_vertical === 'energy')! expect(energyCell.total_unique_terms_observed).toBe(energyCell.terms.length) }) })
  • Step 2: Run, watch fail

Run: npx vitest run tests/lib/vocab-bootstrap/bucketer.test.ts Expected: FAIL — module missing.

  • Step 3: Implement

Create calibre-app/src/lib/vocab-bootstrap/bucketer.ts:

import type { CellTermBundle, DocTerms, SubFunction, SubVertical } from './types' function termSources(doc: DocTerms): string[] { return [ ...doc.categorical_terms, ...doc.tools_and_systems, ...doc.certifications, ...doc.geo_signals, ...doc.seniority_indicators, ...doc.technical_knowledge, ] } export function bucketDocs(docs: DocTerms[]): CellTermBundle[] { // Map<cellKey, Map<term, {count, sourceSet}>> type TermAgg = { count: number; sourceSet: Set<string> } const cells = new Map<string, Map<string, TermAgg>>() const cellMeta = new Map<string, { sf: SubFunction; sv: SubVertical }>() for (const doc of docs) { const terms = termSources(doc) for (const sv of doc.sub_verticals) { const key = `${doc.sub_function}::${sv}` if (!cells.has(key)) { cells.set(key, new Map()) cellMeta.set(key, { sf: doc.sub_function, sv }) } const agg = cells.get(key)! for (const t of terms) { if (!agg.has(t)) agg.set(t, { count: 0, sourceSet: new Set() }) const slot = agg.get(t)! slot.count++ slot.sourceSet.add(doc.source_file) } } } const out: CellTermBundle[] = [] for (const [key, agg] of cells) { const meta = cellMeta.get(key)! const terms = Array.from(agg.entries()).map(([term, { count, sourceSet }]) => ({ term, count, source_docs: Array.from(sourceSet).sort(), })) out.push({ sub_function: meta.sf, sub_vertical: meta.sv, terms: terms.sort((a, b) => b.count - a.count || a.term.localeCompare(b.term)), total_unique_terms_observed: terms.length, }) } return out.sort((a, b) => a.sub_function.localeCompare(b.sub_function) || a.sub_vertical.localeCompare(b.sub_vertical) ) }
  • Step 4: Run, watch pass

Run: npx vitest run tests/lib/vocab-bootstrap/bucketer.test.ts Expected: PASS, 3/3.

  • Step 5: Commit
git add src/lib/vocab-bootstrap/bucketer.ts tests/lib/vocab-bootstrap/bucketer.test.ts git commit -m "feat(vocab-bootstrap): bucketer groups terms by (SF, SV) cell (Step 3.1 Task 5)"

Task 6 — Clusterer (Claude tool-use per cell → CellVocabCandidate)

One Claude call per cell. ~20 cells.

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/clusterer.ts

  • Create: calibre-app/tests/lib/vocab-bootstrap/clusterer.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/vocab-bootstrap/clusterer.test.ts:

import { describe, it, expect, vi } from 'vitest' import { clusterCell } from '@/lib/vocab-bootstrap/clusterer' import type { CellTermBundle } from '@/lib/vocab-bootstrap/types' const bundle: CellTermBundle = { sub_function: 'cargo-ops', sub_vertical: 'energy', terms: [ { term: 'LNG', count: 5, source_docs: ['jd-a.pdf', 'cv-x.json'] }, { term: 'Liquefied Natural Gas', count: 2, source_docs: ['cv-y.json'] }, { term: 'Crude Oil', count: 4, source_docs: ['jd-b.pdf'] }, { term: 'team-player', count: 1, source_docs: ['cv-z.json'] }, ], total_unique_terms_observed: 4, } describe('clusterCell', () => { it('returns a CellVocabCandidate with the cell key + clustered tags', async () => { const fakeClient = { messages: { create: vi.fn().mockResolvedValue({ content: [ { type: 'tool_use', name: 'cluster_cell_vocab', input: { tags: [ { slug: 'lng', display: 'LNG', synonyms: ['LNG', 'Liquefied Natural Gas'], source_docs: ['jd-a.pdf', 'cv-x.json', 'cv-y.json'], confidence: 0.95, }, { slug: 'crude-oil', display: 'Crude Oil', synonyms: ['Crude Oil'], source_docs: ['jd-b.pdf'], confidence: 0.92, }, ], rationale: 'Merged LNG variants; dropped generic noise.', }, }, ], }), }, } as unknown as import('@anthropic-ai/sdk').default const result = await clusterCell(fakeClient, bundle) expect(result.sub_function).toBe('cargo-ops') expect(result.sub_vertical).toBe('energy') expect(result.tags).toHaveLength(2) expect(result.tags[0].slug).toBe('lng') expect(result.tags[0].synonyms).toContain('Liquefied Natural Gas') expect(result.total_unique_terms_observed).toBe(4) expect(result.coverage_low).toBe(false) expect(result.rationale.length).toBeGreaterThan(0) }) it('flags coverage_low=true when the cell has ≤ 2 distinct source docs', async () => { const sparseBundle: CellTermBundle = { sub_function: 'cargo-ops', sub_vertical: 'metals', terms: [ { term: 'precious metals', count: 1, source_docs: ['jd-c.pdf'] }, { term: 'gold', count: 1, source_docs: ['jd-c.pdf'] }, ], total_unique_terms_observed: 2, } const fakeClient = { messages: { create: vi.fn().mockResolvedValue({ content: [{ type: 'tool_use', name: 'cluster_cell_vocab', input: { tags: [], rationale: 'sparse' }, }], }), }, } as unknown as import('@anthropic-ai/sdk').default const result = await clusterCell(fakeClient, sparseBundle) expect(result.coverage_low).toBe(true) }) })
  • Step 2: Run, watch fail

Run: npx vitest run tests/lib/vocab-bootstrap/clusterer.test.ts Expected: FAIL — module missing.

  • Step 3: Implement

Create calibre-app/src/lib/vocab-bootstrap/clusterer.ts:

import type Anthropic from '@anthropic-ai/sdk' import type { CellTermBundle, CellVocabCandidate } from './types' const CLUSTER_TOOL: Anthropic.Tool = { name: 'cluster_cell_vocab', description: 'Cluster raw recruitment terms into a controlled tag vocabulary for one (sub_function, sub_vertical) cell. Merge synonyms, drop generic noise, propose hyphen-case slugs ≤ 24 chars. Aim for 5–15 tags.', input_schema: { type: 'object' as const, required: ['tags', 'rationale'], properties: { tags: { type: 'array', items: { type: 'object', required: ['slug', 'display', 'synonyms', 'source_docs', 'confidence'], properties: { slug: { type: 'string', description: 'hyphen-case, ≤ 24 chars, no spaces, no colons' }, display: { type: 'string', description: 'human-readable label, e.g. "LNG", "Crude Oil"' }, synonyms: { type: 'array', items: { type: 'string' }, description: 'raw terms that map to this tag — include the canonical term itself', }, source_docs: { type: 'array', items: { type: 'string' }, description: 'union of source_docs of all synonyms', }, confidence: { type: 'number', description: '0–1 — how coherent is this cluster?', }, }, }, }, rationale: { type: 'string', description: '1–2 sentences: what was merged, what was dropped, why.', }, }, }, } const SYSTEM_PROMPT = `You are a recruitment-taxonomy curator. Given raw categorical terms observed across recruitment documents for one (sub_function, sub_vertical) cell, produce a controlled tag vocabulary. Rules: - Merge synonyms aggressively. "LNG", "Liquefied Natural Gas", "LNG carriers" → one tag with slug "lng". - Drop generic stop-words and single-occurrence noise ("operations", "team-player", "fast-paced"). - Slugs are hyphen-case, ≤ 24 chars, no nested colons. - Aim for 5–15 tags total. Fewer is fine if data is sparse. - Every synonym you list must appear in the input terms. - Every source_docs entry must originate from a doc that contributed at least one of the tag's synonyms.` export async function clusterCell( client: Anthropic, bundle: CellTermBundle, model = 'claude-sonnet-4-5' ): Promise<CellVocabCandidate> { const termSummary = bundle.terms .map(t => `- ${t.term} (×${t.count}, sources: ${t.source_docs.length})`) .join('\n') const response = await client.messages.create({ model, max_tokens: 4096, tools: [CLUSTER_TOOL], tool_choice: { type: 'tool', name: 'cluster_cell_vocab' }, system: SYSTEM_PROMPT, messages: [{ role: 'user', content: `Cell: ${bundle.sub_function} / ${bundle.sub_vertical}\n` + `Observed terms (${bundle.terms.length}):\n${termSummary}`, }], }) const block = response.content.find(b => b.type === 'tool_use') if (!block || block.type !== 'tool_use') { throw new Error(`clusterCell: no tool_use in response for ${bundle.sub_function}/${bundle.sub_vertical}`) } const input = block.input as { tags: CellVocabCandidate['tags'] rationale: string } const distinctSourceDocs = new Set<string>() for (const t of bundle.terms) for (const s of t.source_docs) distinctSourceDocs.add(s) return { sub_function: bundle.sub_function, sub_vertical: bundle.sub_vertical, tags: input.tags, total_unique_terms_observed: bundle.total_unique_terms_observed, rationale: input.rationale, coverage_low: distinctSourceDocs.size <= 2, } }
  • Step 4: Run, watch pass

Run: npx vitest run tests/lib/vocab-bootstrap/clusterer.test.ts Expected: PASS, 2/2.

  • Step 5: Commit
git add src/lib/vocab-bootstrap/clusterer.ts tests/lib/vocab-bootstrap/clusterer.test.ts git commit -m "feat(vocab-bootstrap): per-cell clusterer via Claude tool-use (Step 3.1 Task 6)"

Task 7 — Public API + Stages 1–4 orchestrator script

Wire the modules and run them across all 41 JDs + all parsed CVs. Writes the candidate JSON.

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/index.ts

  • Create: calibre-app/scripts/run-vocab-bootstrap.ts

  • Step 1: Public API

Create calibre-app/src/lib/vocab-bootstrap/index.ts:

export * from './types' export { cvToDocTerms } from './cv-term-extractor' export { pathToSubFunction, pathToSubVertical, extractJDText } from './jd-extractor' export { jdToDocTerms } from './jd-term-extractor' export { bucketDocs } from './bucketer' export { clusterCell } from './clusterer'
  • Step 2: Orchestrator script

Create calibre-app/scripts/run-vocab-bootstrap.ts:

import fs from 'fs' import path from 'path' // Load .env.local BEFORE importing modules that instantiate the Anthropic client. const envPath = path.resolve(__dirname, '../.env.local') if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) { const m = line.match(/^([^#=\s][^=]*)=(.*)$/) if (m) process.env[m[1].trim()] = m[2].trim() } } import Anthropic from '@anthropic-ai/sdk' // eslint-disable-next-line @typescript-eslint/no-require-imports const vb = require('../src/lib/vocab-bootstrap') as typeof import('../src/lib/vocab-bootstrap') type DocTerms = import('../src/lib/vocab-bootstrap').DocTerms type CandidateVocab = import('../src/lib/vocab-bootstrap').CandidateVocab const REFERENCE_DIR = path.resolve(__dirname, '../../reference') const PARSED_CVS_DIR = path.resolve(__dirname, '../outputs/parsed-cvs') const JD_DIR = path.join(REFERENCE_DIR, 'JD') const CONFIG_DIR = path.resolve(__dirname, '../config') const CANDIDATE_OUT = path.join(CONFIG_DIR, 'tag-vocab.candidate.json') const CV_FOLDER_TO_SF: Record<string, import('../src/lib/vocab-bootstrap').SubFunction> = { 'Operations - cargo, commodities brokerage': 'cargo-ops', 'Operations - ship, shipping brokerage': 'ship-ops', 'Risk - Analytics': 'risk-analytics', 'Finance - RM, wealth management, FA, wealth planning advisor, channel sales, institutional sales, fund sales,': 'finance-fo', 'Operations - bank, finance brokerage, funds, asset management': 'ops-back-office', } async function walkJDs(): Promise<string[]> { const out: string[] = [] function recurse(dir: string) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name) if (e.isDirectory()) recurse(p) else if (/\.(pdf|docx|doc)$/i.test(e.name)) out.push(p) } } recurse(JD_DIR) return out } async function extractAllJDs(client: Anthropic): Promise<DocTerms[]> { const files = await walkJDs() console.log(`📄 ${files.length} JDs to extract`) const results: DocTerms[] = [] for (const abs of files) { const rel = path.relative(JD_DIR, abs).replace(/\\/g, '/') try { const sf = vb.pathToSubFunction(rel) const sv = vb.pathToSubVertical(rel) const text = await vb.extractJDText(abs) const doc = await vb.jdToDocTerms(client, text, `reference/JD/${rel}`, sf, sv) results.push(doc) console.log(` ✓ ${rel}`) } catch (err) { console.error(` ✗ ${rel} — ${(err as Error).message}`) } } return results } function extractAllCVs(): DocTerms[] { const out: DocTerms[] = [] for (const folder of Object.keys(CV_FOLDER_TO_SF)) { // Parsed CV filenames live flat in PARSED_CVS_DIR, but their source folder identifies the SF. // Use the parsed-cv's filename pattern (which the batch-parse script writes) to look up the SF. // Strategy: cross-reference the reference/<folder>/<file>.{pdf,docx,doc} list — each parsed-cv json // has the same basename. const refFolder = path.join(REFERENCE_DIR, folder) if (!fs.existsSync(refFolder)) continue const sf = CV_FOLDER_TO_SF[folder] for (const f of fs.readdirSync(refFolder)) { if (!/\.(pdf|docx|doc)$/i.test(f)) continue const jsonPath = path.join(PARSED_CVS_DIR, f.replace(/\.[^.]+$/, '.json')) if (!fs.existsSync(jsonPath)) continue const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) // CVs span multiple sub-verticals — start with [] and let downstream code keep // all candidate cells. For Step 3.1 we tag CVs to ALL sub-verticals of their SF // by default; the clusterer will surface what's actually relevant. const allSVs = inferAllSubVerticals(sf) out.push(vb.cvToDocTerms(parsed, `outputs/parsed-cvs/${path.basename(jsonPath)}`, sf, allSVs)) } } console.log(`📄 ${out.length} parsed CVs extracted`) return out } function inferAllSubVerticals(sf: import('../src/lib/vocab-bootstrap').SubFunction): import('../src/lib/vocab-bootstrap').SubVertical[] { // Locked sub-vertical map per SOT §3b. const M: Record<string, string[]> = { 'cargo-ops': ['agri', 'energy', 'metals'], 'ship-ops': ['tanker', 'drybulk', 'container', 'offshore-support', 'broker'], 'risk-analytics': ['credit', 'market', 'operational', 'product-controller', 'trading-analytics'], 'finance-fo': ['rm', 'wealth-mgmt', 'institutional-sales', 'portfolio-mgmt', 'trading'], 'ops-back-office': ['aml', 'compliance', 'assistant-rm', 'finance-ops'], } return M[sf] } async function main() { fs.mkdirSync(CONFIG_DIR, { recursive: true }) const client = new Anthropic() console.log('Stage 1a — CV term extraction (pure JSON)') const cvDocs = extractAllCVs() console.log('Stage 1b — JD term extraction (Claude tool-use)') const jdDocs = await extractAllJDs(client) const allDocs = [...cvDocs, ...jdDocs] console.log(`Total DocTerms: ${allDocs.length}`) console.log('Stage 2 — Bucketing by (SF, SV)') const cells = vb.bucketDocs(allDocs) console.log(`Cells: ${cells.length}`) console.log('Stage 3 — Clustering per cell') const candidateCells = [] for (const bundle of cells) { process.stdout.write(` ${bundle.sub_function}/${bundle.sub_vertical} … `) try { const cand = await vb.clusterCell(client, bundle) candidateCells.push(cand) console.log(`${cand.tags.length} tags${cand.coverage_low ? ' (coverage_low)' : ''}`) } catch (err) { console.log(`✗ ${(err as Error).message}`) } } console.log('Stage 4 — Writing candidate JSON') const candidate: CandidateVocab = { version: `candidate-${new Date().toISOString().slice(0, 10)}`, generated_at: new Date().toISOString(), cells: candidateCells, } fs.writeFileSync(CANDIDATE_OUT, JSON.stringify(candidate, null, 2)) console.log(`\n✓ Wrote ${path.relative(process.cwd(), CANDIDATE_OUT)}`) console.log(` ${candidateCells.length} cells, ${candidateCells.reduce((n, c) => n + c.tags.length, 0)} tags total`) } main().catch(err => { console.error(err); process.exit(1) })
  • Step 3: Add the npm script

Modify calibre-app/package.json — add to the "scripts" block:

"vocab-bootstrap": "tsx scripts/run-vocab-bootstrap.ts", "vocab-review": "tsx scripts/review-vocab-candidate.ts"

(The vocab-review script is added in Task 9; declaring it now keeps the package.json edit atomic.)

  • Step 4: Type-check the script (cannot run it yet — Task 9 not done)

Run: npx tsc --noEmit Expected: PASS.

  • Step 5: Commit
git add src/lib/vocab-bootstrap/index.ts scripts/run-vocab-bootstrap.ts package.json git commit -m "feat(vocab-bootstrap): orchestrator script for Stages 1-4 (Step 3.1 Task 7)"

Task 8 — Locked-vocab applier (apply ratification actions)

Pure-function module that takes a candidate + a list of actions and emits LockedVocab + AuditLog. Driven by Task 9’s CLI.

Files:

  • Create: calibre-app/src/lib/vocab-bootstrap/locked-vocab.ts

  • Create: calibre-app/tests/lib/vocab-bootstrap/locked-vocab.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/vocab-bootstrap/locked-vocab.test.ts:

import { describe, it, expect } from 'vitest' import { applyActions } from '@/lib/vocab-bootstrap/locked-vocab' import type { CandidateVocab, AuditEntry } from '@/lib/vocab-bootstrap/types' const cand: CandidateVocab = { version: 'candidate-2026-06-03', generated_at: '2026-06-03T00:00:00Z', cells: [{ sub_function: 'cargo-ops', sub_vertical: 'energy', total_unique_terms_observed: 4, rationale: 'r', coverage_low: false, tags: [ { slug: 'lng', display: 'LNG', synonyms: ['LNG', 'Liquefied Natural Gas'], source_docs: ['a.pdf'], confidence: 0.9 }, { slug: 'crude-oil', display: 'Crude Oil', synonyms: ['Crude Oil'], source_docs: ['b.pdf'], confidence: 0.85 }, { slug: 'noise-tag', display: 'Noise', synonyms: ['Noise'], source_docs: ['c.pdf'], confidence: 0.3 }, ], }], } describe('applyActions', () => { it('approves a tag verbatim', () => { const actions: AuditEntry[] = [{ timestamp: '2026-06-03T01:00:00Z', sub_function: 'cargo-ops', sub_vertical: 'energy', action: 'approve', candidate_slug: 'lng', resolved_slug: 'lng', reason: 'matches recruiter mental model', reviewer: 'calibre-team', }] const { locked, audit } = applyActions(cand, actions, 'calibre-team') const cell = locked.cells.find(c => c.sub_vertical === 'energy')! expect(cell.tags.find(t => t.slug === 'lng')).toBeDefined() expect(audit.entries).toEqual(actions) }) it('renames a tag', () => { const actions: AuditEntry[] = [{ timestamp: 't', sub_function: 'cargo-ops', sub_vertical: 'energy', action: 'rename', candidate_slug: 'crude-oil', resolved_slug: 'crude', reason: 'shorter', reviewer: 'calibre-team', }] const { locked } = applyActions(cand, actions, 'calibre-team') const cell = locked.cells.find(c => c.sub_vertical === 'energy')! expect(cell.tags.find(t => t.slug === 'crude')).toBeDefined() expect(cell.tags.find(t => t.slug === 'crude-oil')).toBeUndefined() }) it('merges tag B into tag A — A keeps the union of synonyms', () => { const actions: AuditEntry[] = [ { timestamp: 't', sub_function: 'cargo-ops', sub_vertical: 'energy', action: 'approve', candidate_slug: 'lng', resolved_slug: 'lng', reason: 'r', reviewer: 'r' }, { timestamp: 't', sub_function: 'cargo-ops', sub_vertical: 'energy', action: 'merge', candidate_slug: 'crude-oil', resolved_slug: 'lng', reason: 'consolidate', reviewer: 'r' }, ] const { locked } = applyActions(cand, actions, 'r') const cell = locked.cells.find(c => c.sub_vertical === 'energy')! const lng = cell.tags.find(t => t.slug === 'lng')! expect(lng.synonyms).toContain('LNG') expect(lng.synonyms).toContain('Crude Oil') expect(cell.tags.find(t => t.slug === 'crude-oil')).toBeUndefined() }) it('rejects a tag — drops it from locked, keeps audit entry', () => { const actions: AuditEntry[] = [{ timestamp: 't', sub_function: 'cargo-ops', sub_vertical: 'energy', action: 'reject', candidate_slug: 'noise-tag', resolved_slug: '', reason: 'not used in practice', reviewer: 'r', }] const { locked, audit } = applyActions(cand, actions, 'r') const cell = locked.cells.find(c => c.sub_vertical === 'energy')! expect(cell.tags.find(t => t.slug === 'noise-tag')).toBeUndefined() expect(audit.entries[0].action).toBe('reject') }) it('adds a manual tag not in the candidate', () => { const actions: AuditEntry[] = [ { timestamp: 't', sub_function: 'cargo-ops', sub_vertical: 'energy', action: 'approve', candidate_slug: 'lng', resolved_slug: 'lng', reason: 'r', reviewer: 'r' }, { timestamp: 't', sub_function: 'cargo-ops', sub_vertical: 'energy', action: 'add', candidate_slug: null, resolved_slug: 'biofuels', reason: 'missing — recruiter sees this often', reviewer: 'r' }, ] const { locked } = applyActions(cand, actions, 'r') const cell = locked.cells.find(c => c.sub_vertical === 'energy')! expect(cell.tags.find(t => t.slug === 'biofuels')).toBeDefined() }) })
  • Step 2: Run, watch fail

Run: npx vitest run tests/lib/vocab-bootstrap/locked-vocab.test.ts Expected: FAIL — module missing.

  • Step 3: Implement

Create calibre-app/src/lib/vocab-bootstrap/locked-vocab.ts:

import type { AuditEntry, AuditLog, CandidateVocab, LockedVocab, SubFunction, SubVertical } from './types' type LockedCell = LockedVocab['cells'][number] type LockedTag = LockedCell['tags'][number] function cellKey(sf: SubFunction, sv: SubVertical) { return `${sf}::${sv}` } export function applyActions( candidate: CandidateVocab, actions: AuditEntry[], reviewer: string ): { locked: LockedVocab; audit: AuditLog } { // Build a working set of (cellKey → Map<slug, LockedTag>) const work = new Map<string, Map<string, LockedTag>>() const meta = new Map<string, { sf: SubFunction; sv: SubVertical }>() for (const cell of candidate.cells) { const k = cellKey(cell.sub_function, cell.sub_vertical) work.set(k, new Map()) meta.set(k, { sf: cell.sub_function, sv: cell.sub_vertical }) } // Index candidate tags by (cellKey, slug) for lookup during action application const candIndex = new Map<string, Map<string, CandidateVocab['cells'][number]['tags'][number]>>() for (const cell of candidate.cells) { const k = cellKey(cell.sub_function, cell.sub_vertical) const m = new Map<string, CandidateVocab['cells'][number]['tags'][number]>() for (const t of cell.tags) m.set(t.slug, t) candIndex.set(k, m) } for (const a of actions) { const k = cellKey(a.sub_function, a.sub_vertical) if (!work.has(k)) { work.set(k, new Map()) meta.set(k, { sf: a.sub_function, sv: a.sub_vertical }) } const cellWork = work.get(k)! const candTagMap = candIndex.get(k) ?? new Map() switch (a.action) { case 'approve': { const src = a.candidate_slug ? candTagMap.get(a.candidate_slug) : null if (!src) throw new Error(`approve: candidate slug '${a.candidate_slug}' not found in ${k}`) cellWork.set(a.resolved_slug, { slug: a.resolved_slug, display: src.display, synonyms: [...src.synonyms] }) break } case 'rename': { const src = a.candidate_slug ? candTagMap.get(a.candidate_slug) : null if (!src) throw new Error(`rename: candidate slug '${a.candidate_slug}' not found in ${k}`) cellWork.set(a.resolved_slug, { slug: a.resolved_slug, display: src.display, synonyms: [...src.synonyms] }) // (Old slug never makes it into cellWork) break } case 'merge': { const src = a.candidate_slug ? candTagMap.get(a.candidate_slug) : null if (!src) throw new Error(`merge: candidate slug '${a.candidate_slug}' not found in ${k}`) const tgt = cellWork.get(a.resolved_slug) if (!tgt) throw new Error(`merge: target slug '${a.resolved_slug}' must be approved/added first in ${k}`) tgt.synonyms = Array.from(new Set([...tgt.synonyms, ...src.synonyms])) break } case 'reject': { // Nothing to add to cellWork; audit entry records the rejection. break } case 'add': { if (!a.resolved_slug) throw new Error(`add: resolved_slug required`) cellWork.set(a.resolved_slug, { slug: a.resolved_slug, display: a.resolved_slug.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' '), synonyms: [a.resolved_slug], }) break } } } const cells: LockedCell[] = [] for (const [k, tagMap] of work) { const { sf, sv } = meta.get(k)! cells.push({ sub_function: sf, sub_vertical: sv, tags: Array.from(tagMap.values()).sort((a, b) => a.slug.localeCompare(b.slug)), }) } cells.sort((a, b) => a.sub_function.localeCompare(b.sub_function) || a.sub_vertical.localeCompare(b.sub_vertical) ) return { locked: { version: `locked-${new Date().toISOString().slice(0, 10)}`, locked_at: new Date().toISOString(), approved_by: reviewer, cells, }, audit: { version: `audit-${new Date().toISOString().slice(0, 10)}`, entries: actions, }, } }
  • Step 4: Run, watch pass

Run: npx vitest run tests/lib/vocab-bootstrap/locked-vocab.test.ts Expected: PASS, 5/5.

  • Step 5: Commit
git add src/lib/vocab-bootstrap/locked-vocab.ts tests/lib/vocab-bootstrap/locked-vocab.test.ts git commit -m "feat(vocab-bootstrap): ratification action applier (Step 3.1 Task 8)"

Task 9 — Ratification CLI (review-vocab-candidate.ts)

Interactive TTY. Loads tag-vocab.candidate.json, walks every tag of every cell, prompts the reviewer. Saves tag-vocab.json + tag-vocab.audit.json at the end.

Files:

  • Create: calibre-app/scripts/review-vocab-candidate.ts

(No automated test — interactive. Task 11 covers end-to-end validation of the output files.)

  • Step 1: Implement the CLI

Create calibre-app/scripts/review-vocab-candidate.ts:

import fs from 'fs' import path from 'path' import readline from 'readline' // eslint-disable-next-line @typescript-eslint/no-require-imports const vb = require('../src/lib/vocab-bootstrap') as typeof import('../src/lib/vocab-bootstrap') type AuditEntry = import('../src/lib/vocab-bootstrap').AuditEntry type CandidateVocab = import('../src/lib/vocab-bootstrap').CandidateVocab const CONFIG_DIR = path.resolve(__dirname, '../config') const CANDIDATE_IN = path.join(CONFIG_DIR, 'tag-vocab.candidate.json') const LOCKED_OUT = path.join(CONFIG_DIR, 'tag-vocab.json') const AUDIT_OUT = path.join(CONFIG_DIR, 'tag-vocab.audit.json') function rlPrompt(rl: readline.Interface, q: string): Promise<string> { return new Promise(resolve => rl.question(q, ans => resolve(ans.trim()))) } async function main() { if (!fs.existsSync(CANDIDATE_IN)) { console.error(`Missing ${CANDIDATE_IN}. Run 'npm run vocab-bootstrap' first.`) process.exit(1) } const candidate: CandidateVocab = JSON.parse(fs.readFileSync(CANDIDATE_IN, 'utf-8')) const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) const reviewer = (await rlPrompt(rl, 'Reviewer (default: calibre-team): ')) || 'calibre-team' const actions: AuditEntry[] = [] for (const cell of candidate.cells) { console.log(`\n━━━ ${cell.sub_function} / ${cell.sub_vertical} ━━━`) console.log(` ${cell.tags.length} candidate tags · coverage_low=${cell.coverage_low}`) if (cell.rationale) console.log(` rationale: ${cell.rationale}`) for (const tag of cell.tags) { console.log(`\n • ${tag.slug} (${tag.display}) — conf ${tag.confidence.toFixed(2)}`) console.log(` synonyms: ${tag.synonyms.join(', ')}`) console.log(` docs: ${tag.source_docs.length}`) const ans = await rlPrompt(rl, ` [a]pprove / [r]ename / [m]erge / [x]reject / [s]kip → `) const now = new Date().toISOString() switch (ans.toLowerCase()) { case 'a': case '': { actions.push({ timestamp: now, sub_function: cell.sub_function, sub_vertical: cell.sub_vertical, action: 'approve', candidate_slug: tag.slug, resolved_slug: tag.slug, reason: 'approved as candidate', reviewer, }) break } case 'r': { const newSlug = await rlPrompt(rl, ' new slug: ') const reason = await rlPrompt(rl, ' reason: ') actions.push({ timestamp: now, sub_function: cell.sub_function, sub_vertical: cell.sub_vertical, action: 'rename', candidate_slug: tag.slug, resolved_slug: newSlug, reason, reviewer, }) break } case 'm': { const into = await rlPrompt(rl, ' merge into slug (must be approved/added already): ') const reason = await rlPrompt(rl, ' reason: ') actions.push({ timestamp: now, sub_function: cell.sub_function, sub_vertical: cell.sub_vertical, action: 'merge', candidate_slug: tag.slug, resolved_slug: into, reason, reviewer, }) break } case 'x': { const reason = await rlPrompt(rl, ' reason: ') actions.push({ timestamp: now, sub_function: cell.sub_function, sub_vertical: cell.sub_vertical, action: 'reject', candidate_slug: tag.slug, resolved_slug: '', reason, reviewer, }) break } case 's': break default: { console.log(' (unrecognised; treated as skip)') } } } // Per-cell hook to add missing tags manually while (true) { const ans = await rlPrompt(rl, `\n add missing tag for this cell? slug or [enter to skip]: `) if (!ans) break const reason = await rlPrompt(rl, ' reason: ') actions.push({ timestamp: new Date().toISOString(), sub_function: cell.sub_function, sub_vertical: cell.sub_vertical, action: 'add', candidate_slug: null, resolved_slug: ans, reason, reviewer, }) } } rl.close() const { locked, audit } = vb.applyActions(candidate, actions, reviewer) fs.writeFileSync(LOCKED_OUT, JSON.stringify(locked, null, 2)) fs.writeFileSync(AUDIT_OUT, JSON.stringify(audit, null, 2)) console.log(`\n✓ Wrote ${path.relative(process.cwd(), LOCKED_OUT)}`) console.log(`✓ Wrote ${path.relative(process.cwd(), AUDIT_OUT)}`) console.log(` ${locked.cells.length} cells, ${locked.cells.reduce((n, c) => n + c.tags.length, 0)} locked tags`) } main().catch(err => { console.error(err); process.exit(1) })
  • Step 2: Type-check

Run: npx tsc --noEmit Expected: PASS.

  • Step 3: Commit
git add scripts/review-vocab-candidate.ts git commit -m "feat(vocab-bootstrap): ratification CLI (Step 3.1 Task 9)"

Task 10 — End-to-end test (mocked, no Claude calls)

Before running the real pipeline against 41 JDs, do one synthetic end-to-end pass that confirms the orchestrator wires up correctly.

Files:

  • Create: calibre-app/tests/lib/vocab-bootstrap/orchestration.test.ts

  • Step 1: Write the integration test

Create calibre-app/tests/lib/vocab-bootstrap/orchestration.test.ts:

import { describe, it, expect, vi } from 'vitest' import { bucketDocs, clusterCell, cvToDocTerms } from '@/lib/vocab-bootstrap' import type { CellVocabCandidate } from '@/lib/vocab-bootstrap/types' describe('Stages 2 + 3 wiring', () => { it('runs CV-only docs through bucket + cluster (mock Claude)', async () => { const cv = cvToDocTerms( { identity: { location: { value: 'Singapore', confidence: 0.9 } }, current_role: { raw_title: { value: 'Senior LNG Trader', confidence: 0.95 } }, employment_history: [], signals: { commodity_specialisations: { value: ['LNG', 'Crude Oil'], confidence: 0.9 }, vertical_signals: { value: ['Energy'], confidence: 0.9 }, tools_and_systems: { value: [], confidence: 1 }, certifications: { value: [], confidence: 1 }, }, }, 'cv-test.json', 'cargo-ops', ['energy'] ) const cells = bucketDocs([cv]) expect(cells).toHaveLength(1) expect(cells[0].sub_function).toBe('cargo-ops') const fakeClient = { messages: { create: vi.fn().mockResolvedValue({ content: [{ type: 'tool_use', name: 'cluster_cell_vocab', input: { tags: [ { slug: 'lng', display: 'LNG', synonyms: ['LNG'], source_docs: ['cv-test.json'], confidence: 0.9 }, { slug: 'crude-oil', display: 'Crude Oil', synonyms: ['Crude Oil'], source_docs: ['cv-test.json'], confidence: 0.85 }, ], rationale: 'two distinct specialisations', }, }], }), }, } as unknown as import('@anthropic-ai/sdk').default const result: CellVocabCandidate = await clusterCell(fakeClient, cells[0]) expect(result.tags.map(t => t.slug).sort()).toEqual(['crude-oil', 'lng']) expect(result.coverage_low).toBe(true) // 1 source doc }) })
  • Step 2: Run + verify pass

Run: npx vitest run tests/lib/vocab-bootstrap/orchestration.test.ts Expected: PASS, 1/1.

  • Step 3: Run the full vitest suite to make sure nothing regressed

Run: npm test Expected: ALL tests pass — both the new vocab-bootstrap suite and the existing cv-parser suites.

  • Step 4: Commit
git add tests/lib/vocab-bootstrap/orchestration.test.ts git commit -m "test(vocab-bootstrap): Stages 2+3 wiring (Step 3.1 Task 10)"

Task 11 — Live run + validation

Execute the real pipeline. Inspect the output. Validate before handing off to the Calibre team for ratification.

  • Step 1: Sanity-check the JD inventory

Run: find ../reference/JD -type f \( -name "*.pdf" -o -name "*.docx" -o -name "*.doc" \) | wc -l Expected: 41 (per SOT §6).

If the count differs, investigate before running the pipeline (a missing or new JD shifts the cell distribution).

  • Step 2: Sanity-check the parsed-cv inventory

Run: ls outputs/parsed-cvs/*.json | wc -l Expected: 66+ (the parser-hardening batch result). Re-batch the full ~107 CVs only after Step 5.2 ships (per SOT §4 Step 5 status); for Step 3.1 the 66-CV set is sufficient — clustering surfaces the categorical structure, additional CVs would mostly add depth not breadth.

  • Step 3: Run the bootstrap pipeline

Run: npm run vocab-bootstrap 2>&1 | tee /tmp/vocab-bootstrap-run.log Expected:

  • Stage 1a logs the CV count.
  • Stage 1b logs 41 JD extractions with a ✓ per JD; max 2–3 ✗ allowed (scanned-image PDFs).
  • Stage 2 logs ~15–25 cells.
  • Stage 3 logs N tags per cell, with (coverage_low) annotation on cells with ≤ 2 source docs.
  • Stage 4 writes config/tag-vocab.candidate.json.

Estimated wall-clock: 15–30 minutes. Cost: USD 5–10.

  • Step 4: Validate the candidate JSON

Run a one-off node script to check invariants. Create calibre-app/scripts/validate-candidate.ts:

import fs from 'fs' import path from 'path' const cand = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../config/tag-vocab.candidate.json'), 'utf-8')) let warnings = 0 let errors = 0 for (const cell of cand.cells) { const tagCount = cell.tags.length if (tagCount < 5 && !cell.coverage_low) { console.warn(`WARN ${cell.sub_function}/${cell.sub_vertical}: only ${tagCount} tags (target 5–15)`) warnings++ } if (tagCount > 15) { console.warn(`WARN ${cell.sub_function}/${cell.sub_vertical}: ${tagCount} tags (over the 15 ceiling)`) warnings++ } for (const tag of cell.tags) { if (!/^[a-z0-9-]+$/.test(tag.slug)) { console.error(`ERR ${cell.sub_function}/${cell.sub_vertical}: slug '${tag.slug}' is not hyphen-case`) errors++ } if (tag.slug.length > 24) { console.error(`ERR ${cell.sub_function}/${cell.sub_vertical}: slug '${tag.slug}' exceeds 24 chars`) errors++ } if (tag.synonyms.length === 0) { console.error(`ERR ${cell.sub_function}/${cell.sub_vertical}/${tag.slug}: empty synonyms`) errors++ } } } console.log(`\n${errors} errors · ${warnings} warnings · ${cand.cells.length} cells · ${cand.cells.reduce((n: number, c: typeof cand.cells[number]) => n + c.tags.length, 0)} tags`) process.exit(errors > 0 ? 1 : 0)

Run: tsx scripts/validate-candidate.ts Expected: 0 errors. Warnings acceptable; review them with the team.

  • Step 5: Commit the validator + the candidate output
git add scripts/validate-candidate.ts config/tag-vocab.candidate.json git commit -m "chore(vocab-bootstrap): live candidate output + validator (Step 3.1 Task 11)"

Task 12 — Ratification session + lock the vocab

This is the manual step. Drive the CLI; the Calibre team reviews.

  • Step 1: Schedule a ratification session with the Calibre team

Block 1–2 hours. The reviewer walks every cell × every tag. Estimated 5–10 seconds per tag at the CLI; total 10–20 minutes of decisions plus discussion overhead.

  • Step 2: Drive the CLI in the session

Run: npm run vocab-review The CLI walks every tag of every cell. The Calibre team answers a/r/m/x/s per prompt; reason is required for non-default actions.

  • Step 3: Inspect the locked output

Run: cat config/tag-vocab.json | jq '.cells | length' && cat config/tag-vocab.json | jq '[.cells[].tags | length] | add' Expected: cell count matches Task 11 Step 3. Tag count typically lower than candidate (rejections + merges).

  • Step 4: Inspect the audit log

Run: cat config/tag-vocab.audit.json | jq '.entries | group_by(.action) | map({action: .[0].action, count: length})' Expected: a breakdown of approve / rename / merge / reject / add counts. Every action carries a non-empty reason (the CLI enforces this for non-default actions; spot-check 5 random entries).

  • Step 5: Commit the locked outputs
git add config/tag-vocab.json config/tag-vocab.audit.json git commit -m "feat(vocab-bootstrap): lock tag vocab via Calibre-team ratification (Step 3.1 Task 12 — Step 3.1 complete)"
  • Step 6: Update SOURCE-OF-TRUTH.md status and PLANS-LOG

In the outer Calibre/ repo, edit SOURCE-OF-TRUTH.md §4 Step 3 Step 3.1 status to ✅ Locked YYYY-MM-DD. Edit plans/PLANS-LOG.md Step 3 Step 3.1 row to ✅ Locked. Commit:

git add SOURCE-OF-TRUTH.md plans/PLANS-LOG.md git commit -m "docs(sot): Step 3 Step 3.1 locked — tag-vocab.json ratified by Calibre team" git push origin main
  • Step 7: Notify downstream

Step 3.2 (JD normaliser build), Step 5.2 (CV normaliser build), and Step 6.1 (rubric loader) are now unblocked. Open the next plans in parallel — they read the same tag-vocab.json and do not interfere with each other.


Self-Review

1. Spec coverage — every stage of the design has at least one task:

  • Stage 1 (term extraction): Tasks 2 (CV), 3 (JD text), 4 (JD terms via Claude).
  • Stage 2 (bucketing): Task 5.
  • Stage 3 (clustering): Task 6.
  • Stage 4 (candidate JSON): Task 7 orchestrator.
  • Stage 5 (ratification): Tasks 8 (applier), 9 (CLI), 12 (drive the session).
  • Validation (design §“Validation Strategy”): Tasks 10 (mock E2E), 11 (live + validator).

2. Placeholder scan — no TBDs. Every step has either runnable code or an exact shell command with expected output.

3. Type consistency — DocTerms, CellTermBundle, CellVocabCandidate, CandidateVocab, LockedVocab, AuditEntry, AuditLog all defined in Task 1 and used consistently from Task 2 onward. applyActions(candidate, actions, reviewer) signature same in Task 8 implementation and Task 9 CLI consumer.

4. Skill alignment — TDD throughout (test before implementation in every code task). Small commits (one per task). DRY: cv-parser/extractor reused for JD text extraction. YAGNI: no live-inference, no versioning beyond v1, no multi-language synonyms — all explicitly deferred per design §“What This Plan Does NOT Cover”.


Execution Handoff

Plan complete and saved to plans/step-3.1-vocab-bootstrap-impl.md. Two execution options:

1. Subagent-Driven (recommended) — Dispatch a fresh subagent per task. Review between tasks. Fast iteration. Best when you want to keep an oversight loop without hand-coding.

2. Inline Execution — Execute tasks in this session using the executing-plans skill. Batch execution with checkpoints. Best when you want to drive the build yourself in one continuous session.

Which approach?

Last updated on