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 writtenThe 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. Noascasts except for narrowing Anthropic SDK union types. - Test framework: Vitest.
npm testrunsvitest run. Each test file imports fromvitest:import { describe, it, expect } from 'vitest'. - No new dependencies. Everything ships with what’s already in
package.json. - Imports use the
@/alias that points atsrc/. E.g.import { extractText } from '@/lib/cv-parser/extractor'. - Claude tool-use schema follows the cv-parser shape. See
src/lib/cv-parser/parser.tsfor the canonical pattern (Anthropic.Toolliteral ·input_schemawith explicitrequiredarrays · response handled viacontent.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.
- JD folders nested by sub-vertical (
- Frequent commits. Each task ends with a commit. Don’t squash; the audit trail matters.
- Branch:
feat/vocab-bootstrap. Already cut frommasterin calibre-app/ (2026-06-03). All commits in this plan land here. .env.localmust exist atcalibre-app/.env.localwithANTHROPIC_API_KEY=.... Scripts load it in the same pattern asscripts/batch-parse.tslines 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
extractTextwith 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 tagsper 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?