Skip to content

compass test ready#1

Merged
akminx merged 46 commits into
mainfrom
phase-1b2-rag
May 20, 2026
Merged

compass test ready#1
akminx merged 46 commits into
mainfrom
phase-1b2-rag

Conversation

@akminx

@akminx akminx commented May 20, 2026

Copy link
Copy Markdown
Owner

should be testable and semi-ready to use.

Akash Aedavelli added 30 commits May 18, 2026 23:57
…ad_id

C1: resume_pending now derives state_store status from the FINAL graph state's
    human_approved, not the input decision — a graph that auto-rejects on resume
    (SCORE_THRESHOLD edited between pause and resume, hitl_node short-circuits
    before consuming interrupt()) no longer leaves state_store='approved' while
    the JobNote says hitl_decision='auto_rejected'.

C1 sticky-threshold: score_node now captures SCORE_THRESHOLD into CompassState
    at score time. hitl_node and vault_write_node prefer the captured value
    over the live config, so threshold edits between pause and resume can no
    longer silently flip the decision.

C2: _append_run_log self-migrates 5-col pre-1.B.1 pipeline-runs.md to 6-col
    on next append (Paused=0 inserted into historical rows).

I2: _thread_id_for includes os.getpid() to disambiguate cross-process same-
    microsecond collisions (defensive for 1.B.3 Modal cron + local CLI race).
C3: gap_aggregator.regenerate() was only called at end of run_pipeline().
Resume paths (MCP approve + timeout_checker) bypassed it, so CompanyNote
roles_seen and SkillNote appears_in_jobs drifted from JobNote ground truth.
Adding the call inside resume_pending closes the single source of truth.

Defers I4 (double-resume race claim_pending) to Phase 1.B.3 alongside Modal
cron — the real-world race trigger.
…tion leak

Raw 'ss._now = lambda: ...' assignments in test_state_store.py bypassed
monkeypatch's tracking, leaking the frozen time into subsequent tests in
the suite. Caused timeout_checker tests to see a stale _now() value and
mis-classify backdated rows as not-yet-stale.

Pre-existing bug from Phase 1.B.1 — passed the full suite by ordering luck;
exposed when 1.B.2's new tests shifted the test interaction.
…r bugs

Live behavior review surfaced 4 false-negatives at intake_filter — added:
- engineering manager / lead / director / head / vp of engineering
- solutions engineer (the bare title — 'sales engineer' already caught)
- security engineer / application security / infrastructure security
- operations specialist / product operations / program manager

'Solutions architect' intentionally NOT added — borderline per Phase 1.A spec.

Three deeper LLM-behavior bugs (extract under-extraction, OR-as-AND, and
candidate-strong-skill-marked-missing) documented in
docs/KNOWN_DATA_QUALITY_ISSUES.md for Phase 2.A eval-harness work.
…r, tailor hallucination, portfolio-claim risks

Second adversarial pass surfaced two new bug classes (derived-field
staleness asymmetry on role_family; gap_aggregator includes auto_rejected/
timed_out jobs at full weight) plus three operational portfolio-claim
risks (skill assessor never exercised, applications never created,
concurrent runs not protected).
B6 from KNOWN_DATA_QUALITY_ISSUES.md: role_family is set once at intake;
when the OUT keyword list expanded (commit 3828d8f), stale JobNotes kept
contributing to the gap plan. Two-part fix:

1. scripts/migrate_role_family.py — one-time migration. Re-runs
   keyword_classify on each JobNote title; rewrites role_family to
   'out-of-scope' for titles the current classifier rejects. In-scope
   re-classifications (e.g. body-signal upgrades) are left alone since
   this script doesn't re-evaluate the JD body.

2. gap_aggregator.load_jobs() now skips JobNotes with role_family=
   'out-of-scope'. Without this, the migration alone would only rename
   the field but the gap math would still include them.

Migrated 5 stale entries (Sierra/Ramp Security Engineer x2,
Decagon Senior Solutions Engineer, Decagon Engineering Manager,
Ramp AI Operations Specialist) — all were leaking through the
pre-3828d8f filter.
Day-1 Obsidian P1: writer now emits a `## Skills` block between the LLM
summary and `## Full JD`, rendering required / nice-to-have / matched /
missing as `[[Python]] · [[LangGraph]]` wikilinks so the Obsidian graph
view edges JobNotes to SkillNotes. Empty categories are omitted; skill
names with characters that get rewritten by `_safe_segment` use the alias
form `[[AWS_Bedrock|AWS Bedrock]]` so the link resolves.

Backfilled 18 existing JobNotes via scripts/migrate_jobnote_skills_section
(idempotent — replaces an existing block if present, inserts before
`## Full JD` otherwise).
Akash Aedavelli added 16 commits May 19, 2026 15:44
P2 — `gap_aggregator._sync_skill_backlinks` writes a `## Jobs requiring this
skill` block to every SkillNote body, listing every JobNote whose required/
nice-to-have skills include the canonical name. Sorted by match_score DESC.
Preserves existing body content above the block (seed notes carry category
descriptions). Idempotent — re-runs replace the block in place.

P3 — `vault_write_node` now auto-generates Obsidian-tag-pane-filterable tags
on every JobNote write: `#tier/{apply-now,opportunistic,...}`,
`#fit/{strong,decent,stretch,weak}`, `#role/{agent-engineer,...}`,
`#decision/{approved,timed_out,...}`. Empty role_family and absent hitl_decision
correctly omit their tag.

Dashboard panels for skill rarity + rare/specialized skills added to
compass-vault/dashboard.md (zero-code Dataview blocks).

8 new tests. 270 passing. Ruff clean.
Item 1 — `compass.vault.reader.load_reject_rules` parses
`reject_if_title_contains` and `reject_if_jd_contains` YAML blocks out of
preferences.md. `intake_filter_node` applies them as substring matches BEFORE
the keyword classifier, so senior/staff/principal titles and
`5+ years` / `PhD required` JDs are dropped at zero LLM cost. On a 41-board
scrape this is ~40-60% of incoming volume.

Item 2 — `_scrape_all` filters each board's results to `date_posted` within
the last 30 days then sorts by date_posted DESC (None-dated jobs sink to the
bottom) before round-robin interleave + MAX_JOBS_PER_RUN cap. Each board now
contributes its freshest postings to the cap rather than arbitrary order.
Undated postings still get a shot — many ATSes don't expose date_posted and
we can't distinguish stale from undated.

7 new tests. 278 passing. Ruff clean.
…y fields

Item 4 — `interview_difficulty` + `cisco_adjacency` fields added to JobNote
and CompanyNote schemas. `vault_write_node` populates JobNote fields from
`target_companies.get_interview_difficulty` / `get_cisco_adjacency` at
write time so each posting carries a snapshot.

Tier literal expanded with `opportunistic` and `backend-prep` to match
`_profile/preferences.md::tier_weights` (the 3-month pivot tiers were
unrepresented and would have failed Pydantic validation).

`compass.vault.target_companies` now reads `_profile/target-companies.yaml`
alongside the legacy markdown. New helpers: `get_company_meta`,
`get_interview_difficulty`, `get_cisco_adjacency`, `get_ats`,
`list_yaml_companies`. Invalid Literal values in YAML collapse to
"unknown"/"none" so hand-edit typos don't crash the pipeline.

Item 3 — `scripts/seed_companies_from_yaml.py` bulk-creates CompanyNotes
from the YAML, pre-populating tier + geos + why_interesting +
interview_difficulty + cisco_adjacency. write_company_note's existing merge
logic preserves any prior human edits. Applied: seeded 41 CompanyNotes.

11 new tests. 289 passing. Ruff clean.
Item 5 — `writer._normalize_full_jd` runs a belt-and-suspenders HTML strip on
the JD body at vault-write time. Scrapers already strip at their boundary
(greenhouse/lever `_strip_html`, ashby `descriptionPlain`); this is the
backstop for any future scraper or hand-built RawJob that bypasses that.
A `_looks_like_html` guard ensures JDs containing literal `<` (code snippets,
ASCII art, `<200ms latency`) pass through untouched — only visibly-HTML
inputs get normalized.

Item 6 — langfuse 4.x split credentials away from `CallbackHandler.__init__`
which now only accepts `public_key` + `trace_context`. Passing `host=` /
`secret_key=` raised TypeError and silently disabled all tracing. Fixed by
initializing the `Langfuse(...)` singleton once with full creds, then
constructing the bare `CallbackHandler()` which inherits config from the
singleton. The refresh will now emit traces.

Also: ruff format pass across the files touched in earlier commits this
session.

291 passing. Ruff clean.
…ody gate

Three changes that together make the next refresh actually reflect the
user's stated target market (per _profile/target-roles.md::JD-master-boolean):

1. **Positive scraper filter from YAML** — `_scrape_all` now reads
   `_profile/target-companies.yaml` (apply-now + opportunistic tiers) and
   groups by ATS provider. Each scraper is called with the YAML-derived
   slug list. Static `GREENHOUSE_BOARDS` / `LEVER_COMPANIES` /
   `ASHBY_BOARDS` config lists are fallback only (tests + safety net when
   YAML is missing). 17 greenhouse + 24 ashby boards picked up from the
   current YAML.

2. **role_family.IN_TITLE_KEYWORDS expanded** with the master-boolean
   title set:
   - agent-engineer: AI Agent Engineer, Agentic AI Engineer, Software
     Engineer (Agents/Agentic), AI Native Engineer, MTS (frontier-startup
     flat-hierarchy signal — Sierra/Decagon/Cognition/Cursor/Mistral/xAI)
   - applied-ai: GenAI Engineer, AI Enablement Engineer, AI/ML Engineer
   - infra-llm: AI Platform Engineer
   "Member of Technical Staff" test flipped from None→in-scope to
   in-scope→agent-engineer per the new mapping.

3. **JD-body agent-signal gate** — after title routes to agent-engineer
   / applied-ai / infra-llm, intake_filter scans the JD body for any of
   ~17 agentic terms (LangGraph, MCP, tool calling, multi-agent, etc.).
   Zero hits → drop the JD even though title looked right; the user's
   target market wants agents-in-production roles, not generic ML/RAG.
   `agent_signal_count` propagates through state into a
   `#signal/agent-strong` (3+) or `#signal/agent-mention` (1-2) auto-tag
   on the JobNote. SWE-family titles (swe-backend, swe-fullstack) pass
   through unfiltered — generic SWE titles at AI-native companies are
   often real agent-eng roles.

Also: zeroed `cisco_adjacency` across all YAML entries (the dimension was
built on a wrong premise — user's Cisco role is hardware test development,
not networking/security); removed the cybersec-ai block from
`needs_workday_scraper`.

298 passing. Ruff clean.
… dashboard panel

A — Audit fixes:
  - DEFAULT_TIER_WEIGHTS now includes opportunistic (0.85) and backend-prep
    (0.5) to match the expanded Tier literal and preferences.md
  - _initial_state initializes agent_signal_count=None so the state key is
    always present even on graph paths that error before intake_filter

B — Removed `cisco_adjacency` field cleanly:
  - Schema literals + JobNote/CompanyNote fields gone
  - vault_write_node + target_companies + seed script + tests updated
  - Field stripped from 41 existing CompanyNotes
  Dimension was built on a wrong premise (user's Cisco role is hardware
  test dev, not networking/security). Cleaner schema, no dead weight.

F — YAML re-tier + Austin local startup expansion:
  - Distyl AI demoted apply-now → opportunistic (LC-medium-hard, frontier-
    grade bar — not realistic for 2-month landing at 1.5 YoE)
  - Added 8 new apply-now entries (probed + verified scrapable):
    Self Financial (Austin gh), AlertMedia (Austin gh), Diligent (NYC+Austin
    gh), Apptronik (Austin gh), Roboflow (NYC+Austin ashby), Maven Clinic
    (NYC gh), Maven AGI (Boston ashby), Vapi (SF ashby)
  - 49 companies now in YAML (was 41)
  - Re-seeded 49 CompanyNotes from YAML

H — Application velocity panel in dashboard.md:
  - Apps this week / month / by tier
  - "Easy-loop apply-now roles" filter (HackerRank/case/lc-easy/takehome)
    so the 8-week sprint surfaces LC-light landings first

295 passing. Ruff clean.
C — `compass/scrapers/workday.py` — posts to the public
`{tenant}.{wdN}.myworkdayjobs.com/wday/cxs/{tenant}/{site}/jobs` JSON
endpoint that every Workday tenant exposes behind their SPA. Slug format
in YAML is `subdomain/tenant/site` (e.g. `wd5/citi/2`). Two-stage fetch:
list endpoint returns summaries → per-posting detail endpoint returns the
full JD body. Pagination via offset/limit; capped at 10 pages × 50 jobs
per board.

Verified scrapable tenants added to YAML apply-now tier:
  Wells Fargo  · wd1/wf/WellsFargoJobs        hackerrank
  Citi         · wd5/citi/2                   hackerrank
  Morgan Stanley · wd5/ms/External            lc-medium
  BlackRock    · wd1/blackrock/BlackRock_Professional  lc-medium
  Adobe        · wd5/adobe/external_experienced  lc-medium

Capital One, JPM (Oracle Cloud), GS, BofA, USAA, ServiceNow, Atlassian,
HubSpot, Intuit, IBM, Oracle, SAP, Splunk all have Workday tenants but
non-discoverable site IDs — those need the `add_job_from_url` tool
(item D, next).

`Source` literal extended with "workday".

E — score_node prompt now sees a COMPANY TARGETING CONTEXT block with
tier + interview_difficulty + section + notes from the YAML. Prompt
language instructs the LLM to treat this as secondary signal (skill
match still primary) but acknowledge:
  - apply-now + easy loop = realistic landing, don't over-penalize gaps
  - opportunistic/backend-prep + lc-hard = real stretch, score honestly

309 passing (14 new Workday tests + 5 score-prompt test updates). Ruff clean.
D — Two new MCP tools for sites Compass can't auto-scrape (JPM Oracle
Cloud, LinkedIn, custom careers pages):

  add_job_from_url(url, company?, title?) — static-fetches the page,
    strips HTML, runs intake_filter → extract → score → vault_write.
    Returns score + matched/missing skills + agent_signal_count. Skips
    tailor (Sonnet cost) and HiTL (implicit approval by adding manually).

  add_job_from_text(company, title, url, jd_text) — for JS-rendered
    pages where the static fetch returns near-empty body. User pastes
    the JD body from the browser; Compass scores it.

`compass/pipeline/add_url.py` detects ATS provider by URL pattern
(greenhouse/lever/ashby/workday/generic) and runs static httpx fetch with
a browser User-Agent. Bodies under 200 chars return None, signaling
"likely JS-rendered — use paste-text instead."

G — Cover-letter generator (`compass/pipeline/cover_letter.py`):

  Pydantic-structured 3-section draft (opening / body / closing), 250-400
  words, anchored on specific projects from resume.md + role-clarifications.md.
  Uses Sonnet (TAILOR_MODEL). Reads company notes from target-companies.yaml
  to ground the "why this company" close.

  MCP tool: generate_cover_letter(job_filename) — writes to
  vault/cover-letters/{date}-{company}-{title}-{hash}.md. Not part of the
  per-job pipeline (too expensive); user invokes per-application.

  Strict system-prompt rules: no clichés ("I am writing to apply for…",
  "passionate about", "team player"), no invented projects/numbers, plain
  prose (no bullets/emoji/markdown), match candidate's resume voice.

318 passing. Ruff clean.
…cleanups

Audit #4 — Deleted dead `update_skill_note` function from
`compass/vault/writer.py`. `gap_aggregator._sync_skill_counters` has
owned the counter since Phase 0.B; the function had no callers in
production code (tests-only). Removed the 2 tests as well; pruned now-
unused SkillNote/SkillCategory/category_for imports. (-23 / +0 lines)

Added 19 companies to YAML under `provider: manual` — sites Compass
can't auto-scrape (Oracle Cloud / Workday-undiscovered / iCIMS /
SmartRecruiters / brassring). Each entry carries interview-loop
research from public sources so the score node can weight them
correctly when the user adds specific roles via `add_job_from_url` /
`add_job_from_text`:

  Banks (apply-now):
    JPMorgan (NYC, Plano) — hackerrank, 2 problems / 1hr
    Capital One (NYC, Plano, McLean) — lc-medium PowerDay, 70-min CodeSignal
    Goldman Sachs (NYC, Dallas) — lc-medium FAANG-lite
    Bank of America (NYC, Charlotte) — hackerrank, easy
    USAA (San Antonio) — hackerrank, lower bar

  Consulting (apply-now):
    Deloitte (NYC, Austin, Dallas) — case + Python basics, 2.6/5 diff
    Accenture (NYC, Austin, Dallas) — lc-medium + Docker/K8s, 3.3/5
    EY, PwC, KPMG (NYC, Dallas) — case + light tech

  Mid-SaaS (apply-now):
    ServiceNow (Austin), Atlassian (Austin), HubSpot, Intuit (Plano TX)

  Enterprise (apply-now):
    IBM watsonx, Oracle OCI GenAI, SAP Joule

  NYC (apply-now):
    Bloomberg (NYC HQ) — Terminal agent features

Updated `get_tier()` precedence — now reads YAML first (3-month-pivot
source of truth), falls back to markdown. This is needed because the
new manual-add entries live in YAML only; without the change they'd
resolve to tier="unknown" when JobNotes get written via
`add_job_from_url`. Verified: JPMorgan/Capital One/Deloitte/IBM all
correctly resolve to apply-now now.

Re-seeded 72 CompanyNotes (was 49 + 23 new manual entries).
`provider: manual` entries are correctly skipped by
`_yaml_scraper_slugs()` (auto-scrape excludes), but `get_company_meta`
+ `get_tier` + score-node context still find them.

316 passing. Ruff clean.
Phase 2.A eval harness, scaffolded so it works BEFORE any hand-labels exist.

compass/evals/dataset.py — EvalRecord Pydantic schema (id / jd_text /
  source / expected_score / expected_skills / notes). load_dataset() returns
  [] when the JSON file is missing so first-run isn't blocked. save_dataset,
  add_example (auto-increment id), add_from_jobnote (label a real JobNote
  from the vault).

compass/evals/metrics.py — pure-function primitives:
  score_mae, score_rmse, score_bias  (signed bias tells which direction
                                       to tune the prompt — over vs under)
  skill_recall, skill_precision      (case-insensitive set match)
  aggregate(...)                     (rolls per-JD into a ScoreMetrics dc)

compass/evals/judge.py — LLM-as-judge that produces synthetic expected_*
  labels without manual annotation. ~$0.002/JD on Flash. Useful for first-
  pass sanity checks on the upcoming refresh's 50-150 fresh JobNotes
  before you hand-label any of them. Explicitly NOT a substitute for
  hand-labels (two LLMs may share biases) but catches gross failures
  like extract finding 2/12 skills.

compass/evals/runner.py — two modes:
  run_against_labels()  → compares Compass output to EvalRecord.expected_*
  run_against_judge()   → compares to live judge LLM output (no labels needed)
  CLI: `uv run python -m compass.evals.runner [--judge] [--limit N]`
  Writes `compass/evals/results-{mode}-{ts}.json` per-record + summary.

MCP tool `run_evals(mode, limit)` exposes the runner programmatically.

Per-record output includes `missed_skills` + `extra_skills` (set diff between
extracted and expected) so a user reading the JSON can immediately see which
skills extract_node systematically misses — directly drives the B1 prompt-
tuning loop the spec promised.

26 new tests (metrics primitives + dataset roundtrip + runner integration
with mocked extract/score/judge + extract-failure resilience).

342 passing. Ruff clean.
Systematic adversarial probe surfaced these bugs — all fixed with regression
tests so they can't silently come back.

1. **URL deduplication too naive** (HIGH) — `https://example.com/job`,
   `https://example.com/job/`, `https://example.com/job?utm_source=google`,
   `http://EXAMPLE.com/job`, and `https://example.com/job#apply` were treated
   as 5 distinct URLs. The same JD via Google + LinkedIn referrals + RSS
   would dup-write 5 JobNotes and re-burn LLM cost 5 times.
   Fix: new `compass.vault.url_dedup.normalize_url` collapses scheme case,
   host case, default ports, trailing slash, tracking params (utm_*, gclid,
   fbclid, msclkid, ref, source, campaign), fragments, and sorts remaining
   query params. http and https of the same URL collapse to https.
   Applied in `_vault_url_set()` and `write_job_note()` URL match.
   13 new tests in tests/vault/test_url_dedup.py.

2. **Agent-signal gate had false positives** (HIGH) — single hits on "agent"
   or "agents" alone passed the gate even when the JD body was talking
   about "change agent", "User-Agent header", or "outage management agent"
   in irrelevant context. The gate was meant to drop non-agent-eng roles
   with AI-sounding titles; it was letting them through on noise.
   Fix: tiered signals into STRONG (LangGraph, MCP, multi-agent, tool
   calling, agentic-AI as a phrase, etc.) and WEAK ("agent" / "agents" /
   "agentic" alone). Gate now requires at least ONE strong signal. Weak
   hits still increment `agent_signal_count` for the `#signal/*` tag but
   don't satisfy the gate alone.
   Confirmed: "change agent" + "User-Agent header" titles now drop.
   New tests: TestAgentSignalFalsePositives + TestAgentSignalCountConsistency.

3. **`agent_signal_count` was None on reject paths** (LOW) — title/JD-reject
   paths returned `{"in_scope": False, "role_family": "out-of-scope"}` and
   left `agent_signal_count` as None in state. Now consistently set to 0.

4. **Path traversal in `generate_cover_letter` MCP tool** (MED) —
   `job_filename` was joined to VAULT_PATH/jobs/ with no validation.
   A relative path like `../_profile/resume.md` resolved OUTSIDE jobs/
   and would load + cover-letter-ize whatever was there.
   Fix: resolve both paths + `relative_to(jobs_dir)` check before reading.
   Confirmed: "../_profile/resume.md" now rejected with clear error.
   3 new tests in tests/test_mcp_path_traversal.py.

5. **YAML cache stale in long-running MCP server** (MED) — `get_company_meta`
   and `get_tier` cached YAML at module-import time. User editing the YAML
   in Obsidian while the MCP server runs got stale data until restart.
   Fix: `_maybe_reload_yaml()` stat-checks mtime on every accessor call
   (sub-millisecond) and reloads if changed. Cache survives reads, refreshes
   on writes.
   3 new tests in tests/vault/test_target_companies_aliases.py.

6. **No aliases for tenant slug ↔ display name** (MED) — `get_tier("JPMC")`
   returned "unknown" even though "JPMC" is the universal short name for
   JPMorgan. The substring-fuzzy fallback didn't bridge "jpmc" → "jpmorgan".
   Workday/Oracle tenant tokens are short codes (wf=WellsFargo, ms=Morgan
   Stanley, COF=Capital One, GS=Goldman Sachs); without aliases, the scraper
   board_token would route to tier=unknown even when the company is in YAML.
   Fix: YAML `aliases:` field on companies; `_parse_yaml` indexes by primary
   name + all aliases. Added aliases to JPMorgan, Capital One, Goldman Sachs,
   Bank of America, Morgan Stanley, Wells Fargo.
   Confirmed: get_tier("JPMC") = "apply-now", same for COF/MS/WF/etc.

7. **`add_job_from_url` accepted any URL scheme** (MED) — javascript:,
   file://, ftp://, data: all reached httpx unchecked. httpx would presumably
   error but it's not validated upfront; could mask a copy-paste mistake or
   accidental local-file read.
   Fix: scheme allowlist {http, https} enforced before fetch attempt. Also
   rejects URLs with no hostname (e.g. "https://" alone).
   6 new tests in tests/pipeline/test_add_url_scheme.py.

8. **Eval results filename collision when run twice in same second** (LOW) —
   `results-{mode}-{HHMMSS}.json` would overwrite if the runner was invoked
   twice within one second (rapid iteration during prompt tuning). Now
   `HHMMSS-microseconds` for collision-free ID.

342 → 374 tests passing (+32 regression tests). Ruff clean.
Audit of Phase 2.A against the master spec found 1 real correctness bug
and 1 missing spec item. Fixed both.

**Production-fidelity bug** — the runner called `_extract(jd_text)` and
`_score(req, profile, job)` directly. Those are the raw LLM functions.
But the production graph wraps them with post-processing the runner skipped:

  extract_node wraps `_extract` with:
    - `_normalize_skill_list` — canonicalizes skill names against the
      taxonomy AND drops skills the LLM emitted but the JD body doesn't
      actually contain (anti-hallucination guard)
    - `_seniority_with_title_fallback`

  score_node wraps `_score` with:
    - `_score_with_retry` — retries once if reasoning looks truncated
    - `_constrain_to_jd_skills` — drops matched/missing skills outside
      the JD's required ∪ nice_to_have universe (LLM hallucination guard)

The runner was measuring un-normalized, un-constrained output. The recall
metric would have reported worse than the user actually experiences, and
the B1 prompt-tuning loop would have optimized against the wrong baseline.

Runner now applies the same normalization + constraint layers post-LLM-call.
`_extract` / `_score` remain the patchable surface so existing test stubs
work; production helpers are wrapped around the stub output.

Two regression tests pin this:
  test_runner_applies_extract_normalization  — hallucinated skills are
    dropped before metrics computation
  test_runner_applies_score_constraint        — score matched_skills
    outside the JD universe are filtered before match_skill_recall

**Missing spec item — interactive labeling CLI** — `scripts/label_jd.py`,
called for in spec but never built. Two modes:

  uv run python -m scripts.label_jd <JobNote>
    Reads compass-vault/jobs/<JobNote>, shows the body, runs Compass's
    current extract on it, displays the agent's normalized skill list,
    prompts for expected_score + expected_skills + notes. Press Enter
    to accept the agent's list as ground truth (one-keystroke labeling).

  uv run python -m scripts.label_jd
    Paste-mode without a JobNote — useful for labeling JDs found
    manually outside the vault.

Path-traversal guard mirrors the MCP fix from the previous commit. Late-
binds VAULT_PATH via `cfg.VAULT_PATH` (not `from compass.config import
VAULT_PATH`) so the temp_vault test fixture's monkeypatch works.

3 new tests for the labeler CLI.

374 → 379 passing. Ruff clean.

**Still missing from spec, documented as deferred:**
  - Cost-per-run tracking — pydantic-ai exposes usage on the run result;
    surfacing it requires a small refactor of `_extract`/`_score` return
    signatures. Defer until we actually want cost charts.
  - Langfuse Dataset Run integration — the Langfuse 4.x callback is wired
    for the pipeline; eval traces would need explicit propagation through
    `_run_extract_and_score`. Defer until 2.B (public-trace polish).
  - `docs/EVAL_BASELINE.md` — correctly deferred; needs labeled data first.
…pers

Dispatched 4 parallel adversarial subagents across Phase 1.B (HiTL,
checkpointing, RAG) + Phase 1.A pipeline nodes + Phase 2.A eval harness +
scraper layer. 20 candidate findings; 12 verified as real bugs; 8 fixed
here (most-impactful); 7 reported as false alarms or already-handled.

**CRITICAL fixes:**

1. **HiTL race condition** (`compass/hitl/{state_store,resume}.py`) —
   Modal cron's timeout-checker and an MCP `approve_job` call could both
   pass the `get_pending` check on the same thread_id, both call
   `graph.ainvoke` on the same checkpoint, and write conflicting JobNotes
   (one "timed_out", one "approved"). New `claim_pending(thread_id) -> bool`
   atomically transitions `pending → resuming`. `resume_pending` calls it
   before invoking the graph; second caller raises cleanly. `mark_resolved`
   updated to finalize from either `pending` or `resuming`. CHECK
   constraint on SQLite table updated to include 'resuming'.
   The Phase 1.B.3 `claim_pending atomic transition` spec item is now
   shipped. 5 regression tests in tests/hitl/test_claim_pending.py.

2. **Stale RAG chunks after skill rename/delete** (`compass/rag/indexer.py`)
   — `build_index` used `upsert` only, never `delete`. If a skill was
   renamed or removed from skill-inventory.md, the old chunk's id stayed
   in the collection forever and polluted future retrieval. Now after
   every build, any id in the collection that isn't in the current
   sections list is dropped. Force-rebuild flag no longer needed for
   day-to-day correctness.

3. **Workday relative URLs corrupted via dedup** (`compass/scrapers/workday.py`)
   — when detail-fetch's `externalUrl` was null, `_to_rawjob` stored the
   bare `externalPath` (e.g. "/job/abc"). `normalize_url` then produced
   the invalid string `https:///job/abc` — broken vault apply-link AND
   broken dedup. Now `_to_rawjob` accepts `base_url=` and absolutizes
   the relative path. If no usable URL at all, drop the job rather than
   persist a broken row. 4 regression tests.

4. **`swe-mobile` + `swe-frontend` promotable to `agent-engineer`**
   (`compass/pipeline/role_family.py:GENERIC_FAMILIES`) — a React Native
   job at an LLM-native startup whose JD mentions "LangGraph" + "multi-
   agent" once anywhere in the body got upgraded to agent-engineer.
   User isn't a mobile or frontend specialist; promoting these clutters
   the vault with mis-classified roles. Both families removed from the
   upgrade-eligible set. 4 regression tests.

5. **`tailor_node` fired on any approved job regardless of score**
   (`compass/pipeline/nodes/tailor.py`) — only checked
   `state.get("human_approved")`. A mis-click in the HiTL UI on a
   score=1.0 row would burn a Sonnet call (~$0.05). Added explicit
   score-vs-threshold gate after retrieving the score_result. Uses
   sticky `state["score_threshold"]` (set by score_node) over the
   live SCORE_THRESHOLD config. 3 regression tests.

**IMPORTANT fixes:**

6. **`list_yaml_companies` returned duplicate entries** for companies
   with aliases (`compass/vault/target_companies.py`). `_yaml_meta`
   indexes by primary name AND every alias, so `list(_yaml_meta.values())`
   yielded N+1 copies for N aliases. JPMorgan (5 aliases) appeared 6
   times. seed_companies_from_yaml + gap_aggregator + MCP tools saw
   inflated counts. Now deduplicated by object identity.

7. **Judge skill normalization mismatch deflated eval recall**
   (`compass/evals/runner.py:run_against_judge`) — judge prompt instructs
   "use exact phrases from the JD" so it returns raw forms like
   "pydantic-ai". Compass's extract returns canonical form ("Pydantic AI").
   Naïve lowercased set match between them missed legitimate hits on
   every multi-word punctuated skill. Now applies `_normalize_skill_list`
   to the judge's output before comparison so both sides are canonical.

8. **Ashby silently dropped jobs without `descriptionPlain`**
   (`compass/scrapers/ashby.py`) — some boards populate `description` (HTML)
   without `descriptionPlain`. Mirrored Lever's HTML-fallback pattern.

**Bugs the reviewers flagged that turned out to be FALSE:**

- claim_pending was on a real check-in-table named `writes`, not the
  non-existent `checkpoint_writes` reviewer guessed. Verified by probing
  the actual langgraph schema (`['checkpoints', 'writes']`).
- `score_threshold` IS set in state by score_node (lines 176 + 181) —
  reviewer missed it. Sticky-threshold guarantee already holds.

**Bugs filed as known gaps (not fixed here):**

- Workday detail-fetch rate-limit handling (50 parallel requests per
  page, no 429 detection)
- Chroma PersistentClient singleton (multiple instances on same path
  with high concurrency)
- Empty `_company_context_block` crash potential
- `taxonomy.load_taxonomy` LRU cache not invalidated in tests that
  monkeypatch TAXONOMY_PATH
- Lever scraper is dead code (no YAML entries use it)

379 → 396 passing (+17 wave-2 regression tests). Ruff clean.
…he/cold-start

Dispatched 4 more parallel adversarial agents across surfaces NOT deeply
reviewed in waves 1+2: analysis layer (assessor/gap/learning_bridge/taxonomy),
MCP server + applications + scripts, graph orchestration + node interactions,
and cold-start + concurrency + encoding edge cases.

20+ candidate findings; ~12 verified as real; 9 fixed here. Agent C's most-
alarming claim (`__interrupt__` reads from wrong object → "HiTL entirely
broken") was verified FALSE — LangGraph DOES populate `__interrupt__` in
return state. Current code works.

**SECURITY (path traversal — actively leaks .env contents pre-fix):**

1. **`learning_bridge.resolve()` path traversal** — `learning-vault://../../.env`
   resolved through bare Path-join (no `..` normalization), returned contents
   of any sibling file. VERIFIED live before fix: leaked `OPENROUTER_API_KEY`
   from a sibling `secret.env`. Now `_parse_uri` resolves both candidate and
   root, checks `relative_to(root)`, raises ValueError on escape; `resolve()`
   catches and returns None. 5 regression tests in
   tests/vault/test_learning_bridge_traversal.py.

2. **`get_profile(section)` MCP tool path traversal** — same threat class
   as the cover_letter fix from wave 1, but unfixed. `../.env` resolved to
   vault root with `.md` suffix. Now resolves + `relative_to(profile_dir)`
   guard; returns clear error on escape. Late-binds VAULT_PATH via
   `cfg.VAULT_PATH` so the temp_vault fixture's monkeypatch actually applies
   (CLAUDE.md lesson #2 footgun was present here too). 3 regression tests.

**CORRECTNESS:**

3. **`normalize(None)` crashed `gap_aggregator.regenerate()`** — a JobNote
   with a YAML list-entry written as `- ` parses as `[None]`, and
   `taxonomy.normalize(None)` raised AttributeError on `.lower()`, taking
   down the whole batch's gap-plan regeneration. Now `normalize()` accepts
   None/empty/non-str defensively and returns None. 5 tests.

4. **`master-gap-plan.md` non-atomic write** — `Path.write_text` is
   truncate-then-write; SIGKILL or disk-full mid-write left a half-written
   markdown file that the MCP `get_master_gap_plan` tool returned as garbage.
   Now writes to `.tmp` sibling + `os.replace()` for atomic swap.

5. **`save_dataset` non-atomic write** — same class for the eval dataset
   JSON. Pre-fix, a SIGKILL during `label_jd.py` could corrupt the labeled
   dataset to unparseable JSON, making the whole history unloadable. Same
   `.tmp + os.replace` pattern.

6. **`add_job_from_text` accepted any URL scheme** — `add_job_from_url`
   already enforces http/https; sibling tool didn't. Now matches.

7. **`seed_companies_from_yaml.py` empty-string company name** — would
   produce `companies/.md` dotfile carrying no information. Now skipped
   with a visible warning.

**CACHE / TEST-MOCKABILITY:**

8. **`taxonomy.load_taxonomy` + `_synonym_index` LRU caches never cleared**
   — long-running MCP server kept stale taxonomy after the user edited
   `skill-taxonomy.md`. Added `refresh_taxonomy()` helper that clears both
   LRU caches; ready to be called from MCP analysis tools that depend on
   fresh taxonomy.

9. **`llm.get_model_id` bypassed `compass.config`** — read os.environ
   directly, so tests that monkeypatched `compass.config.SCORE_MODEL`
   silently used the production model. Now reads `cfg.<NAME>` first,
   falls back to os.environ. Existing env-patching tests still work after
   they also clear the cfg attr. Added a regression test that pins the
   cfg-monkeypatch path.

**Bugs flagged FALSE (verified by direct probe before fixing):**

- "HiTL `__interrupt__` reads from wrong object — entirely broken" —
  Probed langgraph directly: `ainvoke` DOES return state with
  `__interrupt__` populated when `interrupt()` fires. Current code works.

**Bugs filed as known gaps (not fixed here — acceptable per scope/risk):**

- `skill_assessor._apply` SkillNote read-modify-write race (sequential
  per call, MCP vs cron race unlikely without Modal deployed)
- `append_agent_log` concurrency interleaving (< PIPE_BUF on Linux, rare
  on macOS, low impact)
- `write_company_note` race for same company across 5 concurrent jobs
  (acceptable per existing code comment — roles_seen owned by
  gap_aggregator anyway)
- Workday detail-fetch rate-limit handling (no 429 detection)
- Chroma PersistentClient singleton (multiple instances per process)
- `build_graph(checkpointer=None)` no guard (footgun but documented)
- `intake_node` doesn't set in_scope on null current_job (downstream
  recovers via intake_filter's same check)

396 → 411 tests passing (+15 wave-3 regression tests). Ruff clean.
14 commits past the `phase-1b2-rag` tag.
…ough

Plain-English explanation of what's in the project, how the pieces fit
together, and how data flows from a scraped JD to a JobNote. Replaces
the 3-day-stale ARCHITECTURE.md as the canonical "explain this project
to me" doc.

Covers:
- The three vaults (compass-vault, learning-vault, code repo) and their
  separate ownership
- ASCII data-flow diagram from scraper → vault_write → gap_aggregator
- Per-node behavior (intake_filter / extract / score / reflect / hitl /
  tailor / vault_write) including the post-LLM cleanup layers each one
  applies
- Per-module description with file paths
- The 12 MCP tools and what they're for
- The skill-assessor meta-loop — the differentiated angle that makes
  Compass interesting as a portfolio artifact
- Vault file layout with directory tree
- Cost expectations per LLM-touching node
- What's NOT in the pipeline (deferred Modal cron, Cisco internal,
  Oracle Cloud / iCIMS scrapers, auto-apply, LinkedIn)
- Honest "what Compass does well vs doesn't do yet" closing summary

Companion to but distinct from PROJECT_OVERVIEW.md (which is the
510-line phase-by-phase narrative). This doc is the current-state
architecture; PROJECT_OVERVIEW is the historical phase walkthrough.
Self-contained doc for the next agent + the user to pick up tomorrow
morning. Captures:

- Current branch/HEAD state + 90-second pre-flight check
- Summary of the long session that ended 2026-05-19 (24 bugs fixed
  across 3 adversarial review waves; 411 tests passing)
- The single uncompleted task that blocks the refresh: the user's
  resume + skill-inventory pass
- Tomorrow's plan in time-ordered steps: pre-flight → resume pass →
  first refresh → Obsidian verification → eval --judge baseline → HiTL
  approvals → label 10 JDs → rigorous eval baseline → first
  applications
- Communication style preferences (verbatim from user's CLAUDE.md)
- Known data-quality issues likely to surface in the first refresh
  (B1 under-extraction, Workday rate limits, ML/Vision over-ranking,
  Anthropic/Sierra dominance, Adobe scrape breadth)
- Cost expectations (~$0.70 for day 1)
- Reading order for context
- Deferred items with rationale (Modal cron, claim_pending callers,
  RAG chunking, portfolio polish, blog post)
- The single most likely opening scenario + step-by-step response

Doc is intentionally action-oriented (tomorrow is for running Compass
+ applying, not engineering). Closes with a reality check: code is
done; the bottleneck moved to runtime + user action.
@akminx akminx merged commit 9ed7323 into main May 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant