From dc3987ba8b5dcc1c38a9f9b7cab03cd859a21305 Mon Sep 17 00:00:00 2001 From: darthrootbeer Date: Thu, 1 Oct 2026 17:45:18 -0400 Subject: [PATCH 1/2] feat(job-assessment): wire end to end and retire job-fit-screen Adds assess_offline.py, golden-file end-to-end tests, a separate live run with the claude CLI, a clean-clone CI job, and removes tools/job-fit-screen. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01XV3Ggh86cf2xwUyz28XaN6 --- .github/workflows/ci.yml | 47 + README.md | 12 +- ROADMAP.md | 2 +- pipelines/job-assessment/README.md | 16 +- .../fixtures/golden/01-strong-fit/email.html | 148 ++ .../golden/01-strong-fit/note-name.txt | 2 + .../fixtures/golden/01-strong-fit/note.md | 155 +++ .../golden/01-strong-fit/terminal.txt | 22 + .../golden/02-job-type-override/email.html | 139 ++ .../golden/02-job-type-override/note-name.txt | 2 + .../golden/02-job-type-override/note.md | 147 ++ .../golden/02-job-type-override/terminal.txt | 19 + .../golden/03-unlisted-pay-perks/email.html | 139 ++ .../03-unlisted-pay-perks/note-name.txt | 2 + .../golden/03-unlisted-pay-perks/note.md | 144 ++ .../golden/03-unlisted-pay-perks/terminal.txt | 19 + .../fixtures/robin-sample/intake-answers.txt | 5 + .../job-assessment/scripts/assess_offline.py | 203 +++ .../job-assessment/tests/e2e-output-run1.md | 579 ++++++++ pipelines/job-assessment/tests/e2e-output.md | 613 +++++++++ .../job-assessment/tests/intake-transcript.md | 1210 +++++++++++++++++ .../job-assessment/tests/live/conftest.py | 25 + pipelines/job-assessment/tests/live/driver.py | 110 ++ .../tests/live/test_live_assessment.py | 109 ++ .../tests/live/test_live_intake.py | 88 ++ .../job-assessment/tests/test_e2e_offline.py | 219 +++ tools/job-fit-screen/.gitignore | 9 - tools/job-fit-screen/README.md | 100 -- tools/job-fit-screen/criteria.example.yaml | 152 --- tools/job-fit-screen/install.sh | 51 - tools/job-fit-screen/job_fit_screen.py | 285 ---- tools/job-fit-screen/requirements.txt | 4 - tools/job-fit-screen/run_screen.sh | 3 - tools/job-fit-screen/search.example.md | 76 -- tools/job-fit-screen/test_example.py | 73 - 35 files changed, 4162 insertions(+), 767 deletions(-) create mode 100644 pipelines/job-assessment/fixtures/golden/01-strong-fit/email.html create mode 100644 pipelines/job-assessment/fixtures/golden/01-strong-fit/note-name.txt create mode 100644 pipelines/job-assessment/fixtures/golden/01-strong-fit/note.md create mode 100644 pipelines/job-assessment/fixtures/golden/01-strong-fit/terminal.txt create mode 100644 pipelines/job-assessment/fixtures/golden/02-job-type-override/email.html create mode 100644 pipelines/job-assessment/fixtures/golden/02-job-type-override/note-name.txt create mode 100644 pipelines/job-assessment/fixtures/golden/02-job-type-override/note.md create mode 100644 pipelines/job-assessment/fixtures/golden/02-job-type-override/terminal.txt create mode 100644 pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/email.html create mode 100644 pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note-name.txt create mode 100644 pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note.md create mode 100644 pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/terminal.txt create mode 100644 pipelines/job-assessment/scripts/assess_offline.py create mode 100644 pipelines/job-assessment/tests/e2e-output-run1.md create mode 100644 pipelines/job-assessment/tests/e2e-output.md create mode 100644 pipelines/job-assessment/tests/intake-transcript.md create mode 100644 pipelines/job-assessment/tests/live/conftest.py create mode 100644 pipelines/job-assessment/tests/live/driver.py create mode 100644 pipelines/job-assessment/tests/live/test_live_assessment.py create mode 100644 pipelines/job-assessment/tests/live/test_live_intake.py create mode 100644 pipelines/job-assessment/tests/test_e2e_offline.py delete mode 100644 tools/job-fit-screen/.gitignore delete mode 100644 tools/job-fit-screen/README.md delete mode 100644 tools/job-fit-screen/criteria.example.yaml delete mode 100755 tools/job-fit-screen/install.sh delete mode 100755 tools/job-fit-screen/job_fit_screen.py delete mode 100644 tools/job-fit-screen/requirements.txt delete mode 100755 tools/job-fit-screen/run_screen.sh delete mode 100644 tools/job-fit-screen/search.example.md delete mode 100755 tools/job-fit-screen/test_example.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddb8ffd..7663efc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,50 @@ jobs: # first work package adds some, so only that exit code is tolerated. shell: bash run: python3 -m pytest -q || [ $? -eq 5 ] + + clean-clone: + name: clean-clone + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Clone the repo fresh, at the commit under test + shell: bash + run: | + git clone "https://github.com/${{ github.repository }}" fresh + cd fresh + git fetch --no-tags origin "${GITHUB_SHA}" + git checkout --detach "${GITHUB_SHA}" + git log -1 --format='testing commit %H' + - name: Install into a new virtual environment + working-directory: fresh/pipelines/job-assessment + run: | + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt + - name: The validator accepts the fictional person and rejects the broken file + working-directory: fresh/pipelines/job-assessment + shell: bash + run: | + .venv/bin/python scripts/validate_profile.py --fixture fixtures/robin-sample/career-profile.yaml + set +e + .venv/bin/python scripts/validate_profile.py --fixture fixtures/broken-profile.yaml + [ $? -eq 1 ] + - name: Run the whole chain offline on all three fictional postings + working-directory: fresh/pipelines/job-assessment + shell: bash + run: | + for id in 01-strong-fit 02-job-type-override 03-unlisted-pay-perks; do + echo "=== $id" + .venv/bin/python scripts/assess_offline.py "fixtures/postings/$id.md" \ + --findings "fixtures/findings/$id.findings.json" \ + --profile fixtures/robin-sample/career-profile.yaml \ + --out "/tmp/ja-out/$id" --date 2026-10-01 2>/dev/null + done + - name: Run the full test suite, including the golden-file comparison + working-directory: fresh/pipelines/job-assessment + run: .venv/bin/python -m pytest -q + - name: The run left the clone unchanged + working-directory: fresh + shell: bash + run: test -z "$(git status --porcelain)" diff --git a/README.md b/README.md index d3fd606..0073d11 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,11 @@ Skills, pipelines, and technique write-ups for building AI-agent systems that st **1. [`pipelines/docs-pipeline`](pipelines/docs-pipeline/)** is the strongest piece in this repo. It is a chain of Claude Code skills that takes a documentation change from the first request through structure review, voice, grammar, links, visuals, expert review, and publishing, with a real gate between every stage. I used a version of it every working day on real documentation. If you only open one thing, open this, and start with its [README](pipelines/docs-pipeline/README.md) and [ARCHITECTURE](pipelines/docs-pipeline/ARCHITECTURE.md). -**2. [`patterns/block-and-tell-hooks.md`](patterns/block-and-tell-hooks.md)** explains how to make an AI agent follow a rule every time instead of hoping it remembers. It is the idea behind most of the guards in my own setup. +**2. [`pipelines/job-assessment`](pipelines/job-assessment/)** scores a job posting against your own written criteria and gives a plain verdict: Apply, Apply with reservations, or Skip. A model reads the posting and writes down findings, quoting the posting for every claim. Ordinary code then checks those quotes, does the scoring and picks the verdict, so the same findings always give the same answer. It was built by directing Claude Code, not hand-typed. What is shown: the whole chain runs offline on three invented postings with no model, no network and no API key, and the 461 tests in that folder (run again in a fresh clone by CI) compare its scores, verdicts and saved output files to written-down expected values. A separate run with the `claude` CLI on Claude Sonnet is saved in [`tests/`](pipelines/job-assessment/tests/): the interview built a file that passed the checker, and the assessment gave the expected verdicts on 2 of 2 postings in its second attempt, after the first attempt got one verdict wrong ([both records are kept](pipelines/job-assessment/tests/e2e-output-run1.md)). What is not shown: that a verdict predicts getting hired, or that it beats any other method. No real postings or real person's data are in it. Its architecture and setup docs are still being written; the folder's README has a status list. -**3. [`patterns/typed-memory-system.md`](patterns/typed-memory-system.md) and [`patterns/rules-index-architecture.md`](patterns/rules-index-architecture.md)** cover how to keep an agent's memory and standing rules organized as they grow. +**3. [`patterns/block-and-tell-hooks.md`](patterns/block-and-tell-hooks.md)** explains how to make an AI agent follow a rule every time instead of hoping it remembers. It is the idea behind most of the guards in my own setup. -**4. [`pipelines/job-assessment`](pipelines/job-assessment/)** is a job-posting assessment system, being built in small pull requests. It is a scaffold only right now and nothing in it runs yet. Its README has a status list that shows what exists. - -**5. [`tools/job-fit-screen`](tools/job-fit-screen/)** is an early job-posting scorer. A much larger version is being published to replace it. +**4. [`patterns/typed-memory-system.md`](patterns/typed-memory-system.md) and [`patterns/rules-index-architecture.md`](patterns/rules-index-architecture.md)** cover how to keep an agent's memory and standing rules organized as they grow. ## Why this exists @@ -28,11 +26,9 @@ Every piece in here started as a real problem: a rule that kept getting skipped, **`patterns/`** — written technique docs for ideas that are more valuable described in prose than shipped as literal runnable code, either because the real implementation is too specific to one project to be useful as-is, or because the idea itself is the point. Covers: how to make an "always do X first" instruction actually reliable instead of hoped-for (block-and-tell hooks), how to keep an agent's standing instructions from becoming an unmaintainable single file as they grow (rules-index architecture), and how to give an agent memory that survives months of use without turning into an unreadable dump (typed, size-bounded memory). -**`tools/`** — small standalone utilities. A job-fit screener that scores a real job posting against your own personal fit criteria — culture, autonomy, and actual eligibility, not just keyword matching — using Claude to make the judgment calls a plain keyword scan can't. It is an early version (August 2026), and a much larger job assessment system is being published to replace it. - ## How the pieces relate -A skill is one task. A pipeline is several skills chained with real gates between them (nothing moves to the next stage until the current one passes). A pattern is the idea behind a mechanism, written down so it can be rebuilt in a different codebase without copying code that won't fit. The tools are the smaller, single-purpose things that don't need any of that structure. +A skill is one task. A pipeline is several skills chained with real gates between them (nothing moves to the next stage until the current one passes). A pattern is the idea behind a mechanism, written down so it can be rebuilt in a different codebase without copying code that won't fit. ## A note on how this was built diff --git a/ROADMAP.md b/ROADMAP.md index 1fca412..e773a6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ What's here now is a first batch. This is what's planned next, so it's clear wha ## In progress -**Job assessment system.** Being built in small pull requests under `pipelines/job-assessment/`. Only the scaffold and safety checks exist so far. Its README has the status list. +**Job assessment system.** Built in small pull requests under `pipelines/job-assessment/`. The scripts, schemas, fictional sample data, both skills and the offline end-to-end run are merged and tested; the earlier `tools/job-fit-screen` it replaces has been removed. Still to come: `ARCHITECTURE.md`, `SETUP.md` and the tested prompt blocks. Its README has the status list. ## Planned — batch 2 diff --git a/pipelines/job-assessment/README.md b/pipelines/job-assessment/README.md index 23d82c9..e08588a 100644 --- a/pipelines/job-assessment/README.md +++ b/pipelines/job-assessment/README.md @@ -1,6 +1,6 @@ # Job assessment system -**Status: scaffold only. Nothing in this folder is runnable yet.** This folder holds the repo checks and test setup that the rest of the system will be built on. The scripts, schemas, sample data and skills listed below do not exist yet, and each one will be added in its own pull request. +**Status: the chain runs offline on fictional data; the architecture, setup and tested-prompt docs are still to come.** Everything listed as done below exists in this folder and is covered by tests. The last two bullets are the only work left. The system is meant to score a job posting against a person's own written criteria and give a plain verdict: Apply, Apply with reservations, or Skip. Its docs will follow the style guides and prompt-block standard of [`pipelines/docs-pipeline`](../docs-pipeline/), which stays the first thing to look at in this repo. @@ -28,16 +28,22 @@ Each bullet flips from planned to done in the pull request that delivers it. - [x] **Skills catalog and survey.** A generic skills list and an offline form for scoring yourself against it. -- [ ] **Integration and proof.** The whole chain run end to end from a clean clone, plus the removal of the earlier `tools/job-fit-screen`. +- [x] **Integration and proof.** The whole chain run end to end offline, a separate live run with the `claude` CLI, a clean-clone check in CI, and the removal of the earlier `tools/job-fit-screen` (git history keeps it). - [ ] **Docs and prompt blocks.** `ARCHITECTURE.md`, `SETUP.md`, and the tested prompts for each doc. ## What can be run today -Only the repo checks: +From this folder, after `pip install -r requirements.txt`: ```text -python3 .github/scripts/safety_scan.py --tree +python3 -m pytest -q +python3 scripts/assess_offline.py fixtures/postings/03-unlisted-pay-perks.md \ + --findings fixtures/findings/03-unlisted-pay-perks.findings.json \ + --profile fixtures/robin-sample/career-profile.yaml --out /tmp/ja-out +python3 ../../.github/scripts/safety_scan.py --tree ``` -It prints `safety_scan: clean (tree)` and exits 0 when no email address, home path, private network address, tracker link, ticket id or secret-shaped string is in a tracked file. +The second command runs the whole chain with no model and no network: it checks the findings, scores them, picks the verdict, writes the assessment into an archived note, renames the note and writes the email card as a local HTML file. The tests in `tests/test_e2e_offline.py` compare that output for all three fictional postings to `fixtures/expected.yaml` and `fixtures/golden/`. + +The live runs in `tests/live/` need the `claude` CLI and `JA_LIVE=1`, are skipped otherwise, and never run in CI. Their saved records are `tests/intake-transcript.md`, `tests/e2e-output.md` and the first attempt, `tests/e2e-output-run1.md`. diff --git a/pipelines/job-assessment/fixtures/golden/01-strong-fit/email.html b/pipelines/job-assessment/fixtures/golden/01-strong-fit/email.html new file mode 100644 index 0000000..d60cc9e --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/01-strong-fit/email.html @@ -0,0 +1,148 @@ + + + + + + +Job Assessment + + + + + + + +
+ + + + + + + + + + + + + + + + +
+View the job posting +
+
Apply
+
Copperline Example Co. · Docs Platform Engineer
+
+

This is the job Robin is looking for: build the docs system, with AI drafting and a say in the tools. The only gap is reading Go code.

+
+
Scores
+ + + + + + + + + + + + + + + + + + + + + +
Fit + + + +
  
+
9
Comp + + + +
  
+
10
Qualifications + + + +
  
+
9
Culture + + + +
  
+
10
+
+
Checks
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
✨ AI-forward team
+
● Strong
+
+
AI drafting is normal here and people still check the result.
+
+
📦 Docs live in version control
+
● Strong
+
+
Docs are built like code, which is how Robin works.
+
+
🧪 Docs are built and checked automatically
+
● Weak
+
+
No automatic checks yet. The job is to build them.
+
+
🦸 Owns the docs system
+
● Fair
+
+
Robin would own the system. This is a bonus item, so it never costs points.
+
+
Autonomy
+
● Strong
+
+
Robin picks the tools and the priorities.
+
+
Pay
+
● Unknown
+
+
The posting lists base pay up to 150000.
+
+
+
+

Continue in your AI tool:

+
Open the saved assessment for Copperline Example Co., Docs Platform Engineer. Check each claim in it against the posting text, then help me decide whether to apply.
+
+
+ + diff --git a/pipelines/job-assessment/fixtures/golden/01-strong-fit/note-name.txt b/pipelines/job-assessment/fixtures/golden/01-strong-fit/note-name.txt new file mode 100644 index 0000000..04d01a5 --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/01-strong-fit/note-name.txt @@ -0,0 +1,2 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md diff --git a/pipelines/job-assessment/fixtures/golden/01-strong-fit/note.md b/pipelines/job-assessment/fixtures/golden/01-strong-fit/note.md new file mode 100644 index 0000000..5bbbe15 --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/01-strong-fit/note.md @@ -0,0 +1,155 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +--- +type: reference +title: "🟢 📋 🔧 Job Posting - Copperline Example Co.: Docs Platform Engineer" +company: "Copperline Example Co." +role: "Docs Platform Engineer" +lane: "docs-platform" +source: "board-a" +url: "https://example.com/fictional/jobs/copperline-docs-platform" +job_url: "https://example.com/fictional/jobs/copperline-docs-platform" +updated: 2026-10-01 +status: archived +purpose: > + Full saved copy of a job posting that was considered. +how_to_use_this_file: > + Read-only source record. Do not edit the posting text itself; if the + listing changes or comes down, add a dated note instead. + The assessment block at the top is the go/no-go read; the requirements split further down is a reading aid, not a fit score on its own. + Judge this posting against the docs-platform lane rules only. +opportunity_id: "copperline-example-co-docs-platform-engineer-2026-10-01" +source_urls: + - "https://example.com/fictional/jobs/copperline-docs-platform" +ats_id: "none" +state: apply_ready +state_updated: 2026-10-01 +next_action: "Decide: apply, hold, or skip" +next_action_due: none +--- + +# Job Posting - Copperline Example Co.: Docs Platform Engineer + +**Source URL:** https://example.com/fictional/jobs/copperline-docs-platform +**Considered:** 2026-10-01 +**Application status:** Not yet decided. Update this line when you apply, or record why you did not. + + +*Assessed 2026-10-01.* + +## Assessment + +**🚦 Verdict:** Apply +This is the job Robin is looking for: build the docs system, with AI drafting and a say in the tools. The only gap is reading Go code. + +**Scores:** +Fit 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Comp 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Qualifications 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Culture 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 + +**Assessed:** 2026-10-01 + +### ⛔ Hard blocks +None tripped. + +### 🏢 The company itself +Copperline Example Co. is one you have said you want to work for. Your reason: Makes tools for writers, which is the work Robin cares about most. This did not change the scores or the verdict. + +### ✅ / ⚠️ Requirements + +| Criterion | Rating | Read | +|---|---|---| +| ✨ AI-forward team | 🟢 Strong | "Our writers and engineers draft with AI assistants every day, and you will help decide which checks a human must still make." AI drafting is normal here and people still check the result. | +| 📦 Docs live in version control | 🟢 Strong | "all docs live in a Git repository as Markdown and are reviewed in pull requests" Docs are built like code, which is how Robin works. | +| 🧪 Docs are built and checked automatically | 🟠 Weak | "The docs build is still run by hand before each release, and we want you to change that." No automatic checks yet. The job is to build them. | +| 🦸 Owns the docs system | 🟡 Fair | "You will own the documentation system from the first week, with one other engineer to review your work." Robin would own the system. This is a bonus item, so it never costs points. | + +### 🎯 How much say you'd actually have +Robin picks the tools and the priorities. + +### 🔑 Language worth noticing + +| Phrase | What it signals | +|---|---| +| "definition of done" | Documentation counts as part of finished work. | + +### 🧩 How well the skills line up +- "all docs live in a Git repository as Markdown and are reviewed in pull requests" lines up with: Built a pipeline that publishes the docs from Markdown files kept in Git. +- "Experience generating API reference pages from an OpenAPI file." lines up with: Moved the API reference from hand-edited pages to pages generated from the API spec. +- "You can read Go code well enough to follow how our build tool works.": you rate yourself here, but nothing in your file proves it (go_lang). +- Years asked for: 7. + +### 🚩 Other things worth knowing + +| Item | Read | +|---|---| +| Nothing else | The posting raised nothing more. | + + + +--- + +## Requirements read + +9 read as likely real · 1 read as likely filters + +The split follows Modestino, Shoag and Ballance (*Review of Economics and Statistics* 102(4), 2020): stated requirements partly track how many applicants there are, not only the job. A line marked *likely filter* is not a reason to skip the role. + +| Requirement | Type | Read as | +|---|---|---| +| Build and run the system that turns our documentation into a public help site. | responsibility | likely real | +| You will own the documentation system from the first week, with one other engineer to review your work. | responsibility | likely real | +| Keep docs-as-code habits healthy: all docs live in a Git repository as Markdown and are reviewed in pull requests. | responsibility | likely real | +| Our writers and engineers draft with AI assistants every day, and you will help decide which checks a human must still make. | responsibility | likely real | +| Documentation is part of the definition of done for every feature. | responsibility | likely real | +| You choose the tools and set the priorities for the docs system; your manager checks in once a week. | responsibility | likely real | +| 7+ years of experience in technical writing or documentation engineering. | experience | likely filter | +| Experience generating API reference pages from an OpenAPI file. | skill | likely real | +| You can read Go code well enough to follow how our build tool works. | skill | likely real | +| The docs build is still run by hand before each release, and we want you to change that. | skill | likely real | + +--- + +## Full posting text + +# FICTIONAL EXAMPLE DATA. Not a real person. + +company: Copperline Example Co. +role: Docs Platform Engineer +lane: docs-platform +url: https://example.com/fictional/jobs/copperline-docs-platform +source: board-a + +--- + +# Docs Platform Engineer, Copperline Example Co. + +This is an invented job posting used to test a job assessment tool. Copperline Example Co. does not exist. + +## About the team + +Copperline makes writing tools for software teams. We are a remote company with about 60 people. + +## What you will do + +- Build and run the system that turns our documentation into a public help site. +- You will own the documentation system from the first week, with one other engineer to review your work. +- Keep docs-as-code habits healthy: all docs live in a Git repository as Markdown and are reviewed in pull requests. +- Our writers and engineers draft with AI assistants every day, and you will help decide which checks a human must still make. +- Documentation is part of the definition of done for every feature. +- You choose the tools and set the priorities for the docs system; your manager checks in once a week. + +## What we are looking for + +- 7+ years of experience in technical writing or documentation engineering. +- Experience generating API reference pages from an OpenAPI file. +- You can read Go code well enough to follow how our build tool works. +- The docs build is still run by hand before each release, and we want you to change that. + +## Pay and benefits + +- Base pay range for Region B: $125,000 to $150,000. Region A: $140,000 to $170,000. +- Unlimited paid time off, with a three-week minimum we ask everyone to take. +- A $3,000 yearly learning budget. +- Two team meetups a year. + diff --git a/pipelines/job-assessment/fixtures/golden/01-strong-fit/terminal.txt b/pipelines/job-assessment/fixtures/golden/01-strong-fit/terminal.txt new file mode 100644 index 0000000..5d844e9 --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/01-strong-fit/terminal.txt @@ -0,0 +1,22 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +ASSESSMENT: fixtures/postings/01-strong-fit.md + +🚦 VERDICT: Apply + +🏢 Copperline Example Co. is one you have said you want to work for. Your reason: Makes tools for writers, which is the work Robin cares about most. This did not change the scores or the verdict. + +**Scores:** +Fit 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Comp 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Qualifications 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Culture 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 + +| Check | Rating | Read | +|---|---|---| +| ✨ AI-forward team | 🟢 Strong | AI drafting is normal here and people still check the result. | +| 📦 Docs live in version control | 🟢 Strong | Docs are built like code, which is how Robin works. | +| 🧪 Docs are built and checked automatically | 🟠 Weak | No automatic checks yet. The job is to build them. | +| 🦸 Owns the docs system | 🟡 Fair | Robin would own the system. This is a bonus item, so it never costs points. | +| How much say you'd have | 🟢 Strong | Robin picks the tools and the priorities. | + +🟢 Strong · 🟡 Fair · 🟠 Weak · 🔴 Poor · ❓ Unknown diff --git a/pipelines/job-assessment/fixtures/golden/02-job-type-override/email.html b/pipelines/job-assessment/fixtures/golden/02-job-type-override/email.html new file mode 100644 index 0000000..5ed7c9c --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/02-job-type-override/email.html @@ -0,0 +1,139 @@ + + + + + + +Job Assessment + + + + + + + +
+ + + + + + + + + + + + + + + + +
+View the job posting +
+
Skip
+
Driftmark Example Media · Technical Writer, Video Tutorials
+
+

Most of the week is recording video and managing translation vendors, two kinds of work Robin would rather avoid. Pay tops out at the bottom of Robin's range too.

+
+
Scores
+ + + + + + + + + + + + + + + + + + + + + +
Fit + + + +
  
+
7
Comp + + + +
  
+
3
Qualifications + + + +
  
+
6
Culture + + + +
  
+
5
+
+
Checks
+ + + + + + + + + + + + + + + + + + + + + +
+
🧑‍🔬 Regular access to experts
+
● Fair
+
+
A weekly session is regular, though not constant.
+
+
🛠️ A say in the authoring tools
+
● Fair
+
+
Robin shares the choice of tools.
+
+
📖 A written style guide
+
● Poor
+
+
No written guide, and style is set by someone outside the writing team.
+
+
Autonomy
+
● Unknown
+
+
The posting gives no clear signal either way.
+
+
Pay
+
● Unknown
+
+
The posting lists base pay up to 100000.
+
+
+
+

Continue in your AI tool:

+
Open the saved assessment for Driftmark Example Media, Technical Writer, Video Tutorials. Check each claim in it against the posting text, then help me decide whether to apply.
+
+
+ + diff --git a/pipelines/job-assessment/fixtures/golden/02-job-type-override/note-name.txt b/pipelines/job-assessment/fixtures/golden/02-job-type-override/note-name.txt new file mode 100644 index 0000000..b062549 --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/02-job-type-override/note-name.txt @@ -0,0 +1,2 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +🔴 📰 ✍️ Driftmark Example Media - Technical Writer, Video Tutorials - 2026-10-01.md diff --git a/pipelines/job-assessment/fixtures/golden/02-job-type-override/note.md b/pipelines/job-assessment/fixtures/golden/02-job-type-override/note.md new file mode 100644 index 0000000..4040d5c --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/02-job-type-override/note.md @@ -0,0 +1,147 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +--- +type: reference +title: "🔴 📰 ✍️ Job Posting - Driftmark Example Media: Technical Writer, Video Tutorials" +company: "Driftmark Example Media" +role: "Technical Writer, Video Tutorials" +lane: "tech-writing" +source: "board-b" +url: "https://example.org/fictional/jobs/driftmark-video-tutorials" +job_url: "https://example.org/fictional/jobs/driftmark-video-tutorials" +updated: 2026-10-01 +status: archived +purpose: > + Full saved copy of a job posting that was considered. +how_to_use_this_file: > + Read-only source record. Do not edit the posting text itself; if the + listing changes or comes down, add a dated note instead. + The assessment block at the top is the go/no-go read; the requirements split further down is a reading aid, not a fit score on its own. + Judge this posting against the tech-writing lane rules only. +opportunity_id: "driftmark-example-media-technical-writer-video-tutorials-2026-10-01" +source_urls: + - "https://example.org/fictional/jobs/driftmark-video-tutorials" +ats_id: "none" +state: closed +state_updated: 2026-10-01 +next_action: none +next_action_due: none +--- + +# Job Posting - Driftmark Example Media: Technical Writer, Video Tutorials + +**Source URL:** https://example.org/fictional/jobs/driftmark-video-tutorials +**Considered:** 2026-10-01 +**Application status:** Not yet decided. Update this line when you apply, or record why you did not. + + +*Assessed 2026-10-01.* + +## Assessment + +**🚦 Verdict:** Skip +Most of the week is recording video and managing translation vendors, two kinds of work Robin would rather avoid. Pay tops out at the bottom of Robin's range too. + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 3 🟠🟠🟠⬛⬛⬛⬛⬛⬛⬛ +Qualifications 6 🟡🟡🟡🟡🟡🟡⬛⬛⬛⬛ +Culture 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ + +**Assessed:** 2026-10-01 + +### ⛔ Hard blocks +None tripped. + +### 🏢 The company itself +No stored reaction for this company. This did not change the scores or the verdict. + +### ✅ / ⚠️ Requirements + +| Criterion | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟡 Fair | "Writers have a weekly session with the product engineers to check facts." A weekly session is regular, though not constant. | +| 🛠️ A say in the authoring tools | 🟡 Fair | "You will pick the authoring tools together with the lead writer." Robin shares the choice of tools. | +| 📖 A written style guide | 🔴 Poor | "Style is decided by the marketing team, and there is no written guide." No written guide, and style is set by someone outside the writing team. | + +### 🎯 How much say you'd actually have +The posting gives no clear signal either way. + +### 🔑 Language worth noticing + +Nothing stood out. + +### 🧩 How well the skills line up +- "Fluent in DITA XML and a component content management system.": you rate yourself here, but nothing in your file proves it (dita_xml). +- "Experience documenting iOS and Android SDKs.": you rate yourself here, but nothing in your file proves it (mobile_sdk). +- Years asked for: 12. + +### 🚩 Other things worth knowing + +| Item | Read | +|---|---| +| Nothing else | The posting raised nothing more. | + + + +--- + +## Requirements read + +9 read as likely real · 1 read as likely filters + +The split follows Modestino, Shoag and Ballance (*Review of Economics and Statistics* 102(4), 2020): stated requirements partly track how many applicants there are, not only the job. A line marked *likely filter* is not a reason to skip the role. + +| Requirement | Type | Read as | +|---|---|---| +| Most of your week will be recording and editing screen-capture video tutorials. | responsibility | likely real | +| You will also coordinate our translation vendors and review their delivered files. | responsibility | likely real | +| Writers have a weekly session with the product engineers to check facts. | responsibility | likely real | +| You will pick the authoring tools together with the lead writer. | responsibility | likely real | +| Style is decided by the marketing team, and there is no written guide. | responsibility | likely real | +| 12+ years of technical writing experience. | experience | likely filter | +| Fluent in DITA XML and a component content management system. | skill | likely real | +| Experience documenting iOS and Android SDKs. | skill | likely real | +| Region A: $105,000 to $120,000. | skill | likely real | +| Everywhere else: $85,000 to $100,000. | skill | likely real | + +--- + +## Full posting text + +# FICTIONAL EXAMPLE DATA. Not a real person. + +company: Driftmark Example Media +role: Technical Writer, Video Tutorials +lane: tech-writing +url: https://example.org/fictional/jobs/driftmark-video-tutorials +source: board-b + +--- + +# Technical Writer, Video Tutorials, Driftmark Example Media + +This is an invented job posting used to test a job assessment tool. Driftmark Example Media does not exist. + +## About the role + +Driftmark sells training courses for a software product. We are a friendly team and we work from anywhere. + +## What you will do + +- Most of your week will be recording and editing screen-capture video tutorials. +- You will also coordinate our translation vendors and review their delivered files. +- Writers have a weekly session with the product engineers to check facts. +- You will pick the authoring tools together with the lead writer. +- Style is decided by the marketing team, and there is no written guide. + +## What we are looking for + +- 12+ years of technical writing experience. +- Fluent in DITA XML and a component content management system. +- Experience documenting iOS and Android SDKs. + +## Pay + +- Region A: $105,000 to $120,000. +- Everywhere else: $85,000 to $100,000. + diff --git a/pipelines/job-assessment/fixtures/golden/02-job-type-override/terminal.txt b/pipelines/job-assessment/fixtures/golden/02-job-type-override/terminal.txt new file mode 100644 index 0000000..055f134 --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/02-job-type-override/terminal.txt @@ -0,0 +1,19 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +ASSESSMENT: fixtures/postings/02-job-type-override.md + +🚦 VERDICT: Skip + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 3 🟠🟠🟠⬛⬛⬛⬛⬛⬛⬛ +Qualifications 6 🟡🟡🟡🟡🟡🟡⬛⬛⬛⬛ +Culture 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ + +| Check | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟡 Fair | A weekly session is regular, though not constant. | +| 🛠️ A say in the authoring tools | 🟡 Fair | Robin shares the choice of tools. | +| 📖 A written style guide | 🔴 Poor | No written guide, and style is set by someone outside the writing team. | +| How much say you'd have | ❓ Unknown | The posting gives no clear signal either way. | + +🟢 Strong · 🟡 Fair · 🟠 Weak · 🔴 Poor · ❓ Unknown diff --git a/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/email.html b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/email.html new file mode 100644 index 0000000..a351783 --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/email.html @@ -0,0 +1,139 @@ + + + + + + +Job Assessment + + + + + + + +
+ + + + + + + + + + + + + + + + +
+View the job posting +
+
Apply with reservations
+
Ashgrove Example Software · Senior Technical Writer
+
+

Pay is not listed and the perks are good. The role asks for more years than Robin has and for mobile and Kubernetes knowledge Robin lacks.

+
+
Scores
+ + + + + + + + + + + + + + + + + + + + + +
Fit + + + +
  
+
7
Comp + + + +
  
+
5
Qualifications + + + +
  
+
6
Culture + + + +
  
+
9
+
+
Checks
+ + + + + + + + + + + + + + + + + + + + + +
+
🧑‍🔬 Regular access to experts
+
● Weak
+
+
Access to experts is possible but not planned.
+
+
🛠️ A say in the authoring tools
+
● Unknown
+
+
The posting says nothing about who picks the tools.
+
+
📖 A written style guide
+
● Fair
+
+
A guide exists, and it is short.
+
+
Autonomy
+
● Unknown
+
+
Only generic collaboration phrases appear, which say nothing about autonomy.
+
+
Pay
+
● Unknown
+
+
The posting does not list pay.
+
+
+
+

Continue in your AI tool:

+
Open the saved assessment for Ashgrove Example Software, Senior Technical Writer. Check each claim in it against the posting text, then help me decide whether to apply.
+
+
+ + diff --git a/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note-name.txt b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note-name.txt new file mode 100644 index 0000000..0fffd9f --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note-name.txt @@ -0,0 +1,2 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +🟡 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md diff --git a/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note.md b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note.md new file mode 100644 index 0000000..580e5ff --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/note.md @@ -0,0 +1,144 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +--- +type: reference +title: "🟡 📋 ✍️ Job Posting - Ashgrove Example Software: Senior Technical Writer" +company: "Ashgrove Example Software" +role: "Senior Technical Writer" +lane: "tech-writing" +source: "board-a" +url: "https://example.net/fictional/jobs/ashgrove-senior-writer" +job_url: "https://example.net/fictional/jobs/ashgrove-senior-writer" +updated: 2026-10-01 +status: archived +purpose: > + Full saved copy of a job posting that was considered. +how_to_use_this_file: > + Read-only source record. Do not edit the posting text itself; if the + listing changes or comes down, add a dated note instead. + The assessment block at the top is the go/no-go read; the requirements split further down is a reading aid, not a fit score on its own. + Judge this posting against the tech-writing lane rules only. +opportunity_id: "ashgrove-example-software-senior-technical-writer-2026-10-01" +source_urls: + - "https://example.net/fictional/jobs/ashgrove-senior-writer" +ats_id: "none" +state: apply_ready +state_updated: 2026-10-01 +next_action: "Decide: apply, hold, or skip" +next_action_due: none +--- + +# Job Posting - Ashgrove Example Software: Senior Technical Writer + +**Source URL:** https://example.net/fictional/jobs/ashgrove-senior-writer +**Considered:** 2026-10-01 +**Application status:** Not yet decided. Update this line when you apply, or record why you did not. + + +*Assessed 2026-10-01.* + +## Assessment + +**🚦 Verdict:** Apply with reservations +Pay is not listed and the perks are good. The role asks for more years than Robin has and for mobile and Kubernetes knowledge Robin lacks. + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ +Qualifications 6 🟡🟡🟡🟡🟡🟡⬛⬛⬛⬛ +Culture 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ + +**Assessed:** 2026-10-01 + +### ⛔ Hard blocks +None tripped. + +### 🏢 The company itself +No stored reaction for this company. This did not change the scores or the verdict. + +### ✅ / ⚠️ Requirements + +| Criterion | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟠 Weak | "Engineers are busy and answer questions when they can." Access to experts is possible but not planned. | +| 🛠️ A say in the authoring tools | ❓ Unknown | The posting says nothing about who picks the tools. | +| 📖 A written style guide | 🟡 Fair | "We keep a short style guide in our wiki." A guide exists, and it is short. | + +### 🎯 How much say you'd actually have +Only generic collaboration phrases appear, which say nothing about autonomy. + +### 🔑 Language worth noticing + +Nothing stood out. + +### 🧩 How well the skills line up +- "Strong command of API reference writing and OpenAPI files." lines up with: Moved the API reference from hand-edited pages to pages generated from the API spec. +- "Comfortable with Kubernetes concepts because our product deploys on it.": you rate yourself here, but nothing in your file proves it (kubernetes). +- Years asked for: 11. + +### 🚩 Other things worth knowing + +| Item | Read | +|---|---| +| Pay | Not listed. Scored as unknown, not as bad. | + + + +--- + +## Requirements read + +6 read as likely real · 1 read as likely filters + +The split follows Modestino, Shoag and Ballance (*Review of Economics and Statistics* 102(4), 2020): stated requirements partly track how many applicants there are, not only the job. A line marked *likely filter* is not a reason to skip the role. + +| Requirement | Type | Read as | +|---|---|---| +| Write guides and reference pages for developers. | responsibility | likely real | +| Engineers are busy and answer questions when they can. | responsibility | likely real | +| We keep a short style guide in our wiki. | responsibility | likely real | +| You can document our mobile SDK for iOS and Android. | responsibility | likely real | +| At least 11 years of professional technical writing experience. | experience | likely filter | +| Strong command of API reference writing and OpenAPI files. | skill | likely real | +| Comfortable with Kubernetes concepts because our product deploys on it. | skill | likely real | + +--- + +## Full posting text + +# FICTIONAL EXAMPLE DATA. Not a real person. + +company: Ashgrove Example Software +role: Senior Technical Writer +lane: tech-writing +url: https://example.net/fictional/jobs/ashgrove-senior-writer +source: board-a + +--- + +# Senior Technical Writer, Ashgrove Example Software + +This is an invented job posting used to test a job assessment tool. Ashgrove Example Software does not exist. + +## About the role + +We are a fast-paced team that builds a mobile app and the services behind it. You will work closely with product managers and other stakeholders. + +## What you will do + +- Write guides and reference pages for developers. +- Engineers are busy and answer questions when they can. +- We keep a short style guide in our wiki. +- You can document our mobile SDK for iOS and Android. + +## What we are looking for + +- At least 11 years of professional technical writing experience. +- Strong command of API reference writing and OpenAPI files. +- Comfortable with Kubernetes concepts because our product deploys on it. + +## Pay and benefits + +- Compensation is discussed with candidates who reach the second interview. +- Every employee gets an extra five paid days off each year, on top of vacation. +- Each person has a yearly learning budget for books, courses and conferences. + diff --git a/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/terminal.txt b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/terminal.txt new file mode 100644 index 0000000..68969b6 --- /dev/null +++ b/pipelines/job-assessment/fixtures/golden/03-unlisted-pay-perks/terminal.txt @@ -0,0 +1,19 @@ +FICTIONAL EXAMPLE DATA. Not a real person. +ASSESSMENT: fixtures/postings/03-unlisted-pay-perks.md + +🚦 VERDICT: Apply with reservations + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ +Qualifications 6 🟡🟡🟡🟡🟡🟡⬛⬛⬛⬛ +Culture 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ + +| Check | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟠 Weak | Access to experts is possible but not planned. | +| 🛠️ A say in the authoring tools | ❓ Unknown | The posting says nothing about who picks the tools. | +| 📖 A written style guide | 🟡 Fair | A guide exists, and it is short. | +| How much say you'd have | ❓ Unknown | Only generic collaboration phrases appear, which say nothing about autonomy. | + +🟢 Strong · 🟡 Fair · 🟠 Weak · 🔴 Poor · ❓ Unknown diff --git a/pipelines/job-assessment/fixtures/robin-sample/intake-answers.txt b/pipelines/job-assessment/fixtures/robin-sample/intake-answers.txt index 3a1cdb9..9df2009 100644 --- a/pipelines/job-assessment/fixtures/robin-sample/intake-answers.txt +++ b/pipelines/job-assessment/fixtures/robin-sample/intake-answers.txt @@ -5,6 +5,9 @@ # Stage 0. Orientation s0.q1: Save it in the current folder as career-profile.yaml. s0.q2: No resume or portfolio to read. Start from scratch. +s0.q3: Robin Sample. +s0.q4: Senior Technical Writer and Docs Platform Engineer. +s0.q5: 9. # Stage 1. Job history (employers) s1.q1: Placeholder Labs, an invented developer tool company. @@ -54,6 +57,8 @@ s2.q4: Nothing I can show right now. s2.q5: Writing prompts for AI tools. # Stage 3. Skills survey (option B, in chat). Score 0 to 5, then want, last used, how, evidence. +# The ids below are Robin's own words, not catalog ids. Stage 3 allows skills outside the catalog, +# so a valid run stores these ids as given, with labels in Robin's words and no group or match. s3.openapi: 4, want more, last used within 2 years, self and AI, evidence is the API reference rebuild s3.docs_as_code: 5, want more, within 2 years, self and AI and team, evidence is the docs pipeline and the automatic checks s3.static_sites: 4, neutral, within 2 years, self and AI, evidence is the docs pipeline diff --git a/pipelines/job-assessment/scripts/assess_offline.py b/pipelines/job-assessment/scripts/assess_offline.py new file mode 100644 index 0000000..bd84298 --- /dev/null +++ b/pipelines/job-assessment/scripts/assess_offline.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Run the whole assessment chain on one posting with no model and no network. + + assess_offline.py POSTING --findings F.json --profile P.yaml --out DIR + [--date YYYY-MM-DD] [--company C] [--role R] [--lane L] + [--url U] [--source KEY] [--force] + +The findings file stands in for the model's reading of the posting. Everything +after that is the same code the assessment skill calls, in the same order: + + validate profile -> check findings against the schema -> archive the posting + -> load the lane -> check quotes -> score -> verdict -> render the block + -> write it into the note -> rename the note -> terminal summary -> email card + +The terminal summary goes to stdout. Progress lines and file paths go to stderr. +Files written under DIR: archive/.md, work/*.json, work/block.md, +work/terminal.txt, email/.html. + +A fixture posting may start with a few `key: value` lines (company, role, lane, +url, source) before a `---` line. Those fill in any option you leave out. + +Exit codes: 0 done, 1 a step refused (the step and its message are printed), +2 an input could not be read. +""" +import argparse +import json +import re +import subprocess +import sys +from datetime import date +from pathlib import Path + +import yaml + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent +SCRIPTS = ROOT / "assessment" / "scripts" +sys.path.insert(0, str(SCRIPTS)) +import verdict as verdict_mod # noqa: E402 + +PY = sys.executable +WORD = {"Apply": "apply", "Apply with reservations": "reservations", "Skip": "skip"} + + +class StepFailed(Exception): + def __init__(self, step, message, code=1): + super().__init__(message) + self.step, self.message, self.code = step, message, code + + +def run(step, cmd, ok=(0,)): + """Run one script. Return its stdout. Raise StepFailed on a bad exit code.""" + p = subprocess.run([PY, *[str(c) for c in cmd]], capture_output=True, text=True) + if p.returncode not in ok: + text = (p.stdout + p.stderr).strip() or f"exit {p.returncode}" + raise StepFailed(step, text, 1) + return p.stdout + + +def preamble(text): + """Read the `key: value` lines that may sit above the first `---` line.""" + found = {} + for line in text.splitlines(): + if line.strip() == "---": + break + m = re.match(r"^([a-z_]+):\s*(.+)$", line) + if m: + found[m.group(1)] = m.group(2).strip() + return found + + +def check_schema(findings, step="findings schema"): + try: + import jsonschema + except ImportError: + return + schema = json.loads((ROOT / "schema" / "findings.schema.json").read_text(encoding="utf-8")) + errors = sorted(jsonschema.Draft202012Validator(schema).iter_errors(findings), key=lambda e: list(e.path)) + if errors: + lines = [f"{'/'.join(str(p) for p in e.path) or 'top level'}: {e.message}" for e in errors[:5]] + raise StepFailed(step, "\n".join(lines)) + + +def assess(args): + posting = Path(args.posting) + findings_path = Path(args.findings) + profile = Path(args.profile) + for label, path in (("posting", posting), ("findings", findings_path), ("profile", profile)): + if not path.is_file(): + raise StepFailed("read inputs", f"Cannot read {label}: {path}", 2) + try: + findings = json.loads(findings_path.read_text(encoding="utf-8")) + except ValueError as exc: + raise StepFailed("read inputs", f"{findings_path} is not valid JSON: {exc}", 2) + + pre = preamble(posting.read_text(encoding="utf-8")) + company = args.company or pre.get("company") + role = args.role or pre.get("role") + lane = args.lane or pre.get("lane") or findings.get("lane") + url = args.url or pre.get("url") + source = args.source or pre.get("source") + missing = [n for n, v in (("company", company), ("role", role), ("lane", lane)) if not v] + if missing: + raise StepFailed("read inputs", f"No {', '.join(missing)}: pass it as an option or put it at the top of the posting.", 2) + + out = Path(args.out) + archive, work, mail = out / "archive", out / "work", out / "email" + for d in (archive, work, mail): + d.mkdir(parents=True, exist_ok=True) + day = args.date or date.today().isoformat() + log = lambda msg: print(msg, file=sys.stderr) # noqa: E731 + + log("1/10 validate the profile") + run("validate profile", [ROOT / "scripts" / "validate_profile.py", profile, "--fixture"]) + log("2/10 check the findings against the schema") + check_schema(findings) + + log("3/10 parse and archive the posting") + cmd = [SCRIPTS / "parse_posting.py", posting, "--company", company, "--role", role, "--lane", lane, + "--archive-dir", archive, "--date", day] + if url: + cmd += ["--url", url] + if source: + cmd += ["--source", source] + if args.force: + cmd.append("--force") + parsed = subprocess.run([PY, *[str(c) for c in cmd]], capture_output=True, text=True) + if parsed.returncode == 4: + raise StepFailed("parse posting", (parsed.stdout + parsed.stderr).strip() + "\nUse a new --out folder, or pass --force.") + if parsed.returncode != 0: + raise StepFailed("parse posting", (parsed.stdout + parsed.stderr).strip()) + m = re.search(r"^Archived: (.+)$", parsed.stdout, re.M) + if not m: + raise StepFailed("parse posting", "parse_posting did not say where it saved the note.") + note = Path(m.group(1).strip()) + + log("4/10 load the lane and check the posting's lane matches") + run("load criteria", [SCRIPTS / "load_criteria.py", "--profile", profile, "--lane", lane, "--posting", note]) + + log("5/10 check every quote and id in the findings") + run("check findings", [SCRIPTS / "check_findings.py", findings_path, "--posting", note, "--profile", profile]) + + log("6/10 score and decide") + scores_text = run("score", [SCRIPTS / "score_helpers.py", findings_path, "--profile", profile, "--json"]) + (work / "scores.json").write_text(scores_text, encoding="utf-8") + scores = json.loads(scores_text) + decision = verdict_mod.decide( + scores["fit"], scores["qualifications"], scores["comp"], scores["culture"], + override=verdict_mod.override_skill(findings), hard_block=verdict_mod.hard_block_id(findings)) + (work / "verdict.json").write_text(json.dumps(decision, indent=2) + "\n", encoding="utf-8") + word = WORD[decision["label"]] + + log("7/10 render the assessment block and write it into the note") + render = [SCRIPTS / "render_assessment.py", findings_path, "--scores", work / "scores.json", + "--verdict", work / "verdict.json", "--profile", profile, "--date", day] + (work / "block.md").write_text(run("render block", render), encoding="utf-8") + run("write assessment", [SCRIPTS / "write_assessment.py", note, "--block", work / "block.md", + "--verdict", word, "--date", day]) + + log("8/10 rename the note with its verdict emoji") + renamed = run("rename", [SCRIPTS / "rename_verdict.py", note, "--verdict", word, "--profile", profile]) + m = re.search(r"^Renamed: (.+)$", renamed, re.M) + final = Path(m.group(1).strip()) if m else note + + log("9/10 render the terminal summary") + terminal = run("terminal summary", render + ["--terminal"]) + (work / "terminal.txt").write_text(terminal, encoding="utf-8") + + log("10/10 render the email card") + card = [SCRIPTS / "render_email.py", "--note", final, "--findings", findings_path, "--scores", work / "scores.json", + "--verdict", work / "verdict.json", "--profile", profile, "--out", mail, "--date", day] + emailed = run("email card", card) + + print(terminal, end="" if terminal.endswith("\n") else "\n") + log(f"Verdict: {decision['label']} (trigger {decision['trigger']})") + log(f"Saved note: {final}") + log(emailed.strip()) + return 0 + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Run the assessment chain offline from a findings file.") + ap.add_argument("posting", help="posting text file") + ap.add_argument("--findings", required=True, help="findings JSON that stands in for the model's reading") + ap.add_argument("--profile", required=True, help="career-profile.yaml") + ap.add_argument("--out", required=True, help="folder for the note, work files and email card") + ap.add_argument("--date", help="assessment date YYYY-MM-DD (default today; pass it for repeatable output)") + ap.add_argument("--company") + ap.add_argument("--role") + ap.add_argument("--lane") + ap.add_argument("--url") + ap.add_argument("--source", help="job source key from the profile") + ap.add_argument("--force", action="store_true", help="archive even if the posting is already in DIR/archive") + args = ap.parse_args(argv) + try: + return assess(args) + except StepFailed as exc: + print(f"assess_offline: step '{exc.step}' stopped the run.\n{exc.message}", file=sys.stderr) + return exc.code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pipelines/job-assessment/tests/e2e-output-run1.md b/pipelines/job-assessment/tests/e2e-output-run1.md new file mode 100644 index 0000000..50d7835 --- /dev/null +++ b/pipelines/job-assessment/tests/e2e-output-run1.md @@ -0,0 +1,579 @@ + +# Live run, first attempt (posting 03 FAILED, kept as evidence of model variance) + +The second attempt, which passed, is in `e2e-output.md`. Nothing was changed between the two runs. + +Date: 2026-10-01. Model: `sonnet`. A model followed `assessment/SKILL.md` and wrote its own findings. The test recomputes scores and the verdict with the scripts and does not trust the model's numbers. Tool output is cut to 500 characters. Folder names are replaced with `` and ``. + +## Posting 01-strong-fit + +Commands run by the model: 11. Turns: 12. Cost: $0.25. + +### Checks + +- findings.json written: pass +- exactly one archived note: pass (['🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md']) +- email card written: pass +- check_findings.py passes on the model's findings: pass (check_findings: ok) +- verdict matches expected.yaml: pass (got Apply (4d), expected Apply) +- Scores from the model's findings: {'fit': 10, 'comp': 10, 'qualifications': 9, 'culture': 10}. Expected: {'fit': 9, 'comp': 10, 'qualifications': 9, 'culture': 10}. They differ: the model read the posting a little differently. The test requires the verdict, not identical scores. +- note name starts with the verdict dot: pass (🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md) + +Result: PASS + +### Transcript + +*Read* `/assessment/SKILL.md` + +```text +$ cd ; ls -R . | head -50; cat posting.md; cat career-profile.yaml +``` + +```text +assessment +career-profile.yaml +intake +posting.md +pytest.ini +README.md +requirements.txt +schema +scripts + +./assessment: +hooks +README.md +scripts +SKILL.md +templates + +./assessment/hooks: +assessment-email-template-guard.sh +README.md + +./assessment/scripts: +check_email.py +check_findings.py +load_criteria.py +match_skills.py +parse_posting.py +rename_verdict.py +render_assessment.py +render_email.py +score_helpers.py +verdict.py +write_assessment.py + +./assessment/templates: +assessment-email.html + +./intake: +README. +... (13077 more characters) +``` + +```text +1 --- +2 name: job-assessment +3 description: > +4 Go/no-go assessment of one job posting (a link or pasted text) against the +5 user's own career-profile.yaml. Archives the posting, loads the one lane's +6 checklist, reads the posting and writes a findings JSON file in which every +7 rating quotes the posting and every claimed qualification cites an evidence +8 id. Scripts then check the findings, compute the four scores (Fit, Comp, +9 Qualifications, Culture), apply the verdict rules, writ +... (27092 more characters) +``` + +```text +$ cd ; mkdir -p archive work out +python3 assessment/../scripts/validate_profile.py career-profile.yaml 2>&1 | tail -5; ls scripts +python3 assessment/scripts/parse_posting.py posting.md --company "Copperline Example Co." --role "Docs Platform Engineer" --lane docs-platform --archive-dir archive --url "https://example.com/fictional/jobs/copperline-docs-platform" --source board-a +``` + +```text +validate_profile: clean, 0 warning(s) in career-profile.yaml +add_entry.py +assess_offline.py +validate_profile.py +POSTING PARSE: Copperline Example Co. / Docs Platform Engineer + +POSTING READ + 9 requirements likely real, 1 likely filters + +LIKELY REAL: these describe the actual work + + [responsibility] Build and run the system that turns our documentation into a public help site. + -> Describes the actual work, not a screening filter. + + [responsibility] You will own the documentation system +... (1993 more characters) +``` + +```text +$ cd ; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" +python3 assessment/scripts/load_criteria.py --profile career-profile.yaml --lane docs-platform --posting "$N" | tail -40 +python3 assessment/scripts/match_skills.py "$N" --profile career-profile.yaml +``` + +```text +perks (big = +2, nice = +1): + [unlimited_pto] Unlimited PTO (big) + [learning_budget] Learning budget (big) + [extra_days_off] Extra paid days off (big) + [offsites] Regular meetups or offsites (nice) + [home_office] Home-office or wellness stipend (nice) + [parental_leave] Paid parental leave (nice) + low time off: below 15 days a year + hustle phrases: rockstar, work hard play hard, on-call, weekend work + free phrases (read, cost nothing): fast-paced + +============================ +... (2340 more characters) +``` + +```text +$ cd ; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" +cat > work/findings.json </assessment/scripts/check_findings.py", line 259, in + sys.exit(main()) + ^^^^^^ + File "/assessment/scripts/check_findings.py", line 248, in main + problems = check(findings, text, meta.get("lane"), +... (417 more characters) +``` + +```text +$ cd ; grep -n -B2 -A12 "strong_positive_phrases\|keyword_signals_found" schema/findings.schema.json | head -70 +``` + +```text +9- "posting_file", "lane", "hard_block", "job_type_override", "requirements", "autonomy", +10- "pay", "culture", "qualifications", "avoid_core_skills", "company_read", +11: "verdict_reason", "keyword_signals_found", "new_signals_to_consider" +12- ], +13- "properties": { +14- "_banner": {"type": "string", "description": "Fixture files only: the FICTIONAL banner, as the first key."}, +15- "posting_file": {"type": "string", "minLength": 1}, +16- "lane": {"type": "string", "minLength": +... (1702 more characters) +``` + +```text +$ cd ; grep -n -A8 '"phrase_quotes"' schema/findings.schema.json +``` + +```text +207: "phrase_quotes": { +208- "type": "array", +209- "items": { +210- "type": "object", +211- "additionalProperties": false, +212- "required": ["phrase", "quote"], +213- "properties": {"phrase": {"type": "string"}, "quote": {"type": "string"}} +214- } +215- } +``` + +Fixing the findings file's phrase format (the schema wants objects, and keyword signals need a `read`), then re-checking. + +```text +$ cd ; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" +python3 - <<'EOF' +import json +p='work/findings.json'; f=json.load(open(p)) +q="Documentation is part of the definition of done for every feature." +f['culture']['strong_positive_phrases']=[{"phrase":"definition of done","quote":q}] +f['keyword_signals_found']=[{"phrase":"definition of done","read":"Documentation counts as part of finished work, which is what you want.","quote":q}] +json.dump(f,open(p,'w'),indent=1) +EOF +python3 assessment/scripts/check_findings.py work/findings.json --posting "$N" --profile career-profile.yaml +``` + +```text +check_findings: ok +``` + +```text +$ cd ; P=career-profile.yaml +python3 assessment/scripts/score_helpers.py work/findings.json --profile $P --json > work/scores.json && cat work/scores.json +python3 -c "import json,sys; sys.path.insert(0,'assessment/scripts'); import verdict as v; \ +s=json.load(open('work/scores.json')); f=json.load(open('work/findings.json')); \ +print(json.dumps(v.decide(s['fit'],s['qualifications'],s['comp'],s['culture'], \ +override=v.override_skill(f),hard_block=v.hard_block_id(f))))" > work/verdict.json; cat work/verdict.json +``` + +```text +{ + "fit": 10, + "comp": 10, + "qualifications": 9, + "culture": 10, + "why": { + "fit": [], + "comp": [ + "Tier used: Region B", + "Top of range 150000 against target 140000, min 110000, floor 90000" + ], + "qualifications": [ + "1 load-bearing gap(s), -1 (cap 4)" + ], + "culture": [ + "Perk unlimited_pto (big), +2", + "Perk learning_budget (big), +2", + "Perk offsites (nice), +1", + "1 strong positive phrase(s), +1" + ] + } +} +{"label": "Apply", "trigge +... (41 more characters) +``` + +```text +$ cd ; P=career-profile.yaml; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile $P > work/block.md +cat >> work/block.md <<'EOF' + +### 🗒️ Notes on your skills +- OpenAPI / Swagger and Docs as code are both things you want to do more of, and both are core daily work here. +- Go is a gap: the posting wants you to read Go well enough to follow the build tool, and you have no proof of recent Go work. Expect a question about it. +EOF +python3 assessment/scripts/write_assessment.py "$N" --block work/block.md --verdict apply --date 2026-10-01 +python3 assessment/scripts/rename_verdict.py "$N" --verdict apply --profile $P +``` + +```text +Assessment written to archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md (state set to apply_ready). +Renamed: archive/🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md +``` + +```text +$ cd ; P=career-profile.yaml +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile $P --terminal +python3 assessment/scripts/render_email.py --note "archive/🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" --findings work/findings.json --scores work/scores.json --verdict work/verdict.json --profile $P --out out --next-prompt "Copperline Example Co., Docs Platform Engineer: Apply. Fit 10, Comp 10, Qualifications 9, Culture 10. Note: archive/🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md. Next: check for a warm introduction and prepare to speak to reading Go."; pwd +``` + +```text +ASSESSMENT: archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md + +🚦 VERDICT: Apply + +🏢 Copperline Example Co. is one you have said you want to work for. Your reason: Makes tools for writers, which is the work Robin cares about most. This did not change the scores or the verdict. + +**Scores:** +Fit 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Comp 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Qualifications 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Culture 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 + +| Check | Rating | Read | +|---|---|---| +| ✨ AI-forward team | 🟢 Strong | The team drafts with +... (928 more characters) +``` + +ASSESSMENT: Copperline Example Co., Docs Platform Engineer (docs-platform lane) + +🚦 **VERDICT: Apply** + +🏢 Copperline Example Co. is one you have said you want to work for. Your reason: Makes tools for writers, which is the work Robin cares about most. This did not change the scores or the verdict. + +**Scores:** +Fit 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Comp 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Qualifications 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Culture 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 + +| Check | Rating | Read | +|---|---|---| +| ✨ AI-forward team | 🟢 Strong | The team drafts with AI every day and people still check the work. That is what you want. | +| 📦 Docs live in version control | 🟢 Strong | Docs are in Git and reviewed in pull requests. This matches your best work. | +| 🧪 Docs are built and checked automatically | 🟡 Fair | Builds are not automatic today, but you would be the one to fix that. You have built automatic checks before. | +| 🦸 Owns the docs system | 🟢 Strong | You would own the docs system from week one. That is a bonus. | +| How much say you'd have | 🟢 Strong | You would pick the tools and set priorities. Your manager only checks in weekly, so you would have a lot of say. | + +🟢 Strong · 🟡 Fair · 🟠 Weak · 🔴 Poor · ❓ Unknown + +[Open the note](file:///archive/🟢%20📋%20🔧%20Copperline%20Example%20Co.%20-%20Docs%20Platform%20Engineer%20-%202026-10-01.md) + +The verdict is Apply, and no earlier rule fired: there was no hard block, no job-type override, and no score below the floor or in the reservations band. Because Copperline is a high-interest company, move fast and check for a warm introduction. The one thing to prepare for is the Go line: the posting wants you to read Go code, and you have no recorded evidence of recent Go work. That cost one Qualifications point. The Fit, Comp and Culture bars are all at 10, and Comp is based on the Region B tier ($125k–$150k). + +The email card is at `out/copperline-example-co-docs-platform-engineer.html`. It was written and passed its own check, and nothing was sent. + +I skipped one thing: the pay-range-listed benefit isn't a field in the findings file, so I didn't record it. + + +## Posting 03-unlisted-pay-perks + +Commands run by the model: 7. Turns: 8. Cost: $0.21. + +### Checks + +- findings.json written: pass +- exactly one archived note: pass (['🟢 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md']) +- email card written: pass +- check_findings.py passes on the model's findings: pass (check_findings: ok) +- verdict matches expected.yaml: FAIL (got Apply (4d), expected Apply with reservations) +- Scores from the model's findings: {'fit': 7, 'comp': 5, 'qualifications': 7, 'culture': 9}. Expected: {'fit': 7, 'comp': 5, 'qualifications': 6, 'culture': 9}. They differ: the model read the posting a little differently. The test requires the verdict, not identical scores. +- note name starts with the verdict dot: pass (🟢 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md) + +Result: FAIL + +### Transcript + +```text +$ cd ; ls -R . | head -50; cat assessment/SKILL.md +``` + +```text +assessment +career-profile.yaml +intake +posting.md +pytest.ini +README.md +requirements.txt +schema +scripts + +./assessment: +hooks +README.md +scripts +SKILL.md +templates + +./assessment/hooks: +assessment-email-template-guard.sh +README.md + +./assessment/scripts: +check_email.py +check_findings.py +load_criteria.py +match_skills.py +parse_posting.py +rename_verdict.py +render_assessment.py +render_email.py +score_helpers.py +verdict.py +write_assessment.py + +./assessment/templates: +assessment-email.html + +./intake: +README. +... (25834 more characters) +``` + +```text +$ cd ; mkdir -p archive work out; cat posting.md; python3 scripts/validate_profile.py career-profile.yaml; echo; cat career-profile.yaml +``` + +```text +# FICTIONAL EXAMPLE DATA. Not a real person. + +company: Ashgrove Example Software +role: Senior Technical Writer +lane: tech-writing +url: https://example.net/fictional/jobs/ashgrove-senior-writer +source: board-a + +--- + +# Senior Technical Writer, Ashgrove Example Software + +This is an invented job posting used to test a job assessment tool. Ashgrove Example Software does not exist. + +## About the role + +We are a fast-paced team that builds a mobile app and the services behind it. You will work closely w +... (12077 more characters) +``` + +```text +$ cd ; python3 assessment/scripts/parse_posting.py posting.md --company "Ashgrove Example Software" --role "Senior Technical Writer" --lane tech-writing --archive-dir archive --url "https://example.net/fictional/jobs/ashgrove-senior-writer" --source board-a; ls archive +``` + +```text +POSTING PARSE: Ashgrove Example Software / Senior Technical Writer + +POSTING READ + 6 requirements likely real, 1 likely filters + +LIKELY REAL: these describe the actual work + + [responsibility] Write guides and reference pages for developers. + -> Describes the actual work, not a screening filter. + + [responsibility] Engineers are busy and answer questions when they can. + -> Describes the actual work, not a screening filter. + + [responsibility] We keep a short style guide in our wiki +... (1115 more characters) +``` + +```text +$ cd ; N="archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md"; python3 assessment/scripts/load_criteria.py --profile career-profile.yaml --lane tech-writing --posting "$N"; python3 assessment/scripts/match_skills.py "$N" --profile career-profile.yaml; cat schema/findings.schema.json | head -5 +``` + +```text +CRITERIA SOURCE: career-profile.yaml +LANE: tech-writing (verified: posting and profile agree) + +====================================================================== +PERSON (context for Qualifications and Comp) +====================================================================== + years_experience: 9 + working_style: gather_from_experts + location_label: Region B + target_roles: Senior Technical Writer, Docs Platform Engineer + +================================================================== +... (6433 more characters) +``` + +```text +$ cd ; cat > work/findings.json <<'EOF' +{ + "posting_file": "archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md", + "lane": "tech-writing", + "hard_block": {"tripped": false, "id": null, "quote": null, "named_exception": null}, + "job_type_override": {"fired": false, "skill": null, "quotes": [], "evidence_checked": []}, + "requirements": [ + {"id": "expert_access", "rating": "weak", "quote": "Engineers are busy and answer questions when they can.", + "read": "This one matters a lot. The posting says engineers are busy and answer when they can. That is not regular access, so you may wait on the people you need."}, + {"id": "tooling_voice", "rating": "unknown", "quote": null, + "read": "The posting says nothing about who picks the authoring tools."}, + {"id": "style_guide", "rating": "strong", "quote": "We keep a short style guide in our wiki.", + "read": "A written style guide exists. It is short, but it is there."} + ], + "autonomy": {"net": "unknown", "quotes": [], "read": "The posting does not say how much say you would have. +... (2150 more characters) +``` + +```text +check_findings: ok +{ + "fit": 7, + "comp": 5, + "qualifications": 7, + "culture": 9, + "why": { + "fit": [ + "🧑‍🔬 Regular access to experts: weak, -1.5", + "🛠️ A say in the authoring tools: unknown, -1.5" + ], + "comp": [ + "Pay is not listed, neutral 5" + ], + "qualifications": [ + "Asks for more years than the person has, -2", + "1 load-bearing gap(s), -1 (cap 4)" + ], + "culture": [ + "Perk extra_days_off (big), +2", + "Perk learning_budget (big), +2" +... (80 more characters) +``` + +Scoring is done and the verdict is Apply. Next I'll write the note, rename it, and render the summary and email card. + +```text +$ cd ; N="archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md" +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile career-profile.yaml > work/block.md +cat >> work/block.md <<'EOF' + +### 🗒️ Notes on your skills +- OpenAPI / Swagger is a skill you want more of, and the posting asks for it directly. +- Kubernetes is a gap for you. The posting only asks for comfort with the concepts, so it is a small miss. +- The posting also mentions documenting a mobile SDK for iOS and Android, which you have not done. It is worded as something you can do, not a requirement. +- The posting asks for 11 years and you have 9. That kind of number mostly tracks how many people apply. +EOF +python3 assessment/scripts/write_assessment.py "$N" --block work/block.md --verdict apply --date 2026-10-01 +python3 assessment/scripts/rename_verdict.py "$N" --verdict apply --profile career-profile.yaml; ls archive +``` + +```text +Assessment written to archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md (state set to apply_ready). +Renamed: archive/🟢 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md +🟢 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md +``` + +```text +$ cd ; R="archive/🟢 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md" +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile career-profile.yaml --terminal +echo ---- +python3 assessment/scripts/render_email.py --note "$R" --findings work/findings.json --scores work/scores.json --verdict work/verdict.json --profile career-profile.yaml --out out --next-prompt "Ashgrove Example Software, Senior Technical Writer: Apply. Fit 7, Comp 5, Qualifications 7, Culture 9. Note: $PWD/$R. Next: ask about pay and engineer access, then decide whether to apply."; pwd +``` + +```text +ASSESSMENT: archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md + +🚦 VERDICT: Apply + +🏢 Ashgrove Example Software is a good company, not a dream one. Your reason: Solid product and a fair size, but nothing Robin would move for. This did not change the scores or the verdict. + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ +Qualifications 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Culture 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ + +| Check | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟠 Weak | This one matter +... (884 more characters) +``` + +**ASSESSMENT: Ashgrove Example Software, Senior Technical Writer (tech-writing lane)** + +🚦 **VERDICT: Apply** + +🏢 Ashgrove is a good company, not a dream one. That read did not change any score or the verdict. + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ +Qualifications 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Culture 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ + +| Check | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟠 Weak | Engineers are busy and answer when they can. That is not regular access. | +| 🛠️ A say in the authoring tools | ❓ Unknown | The posting says nothing about who picks the tools. | +| 📖 A written style guide | 🟢 Strong | A short style guide exists. | +| How much say you'd have | ❓ Unknown | Only generic "work closely with" language. | + +🟢 Strong · 🟡 Fair · 🟠 Weak · 🔴 Poor · ❓ Unknown + +Note: file:///archive/🟢%20📋%20✍️%20Ashgrove%20Example%20Software%20-%20Senior%20Technical%20Writer%20-%202026-10-01.md + +The verdict is Apply because no hard block, job-type override or score floor fired. Pay is not listed, so Comp stays at a neutral 5. Culture is high because the posting lists extra paid days off and a learning budget. + +Things to act on: +- **Pay:** ask for the pay range early. The posting only says it is discussed at the second interview. +- **Engineer access:** confirm how much time you would get with the engineers. +- **Gaps:** the posting asks for 11 years and you have 9, which mostly tracks how many people apply. Kubernetes is a small gap, since it only asks for comfort with the concepts. Mobile SDK docs are a duty you have not done, but they are worded as something you "can" do. +- **Outside research:** reviews and funding were not checked. + +The email card is at `out/ashgrove-example-software-senior-technical-writer.html`, and its subject is "🟢 Job Assessment: Ashgrove Example Software - Senior Technical Writer - 2026-10-01". It was not sent. + diff --git a/pipelines/job-assessment/tests/e2e-output.md b/pipelines/job-assessment/tests/e2e-output.md new file mode 100644 index 0000000..886dea7 --- /dev/null +++ b/pipelines/job-assessment/tests/e2e-output.md @@ -0,0 +1,613 @@ + +# Live run: assessment on the fictional postings + +Date: 2026-10-01. Model: `sonnet`. A model followed `assessment/SKILL.md` and wrote its own findings. The test recomputes scores and the verdict with the scripts and does not trust the model's numbers. Tool output is cut to 500 characters. Folder names are replaced with `` and ``. + +## Posting 01-strong-fit + +Commands run by the model: 12. Turns: 13. Cost: $0.24. + +### Checks + +- findings.json written: pass +- exactly one archived note: pass (['🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md']) +- email card written: pass +- check_findings.py passes on the model's findings: pass (check_findings: ok) +- verdict matches expected.yaml: pass (got Apply (4d), expected Apply) +- Scores from the model's findings: {'fit': 10, 'comp': 10, 'qualifications': 9, 'culture': 10}. Expected: {'fit': 9, 'comp': 10, 'qualifications': 9, 'culture': 10}. They differ: the model read the posting a little differently. The test requires the verdict, not identical scores. +- note name starts with the verdict dot: pass (🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md) + +Result: PASS + +### Transcript + +*Read* `/assessment/SKILL.md` + +```text +$ cd ; ls -R . | head -50; mkdir -p archive work out +``` + +```text +1 --- +2 name: job-assessment +3 description: > +4 Go/no-go assessment of one job posting (a link or pasted text) against the +5 user's own career-profile.yaml. Archives the posting, loads the one lane's +6 checklist, reads the posting and writes a findings JSON file in which every +7 rating quotes the posting and every claimed qualification cites an evidence +8 id. Scripts then check the findings, compute the four scores (Fit, Comp, +9 Qualifications, Culture), apply the verdict rules, writ +... (27092 more characters) +``` + +```text +assessment +career-profile.yaml +intake +posting.md +pytest.ini +README.md +requirements.txt +schema +scripts + +./assessment: +hooks +README.md +scripts +SKILL.md +templates + +./assessment/hooks: +assessment-email-template-guard.sh +README.md + +./assessment/scripts: +check_email.py +check_findings.py +load_criteria.py +match_skills.py +parse_posting.py +rename_verdict.py +render_assessment.py +render_email.py +score_helpers.py +verdict.py +write_assessment.py + +./assessment/templates: +assessment-email.html + +./intake: +README. +... (129 more characters) +``` + +```text +$ cd ; cat posting.md; ls scripts schema; python3 scripts/validate_profile.py career-profile.yaml +``` + +```text +# FICTIONAL EXAMPLE DATA. Not a real person. + +company: Copperline Example Co. +role: Docs Platform Engineer +lane: docs-platform +url: https://example.com/fictional/jobs/copperline-docs-platform +source: board-a + +--- + +# Docs Platform Engineer, Copperline Example Co. + +This is an invented job posting used to test a job assessment tool. Copperline Example Co. does not exist. + +## About the team + +Copperline makes writing tools for software teams. We are a remote company with about 60 people. + +## What you +... (1370 more characters) +``` + +```text +$ cd ; python3 assessment/scripts/parse_posting.py posting.md --company "Copperline Example Co." --role "Docs Platform Engineer" --lane docs-platform --archive-dir archive --url "https://example.com/fictional/jobs/copperline-docs-platform" --source board-a +``` + +```text +POSTING PARSE: Copperline Example Co. / Docs Platform Engineer + +POSTING READ + 9 requirements likely real, 1 likely filters + +LIKELY REAL: these describe the actual work + + [responsibility] Build and run the system that turns our documentation into a public help site. + -> Describes the actual work, not a screening filter. + + [responsibility] You will own the documentation system from the first week, with one other engineer to review your work. + -> Describes the actual work, not a scr +... (1881 more characters) +``` + +```text +$ cd ; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md"; python3 assessment/scripts/load_criteria.py --profile career-profile.yaml --lane docs-platform --posting "$N"; python3 assessment/scripts/match_skills.py "$N" --profile career-profile.yaml +``` + +```text +CRITERIA SOURCE: career-profile.yaml +LANE: docs-platform (verified: posting and profile agree) + +====================================================================== +PERSON (context for Qualifications and Comp) +====================================================================== + years_experience: 9 + working_style: gather_from_experts + location_label: Region B + target_roles: Senior Technical Writer, Docs Platform Engineer + +================================================================= +... (6111 more characters) +``` + +```text +$ cd ; grep -n -B2 -A14 "^evidence" career-profile.yaml | head -120; grep -n "go_lang\|openapi\|docs_as_code" career-profile.yaml +``` + +```text +90- - {id: northwind, name: Northwind Example Co., title: Technical Writer, start: "2017-02", end: "2021-12", summary: "Wrote and maintained the help center and API reference for a small invented software product."} +91- - {id: placeholder_labs, name: Placeholder Labs, title: Senior Technical Writer, start: "2022-01", summary: "Leads documentation for an invented developer tool and runs the system that builds and checks the docs."} +92:evidence: +93- - id: ev-northwind-api-rebuild +94- employe +... (1538 more characters) +``` + +Status: the posting is archived and the checklist and skill match are done. I'm writing the findings file now, then I'll run the checks, scores, verdict, note, summary and email card. + +```text +$ cd ; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" +cat > work/findings.json </assessment/scripts/check_findings.py", line 259, in + sys.exit(main()) + ^^^^^^ + File "/assessment/scripts/check_findings.py", line 248, in main + problems = check(findings, text, meta.get("lane"), +... (417 more characters) +``` + +```text +$ cd ; python3 - <<'E' +import json +s=json.load(open('schema/findings.schema.json')) +c=s['properties']['culture']['properties'] +print(json.dumps(c['strong_positive_phrases']));print(json.dumps(s['properties']['keyword_signals_found'])) +E +``` + +```text +{"$ref": "#/$defs/phrase_quotes"} +{"type": "array", "items": {"type": "object", "required": ["phrase", "read"], "properties": {"phrase": {"type": "string"}, "read": {"type": "string"}}}} +``` + +```text +$ cd ; python3 -c " +import json;s=json.load(open('schema/findings.schema.json'));print(json.dumps(s['\$defs']['phrase_quotes']))" +``` + +```text +{"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["phrase", "quote"], "properties": {"phrase": {"type": "string"}, "quote": {"type": "string"}}}} +``` + +```text +$ cd ; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" +python3 - <<'E' +import json +p='work/findings.json';f=json.load(open(p)) +f['culture']['strong_positive_phrases']=[{"phrase":"definition of done","quote":"Documentation is part of the definition of done for every feature."}] +f['keyword_signals_found']=[{"phrase":"definition of done","read":"Documentation counts as part of finished work, which is a strong good sign."}] +json.dump(f,open(p,'w'),indent=1) +E +P=career-profile.yaml +python3 assessment/scripts/check_findings.py work/findings.json --posting "$N" --profile $P && python3 assessment/scripts/score_helpers.py work/findings.json --profile $P --json > work/scores.json && cat work/scores.json && python3 -c "import json,sys; sys.path.insert(0,'assessment/scripts'); import verdict as v; \ +s=json.load(open('work/scores.json')); f=json.load(open('work/findings.json')); \ +print(json.dumps(v.decide(s['fit'],s['qualifications'],s['comp'],s['culture'], \ +override=v.override_skill(f),hard_block=v.hard_block_id(f))))" > work/verdict.json; cat work/verdict. +... (4 more characters) +``` + +```text +check_findings: ok +{ + "fit": 10, + "comp": 10, + "qualifications": 9, + "culture": 10, + "why": { + "fit": [], + "comp": [ + "Tier used: Region B", + "Top of range 150000 against target 140000, min 110000, floor 90000" + ], + "qualifications": [ + "1 load-bearing gap(s), -1 (cap 4)" + ], + "culture": [ + "Perk unlimited_pto (big), +2", + "Perk learning_budget (big), +2", + "Perk offsites (nice), +1", + "1 strong positive phrase(s), +1" + ] + } +} +{"label +... (60 more characters) +``` + +Verdict is Apply, which matches my reason text. Next I'll write the note block, rename the note, and render the summary and card. + +```text +$ cd ; N="archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md"; P=career-profile.yaml +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile $P > work/block.md +cat >> work/block.md <<'E' + +### 🗒️ Notes on your skills +- Go is a stretch: you scored it low and last used it over five years ago, and the posting asks you to read it well enough to follow the build tool. +- OpenAPI and docs as code are things you want more of, and both are core to this job. +- Your OpenAPI work was done by directing AI, so interviews may probe hands-on depth. +E +python3 assessment/scripts/write_assessment.py "$N" --block work/block.md --verdict apply --date 2026-10-01 +python3 assessment/scripts/rename_verdict.py "$N" --verdict apply --profile $P +ls archive +``` + +```text +Assessment written to archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md (state set to apply_ready). +Renamed: archive/🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md +🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md +``` + +```text +$ cd ; P=career-profile.yaml; R="archive/🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md" +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile $P --terminal +echo ---- +python3 assessment/scripts/render_email.py --note "$R" --findings work/findings.json --scores work/scores.json --verdict work/verdict.json --profile $P --out out --next-prompt "Copperline Example Co., Docs Platform Engineer: Apply. Fit 10, Comp 10, Qualifications 9, Culture 10. Note: $PWD/$R. Next: check for a warm introduction, then draft the application." +``` + +```text +ASSESSMENT: archive/Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md + +🚦 VERDICT: Apply + +🏢 Copperline Example Co. is one you have said you want to work for. Your reason: Makes tools for writers, which is the work Robin cares about most. This did not change the scores or the verdict. + +**Scores:** +Fit 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Comp 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Qualifications 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Culture 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 + +| Check | Rating | Read | +|---|---|---| +| ✨ AI-forward team | 🟢 Strong | The team drafts with +... (896 more characters) +``` + +ASSESSMENT: Copperline Example Co. – Docs Platform Engineer (docs-platform lane) + +🚦 **VERDICT: Apply** + +🏢 Copperline is one of your high-interest companies. Your reason on file: "Makes tools for writers, which is the work Robin cares about most." This did not change any score or the verdict. + +**Scores:** +Fit 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Comp 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 +Qualifications 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ +Culture 10 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟 + +| Check | Rating | Read | +|---|---|---| +| ✨ AI-forward team | 🟢 Strong | The team drafts with AI every day and people still check the result. That is the setup you want. | +| 📦 Docs live in version control | 🟢 Strong | Docs sit in Git and go through pull request review. This matters a lot to you and it is clearly met. | +| 🧪 Docs are built and checked automatically | 🟡 Fair | Docs are not built and checked automatically yet. The job is to build that, which suits you, but you would start from nothing. | +| 🦸 Owns the docs system | 🟢 Strong | You would own the docs system from week one, with one engineer reviewing your work. | +| How much say you'd have | 🟢 Strong | You would pick the tools and set the priorities. Your manager only checks in weekly, so you would have a lot of say. | + +🟢 Strong · 🟡 Fair · 🟠 Weak · 🔴 Poor · ❓ Unknown + +📄 Note: [file://…/pipeline/archive/🟢 📋 🔧 Copperline Example Co. - Docs Platform Engineer - 2026-10-01.md](file:///archive/🟢%20📋%20🔧%20Copperline%20Example%20Co.%20-%20Docs%20Platform%20Engineer%20-%202026-10-01.md) +✉️ Email card: `out/copperline-example-co-docs-platform-engineer.html` + +The verdict is Apply because no hard block, job-type override, score floor or reservations band fired. The one deduction is reading Go, a known gap for you, so Qualifications is 9. You meet the 7+ years ask with 9 years. Because Copperline is high-interest, move quickly and check for a warm introduction. Interviews may probe hands-on depth on OpenAPI, since that work was done by directing AI. + + +## Posting 03-unlisted-pay-perks + +Commands run by the model: 9. Turns: 10. Cost: $0.22. + +### Checks + +- findings.json written: pass +- exactly one archived note: pass (['🟡 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md']) +- email card written: pass +- check_findings.py passes on the model's findings: pass (check_findings: ok) +- verdict matches expected.yaml: pass (got Apply with reservations (4c average), expected Apply with reservations) +- Scores from the model's findings: {'fit': 7, 'comp': 5, 'qualifications': 6, 'culture': 9}. Expected: {'fit': 7, 'comp': 5, 'qualifications': 6, 'culture': 9}. Identical. +- note name starts with the verdict dot: pass (🟡 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md) + +Result: PASS + +### Transcript + +```text +$ cd ; ls -R . | head -50; cat assessment/SKILL.md; cat posting.md; cat career-profile.yaml +``` + +```text + +Output too large (38KB). Full output saved to: + +Preview (first 2KB): +assessment +career-profile.yaml +intake +posting.md +pytest.ini +README.md +requirements.txt +schema +scripts + +./assessment: +hooks +README.md +scripts +SKILL.md +templates + +./assessment/hooks: +assessment-e +... (1827 more characters) +``` + +```text +$ cd ; cat assessment/SKILL.md +``` + +```text +--- +name: job-assessment +description: > + Go/no-go assessment of one job posting (a link or pasted text) against the + user's own career-profile.yaml. Archives the posting, loads the one lane's + checklist, reads the posting and writes a findings JSON file in which every + rating quotes the posting and every claimed qualification cites an evidence + id. Scripts then check the findings, compute the four scores (Fit, Comp, + Qualifications, Culture), apply the verdict rules, write the assessment + +... (25204 more characters) +``` + +```text +$ cd ; cat posting.md; cat career-profile.yaml +``` + +```text +# FICTIONAL EXAMPLE DATA. Not a real person. + +company: Ashgrove Example Software +role: Senior Technical Writer +lane: tech-writing +url: https://example.net/fictional/jobs/ashgrove-senior-writer +source: board-a + +--- + +# Senior Technical Writer, Ashgrove Example Software + +This is an invented job posting used to test a job assessment tool. Ashgrove Example Software does not exist. + +## About the role + +We are a fast-paced team that builds a mobile app and the services behind it. You will work closely w +... (12015 more characters) +``` + +```text +$ cd ; mkdir -p archive work out +python3 scripts/validate_profile.py career-profile.yaml +python3 assessment/scripts/parse_posting.py posting.md --company "Ashgrove Example Software" --role "Senior Technical Writer" --lane tech-writing --archive-dir archive --url "https://example.net/fictional/jobs/ashgrove-senior-writer" --source board-a +ls archive +``` + +```text +validate_profile: clean, 0 warning(s) in career-profile.yaml +POSTING PARSE: Ashgrove Example Software / Senior Technical Writer + +POSTING READ + 6 requirements likely real, 1 likely filters + +LIKELY REAL: these describe the actual work + + [responsibility] Write guides and reference pages for developers. + -> Describes the actual work, not a screening filter. + + [responsibility] Engineers are busy and answer questions when they can. + -> Describes the actual work, not a screening filter. +... (1176 more characters) +``` + +```text +$ cd ; N="archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md" +python3 assessment/scripts/load_criteria.py --profile career-profile.yaml --lane tech-writing --posting "$N" | head -60 +python3 assessment/scripts/match_skills.py "$N" --profile career-profile.yaml +``` + +```text +CRITERIA SOURCE: career-profile.yaml +LANE: tech-writing (verified: posting and profile agree) + +====================================================================== +PERSON (context for Qualifications and Comp) +====================================================================== + years_experience: 9 + working_style: gather_from_experts + location_label: Region B + target_roles: Senior Technical Writer, Docs Platform Engineer + +================================================================== +... (3043 more characters) +``` + +```text +$ cd ; N="archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md" +cat > work/findings.json < +python3 assessment/scripts/score_helpers.py work/findings.json --profile career-profile.yaml --json > work/scores.json +python3 -c "import json,sys; sys.path.insert(0,'assessment/scripts'); import verdict as v; \ +s=json.load(open('work/scores.json')); f=json.load(open('work/findings.json')); \ +print(json.dumps(v.decide(s['fit'],s['qualifications'],s['comp'],s['culture'], \ +override=v.override_skill(f),hard_block=v.hard_block_id(f))))" > work/verdict.json +cat work/scores.json work/verdict.json +``` + +```text +{ + "fit": 7, + "comp": 5, + "qualifications": 6, + "culture": 9, + "why": { + "fit": [ + "🧑‍🔬 Regular access to experts: weak, -1.5", + "🛠️ A say in the authoring tools: unknown, -1.5" + ], + "comp": [ + "Pay is not listed, neutral 5" + ], + "qualifications": [ + "Asks for more years than the person has, -2", + "2 load-bearing gap(s), -2 (cap 4)" + ], + "culture": [ + "Perk extra_days_off (big), +2", + "Perk learning_budget (big), +2" + ] + } +} +{"labe +... (106 more characters) +``` + +```text +$ cd ; N="archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md" +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile career-profile.yaml > work/block.md +cat >> work/block.md <<'EOF' + +### 🗒️ Notes on your skills +- OpenAPI / Swagger is something you want more of, and it is a named requirement here. +- Kubernetes is a stretch. The posting only asks for "comfortable with concepts", and you have no proof of it yet. +- Mobile SDK documentation is a gap. The posting lists it as a duty, and you have not done it. +EOF +python3 assessment/scripts/write_assessment.py "$N" --block work/block.md --verdict reservations --date 2026-10-01 +python3 assessment/scripts/rename_verdict.py "$N" --verdict reservations --profile career-profile.yaml +``` + +```text +Assessment written to archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md (state set to apply_ready). +Renamed: archive/🟡 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md +``` + +```text +$ cd +python3 assessment/scripts/render_assessment.py work/findings.json --scores work/scores.json --verdict work/verdict.json --profile career-profile.yaml --terminal +python3 assessment/scripts/render_email.py --note "archive/🟡 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md" --findings work/findings.json --scores work/scores.json --verdict work/verdict.json --profile career-profile.yaml --out out --next-prompt "Ashgrove Example Software, Senior Technical Writer: Apply with reservations. Fit 7, Comp 5, Qualifications 6, Culture 9. Note: archive/🟡 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md. Next: if you apply, ask about pay, expert access and the mobile SDK work." +``` + +```text +ASSESSMENT: archive/Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md + +🚦 VERDICT: Apply with reservations + +🏢 Ashgrove Example Software is a good company, not a dream one. Your reason: Solid product and a fair size, but nothing Robin would move for. This did not change the scores or the verdict. + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ +Qualifications 6 🟡🟡🟡🟡🟡🟡⬛⬛⬛⬛ +Culture 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ + +| Check | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟠 Weak +... (779 more characters) +``` + +ASSESSMENT: Ashgrove Example Software, Senior Technical Writer (tech-writing lane) + +🚦 **VERDICT: Apply with reservations** 🟡 + +**Scores:** +Fit 7 🟢🟢🟢🟢🟢🟢🟢⬛⬛⬛ +Comp 5 🟡🟡🟡🟡🟡⬛⬛⬛⬛⬛ +Qualifications 6 🟡🟡🟡🟡🟡🟡⬛⬛⬛⬛ +Culture 9 🟢🟢🟢🟢🟢🟢🟢🟢🟢⬛ + +| Check | Rating | Read | +|---|---|---| +| 🧑‍🔬 Regular access to experts | 🟠 Weak | Engineers "answer questions when they can", so access is not regular. This matters a lot for how you work. | +| 🛠️ A say in the authoring tools | ❓ Unknown | The posting doesn't say. | +| 📖 A written style guide | 🟢 Strong | A short style guide exists. | +| How much say you'd have | ❓ Unknown | Only generic teamwork phrases, so it's unclear. | + +🟢 Strong · 🟡 Fair · 🟠 Weak · 🔴 Poor · ❓ Unknown + +Note: [file:///archive/🟡 📋 ✍️ Ashgrove Example Software - Senior Technical Writer - 2026-10-01.md](file:///archive/🟡%20📋%20✍️%20Ashgrove%20Example%20Software%20-%20Senior%20Technical%20Writer%20-%202026-10-01.md) +Email card: `out/ashgrove-example-software-senior-technical-writer.html`. Its subject starts with 🟡. + +**Why this verdict:** Fit and Qualifications average under 7, which puts it in the reservations band. No hard block or job-type override applied. Ashgrove is a "good, not dream" company, and that did not affect the scores. + +**What to act on:** +- **Pay:** none is listed, so Comp is a neutral 5. Ask early. +- **Qualifications:** the posting asks for 11 years and you have 9, which is mostly a filter number. Mobile SDK documentation is a known gap, and Kubernetes has no proof behind it. Your OpenAPI work is backed by evidence. +- **Culture:** five extra paid days off and a learning budget score well. +- **Not checked:** I did no outside research, such as reviews or funding. + +I treated the mobile SDK duty and the Kubernetes line as load-bearing, which is a judgment call. I dropped one false skill match: "Interviewing experts" matched on "assessment tool". + diff --git a/pipelines/job-assessment/tests/intake-transcript.md b/pipelines/job-assessment/tests/intake-transcript.md new file mode 100644 index 0000000..21235cf --- /dev/null +++ b/pipelines/job-assessment/tests/intake-transcript.md @@ -0,0 +1,1210 @@ + +# Live run: intake transcript + +Date: 2026-10-01. Model: `sonnet`. Commands run by the model: 32. Turns: 33. Cost: $0.81. + +A model followed `intake/SKILL.md` and answered from `fixtures/robin-sample/intake-answers.txt`. Tool output is cut to the first 700 characters. Folder names are replaced with `` and ``. + +## Transcript + +*Read* `/intake/SKILL.md` + +```text +1 --- +2 name: career-intake +3 description: > +4 Interview the user, one question at a time, to build their career-profile.yaml: +5 job history, accomplishments with a source for every one, a skills self-score, +6 what they need from a job, and the kinds of role they are looking at. Every +7 answer is read back as the exact YAML entry and written only after the user +8 says yes, and only through scripts/add_entry.py. Never invents, rounds up or +9 upgrades a claim. Resumes at the next unfinished stage. Use when the user +10 wants to build or extend the central file for the job assessment, says +11 "interview me for my career profile", or runs "/career-intake". +12 argument-hint: "[path +... (10255 more characters) +``` + +```text +$ ls -R . | head -50 && cat intake-answers.txt +``` + +```text +assessment +intake +intake-answers.txt +pytest.ini +README.md +requirements.txt +schema +scripts + +./assessment: +hooks +README.md +scripts +SKILL.md +templates + +./assessment/hooks: +assessment-email-template-guard.sh +README.md + +./assessment/scripts: +check_email.py +check_findings.py +load_criteria.py +match_skills.py +parse_posting.py +rename_verdict.py +render_assessment.py +render_email.py +score_helpers.py +verdict.py +write_assessment.py + +./assessment/templates: +assessment-email.html + +./intake: +README.md +scripts +SKILL.md +stage-prompts +stages +templates + +./intake/scripts: +merge_survey.py + +./intake/stage-prompts: +00-orientation.txt +01-job-history.txt +# FICTIONAL EXAMPLE DATA. Not a real person. +# Scripted answers +... (5827 more characters) +``` + +```text +$ ls intake/stages intake/templates schema scripts; cat intake/stages/00-orientation.md +``` + +```text +intake/stages: +00-orientation.md +01-job-history.md +02-accomplishments.md +03-skills.md +04-needs.md +05-lanes.md +06-review.md + +intake/templates: +career-profile.template.yaml +skills-catalog.yaml +skills-survey.html + +schema: +career-profile.schema.json +findings.schema.json + +scripts: +add_entry.py +assess_offline.py +validate_profile.py +# Stage 0: Orientation + +**Writes:** a new `career-profile.yaml` copied from the template, then `person`. +**Rules:** every rule in `../SKILL.md` applies. One question at a time. + +## Questions, in order + +Ask one, wait for the answer, then ask the next. + +1. "Where should the file live?" +2. "Do you have a résumé, portfolio or old job descriptions I should read first?" +3. "W +... (2284 more characters) +``` + +```text +$ ls career-profile.yaml 2>&1; cp intake/templates/career-profile.template.yaml ./career-profile.yaml && cat career-profile.yaml | head -80; cat intake/stages/01-job-history.md +``` + +```text +ls: career-profile.yaml: No such file or directory +# career-profile.yaml: your job history, your evidence, your skills and what you need +# from a job, in one file. The intake interview fills it in one checked answer at a +# time. Copy this file, then let the interview write to it through +# scripts/add_entry.py. Check it any time with: +# +# python3 scripts/validate_profile.py career-profile.yaml +# +# Rules that keep the file honest: +# - Every accomplishment is an evidence entry with a source. Where did the fact +# come from: the interview, a document, a public link, an artifact, or a +# reference? Interview and reference facts stay proof: unchecked. +# - A skill score is your own cl +... (5860 more characters) +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section person --entry - <<'YAML' +display_name: Robin Sample +target_roles: [Senior Technical Writer, Docs Platform Engineer] +years_experience: 9 +YAML +echo "exit $?" +python3 scripts/add_entry.py career-profile.yaml --section meta --entry - <<'YAML' +updated: "2026-10-01" +intake: + stages_done: [0] +YAML +echo "exit $?" +``` + +```text +add_entry: updated person display_name, target_roles, years_experience in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +add_entry: updated meta updated, intake in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section employers --entry - <<'YAML' +id: placeholder_labs +name: Placeholder Labs +title: Senior Technical Writer +start: "2022-01" +YAML +echo "exit $?" +``` + +```text +add_entry: added employers "placeholder_labs" in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section employers --entry - --replace <<'YAML' +id: placeholder_labs +name: Placeholder Labs +title: Senior Technical Writer +start: "2022-01" +end: present +YAML +echo "exit $?" +``` + +```text +add_entry: replaced employers "placeholder_labs" in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section employers --entry - --replace <<'YAML' +id: placeholder_labs +name: Placeholder Labs +title: Senior Technical Writer +start: "2022-01" +end: present +summary: I lead documentation and run the system that builds and checks the docs. +YAML +echo "exit $?" +``` + +```text +add_entry: replaced employers "placeholder_labs" in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section employers --entry - <<'YAML' +id: northwind +name: Northwind Example Co. +title: Technical Writer +start: "2017-02" +YAML +echo "exit $?" +``` + +```text +add_entry: added employers "northwind" in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +``` + +ASK: What month and year did you leave, or are you still there? +USER: December 2021. + +Read-back (treated as yes): `end: "2021-12"` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section employers --entry - --replace <<'YAML' +id: northwind +name: Northwind Example Co. +title: Technical Writer +start: "2017-02" +end: "2021-12" +YAML +echo "exit $?" +``` + +```text +add_entry: replaced employers "northwind" in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section employers --entry - --replace <<'YAML' +id: northwind +name: Northwind Example Co. +title: Technical Writer +start: "2017-02" +end: "2021-12" +summary: I wrote and maintained the help center and the API reference. +YAML +echo "exit $?" +``` + +```text +add_entry: replaced employers "northwind" in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section meta --entry - <<'YAML' +updated: "2026-10-01" +intake: + stages_done: [0, 1] +YAML +echo "exit $?"; cat intake/stages/02-accomplishments.md +``` + +```text +add_entry: updated meta updated, intake in career-profile.yaml +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 1 warning(s) in career-profile.yaml +exit 0 +# Stage 2: Accomplishments + +**Writes:** `evidence[]` +**Rules:** every rule in `../SKILL.md` applies. One question at a time. + +This is the stage that matters most. The assessment can only claim a +qualification when an evidence entry backs it, so every entry here must be the +user's own account, with an honest label for where it came from. + +## Questions, in order + +Work one employer at a time, starting with the one the user names first. For +each accomplishment, ask one question, wait, then +... (4893 more characters) +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - <<'YAML' +id: ev-northwind-api-reference +employer_id: northwind +claim: Moved the API reference from hand-edited pages to pages generated from the API spec. +details: "Releases stopped breaking the reference pages." +authorship: DIRECTED +proof: unchecked +source: {type: link, ref: "https://example.com/fictional/northwind/api-rebuild", captured_on: "2026-10-01"} +YAML +echo "exit $?" +python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - --replace <<'YAML' +id: ev-northwind-api-reference +employer_id: northwind +claim: Moved the API reference from hand-edited pages to pages generated from the API spec. +details: "Releases stopped breaking the reference pages. I wrote the build step myself and checked it by hand. Skills used (own words): OpenAPI." +authorship: DIRECTED +proof: unchecked +source: {type: link, ref: "https://example.com/fictional/northwind/api-rebuild", captured_on: "2026-10-01"} +YAML +echo "exit $?" +``` + +```text +add_entry: added evidence "ev-northwind-api-reference" in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 2 warning(s) in career-profile.yaml +exit 0 +add_entry: replaced evidence "ev-northwind-api-reference" in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 2 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ ls northwind-style-guide.pdf 2>&1 | head -1 +python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - <<'YAML' +id: ev-northwind-style-guide +employer_id: northwind +claim: Wrote a 20-page style guide that the whole support team used. +details: "Support answers got more consistent." +authorship: WROTE +proof: unchecked +source: {type: document, ref: "northwind-style-guide.pdf", captured_on: "2026-10-01"} +YAML +echo "exit $?" +python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - --replace <<'YAML' +id: ev-northwind-style-guide +employer_id: northwind +claim: Wrote a 20-page style guide that the whole support team used. +details: "Support answers got more consistent. Skills used (own words): Style guides." +authorship: WROTE +proof: unchecked +source: {type: document, ref: "northwind-style-guide.pdf", captured_on: "2026-10-01"} +YAML +echo "exit $?" +``` + +```text +ls: northwind-style-guide.pdf: No such file or directory +add_entry: added evidence "ev-northwind-style-guide" in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: evidence[1]: "ev-northwind-style-guide" has no dates, so it cannot be judged recent or stale. +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 3 warning(s) in career-profile.yaml +exit 0 +add_entry: replaced evidence "ev-northwind-style-guide" in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: evidence[1]: "ev-no +... (221 more characters) +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - <<'YAML' +id: ev-northwind-release-notes +employer_id: northwind +claim: Ran a monthly release-notes process. +details: "Engineers got a regular slot to tell me what changed." +authorship: WROTE +proof: unchecked +source: {type: interview, ref: "interview:2026-10-01:s2.q14", captured_on: "2026-10-01"} +YAML +echo "exit $?" +python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - --replace <<'YAML' | tail -3 +id: ev-northwind-release-notes +employer_id: northwind +claim: Ran a monthly release-notes process. +details: "Engineers got a regular slot to tell me what changed. Skills used (own words): Interviewing experts." +authorship: WROTE +proof: unchecked +source: {type: interview, ref: "interview:2026-10-01:s2.q14", captured_on: "2026-10-01"} +YAML +echo "exit $?" +``` + +```text +add_entry: added evidence "ev-northwind-release-notes" in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: evidence[1]: "ev-northwind-style-guide" has no dates, so it cannot be judged recent or stale. +warning: evidence[2]: "ev-northwind-release-notes" has no dates, so it cannot be judged recent or stale. +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 4 warning(s) in career-profile.yaml +exit 0 +warning: evidence[2]: "ev-northwind-release-notes" has no dates, so it cannot be judged recent or stale. +warning: culture.perks: the perks list is empty, so no pe +... (89 more characters) +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - <<'YAML' | tail -2 +id: ev-placeholder-labs-docs-pipeline +employer_id: placeholder_labs +claim: Built a pipeline that publishes the docs from Markdown files kept in Git. +details: "Docs go live within minutes of an approved change." +authorship: DIRECTED +proof: unchecked +source: {type: artifact, ref: "example.com/fictional/placeholder-labs/docs-pipeline", captured_on: "2026-10-01"} +YAML +echo "exit $?" +python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - --replace <<'YAML' | tail -2 +id: ev-placeholder-labs-docs-pipeline +employer_id: placeholder_labs +claim: Built a pipeline that publishes the docs from Markdown files kept in Git. +details: "Docs go live within minutes of an approved change. I directed an AI tool to write most of the build scripts, and I reviewed and tested them. Skills used (own words): Docs as code, static site generators." +authorship: DIRECTED +proof: unchecked +source: {type: artifact, ref: "example.com/fictional/placeholder-labs/docs-pipeline", captured_on: "2026-10-01"} +YAML +echo "exit $?" +``` + +```text +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 5 warning(s) in career-profile.yaml +exit 0 +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 5 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - <<'YAML' | tail -2 +id: ev-placeholder-labs-ci-checks +employer_id: placeholder_labs +claim: Added automatic checks for broken links and missing alt text. +details: "Broken links stopped reaching readers." +authorship: CO-WROTE +proof: unchecked +source: {type: link, ref: "https://example.org/fictional/placeholder-labs/ci-checks", captured_on: "2026-10-01"} +YAML +echo "exit $?" +python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - --replace <<'YAML' | tail -2 +id: ev-placeholder-labs-ci-checks +employer_id: placeholder_labs +claim: Added automatic checks for broken links and missing alt text. +details: "Broken links stopped reaching readers. I co-wrote it with an engineer. Skills used (own words): Build and check pipelines, Python." +authorship: CO-WROTE +proof: unchecked +source: {type: link, ref: "https://example.org/fictional/placeholder-labs/ci-checks", captured_on: "2026-10-01"} +YAML +echo "exit $?" +``` + +```text +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 6 warning(s) in career-profile.yaml +exit 0 +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 6 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - <<'YAML' | tail -2 +id: ev-placeholder-labs-style-prompts +employer_id: placeholder_labs +claim: Wrote the prompts that make an AI assistant draft pages in the team style, with a human review step. +details: "First drafts need fewer rewrites." +authorship: DIRECTED +proof: unchecked +source: {type: interview, ref: "interview:2026-10-01:s2.q34", captured_on: "2026-10-01"} +YAML +echo "exit $?" +python3 scripts/add_entry.py career-profile.yaml --section evidence --entry - --replace <<'YAML' | tail -2 +id: ev-placeholder-labs-style-prompts +employer_id: placeholder_labs +claim: Wrote the prompts that make an AI assistant draft pages in the team style, with a human review step. +details: "First drafts need fewer rewrites. I directed the AI tool and wrote the prompts myself. Skills used (own words): Writing prompts for AI tools." +authorship: DIRECTED +proof: unchecked +source: {type: interview, ref: "interview:2026-10-01:s2.q34", captured_on: "2026-10-01"} +YAML +echo "exit $?" +``` + +```text +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 7 warning(s) in career-profile.yaml +exit 0 +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 7 warning(s) in career-profile.yaml +exit 0 +``` + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section meta --entry - <<'YAML' | tail -2 +updated: "2026-10-01" +intake: + stages_done: [0, 1, 2] +YAML +cat intake/stages/03-skills.md; head -60 intake/templates/skills-catalog.yaml; python3 intake/scripts/merge_survey.py -h +``` + +```text +warning: culture.perks: the perks list is empty, so no perk can raise Culture. +validate_profile: clean, 7 warning(s) in career-profile.yaml +# Stage 3: Skills survey + +**Writes:** `skills[]` +**Rules:** every rule in `../SKILL.md` applies. One question at a time. + +A skill score is the user's own claim. It is never proof. The assessment +counts a skill as proven only when it points at an evidence entry, and reports +the rest as "unproven". That is not a failure; it is the file being honest. + +The list of skills comes from `intake/templates/skills-catalog.yaml`: about 60 +items in ten groups. Each group has a 0 anchor and a 5 anchor in plain words. + +## Choose a way to do it + +Ask: "There are about 60 +... (7558 more characters) +``` + +```text +$ python3 - <<'E' +import json +s=json.load(open('schema/career-profile.schema.json')) +print(json.dumps(s['properties']['skills'],indent=1)[:2500]) +E +grep -n "id:" intake/templates/skills-catalog.yaml | head -80 +``` + +```text +{ + "type": "array", + "items": { + "$ref": "#/$defs/skill" + } +} +34: - id: communication +40: - id: plain-language +44: - id: editing-others +48: - id: instructions +52: - id: explain-hard-topics +56: - id: expert-interviews +60: - id: style-guides +65: - id: research +71: - id: desk-research +75: - id: comparison-studies +79: - id: user-interviews +83: - id: source-checking +87: - id: recommendations +91: - id: notes-system +96: - id: data +102: - id: spreadsheets +106: - id: sql +110: - id: data-cleaning +114: - id: dashboards +118: - id: statistics +122: - id: data-formats +127: - id: code +133: - id: python +137: +... (1257 more characters) +``` + +Still on stage 3. I'm checking what a skill entry requires before I write any. + +```text +$ python3 - <<'E' +import json +s=json.load(open('schema/career-profile.schema.json')) +print(json.dumps(s['$defs']['skill'],indent=1)) +E +``` + +```text +{ + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label", + "self_score" + ], + "properties": { + "id": { + "$ref": "#/$defs/id" + }, + "label": { + "$ref": "#/$defs/text" + }, + "group": { + "$ref": "#/$defs/id" + }, + "match": { + "$ref": "#/$defs/text_list", + "description": "Regex patterns used to find this skill in a posting." + }, + "self_score": { + "type": "integer", + "minimum": 0, + "maximum": 5, + "description": "The person's own claim, 0 to 5. Never evidence on its own." + }, + "next": { + "enum": [ + "more", + "neutral", + "avoid" + ] + }, + "last": { + "enum": [ + "2y", + "5y", + "5plus", + "never" + ] + }, + "how": { + "ty +... (234 more characters) +``` + +```text +$ add(){ python3 scripts/add_entry.py career-profile.yaml --section skills --entry - | grep -v "no dates\|perks list"; echo "exit ${PIPESTATUS[0]}"; } +echo "--- openapi"; add <<'YAML' +id: openapi +label: OpenAPI +self_score: 4 +next: more +last: 2y +how: [self, ai] +evidence_ids: [ev-northwind-api-reference] +YAML +echo "--- docs_as_code"; add <<'YAML' +id: docs_as_code +label: Docs as code +self_score: 5 +next: more +last: 2y +how: [self, ai, team] +evidence_ids: [ev-placeholder-labs-docs-pipeline, ev-placeholder-labs-ci-checks] +YAML +echo "--- static_sites"; add <<'YAML' +id: static_sites +label: Static sites +self_score: 4 +next: neutral +last: 2y +how: [self, ai] +evidence_ids: [ev-placeholder-labs-docs-pipeline] +YAML +echo "--- ci_pipelines"; add <<'YAML' +id: ci_pipelines +label: CI pipelines +self_score: 3 +next: neutral +last: 2y +how: [self, ai] +evidence_ids: [ev-placeholder-labs-ci-checks] +YAML +echo "--- python_scripting"; add <<'YAML' +id: python_scripting +label: Python scripting +self_score: 3 +next: more +last: 2y +how: [self, ai] +evidence_ids: [ev-placeholder-labs-ci-checks] +YAML +echo "--- prompt_writing"; add <<'YAML' +id: prompt_writing +label: Prompt writing +self_score: 4 +next: more +last: 2y +how: [self] +... (1207 more characters) +``` + +```text +--- openapi +add_entry: added skills "openapi" in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +exit +--- docs_as_code +add_entry: added skills "docs_as_code" in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +exit +--- static_sites +add_entry: added skills "static_sites" in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +exit +--- ci_pipelines +add_entry: added skills "ci_pipelines" in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +exit +--- python_scripting +add_entry: added skills "python_scripting" in career-profile.yaml +validate_profile: clean, 7 warning(s) i +... (6938 more characters) +``` + +Stage 3 is saved. All 14 skills are written. The 8 scored 3 or higher each point at evidence the user named. + +```text +$ python3 - <<'E' +import json +s=json.load(open('schema/career-profile.schema.json')) +for k in ['hard_blocks','named_exceptions','company_criteria','culture','comp']: + p=s['properties'][k] + print(k,json.dumps(p)[:900]); print() +d=s['$defs'] +for k,v in d.items(): + if k in('hard_block','named_exception','company','perk','criterion'): print(k,json.dumps(v)[:600]) +print(list(d)) +E +``` + +```text +hard_blocks {"type": "array", "items": {"$ref": "#/$defs/hard_block"}} + +named_exceptions {"type": "array", "items": {"$ref": "#/$defs/named_exception"}} + +company_criteria {"$ref": "#/$defs/company_criteria"} + +culture {"$ref": "#/$defs/culture"} + +comp {"$ref": "#/$defs/comp"} + +hard_block {"type": "object", "additionalProperties": false, "required": ["id", "label"], "properties": {"id": {"$ref": "#/$defs/id"}, "label": {"$ref": "#/$defs/text"}, "why": {"type": "string"}, "match_hints": {"$ref": "#/$defs/text_list"}}} +named_exception {"type": "object", "additionalProperties": false, "required": ["company", "block_id"], "properties": {"company": {"$ref": "#/$defs/text"}, "block_id": {"$ref": "#/ +... (618 more characters) +``` + +```text +$ add(){ python3 scripts/add_entry.py career-profile.yaml "$@" | grep -v "no dates\|perks list"; } +add --section hard_blocks --entry - <<'YAML' +id: gambling +label: Gambling and betting +YAML +add --section hard_blocks --entry - <<'YAML' +id: weapons +label: Weapons makers +YAML +add --section hard_blocks --entry - <<'YAML' +id: non_remote +label: Any job that is not remote +YAML +``` + +```text +add_entry: added hard_blocks "gambling" in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +add_entry: added hard_blocks "weapons" in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +add_entry: added hard_blocks "non_remote" in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +``` + +```text +$ add(){ python3 scripts/add_entry.py career-profile.yaml "$@" | grep -v "no dates\|perks list"; } +add --section comp --entry - <<'YAML' +currency: USD +floor: 90000 +min: 110000 +open_ask: 125000 +target: 140000 +stretch_ceiling: 170000 +YAML +python3 - <<'E' +import yaml;d=yaml.safe_load(open('career-profile.yaml'));print(d['person'],d['culture']) +E +``` + +```text +add_entry: updated comp currency, floor, min, open_ask, target, stretch_ceiling in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +{'display_name': 'Robin Sample', 'target_roles': ['Senior Technical Writer', 'Docs Platform Engineer'], 'years_experience': 9, 'location_label': None, 'working_style': None} {'perks': [], 'low_time_off_days': None, 'hustle_phrases': [], 'free_phrases': []} +``` + +```text +$ add(){ python3 scripts/add_entry.py career-profile.yaml "$@" | grep -v "no dates\|perks list"; } +add --section person --entry - <<'YAML' +location_label: Region B +YAML +add --section culture --entry - <<'YAML' +perks: + - {id: unlimited_time_off, label: Unlimited time off, kind: big} + - {id: learning_budget, label: A learning budget, kind: big} + - {id: extra_paid_days_off, label: Extra paid days off, kind: big} + - {id: meetups, label: Meetups, kind: nice} + - {id: home_office_stipend, label: A home-office stipend, kind: nice} + - {id: paid_parental_leave, label: Paid parental leave, kind: nice} +YAML +add --section culture --entry - <<'YAML' +low_time_off_days: 15 +YAML +add --section culture --entry - <<'YAML' +hustle_phrases: [Rockstar, work hard play hard, on-call, weekend work] +free_phrases: [fast-paced] +YAML +python3 - <<'E' +import yaml;d=yaml.safe_load(open('career-profile.yaml'));print(d['person'],d['culture']) +E +``` + +```text +add_entry: updated person location_label in career-profile.yaml +validate_profile: clean, 7 warning(s) in career-profile.yaml +add_entry: updated culture perks in career-profile.yaml +validate_profile: clean, 6 warning(s) in career-profile.yaml +add_entry: updated culture low_time_off_days in career-profile.yaml +validate_profile: clean, 6 warning(s) in career-profile.yaml +add_entry: updated culture hustle_phrases, free_phrases in career-profile.yaml +validate_profile: clean, 6 warning(s) in career-profile.yaml +{'display_name': 'Robin Sample', 'target_roles': ['Senior Technical Writer', 'Docs Platform Engineer'], 'years_experience': 9, 'location_label': 'Region B', 'working_style': None} {'perks': +... (582 more characters) +``` + +```text +$ add(){ python3 scripts/add_entry.py career-profile.yaml "$@" | grep -v "no dates"; } +add --section person --entry - <<'YAML' +working_style: gather_from_experts +YAML +add --section company_criteria --entry - <<'YAML' +high_interest: + - {name: Copperline Example Co., why: Makes tools for writers.} +good_not_dream: + - {name: Ashgrove Example Software, why: Good but not a dream.} +YAML +add --section meta --entry - <<'YAML' +updated: "2026-10-01" +intake: + stages_done: [0, 1, 2, 3, 4] +YAML +cat intake/stages/05-lanes.md +``` + +```text +add_entry: updated person working_style in career-profile.yaml +validate_profile: clean, 6 warning(s) in career-profile.yaml +add_entry: updated company_criteria high_interest, good_not_dream in career-profile.yaml +validate_profile: clean, 6 warning(s) in career-profile.yaml +add_entry: updated meta updated, intake in career-profile.yaml +validate_profile: clean, 6 warning(s) in career-profile.yaml +# Stage 5: Lanes + +**Writes:** `lanes[]` +**Rules:** every rule in `../SKILL.md` applies. One question at a time. + +A lane is one kind of role, with its own must-haves. Every posting is read +against exactly one lane, and lanes are never mixed. A user looking at one kind +of role still has one lane. + +## Qu +... (2825 more characters) +``` + +Stage 4 is saved. Before stage 5, I'm checking the lane schema. + +```text +$ python3 - <<'E' +import json +s=json.load(open('schema/career-profile.schema.json'))['$defs'] +for k in ['lane','requirement','signal','labelled_item']: print(k,json.dumps(s[k]));print() +E +``` + +```text +lane {"type": "object", "additionalProperties": false, "required": ["name", "description", "requirements"], "properties": {"name": {"$ref": "#/$defs/id"}, "emoji": {"type": "string"}, "description": {"$ref": "#/$defs/text"}, "requirements": {"type": "array", "items": {"$ref": "#/$defs/requirement"}}, "autonomy": {"type": "object", "additionalProperties": false, "properties": {"positive_signals": {"$ref": "#/$defs/text_list"}, "negative_signals": {"$ref": "#/$defs/text_list"}}}, "keyword_signals": {"type": "object", "additionalProperties": false, "properties": {"strong_positive": {"type": "array", "items": {"$ref": "#/$defs/signal"}}, "positive": {"type": "array", "items": {"$ref": "#/$defs/s +... (1096 more characters) +``` + +ASK: Are you looking at more than one kind of role? +USER: Yes, two kinds of role. + +ASK: What short name should this kind of role have? +USER: docs-platform + +ASK: Which emoji should mark it? +USER: 🔧 + +ASK: In one sentence, what is the work? +USER: Roles building the systems that produce documentation. + +Read-back (treated as yes), written first with empty requirements: +```yaml +name: docs-platform +emoji: "🔧" +description: Roles building the systems that produce documentation. +requirements: [] +``` + +ASK: Which must-haves are different for this kind of role? +USER: AI-forward team, docs live in version control, docs are built and checked automatically, owns the docs system. + +ASK: How much does each one matter: a must-have that counts hard, a softer one, or a bonus that never counts against a posting? +USER: AI-forward team (strong), docs live in version control (strong), docs are built and checked automatically (soft), owns the docs system (soft, bonus only). + +ASK: What skills does this kind of role want that you don't have yet? +USER: Reading Go code. + +ASK: What language in a posting makes you trust it? +USER: "definition of done" and "single source of truth". + +ASK: What language in a posting makes you distrust it? +USER: "wiki cleanup" and "no engineering support". + +Read-back (treated as yes). "Reading Go code" is linked to your stored skill `go_lang` (Go lang, scored 1). The phrases are `positive` and `negative`, and none are promoted: +```yaml +name: docs-platform +emoji: "🔧" +description: Roles building the systems that produce documentation. +requirements: + - {id: ai_forward_team, label: AI-forward team, severity: strong} + - {id: docs_in_version_control, label: Docs live in version control, severity: strong} + - {id: docs_built_and_checked, label: Docs are built and checked automatically, severity: soft} + - {id: owns_docs_system, label: Owns the docs system, severity: soft, bonus: true} +known_gaps: + - {id: reading_go_code, label: Reading Go code, skill_id: go_lang} +keyword_signals: + positive: [{phrase: definition of done}, {phrase: single source of truth}] + negative: [{phrase: wiki cleanup}, {phrase: no engineering support}] +``` + +```text +$ add(){ python3 scripts/add_entry.py career-profile.yaml "$@" | grep -v "no dates"; } +add --section lanes --entry - <<'YAML' +name: docs-platform +emoji: "🔧" +description: Roles building the systems that produce documentation. +requirements: [] +YAML +add --section lanes --entry - --replace <<'YAML' +name: docs-platform +emoji: "🔧" +description: Roles building the systems that produce documentation. +requirements: + - {id: ai_forward_team, label: AI-forward team, severity: strong} + - {id: docs_in_version_control, label: Docs live in version control, severity: strong} + - {id: docs_built_and_checked, label: Docs are built and checked automatically, severity: soft} + - {id: owns_docs_system, label: Owns the docs system, severity: soft, bonus: true} +known_gaps: + - {id: reading_go_code, label: Reading Go code, skill_id: go_lang} +keyword_signals: + positive: [{phrase: definition of done}, {phrase: single source of truth}] + negative: [{phrase: wiki cleanup}, {phrase: no engineering support}] +YAML +``` + +```text +add_entry: added lanes "docs-platform" in career-profile.yaml +warning: lanes[0].requirements: lane "docs-platform" has no requirements of its own. +validate_profile: clean, 7 warning(s) in career-profile.yaml +add_entry: replaced lanes "docs-platform" in career-profile.yaml +warning: lanes[0].requirements[0]: strong requirement "ai_forward_team" has no why. +warning: lanes[0].requirements[1]: strong requirement "docs_in_version_control" has no why. +validate_profile: clean, 8 warning(s) in career-profile.yaml +``` + +```text +$ add(){ python3 scripts/add_entry.py career-profile.yaml "$@" | grep -v "no dates"; } +add --section lanes --entry - <<'YAML' +name: tech-writing +emoji: "✍️" +description: Roles writing guides, references and help content for a product. +requirements: [] +YAML +add --section lanes --entry - --replace <<'YAML' +name: tech-writing +emoji: "✍️" +description: Roles writing guides, references and help content for a product. +requirements: + - {id: expert_access, label: Regular access to experts, severity: strong} + - {id: authoring_tools_say, label: A say in the authoring tools, severity: strong} + - {id: written_style_guide, label: A written style guide, severity: soft} +known_gaps: + - {id: dita_xml, label: DITA XML, skill_id: dita_xml} + - {id: mobile_sdk_docs, label: Mobile SDK documentation, skill_id: mobile_sdk} +keyword_signals: + positive: [{phrase: writers sit with the engineers}] + negative: [{phrase: content mill}] +YAML +add --section meta --entry - <<'YAML' +updated: "2026-10-01" +intake: + stages_done: [0, 1, 2, 3, 4, 5] +YAML +cat intake/stages/06-review.md +``` + +```text +add_entry: added lanes "tech-writing" in career-profile.yaml +warning: lanes[0].requirements[0]: strong requirement "ai_forward_team" has no why. +warning: lanes[0].requirements[1]: strong requirement "docs_in_version_control" has no why. +warning: lanes[1].requirements: lane "tech-writing" has no requirements of its own. +validate_profile: clean, 9 warning(s) in career-profile.yaml +add_entry: replaced lanes "tech-writing" in career-profile.yaml +warning: lanes[0].requirements[0]: strong requirement "ai_forward_team" has no why. +warning: lanes[0].requirements[1]: strong requirement "docs_in_version_control" has no why. +warning: lanes[1].requirements[0]: strong requirement "expert_access" has no w +... (4351 more characters) +``` + +Stage 5 is saved. Stage 6 starts with the validator. + +```text +$ python3 scripts/validate_profile.py career-profile.yaml +``` + +```text +validate_profile: clean, 10 warning(s) in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: evidence[1]: "ev-northwind-style-guide" has no dates, so it cannot be judged recent or stale. +warning: evidence[2]: "ev-northwind-release-notes" has no dates, so it cannot be judged recent or stale. +warning: evidence[3]: "ev-placeholder-labs-docs-pipeline" has no dates, so it cannot be judged recent or stale. +warning: evidence[4]: "ev-placeholder-labs-ci-checks" has no dates, so it cannot be judged recent or stale. +warning: evidence[5]: "ev-placeholder-labs-style-prompts" has no dates, so it cannot be judged recent or +... (352 more characters) +``` + +The validator reports no problems, only warnings. These labels describe the file, not you. + +**Gap list by label** +- **confirmed:** nothing. No evidence entry is `checked`, because every link or file was unread or missing. +- **overclaimed** (skill at 4 or 5, all linked evidence unchecked): OpenAPI, Docs as code, Static sites, Prompt writing, Style guides, SME interviews. +- **no record** (skill at 3, only unchecked or interview evidence): CI pipelines, Python scripting. +- **stale:** nothing. +- **underclaimed:** nothing. + +**Other gaps** +- All 6 evidence entries have no dates. +- The 4 strong lane requirements have no `why`. +- The 3 hard blocks have no `why`. +- The Lanternfield exception is not written, because the block it overrides is unknown. +- The two lanes' requirements differ, so none look copied. + +Each gap gets one question, and the script has no answers for any of them. I'll print them in order. Each one is treated as "leave it", so the gap stays. + +ASK: Your file scores OpenAPI at 4, and the only thing behind it is a public page I haven't read. Is there something I can read? +USER: (no scripted answer). Left as is. + +ASK: Docs as code is scored 5 and rests on unchecked entries. Is there a page or file I can read? +USER: (no scripted answer). Left as is. + +ASK: Static sites is scored 4 and rests on the unchecked docs pipeline entry. Is there a page or file I can read? +USER: (no scripted answer). Left as is. + +ASK: Prompt writing is scored 4 and rests on your own account of the style prompts. Is there a page or file I can read? +USER: (no scripted answer). Left as is. + +ASK: Style guides is scored 5 and rests on a PDF that isn't in this folder. Is there a copy I can read? +USER: (no scripted answer). Left as is. + +ASK: SME interviews is scored 4 and rests only on your account of the release-notes process. Is there anything I can read? +USER: (no scripted answer). Left as is. + +ASK: Roughly when did you do the API reference rebuild? +USER: (no scripted answer). Left as is. + +ASK: Roughly when did you write the style guide? +USER: (no scripted answer). Left as is. + +ASK: Roughly when did you run the release-notes process? +USER: (no scripted answer). Left as is. + +ASK: Roughly when did you build the docs pipeline? +USER: (no scripted answer). Left as is. + +ASK: Roughly when did you add the link and alt-text checks? +USER: (no scripted answer). Left as is. + +ASK: Roughly when did you write the style prompts? +USER: (no scripted answer). Left as is. + +ASK: Why does an AI-forward team matter to you? +USER: (no scripted answer). Left as is. + +ASK: Why does docs living in version control matter to you? +USER: (no scripted answer). Left as is. + +ASK: Why does regular access to experts matter to you? +USER: (no scripted answer). Left as is. + +ASK: Why does a say in the authoring tools matter to you? +USER: (no scripted answer). Left as is. + +ASK: Why do gambling and betting, weapons makers, and non-remote jobs matter to you as hard blocks? +USER: (no scripted answer). Left as is. + +ASK: Does this file feel like a fair picture of you? +USER: Yes, that is a fair picture of me. + +```text +$ python3 scripts/add_entry.py career-profile.yaml --section meta --entry - <<'YAML' | tail -1 +updated: "2026-10-01" +intake: + stages_done: [0, 1, 2, 3, 4, 5, 6] + closing_answer: "Yes, that is a fair picture of me." +YAML +echo ----; python3 scripts/validate_profile.py career-profile.yaml +``` + +```text +validate_profile: clean, 10 warning(s) in career-profile.yaml +---- +validate_profile: clean, 10 warning(s) in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: evidence[1]: "ev-northwind-style-guide" has no dates, so it cannot be judged recent or stale. +warning: evidence[2]: "ev-northwind-release-notes" has no dates, so it cannot be judged recent or stale. +warning: evidence[3]: "ev-placeholder-labs-docs-pipeline" has no dates, so it cannot be judged recent or stale. +warning: evidence[4]: "ev-placeholder-labs-ci-checks" has no dates, so it cannot be judged recent or stale. +warning: evidence[5]: "ev-placeholder- +... (419 more characters) +``` + +All seven stages are done, and `./career-profile.yaml` passes validation with 0 problems and 10 warnings. The final output of `python3 scripts/validate_profile.py career-profile.yaml`: + +``` +validate_profile: clean, 10 warning(s) in career-profile.yaml +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: evidence[1]: "ev-northwind-style-guide" has no dates, so it cannot be judged recent or stale. +warning: evidence[2]: "ev-northwind-release-notes" has no dates, so it cannot be judged recent or stale. +warning: evidence[3]: "ev-placeholder-labs-docs-pipeline" has no dates, so it cannot be judged recent or stale. +warning: evidence[4]: "ev-placeholder-labs-ci-checks" has no dates, so it cannot be judged recent or stale. +warning: evidence[5]: "ev-placeholder-labs-style-prompts" has no dates, so it cannot be judged recent or stale. +warning: lanes[0].requirements[0]: strong requirement "ai_forward_team" has no why. +warning: lanes[0].requirements[1]: strong requirement "docs_in_version_control" has no why. +warning: lanes[1].requirements[0]: strong requirement "expert_access" has no why. +warning: lanes[1].requirements[1]: strong requirement "authoring_tools_say" has no why. +``` + +The file holds: +- 2 employers +- 6 evidence entries +- 14 skills +- 3 hard blocks +- the pay numbers +- 6 perks +- 2 lanes +- the closing answer, in Robin's words + +**Judgment calls to check** +- **Confirmations:** the answers file has no "yes" lines for the read-backs. I treated each scripted answer as the confirmation and wrote it through `scripts/add_entry.py` only. +- **Other unscripted questions:** "Is there another one?" followed whether the file listed more accomplishments. Every other question the file had no answer for was treated as "I don't know", and the field was left out. +- **Nothing is `checked`:** no link or file was read, and `northwind-style-guide.pdf` isn't in the folder. Every evidence entry is `proof: unchecked`. +- **Lanternfield exception not stored:** the answer named the company but not which hard block it overrides. I asked, got no scripted answer, and wrote nothing rather than guess. +- **Inferred links:** I read "managing translation vendors" as the stored `localization_pm` skill, which is already `avoid`, so nothing was written for it. I linked "Reading Go code" to `go_lang`, and the DITA XML and mobile SDK gaps to `dita_xml` and `mobile_sdk`. These were my matches, not the user's. +- **Gaps left open:** the stage 6 gap questions got no scripted answers, so the 6 overclaimed skills stay overclaimed. The only scripted stage 6 answer was the closing one. + + +## Checks + +- Validator exit code: 0 (pass) +```text +warning: evidence[0]: "ev-northwind-api-reference" has no dates, so it cannot be judged recent or stale. +warning: evidence[1]: "ev-northwind-style-guide" has no dates, so it cannot be judged recent or stale. +warning: evidence[2]: "ev-northwind-release-notes" has no dates, so it cannot be judged recent or stale. +warning: evidence[3]: "ev-placeholder-labs-docs-pipeline" has no dates, so it cannot be judged recent or stale. +warning: evidence[4]: "ev-placeholder-labs-ci-checks" has no dates, so it cannot be judged recent or stale. +warning: evidence[5]: "ev-placeholder-labs-style-prompts" has no dates, so it cannot be judged recent or stale. +warning: lanes[0].requirements[0]: strong requirement "ai_forward_team" has no why. +warning: lanes[0].requirements[1]: strong requirement "docs_in_version_control" has no why. +warning: lanes[1].requirements[0]: strong requirement "expert_access" has no why. +warning: lanes[1].requirements[1]: strong requirement "authoring_tools_say" has no why. +validate_profile: clean, 10 warning(s) in career-profile.yaml +``` +- employer ids: pass (['northwind', 'placeholder_labs'] vs ['northwind', 'placeholder_labs']) +- lane names: pass (['docs-platform', 'tech-writing'] vs ['docs-platform', 'tech-writing']) +- evidence count within 2: pass (6 vs 7) +- skills count: pass (14 vs 14) +- person: Robin Sample, 9 years +- Skill ids are Robin's own words, not catalog ids: ci_pipelines, dita_xml, docs_as_code, go_lang, kubernetes, localization_pm, mobile_sdk, openapi, ... + +Result: PASS diff --git a/pipelines/job-assessment/tests/live/conftest.py b/pipelines/job-assessment/tests/live/conftest.py new file mode 100644 index 0000000..3330379 --- /dev/null +++ b/pipelines/job-assessment/tests/live/conftest.py @@ -0,0 +1,25 @@ +"""Live tests: need the claude CLI and JA_LIVE=1. They never run in CI. + + JA_LIVE=1 python3 -m pytest tests/live -v -s + +Without both, every test here is skipped with the reason printed. +""" +import os +import shutil + +import pytest + + +def live_blocker(): + if os.environ.get("JA_LIVE") != "1": + return "live tests are off: set JA_LIVE=1 to run them (they call a model and cost money)" + if shutil.which("claude") is None: + return "live tests need the claude CLI on PATH, and it was not found" + return None + + +@pytest.fixture(autouse=True) +def _live_only(): + reason = live_blocker() + if reason: + pytest.skip(reason) diff --git a/pipelines/job-assessment/tests/live/driver.py b/pipelines/job-assessment/tests/live/driver.py new file mode 100644 index 0000000..e8561cf --- /dev/null +++ b/pipelines/job-assessment/tests/live/driver.py @@ -0,0 +1,110 @@ +"""Shared helpers for the live runs: a clean work folder, one claude call, a readable record.""" +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +TESTS = ROOT / "tests" +MODEL = os.environ.get("JA_LIVE_MODEL", "sonnet") +TIMEOUT = int(os.environ.get("JA_LIVE_TIMEOUT", "3000")) +IGNORE = shutil.ignore_patterns("tests", "fixtures", "__pycache__", ".pytest_cache", ".venv", "*.pyc") + + +def make_workdir(base: Path) -> Path: + """Copy the pipeline without its tests and fixtures, so the model cannot read the answer key. + + A `python3` that is the interpreter running this test goes first on PATH, so the skills' + `python3 ...` commands find pyyaml. + """ + work = base / "pipeline" + shutil.copytree(ROOT, work, ignore=IGNORE) + bin_dir = base / "bin" + bin_dir.mkdir() + (bin_dir / "python3").symlink_to(sys.executable) + return work + + +def run_claude(prompt: str, cwd: Path, bin_dir: Path): + """One print-mode run with full tool use inside cwd. Returns (events, final_result_dict).""" + env = dict(os.environ) + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + cmd = [ + "claude", "-p", prompt, + "--model", MODEL, + "--output-format", "stream-json", "--verbose", + "--setting-sources", "project", # no personal settings, hooks or memory files + "--disable-slash-commands", "--strict-mcp-config", "--no-session-persistence", + "--permission-mode", "acceptEdits", + "--allowedTools", "Read", "Write", "Edit", "Bash", + ] + proc = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=TIMEOUT) + events = [] + for line in proc.stdout.splitlines(): + try: + events.append(json.loads(line)) + except ValueError: + continue + final = next((e for e in reversed(events) if e.get("type") == "result"), {}) + return proc, events, final + + +def _clip(text, n): + text = text.strip() + return text if len(text) <= n else text[:n] + f"\n... ({len(text) - n} more characters)" + + +def render_record(events, scrub, clip=700): + """Turn the event stream into markdown: what the model said, the commands it ran, what came back.""" + out = [] + for e in events: + msg = e.get("message") or {} + content = msg.get("content") + if e.get("type") == "assistant" and isinstance(content, list): + for part in content: + if part.get("type") == "text" and part["text"].strip(): + out.append(scrub(part["text"].strip()) + "\n") + elif part.get("type") == "tool_use": + inp = part.get("input", {}) + if part["name"] == "Bash": + out.append("```text\n$ " + scrub(_clip(inp.get("command", ""), 1200)) + "\n```\n") + else: + target = inp.get("file_path") or inp.get("path") or "" + out.append(f"*{part['name']}* `{scrub(str(target))}`\n") + elif e.get("type") == "user" and isinstance(content, list): + for part in content: + if part.get("type") == "tool_result": + body = part.get("content") + if isinstance(body, list): + body = "\n".join(b.get("text", "") for b in body if isinstance(b, dict)) + out.append("```text\n" + scrub(_clip(str(body or ""), clip)) + "\n```\n") + return "\n".join(out) + + +def make_scrub(work: Path): + """Replace this machine's paths so a saved record names no one's folders.""" + pairs = [(str(work.resolve()), ""), (str(work), ""), (str(work.parent.resolve()), ""), + (str(work.parent), ""), (str(Path.home()), "")] + + def scrub(text): + for old, new in pairs: + text = text.replace(old, new) + text = re.sub(r"/(?:private/)?(?:var/folders|tmp)/\S+", "", text) + text = re.sub(r"/Users/[^/\s]+", "", text) + return re.sub(r"/\S*", "", text) + return scrub + + +def assert_ran(proc, events, final): + """A print-mode run can fail silently. Prove it did real work before trusting anything.""" + assert events, f"claude printed no events. stderr: {proc.stderr[:500]}" + assert final, "the run never reported a result" + assert not final.get("is_error"), f"claude reported an error: {str(final.get('result'))[:500]}" + assert (final.get("result") or "").strip(), "claude finished with an empty reply" + tool_calls = sum(1 for e in events if e.get("type") == "assistant" + for p in (e.get("message", {}).get("content") or []) if p.get("type") == "tool_use") + assert tool_calls > 0, "claude ran no commands, so nothing was produced" + return tool_calls diff --git a/pipelines/job-assessment/tests/live/test_live_assessment.py b/pipelines/job-assessment/tests/live/test_live_assessment.py new file mode 100644 index 0000000..48943bf --- /dev/null +++ b/pipelines/job-assessment/tests/live/test_live_assessment.py @@ -0,0 +1,109 @@ +"""Live run 2: the assessment skill on fixture postings 01 and 03. + +A model follows assessment/SKILL.md and writes findings.json itself. The test then does +not trust the model's own numbers: it runs check_findings.py on the findings, recomputes the +scores and verdict with the scripts, and compares the verdict to fixtures/expected.yaml. +The answer key (fixtures/findings, expected.yaml, golden) is not in the model's folder. + +The record is saved to tests/e2e-output.md. +""" +import json +import shutil +import subprocess +import sys +from datetime import date + +import pytest +import yaml + +from driver import (MODEL, ROOT, TESTS, assert_ran, make_scrub, make_workdir, render_record, run_claude) + +FIX = ROOT / "fixtures" +EXPECTED = yaml.safe_load((FIX / "expected.yaml").read_text(encoding="utf-8"))["postings"] +SCRIPTS = ROOT / "assessment" / "scripts" +sys.path.insert(0, str(SCRIPTS)) +import verdict as verdict_mod # noqa: E402 + +PROMPT = """Follow assessment/SKILL.md from this folder to assess ONE posting. Nobody is here to answer +questions, and the skill says never to ask, so finish every step. + +- Profile: ./career-profile.yaml +- Posting text: ./posting.md (the text is already here, nothing needs fetching). Its first lines give the + company, role, lane, url and source. +- Folders: ARCHIVE=./archive, WORK=./work, OUT=./out. Create them. Assessment date: 2026-10-01. +- Write your findings to ./work/findings.json. +- Run every command in the skill in order, including the email card. Use `python3` for every script. +- Do not look for answer files outside what is in this folder. Read the posting and judge it yourself. +- End by printing the terminal summary. + +Everything here is fictional.""" + +CASES = [("01-strong-fit", "Apply"), ("03-unlisted-pay-perks", "Apply with reservations")] + + +def run_one(pid, tmp_path): + work = make_workdir(tmp_path) + shutil.copy(FIX / "robin-sample" / "career-profile.yaml", work / "career-profile.yaml") + shutil.copy(FIX / "postings" / f"{pid}.md", work / "posting.md") + proc, events, final = run_claude(PROMPT, work, tmp_path / "bin") + scrub = make_scrub(work) + calls = assert_ran(proc, events, final) + + notes = [] + ok = True + + def check(label, passed, detail=""): + nonlocal ok + ok = ok and passed + notes.append(f"- {label}: {'pass' if passed else 'FAIL'}{(' (' + detail + ')') if detail else ''}") + + findings = work / "work" / "findings.json" + check("findings.json written", findings.is_file()) + archive_notes = sorted((work / "archive").glob("*.md")) + check("exactly one archived note", len(archive_notes) == 1, str([scrub(n.name) for n in archive_notes])) + cards = sorted((work / "out").glob("*.html")) + check("email card written", len(cards) == 1) + if findings.is_file() and archive_notes: + note = archive_notes[0] + profile = work / "career-profile.yaml" + c = subprocess.run([sys.executable, str(SCRIPTS / "check_findings.py"), str(findings), "--posting", str(note), + "--profile", str(profile)], capture_output=True, text=True) + check("check_findings.py passes on the model's findings", c.returncode == 0, scrub((c.stdout + c.stderr).strip())[:300]) + s = subprocess.run([sys.executable, str(SCRIPTS / "score_helpers.py"), str(findings), "--profile", str(profile), "--json"], + capture_output=True, text=True) + if s.returncode == 0: + scores = json.loads(s.stdout) + data = json.loads(findings.read_text(encoding="utf-8")) + decision = verdict_mod.decide(scores["fit"], scores["qualifications"], scores["comp"], scores["culture"], + override=verdict_mod.override_skill(data), hard_block=verdict_mod.hard_block_id(data)) + want = EXPECTED[pid] + got = {k: scores[k] for k in ("fit", "comp", "qualifications", "culture")} + check("verdict matches expected.yaml", decision["label"] == want["verdict"], + f"got {decision['label']} ({decision['trigger']}), expected {want['verdict']}") + same = got == {k: want[k] for k in got} + notes.append(f"- Scores from the model's findings: {got}. Expected: {{'fit': {want['fit']}, 'comp': {want['comp']}, " + f"'qualifications': {want['qualifications']}, 'culture': {want['culture']}}}. " + f"{'Identical.' if same else 'They differ: the model read the posting a little differently. The test requires the verdict, not identical scores.'}") + check("note name starts with the verdict dot", note.name[0] == {"Apply": "\U0001F7E2", + "Apply with reservations": "\U0001F7E1", "Skip": "\U0001F534"}[decision["label"]], scrub(note.name)) + else: + check("score_helpers.py ran on the model's findings", False, scrub(s.stdout + s.stderr)[:300]) + section = [f"## Posting {pid}", "", f"Commands run by the model: {calls}. Turns: {final.get('num_turns')}. " + f"Cost: ${float(final.get('total_cost_usd') or 0):.2f}.", "", "### Checks", "", *notes, "", + f"Result: {'PASS' if ok else 'FAIL'}", "", "### Transcript", "", render_record(events, scrub, clip=500), ""] + return ok, "\n".join(section) + + +def test_assessment_skill_gives_the_expected_verdicts(tmp_path_factory): + parts, results = [], [] + for pid, _ in CASES: + ok, text = run_one(pid, tmp_path_factory.mktemp(pid)) + results.append(ok) + parts.append(text) + head = ["", + "# Live run: assessment on the fictional postings", "", + f"Date: {date.today().isoformat()}. Model: `{MODEL}`. A model followed `assessment/SKILL.md` and wrote its own " + "findings. The test recomputes scores and the verdict with the scripts and does not trust the model's numbers. " + "Tool output is cut to 500 characters. Folder names are replaced with `` and ``.", ""] + (TESTS / "e2e-output.md").write_text("\n".join(head + parts), encoding="utf-8") + assert all(results), "see tests/e2e-output.md" diff --git a/pipelines/job-assessment/tests/live/test_live_intake.py b/pipelines/job-assessment/tests/live/test_live_intake.py new file mode 100644 index 0000000..a27c3df --- /dev/null +++ b/pipelines/job-assessment/tests/live/test_live_intake.py @@ -0,0 +1,88 @@ +"""Live run 1: the intake skill, answered from Robin's scripted answers. + +A model plays the interviewer by following intake/SKILL.md. There is no human, so the +scripted answers stand in for one. The produced file must pass the validator and match +Robin's structure: same employer ids, same lanes, evidence count within 2. +Skill ids in the answers are Robin's own words, not catalog ids, so none are expected. + +The record is saved to tests/intake-transcript.md. +""" +import shutil +import subprocess +import sys +from datetime import date +from pathlib import Path + +import yaml + +from driver import (MODEL, ROOT, TESTS, assert_ran, make_scrub, make_workdir, render_record, run_claude) + +FIX = ROOT / "fixtures" + +PROMPT = """You are running the career-intake skill from this folder. Read intake/SKILL.md and the stage files +it points to, then follow them exactly. + +This is a scripted test, so there is no human. The user's answers are in ./intake-answers.txt, one per +question, keyed like s2.q3 (stage 2, question 3). When the skill says to ask a question, print it on a line +starting with ASK:, then take the next matching answer from the file and print it on a line starting with +USER:. Keys repeat when a stage loops (two employers, several accomplishments), so take repeated keys in file +order. Stage 3 answers are keyed by skill id and use option B, in chat. Stages 4 and 5 use their own key +names, so match them by topic. Never invent an answer the file does not hold: print "USER: (no scripted +answer)" and treat it as an I-don't-know. After each answer do exactly what the skill says: read it back, +write it only through scripts/add_entry.py, and save after each stage. + +Save the profile as ./career-profile.yaml. Use `python3` for every script. When every stage is done, run +`python3 scripts/validate_profile.py career-profile.yaml` and print its output. Everything is fictional.""" + + +def test_intake_builds_a_valid_profile_from_scripted_answers(tmp_path): + work = make_workdir(tmp_path) + shutil.copy(FIX / "robin-sample" / "intake-answers.txt", work / "intake-answers.txt") + proc, events, final = run_claude(PROMPT, work, tmp_path / "bin") + scrub = make_scrub(work) + tool_calls = assert_ran(proc, events, final) + + profile_path = work / "career-profile.yaml" + record = [ + "", + "# Live run: intake transcript", "", + f"Date: {date.today().isoformat()}. Model: `{MODEL}`. Commands run by the model: {tool_calls}. " + f"Turns: {final.get('num_turns')}. Cost: ${float(final.get('total_cost_usd') or 0):.2f}.", "", + "A model followed `intake/SKILL.md` and answered from `fixtures/robin-sample/intake-answers.txt`. " + "Tool output is cut to the first 700 characters. Folder names are replaced with `` and ``.", "", + "## Transcript", "", render_record(events, scrub), "", + ] + + checks = ["## Checks", ""] + ok = True + if not profile_path.is_file(): + checks.append("- FAIL: no career-profile.yaml was produced.") + ok = False + else: + v = subprocess.run([sys.executable, str(ROOT / "scripts" / "validate_profile.py"), str(profile_path)], + capture_output=True, text=True, cwd=work) + checks.append(f"- Validator exit code: {v.returncode} ({'pass' if v.returncode == 0 else 'FAIL'})") + checks.append("```text\n" + scrub((v.stdout + v.stderr).strip())[:1500] + "\n```") + ok = ok and v.returncode == 0 + got = yaml.safe_load(profile_path.read_text(encoding="utf-8")) or {} + want = yaml.safe_load((FIX / "robin-sample" / "career-profile.yaml").read_text(encoding="utf-8")) + got_emp = sorted(e["id"] for e in got.get("employers", [])) + want_emp = sorted(e["id"] for e in want["employers"]) + got_lanes = sorted(l["name"] for l in got.get("lanes", [])) + want_lanes = sorted(l["name"] for l in want["lanes"]) + ev_got, ev_want = len(got.get("evidence", [])), len(want["evidence"]) + rows = [("employer ids", got_emp == want_emp, f"{got_emp} vs {want_emp}"), + ("lane names", got_lanes == want_lanes, f"{got_lanes} vs {want_lanes}"), + ("evidence count within 2", abs(ev_got - ev_want) <= 2, f"{ev_got} vs {ev_want}"), + ("skills count", len(got.get("skills", [])) == len(want["skills"]), + f"{len(got.get('skills', []))} vs {len(want['skills'])}")] + for name, passed, detail in rows: + checks.append(f"- {name}: {'pass' if passed else 'FAIL'} ({detail})") + ok = ok and all(p for n, p, _ in rows if n != "skills count") + if got.get("person"): + checks.append(f"- person: {got['person'].get('display_name')}, {got['person'].get('years_experience')} years") + checks.append("- Skill ids are Robin's own words, not catalog ids: " + + ", ".join(sorted(s["id"] for s in got.get("skills", []))[:8]) + ", ...") + record += checks + ["", f"Result: {'PASS' if ok else 'FAIL'}", ""] + (TESTS / "intake-transcript.md").write_text("\n".join(record), encoding="utf-8") + assert ok, "see tests/intake-transcript.md" diff --git a/pipelines/job-assessment/tests/test_e2e_offline.py b/pipelines/job-assessment/tests/test_e2e_offline.py new file mode 100644 index 0000000..40d391d --- /dev/null +++ b/pipelines/job-assessment/tests/test_e2e_offline.py @@ -0,0 +1,219 @@ +"""The whole chain, offline, on the three fictional postings. + +Runs scripts/assess_offline.py as a user would (a subprocess, no model, no +network) and compares the result to fixtures/expected.yaml and to the files in +fixtures/golden/. Golden files carry one extra first line, the FICTIONAL +banner the safety scan requires; the test drops that line before comparing, so +the rest matches byte for byte. + +To refresh the golden files after a deliberate change to a renderer: + JA_UPDATE_GOLDEN=1 python3 -m pytest tests/test_e2e_offline.py -q +then read the diff before committing. +""" +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parent.parent +FIX = ROOT / "fixtures" +GOLDEN = FIX / "golden" +SCRIPT = ROOT / "scripts" / "assess_offline.py" +PROFILE = FIX / "robin-sample" / "career-profile.yaml" +EXPECTED = yaml.safe_load((FIX / "expected.yaml").read_text(encoding="utf-8"))["postings"] +IDS = sorted(EXPECTED) +DATE = "2026-10-01" +BANNER = "FICTIONAL EXAMPLE DATA. Not a real person." +DOT = {"Apply": "\U0001F7E2", "Apply with reservations": "\U0001F7E1", "Skip": "\U0001F534"} + + +def banner_line(name): + return f"\n" if name.endswith(".html") else f"{BANNER}\n" + + +def assess(posting_id, out, *extra): + cmd = [sys.executable, str(SCRIPT), str(FIX / "postings" / f"{posting_id}.md"), + "--findings", str(FIX / "findings" / f"{posting_id}.findings.json"), + "--profile", str(PROFILE), "--out", str(out), "--date", DATE, *extra] + # Run from the pipeline folder so the posting path in the output is the same for everyone. + return subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) + + +def outputs(out): + note = next((out / "archive").glob("*.md")) + card = next((out / "email").glob("*.html")) + return { + "note.md": note.read_text(encoding="utf-8"), + "terminal.txt": (out / "work" / "terminal.txt").read_text(encoding="utf-8"), + "email.html": card.read_text(encoding="utf-8"), + "note-name.txt": note.name + "\n", + } + + +@pytest.fixture(scope="module") +def runs(tmp_path_factory): + """Run the chain once per posting and keep the results.""" + results = {} + for pid in IDS: + out = tmp_path_factory.mktemp(pid) + proc = assess(pid, out) + results[pid] = (proc, out) + return results + + +@pytest.mark.parametrize("pid", IDS) +def test_chain_exits_clean(runs, pid): + proc, _ = runs[pid] + assert proc.returncode == 0, proc.stderr + + +@pytest.mark.parametrize("pid", IDS) +def test_scores_and_verdict_match_expected_yaml(runs, pid): + _, out = runs[pid] + want = EXPECTED[pid] + scores = json.loads((out / "work" / "scores.json").read_text(encoding="utf-8")) + verdict = json.loads((out / "work" / "verdict.json").read_text(encoding="utf-8")) + got = {k: scores[k] for k in ("fit", "comp", "qualifications", "culture")} + assert got == {k: want[k] for k in got} + assert verdict["label"] == want["verdict"] + assert verdict["trigger"].replace(" ", "-") == want["trigger"] + + +@pytest.mark.parametrize("pid", IDS) +def test_note_is_renamed_and_carries_the_assessment(runs, pid): + _, out = runs[pid] + note = next((out / "archive").glob("*.md")) + want = EXPECTED[pid] + assert note.name.startswith(DOT[want["verdict"]]) + text = note.read_text(encoding="utf-8") + assert "" in text + assert want["verdict"] in text + assert "Full posting text" in text + state = re.search(r"^state: (\S+)$", text, re.M).group(1) + assert state == ("closed" if want["verdict"] == "Skip" else "apply_ready") + assert len(list((out / "archive").glob("*.md"))) == 1 + + +@pytest.mark.parametrize("pid", IDS) +def test_email_card_is_written_and_passes_its_own_check(runs, pid): + _, out = runs[pid] + card = next((out / "email").glob("*.html")) + check = subprocess.run([sys.executable, str(ROOT / "assessment" / "scripts" / "check_email.py"), str(card)], + capture_output=True, text=True) + assert check.returncode == 0, check.stdout + check.stderr + + +@pytest.mark.parametrize("pid", IDS) +def test_stdout_is_the_terminal_summary(runs, pid): + proc, out = runs[pid] + assert proc.stdout == (out / "work" / "terminal.txt").read_text(encoding="utf-8") + assert EXPECTED[pid]["verdict"] in proc.stdout + + +@pytest.mark.parametrize("pid", IDS) +def test_outputs_match_golden_files_byte_for_byte(runs, pid): + _, out = runs[pid] + got = outputs(out) + gdir = GOLDEN / pid + if os.environ.get("JA_UPDATE_GOLDEN") == "1": + gdir.mkdir(parents=True, exist_ok=True) + for name, text in got.items(): + (gdir / name).write_text(banner_line(name) + text, encoding="utf-8") + for name, text in got.items(): + golden = (gdir / name).read_text(encoding="utf-8") + first, rest = golden.split("\n", 1) + assert BANNER in first, f"{gdir / name} lost its banner line" + assert rest == text, f"{pid}/{name} differs from the golden file" + + +def test_golden_folder_has_exactly_the_expected_files(): + for pid in IDS: + names = sorted(p.name for p in (GOLDEN / pid).iterdir()) + assert names == ["email.html", "note-name.txt", "note.md", "terminal.txt"] + assert sorted(p.name for p in GOLDEN.iterdir()) == IDS + + +def test_no_personal_path_in_any_output(runs): + for pid in IDS: + _, out = runs[pid] + for name, text in outputs(out).items(): + for bad in ("/Users/", "/tmp/", "/private/", str(out)): + assert bad not in text, f"{pid}/{name} contains {bad}" + + +def test_a_second_run_into_the_same_folder_is_refused_as_a_duplicate(runs): + _, out = runs[IDS[0]] + proc = assess(IDS[0], out) + assert proc.returncode == 1 + assert "parse posting" in proc.stderr + assert "already archived" in proc.stderr.lower() or "duplicate" in proc.stderr.lower() + + +def test_force_archives_a_second_copy(tmp_path): + assert assess(IDS[0], tmp_path).returncode == 0 + assert assess(IDS[0], tmp_path, "--force").returncode == 0 + + +def test_invented_quote_stops_the_chain_at_the_quote_check(tmp_path): + findings = json.loads((FIX / "findings" / f"{IDS[0]}.findings.json").read_text(encoding="utf-8")) + findings["requirements"][0]["quote"] = "This sentence is nowhere in the posting." + bad = tmp_path / "bad.findings.json" + bad.write_text(json.dumps(findings), encoding="utf-8") + out = tmp_path / "out" + cmd = [sys.executable, str(SCRIPT), str(FIX / "postings" / f"{IDS[0]}.md"), "--findings", str(bad), + "--profile", str(PROFILE), "--out", str(out), "--date", DATE] + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) + assert proc.returncode == 1 + assert "check findings" in proc.stderr + assert not list((out / "email").glob("*.html")), "no card may be written after a failed check" + + +def test_findings_that_break_the_schema_stop_the_chain_first(tmp_path): + findings = json.loads((FIX / "findings" / f"{IDS[0]}.findings.json").read_text(encoding="utf-8")) + findings["requirements"][0]["rating"] = "great" + bad = tmp_path / "bad.findings.json" + bad.write_text(json.dumps(findings), encoding="utf-8") + cmd = [sys.executable, str(SCRIPT), str(FIX / "postings" / f"{IDS[0]}.md"), "--findings", str(bad), + "--profile", str(PROFILE), "--out", str(tmp_path / "out"), "--date", DATE] + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) + assert proc.returncode == 1 + assert "findings schema" in proc.stderr + assert not list((tmp_path / "out" / "archive").glob("*")) + + +def test_missing_input_exits_2(tmp_path): + cmd = [sys.executable, str(SCRIPT), str(tmp_path / "nope.md"), "--findings", str(tmp_path / "nope.json"), + "--profile", str(PROFILE), "--out", str(tmp_path / "out")] + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) + assert proc.returncode == 2 + + +def test_wrong_lane_stops_at_the_lane_gate(tmp_path): + cmd = [sys.executable, str(SCRIPT), str(FIX / "postings" / f"{IDS[0]}.md"), + "--findings", str(FIX / "findings" / f"{IDS[0]}.findings.json"), + "--profile", str(PROFILE), "--out", str(tmp_path / "out"), "--date", DATE, "--lane", "no-such-lane"] + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=ROOT) + assert proc.returncode == 1 + assert "load criteria" in proc.stderr + + +def test_the_documented_clean_clone_commands_behave(tmp_path): + """The two validator lines from the README's Verified block.""" + ok = subprocess.run([sys.executable, str(ROOT / "scripts" / "validate_profile.py"), "--fixture", str(PROFILE)], + capture_output=True, text=True, cwd=ROOT) + assert ok.returncode == 0 + broken = subprocess.run([sys.executable, str(ROOT / "scripts" / "validate_profile.py"), "--fixture", + str(FIX / "broken-profile.yaml")], capture_output=True, text=True, cwd=ROOT) + assert broken.returncode == 1 + assert len(broken.stdout.strip().splitlines()) == 5 + + +def test_the_script_never_needs_the_claude_cli(): + text = SCRIPT.read_text(encoding="utf-8") + for word in ("claude", "anthropic", "requests", "urllib"): + assert word not in text.lower().replace("claude code", "") diff --git a/tools/job-fit-screen/.gitignore b/tools/job-fit-screen/.gitignore deleted file mode 100644 index 386789d..0000000 --- a/tools/job-fit-screen/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# Never commit a real criteria file or reasoning file — only the -# example/placeholder versions ship. -criteria.yaml -*.criteria.yaml -!criteria.example.yaml - -search.md -*.search.md -!search.example.md diff --git a/tools/job-fit-screen/README.md b/tools/job-fit-screen/README.md deleted file mode 100644 index 05e17ee..0000000 --- a/tools/job-fit-screen/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# job-fit-screen - -> **Early version (August 2026), being replaced soon.** This is the first, simple scorer. The job assessment system I use today is far larger and has been updated almost daily since. A cleaned-up public version of it is being added to this repo. - -A tool for scoring a real job posting against your own personal fit criteria — whether the company would treat you the way you need to be treated, *and* whether you're actually qualified for what's being asked. Both questions matter; a posting that's a perfect culture fit is still a bad target if the required experience doesn't line up with your real skills. - -## Status - -**Working.** The scorer (`job_fit_screen.py`) reads your criteria file and a job posting, sends both to Claude via the local `claude` CLI (no API key needed), and prints a scorecard. See [Setup](#setup) and [How to use it](#how-to-use-it) below. - -## Why this exists - -Most job-search tools tell you whether you match a posting's stated requirements. They don't tell you whether the posting matches *you* — whether the company gives real autonomy, whether the interview process is reasonable, whether a "senior" title secretly means people-management when you don't want to manage people. - -This tool separates that judgment into three parts: - -1. **Your reasoning** — a plain-language file where you actually think through what you want and why, grounded in your own real work history rather than a generic wishlist. This is the file you write first. -2. **Your criteria** — a structured file distilled from that reasoning, describing what you actually need and want (hard blocks, must-haves, red flags, and softer signals worth noting). -3. **The scorer** — generic code that reads any criteria file in this shape and checks a job posting against it. - -Neither your reasoning file nor your criteria file are ever checked into this repo. They belong to you, they're personal, and they usually contain things (salary numbers, work history, hard-won lessons from past jobs) that shouldn't sit in a public tool. - -## Setup - -This tool scores postings by shelling out to the local `claude` CLI (Claude Code) — it does **not** use a raw Anthropic API key. - -1. **Install [Claude Code](https://docs.claude.com/claude-code)** if you don't already have it, and log in once by running `claude` interactively. -2. **Install this tool's dependencies:** - ```bash - ./install.sh - ``` - This checks for Python 3.9+, installs the required packages, and confirms the `claude` CLI is on your PATH. - -## How to use it - -1. **Copy both example files** to your own private location — do not edit `search.example.md` or `criteria.example.yaml` in place, and do not commit your real versions to this repo or any public repo: - ```bash - cp search.example.md ~/private/my-job-fit-criteria.md - cp criteria.example.yaml ~/private/my-job-criteria.yaml - ``` -2. **Write your reasoning first, in `my-job-fit-criteria.md`.** Answer the prompts in each section honestly, grounded in real evidence from your own past jobs — not what you think you're supposed to want. This step matters: distilling straight into structured YAML fields without thinking it through first tends to produce a shallower, less accurate criteria file. -3. **Distill that reasoning into `my-job-criteria.yaml`.** Every field in `criteria.example.yaml` is a placeholder — read the comments, replace each example with something true about you, grounded in what you just wrote in step 2. See the file itself for the expected shape: `hard_blocks`, `requirements`, `autonomy_filter`, `keyword_signals`, `qualifications_match`, `soft_flags`, and `availability`. -4. **Save a job posting to a text file**, or grab its URL directly. (A URL works for plain server-rendered pages; a JavaScript-heavy job board — Greenhouse, Ashby, Lever — usually won't extract cleanly from a URL fetch. Save the posting text to a `.txt` file in that case instead.) -5. **Run the tool:** - ```bash - ./run_screen.sh --criteria ~/private/my-job-criteria.yaml --posting ~/Downloads/posting.txt - # or, with a URL: - ./run_screen.sh --criteria ~/private/my-job-criteria.yaml --posting "https://example.com/job/123" - # optionally save the full structured result too: - ./run_screen.sh --criteria ~/private/my-job-criteria.yaml --posting posting.txt --json-out result.json - ``` -6. **Read the output.** The tool is meant to flag things for you to weigh, not make the decision for you — a posting that trips a soft flag isn't automatically a reject, it's a "look closer here." A tripped `hard_blocks` entry is the one case the tool treats as a real reject, and the report says so plainly. - -Every verdict in the report is grounded in a quote or close paraphrase from the actual posting — if the tool can't point to specific language backing a claim, it's instructed to say so rather than guess. - -### Try it without a real criteria file first - -```bash -python3 test_example.py -``` - -Runs the scorer against a fake, fully generic test posting and `criteria.example.yaml` as-is. Confirms the tool works end to end (and that `claude` is logged in and responding) before you've written anything real. - -## Reasoning file shape - -See `search.example.md` for the full annotated template. It walks through the same sections as the criteria YAML — availability/process, location, company size/stage, industries, the pitch you're chasing, and culture red flags — but as prompts to answer in your own words, with space to write out *why*, not just *what*. - -## Criteria file shape - -See `criteria.example.yaml` for the full annotated template. In short: - -| Section | What it's for | Effect | -|---|---|---| -| `hard_blocks` | Instant disqualifiers | Posting is dropped entirely, no scoring | -| `requirements` | Must-haves | Strong reject on miss, but scored/flagged, not silently dropped — a human can still override | -| `autonomy_filter` | A judgment call too nuanced for yes/no | Scored on positive/negative signal matches | -| `keyword_signals` | Weighted language patterns to scan for in a posting's own text | Fast first pass — `strong_positive` / `positive` / `negative` / `strong_negative` phrase lists, each with a `why` | -| `qualifications_match` | Whether you're actually eligible, separate from fit | Checks a posting's required/standout experience against your real `core_skills_and_evidence` and `known_gaps` | -| `soft_flags` | Worth noting, not disqualifying | Surfaced alongside the posting for you to weigh | -| `availability` | Notice period, work authorization | Used to flag postings that need special handling (e.g. non-local employer) | - -`keyword_signals` is different in kind from the other sections — those are *criteria* (facts to check, sometimes needing outside research). `keyword_signals` is *language patterns* worth scanning for directly in the posting text itself, weighted by how strong a signal each phrase is. It grows over time: every real posting you screen either confirms an existing phrase's weight or teaches you a new one — add it to the list rather than losing the pattern in a one-off read. - -`qualifications_match` is different again — every other section answers "would this company treat me right," this one answers "am I actually qualified." A posting can pass every fit check above and still be a poor target if its required experience leans hard into skills you don't have. - -This shape is deliberately generic — it's built from one person's real job search, but nothing in the structure assumes any specific industry, role, or set of values. Your criteria file is what makes it yours. - -## Roadmap - -- [x] Define the exact shape a scraped/pasted job posting takes as input — local text file or URL -- [x] Build the scoring logic — Claude reads the criteria and posting together and returns a structured verdict, since the criteria file's own checks (negation, working-philosophy fit, qualifications judgment) need real reading comprehension, not keyword matching -- [x] Decide on output format — terminal scorecard by default, optional `--json-out` for the full structured result -- [x] Add a CLI entry point and usage example — `job_fit_screen.py` via `click`, wrapped by `run_screen.sh` -- [x] Add a test fixture — `test_example.py` runs a fake generic posting through the real tool end to end -- [ ] Auto-fetch from JS-rendered job boards (Greenhouse, Ashby, Lever) — currently these need the posting text saved to a file first, since a plain URL fetch only sees the unrendered page shell -- [ ] Persistent history across scored postings — right now each run is a one-off report, no log of what's been screened before - -## License - -This project is open source and available under the MIT License. diff --git a/tools/job-fit-screen/criteria.example.yaml b/tools/job-fit-screen/criteria.example.yaml deleted file mode 100644 index da76db9..0000000 --- a/tools/job-fit-screen/criteria.example.yaml +++ /dev/null @@ -1,152 +0,0 @@ -# job-fit-screen — example criteria file -# -# This is a PLACEHOLDER with fake example values. Copy this file to -# criteria.yaml (or any private location outside this repo) and replace -# every value with your own real preferences before running the tool. -# -# Nothing in this file is read by the tool by default — you must point the -# tool at your own criteria file explicitly. Keep your real criteria file -# private; it's meant to hold personal details (salary numbers, employer -# history, hard-won lessons) that don't belong in a shared repo. - -hard_blocks: - # Any single true match here means the tool drops the posting entirely. - # No scoring, no partial credit, no exceptions. - - id: example_industry_block - label: "Example: an industry you refuse to work in" - match_hints: ["replace", "with", "keywords", "that", "indicate", "this", "industry"] - - - id: example_location_block - label: "Example: not remote, if remote is a hard requirement for you" - match_hints: ["hybrid", "onsite", "in-office", "relocation required"] - exceptions: "Describe any exception here, e.g. occasional travel that's still fine" - -requirements: - # Must-haves. A miss here is a strong reject, but — unlike hard_blocks — - # these are judgment calls a human might override for an exceptional - # posting, so the tool should flag and score these, not silently drop them. - - id: example_benefit_requirement - label: "Example: a benefit you need at a minimum standard" - why: "Explain why this matters to you — this is what makes the criteria file yours." - check_via: ["Where you'd actually verify this — Glassdoor, the posting, a recruiter call"] - - - id: example_pay_range - label: "Example: base salary target range" - min: 0 - target: 0 - open_ask: 0 - stretch_ceiling: 0 - currency: USD - note: "Explain any conditions on when the stretch ceiling applies." - -autonomy_filter: - # An example of a non-binary judgment call worth scoring rather than - # reducing to a single yes/no field. - label: "Example: describe the single biggest thing that predicts whether you'll be happy in a role" - positive_signals: - - "A phrase or pattern in a posting that's a good sign" - negative_signals: - - "A phrase or pattern in a posting that's a bad sign" - -keyword_signals: - # A different kind of criterion than everything above: literal LANGUAGE - # PATTERNS worth scanning for directly in a posting's own text, rather - # than facts you'd have to research (Glassdoor, a recruiter call, etc). - # Weight each phrase by how strong a signal it is. This is a fast first - # pass over posting text, not a substitute for the deeper criteria above — - # a posting can score well on keywords and still fail a hard_block or the - # autonomy_filter. - # - # Same caveat as hard_blocks' match_hints: these need context-aware - # matching (an LLM, or careful negation-handling), not naive substring - # search. "We don't micromanage" and "we require close daily oversight" - # could both contain the word "oversight" — the sentence around a phrase - # is what actually carries the meaning, not the phrase alone. - strong_positive: - - phrase: "example: a phrase that's a very strong good sign for you" - why: "Explain why this phrase specifically, and what it maps to among your criteria above." - - positive: - - phrase: "example: a phrase that's a mild good sign" - why: "Explain why." - - negative: - - phrase: "example: a phrase that's a mild bad sign" - why: "Explain why." - - strong_negative: - - phrase: "example: a phrase that's a very strong bad sign, maybe even a backstop for one of your hard_blocks" - why: "Explain why." - - usage_note: > - Seed list only — this grows as you actually screen real postings. - When a posting's wording teaches you something new (a phrase that - turned out to predict a good or bad fit), add it here with a `why` - rather than only noting it once and losing the pattern. - -qualifications_match: - # A different question from everything above. Those sections check FIT — - # would this company treat you the way you need to be treated. This - # section checks ELIGIBILITY — are you actually qualified for what's - # being asked. A posting can pass every fit check and still be a bad - # target if the required experience doesn't line up with your real - # skills. Keep this honest — the point is catching a real mismatch - # early, not talking yourself into (or out of) a posting. - years_of_experience: - # A posting's required-experience section almost always states a - # minimum year count (e.g. "5+ years"). Nothing else in this file - # checks that automatically — worth stating your real total so a - # posting's minimum can be checked directly instead of guessed at. - total_years: 0 - note: "State how you calculated this — which roles/dates count, and whether any of your career history doesn't count toward this specific total (e.g. a role in an unrelated field before you moved into this line of work)." - how_to_apply: "Compare directly against a posting's stated minimum. Flag only if a posting asks for more years than you have, or asks for years specifically in a narrow sub-domain your total experience doesn't automatically cover." - - core_skills_and_evidence: - # What you'd check a posting's required/standout language against — - # your actual documented strengths, not aspirational skills. - - "Example: a real, evidenced skill you can point to concrete work for" - - "Example: another one" - - known_gaps: - # Real, specific gaps — not vague self-doubt, named domains you don't - # have hands-on depth in. A posting leaning heavily on these in its - # REQUIRED experience (not just a nice-to-have) is a real signal. - - "Example: a specific technical domain or credential you genuinely lack" - - working_philosophy: - # A different kind of signal than a skill gap: what KIND of role you're - # good at and want, independent of any single posting's specific - # domain. Useful when a posting's requirements reflect an underlying - # role-design choice, not just an unfamiliar subject. - stance: "Example: describe how you actually prefer to work — e.g. 'I'm a generalist who gathers information from experts rather than becoming the expert myself,' or the opposite, whatever's true for you." - why: "Explain why this is a deliberate choice, not a confidence gap or something you're avoiding." - what_to_check: "Describe what to look for in a posting's language that would signal it's asking for the OTHER working style than yours — not just an unfamiliar domain, but a fundamentally different way of approaching the role." - - how_to_apply: > - For any real posting: pull its "required experience" and "what will - make you stand out" (or equivalent) sections verbatim, then check each - line against core_skills_and_evidence and known_gaps above. A - required-experience list heavy with known_gaps items is a real fit - problem worth flagging — distinct from and in addition to any - autonomy/culture checks elsewhere in this file. A standout- - qualifications list that closely matches core_skills_and_evidence is - worth surfacing as a strong reason to apply even when other parts of - the posting are imperfect. - -soft_flags: - # Worth surfacing next to a posting. None of these disqualify on their - # own — they're context for the human making the final call. - - id: example_soft_flag - label: "Example: something worth noting but not disqualifying" - why: "Explain why this is worth flagging, even though it's not a hard block." - -availability: - notice_period: "Example: how soon you could actually start if offered the job today" - work_authorization: - status: "Example: your citizenship/visa status" - non_us_employer_handling: "Example: how to treat employers based outside your country" - -format_note: > - This file is deliberately flat and hand-editable. Extend it with your own - ids and fields as your own criteria grow more specific — just keep ids - stable once the tool starts referencing them. diff --git a/tools/job-fit-screen/install.sh b/tools/job-fit-screen/install.sh deleted file mode 100755 index 9b21578..0000000 --- a/tools/job-fit-screen/install.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/bash - -# job-fit-screen Installation Script - -echo "job-fit-screen - Installation Script" -echo "=====================================" - -if ! command -v python3 &> /dev/null; then - echo "❌ Python 3 is not installed. Please install Python 3.9+ first." - exit 1 -fi -echo "✅ Python 3 found: $(python3 --version)" - -if ! command -v pip3 &> /dev/null; then - echo "❌ pip3 is not installed. Please install pip first." - exit 1 -fi -echo "✅ pip3 found" - -echo "📦 Installing Python dependencies..." -pip3 install -r requirements.txt - -if [ $? -eq 0 ]; then - echo "✅ Python dependencies installed successfully" -else - echo "❌ Failed to install Python dependencies" - exit 1 -fi - -if ! command -v claude &> /dev/null; then - echo "" - echo "⚠️ The 'claude' CLI (Claude Code) was not found on your PATH." - echo " This tool scores postings by shelling out to 'claude -p' — it does" - echo " NOT use a raw Anthropic API key." - echo "" - echo " Install Claude Code: https://docs.claude.com/claude-code" - echo " Then log in once by running 'claude' interactively before using this tool." -else - echo "✅ claude CLI found: $(claude --version 2>&1 | head -n 1)" -fi - -chmod +x job_fit_screen.py - -echo "" -echo "🎉 Installation complete!" -echo "" -echo "Next steps:" -echo " 1. Copy criteria.example.yaml to a private location and fill in your own values." -echo " 2. Run: ./run_screen.sh --criteria /path/to/your-criteria.yaml --posting /path/to/posting.txt" -echo "" -echo "For more information, see README.md" diff --git a/tools/job-fit-screen/job_fit_screen.py b/tools/job-fit-screen/job_fit_screen.py deleted file mode 100755 index d201e14..0000000 --- a/tools/job-fit-screen/job_fit_screen.py +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env python3 -""" -job-fit-screen -Scores a real job posting against a personal fit-criteria YAML file, using -Claude to make the judgment calls the criteria file itself requires (context, -negation, working-philosophy fit) rather than naive keyword matching. - -Scoring runs through the local `claude` CLI (Claude Code), not a raw -Anthropic API key — this tool never reads or requires ANTHROPIC_API_KEY. -It shells out to `claude -p` and relies on whatever auth the CLI already -has configured (subscription login), same as any other Claude Code session -on the machine running this. -""" - -import json -import shutil -import subprocess -import tempfile -from pathlib import Path -from typing import Optional - -import click -import yaml -import httpx -from rich.console import Console -from rich.panel import Panel - -console = Console() - -CLAUDE_CLI_TIMEOUT_SECONDS = 120 - -SEVERITY_ICON = { - "strong": "🔴", - "soft": "🟡", -} -STATUS_ICON = { - "pass": "🟢", - "fail": "🔴", - "unclear": "🔵", -} - - -class JobFitScreener: - def __init__(self, criteria_path: Path): - self.criteria_path = criteria_path - self.criteria = self.load_criteria(criteria_path) - if not shutil.which("claude"): - raise click.ClickException( - "The 'claude' CLI was not found on your PATH. This tool scores postings " - "through Claude Code, not a raw API key — install Claude Code first: " - "https://docs.claude.com/claude-code" - ) - - def load_criteria(self, path: Path) -> dict: - """Load and parse the criteria YAML file.""" - try: - with open(path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - except yaml.YAMLError as e: - raise click.ClickException(f"Criteria file is not valid YAML: {e}") - except FileNotFoundError: - raise click.ClickException(f"Criteria file not found: {path}") - - if not isinstance(data, dict): - raise click.ClickException("Criteria file did not parse to a mapping — check its structure.") - return data - - def load_posting(self, source: str) -> str: - """Load posting text from a local file path or a URL.""" - if source.startswith("http://") or source.startswith("https://"): - try: - resp = httpx.get(source, timeout=30, follow_redirects=True) - resp.raise_for_status() - except httpx.HTTPError as e: - raise click.ClickException(f"Failed to fetch posting URL: {e}") - # Best-effort plain text — strips HTML tags crudely. Postings - # rendered client-side (JS-heavy job boards) may come back thin; - # if so, save the posting to a text file and pass that instead. - import re - text = re.sub(r"", "", resp.text, flags=re.DOTALL | re.IGNORECASE) - text = re.sub(r"", "", text, flags=re.DOTALL | re.IGNORECASE) - text = re.sub(r"<[^>]+>", " ", text) - text = re.sub(r"\s+", " ", text).strip() - if len(text) < 200: - console.print( - "[yellow]⚠️ Fetched content looks thin — this posting may render via " - "JavaScript. Save the posting text to a .txt file and pass that instead.[/yellow]" - ) - return text - - path = Path(source) - if not path.exists(): - raise click.ClickException(f"Posting file not found: {source}") - return path.read_text(encoding="utf-8") - - def build_prompt(self, posting_text: str) -> str: - criteria_yaml = yaml.safe_dump(self.criteria, sort_keys=False, allow_unicode=True) - return f"""You are screening a job posting against a candidate's personal fit criteria. -The criteria file below explicitly requires real reading comprehension, not keyword -matching — handle negation ("no relocation required" is NOT a relocation requirement), -euphemisms, and implied meaning the way a careful human reader would. - -For every verdict, quote or closely paraphrase the SPECIFIC posting language behind -it. A verdict with no evidence is not usable — never state a conclusion without -citing the words in the posting that led to it. If the posting doesn't address -something, say so plainly rather than guessing. - - -{criteria_yaml} - - - -{posting_text} - - -Respond with ONLY a JSON object (no markdown fences, no commentary before or after) -in exactly this shape: - -{{ - "hard_blocks": [ - {{"id": "", "tripped": true/false, "evidence": ""}} - ], - "requirements": [ - {{"id": "", "severity": "", "status": "pass|fail|unclear", "evidence": ""}} - ], - "autonomy_filter": {{"assessment": "<1-3 sentences, citing specific posting language>"}}, - "keyword_signals": {{ - "strong_positive": [{{"phrase": "", "context": ""}}], - "positive": [...], - "negative": [...], - "strong_negative": [...] - }}, - "qualifications_match": {{ - "years_requirement_met": "yes|no|unclear|not_stated", - "years_evidence": "", - "core_skills_alignment": "<1-3 sentences on how well required/standout language matches core_skills_and_evidence>", - "known_gaps_present": "<1-3 sentences on whether required experience leans on known_gaps>", - "working_philosophy_fit": "<1-3 sentences on whether the role expects the working_philosophy stance or its opposite>" - }}, - "soft_flags": [ - {{"id": "", "present": true/false, "evidence": ""}} - ], - "bottom_line": "<2-4 sentence plain-language summary of whether this is worth applying to, and why>" -}} - -Only include ids that actually exist in the criteria file above — do not invent ids. - -Score whatever is in the criteria file above, even if it looks like placeholder -or example text — do not ask clarifying questions, do not refuse, do not comment -on whether the criteria look real or filled in. Your entire response must be the -raw JSON object and nothing else: no leading or trailing prose, no markdown code -fences, no questions back to the user.""" - - def score(self, posting_text: str) -> dict: - """Send criteria + posting to Claude via the `claude` CLI, return the parsed verdict.""" - prompt = self.build_prompt(posting_text) - try: - # Run from a neutral temp directory, not the caller's cwd — a - # project's own CLAUDE.md / conventions have no business bleeding - # into a job-posting scoring call, and doing so can make Claude - # treat this like a normal coding-session request instead of a - # one-shot structured-output call. - with tempfile.TemporaryDirectory() as neutral_dir: - result = subprocess.run( - ["claude", "-p", "--output-format", "text"], - input=prompt, - capture_output=True, - text=True, - timeout=CLAUDE_CLI_TIMEOUT_SECONDS, - cwd=neutral_dir, - ) - except subprocess.TimeoutExpired: - raise click.ClickException( - f"claude CLI did not respond within {CLAUDE_CLI_TIMEOUT_SECONDS}s. Try again, " - "or check that `claude` is logged in (run `claude` interactively once to verify)." - ) - except FileNotFoundError: - raise click.ClickException("The 'claude' CLI was not found on your PATH.") - - if result.returncode != 0: - raise click.ClickException( - f"claude CLI exited with an error (code {result.returncode}):\n{result.stderr.strip()}" - ) - - raw_text = result.stdout.strip() - if raw_text.startswith("```"): - raw_text = raw_text.split("```")[1] - if raw_text.startswith("json"): - raw_text = raw_text[4:] - raw_text = raw_text.strip() - - try: - return json.loads(raw_text) - except json.JSONDecodeError as e: - raise click.ClickException( - f"Could not parse Claude's response as JSON: {e}\n\nRaw response:\n{raw_text[:1000]}" - ) - - def render_report(self, result: dict): - """Print a readable terminal scorecard.""" - console.print() - console.rule("[bold]job-fit-screen report[/bold]") - - hard_blocks = result.get("hard_blocks", []) - tripped = [h for h in hard_blocks if h.get("tripped")] - console.print("\n[bold]Hard blocks[/bold]") - if not hard_blocks: - console.print(" [dim]none defined in criteria[/dim]") - for h in hard_blocks: - icon = "🔴" if h.get("tripped") else "🟢" - console.print(f" {icon} {h.get('id')} — {h.get('evidence', '')}") - - console.print("\n[bold]Requirements[/bold]") - for r in result.get("requirements", []): - sev = SEVERITY_ICON.get(r.get("severity"), "⚪") - status = STATUS_ICON.get(r.get("status"), "🔵") - console.print(f" {status} {sev} {r.get('id')} — {r.get('evidence', '')}") - - af = result.get("autonomy_filter", {}) - if af.get("assessment"): - console.print("\n[bold]Autonomy filter[/bold]") - console.print(f" {af['assessment']}") - - ks = result.get("keyword_signals", {}) - console.print("\n[bold]Keyword signals[/bold]") - for tier, icon in [("strong_positive", "🟢🟢"), ("positive", "🟢"), ("negative", "🟠"), ("strong_negative", "🔴")]: - for k in ks.get(tier, []): - console.print(f" {icon} \"{k.get('phrase')}\" — {k.get('context', '')}") - - qm = result.get("qualifications_match", {}) - if qm: - console.print("\n[bold]Qualifications match[/bold]") - console.print(f" Years requirement met: {qm.get('years_requirement_met', 'unclear')}") - if qm.get("years_evidence"): - console.print(f" {qm['years_evidence']}") - if qm.get("core_skills_alignment"): - console.print(f" Core skills alignment: {qm['core_skills_alignment']}") - if qm.get("known_gaps_present"): - console.print(f" Known gaps: {qm['known_gaps_present']}") - if qm.get("working_philosophy_fit"): - console.print(f" Working philosophy fit: {qm['working_philosophy_fit']}") - - console.print("\n[bold]Soft flags[/bold]") - for s in result.get("soft_flags", []): - icon = "🟠" if s.get("present") else "⚪" - console.print(f" {icon} {s.get('id')} — {s.get('evidence', '')}") - - if tripped: - console.print() - console.print(Panel( - f"[bold red]Hard block(s) tripped: {', '.join(h['id'] for h in tripped)}[/bold red]\n" - f"This posting is disqualified regardless of anything else below.", - title="⛔ Disqualified", - )) - elif result.get("bottom_line"): - console.print() - console.print(Panel(result["bottom_line"], title="👉 Bottom line")) - - def save_json(self, result: dict, path: Path): - with open(path, "w", encoding="utf-8") as f: - json.dump(result, f, indent=2) - console.print(f"\n[dim]Full result saved to {path}[/dim]") - - -@click.command() -@click.option("--criteria", required=True, type=click.Path(exists=True), help="Path to your criteria YAML file.") -@click.option("--posting", required=True, help="Path to a posting text file, or a URL to fetch.") -@click.option("--json-out", type=click.Path(), default=None, help="Optional path to also save the full result as JSON.") -def main(criteria: str, posting: str, json_out: Optional[str]): - """Score a job posting against your personal fit criteria.""" - screener = JobFitScreener(Path(criteria)) - console.print(f"[dim]Loading posting from {posting}...[/dim]") - posting_text = screener.load_posting(posting) - - console.print("[dim]Scoring against criteria...[/dim]") - result = screener.score(posting_text) - - screener.render_report(result) - - if json_out: - screener.save_json(result, Path(json_out)) - - -if __name__ == "__main__": - main() diff --git a/tools/job-fit-screen/requirements.txt b/tools/job-fit-screen/requirements.txt deleted file mode 100644 index 11946be..0000000 --- a/tools/job-fit-screen/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -click==8.1.7 -rich==13.7.0 -pyyaml==6.0.2 -httpx==0.27.2 diff --git a/tools/job-fit-screen/run_screen.sh b/tools/job-fit-screen/run_screen.sh deleted file mode 100755 index 390b3f0..0000000 --- a/tools/job-fit-screen/run_screen.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash -# Thin launcher for job_fit_screen.py — mirrors run_converter.sh in docx-to-markdown. -python3 "$(dirname "$0")/job_fit_screen.py" "$@" diff --git a/tools/job-fit-screen/search.example.md b/tools/job-fit-screen/search.example.md deleted file mode 100644 index 15c4275..0000000 --- a/tools/job-fit-screen/search.example.md +++ /dev/null @@ -1,76 +0,0 @@ -# Example: Fit Criteria (search.md) - -This is a PLACEHOLDER with fake example values, showing the human-readable half of job-fit-screen. Pair it with `criteria.example.yaml` — that file is the structured version a script reads; this one is the reasoning behind it, written for you to actually think through and read back later. - -Copy this file to your own private location, rename it whatever makes sense to you, and replace every example with something true about your own situation. Nothing in this file is read by the tool automatically — it exists so the structured criteria file isn't the only place your preferences live, and so you have somewhere to write out *why* each rule exists, not just *what* it checks. - ---- - -## Why two files, not one - -A screening tool needs machine-checkable fields (`criteria.example.yaml`). A person trying to figure out their own preferences needs to actually reason through them in plain language first — what happened at past jobs, what patterns repeated, what tradeoffs are real versus assumed. Writing the reasoning down here, then distilling it into the YAML's structured fields, produces a better criteria file than jumping straight to YAML ever would. Keep both in sync: if a rule changes here, update the matching entry in your criteria YAML, and vice versa. - ---- - -## Fit criteria - -Use this section as your own checklist for judging any posting — a place to write down what actually matters to you, grounded in real evidence from your own work history rather than guessed preferences. - -### Availability and process - -Example prompts to answer for yourself: -- How soon could you actually start if offered a job today? (Notice period, if any.) -- What's your work authorization status, and how should postings from employers outside your country be handled? -- What's your real tolerance for interview process length — number of rounds, take-home assignments, timeline expectations? Has a past experience (good or bad) taught you something specific about this? - -**Example:** *Available immediately — no current employer, no notice to serve. Up to 5 interview rounds is fine; 6+ gets flagged. Take-home assignments are okay if reasonably scoped (a couple hours) — a past employer's scoped take-home actually helped land the job, so this isn't a flat rule against them, it's about total process length stacking up.* - -### Location - -Example prompts: -- Remote, hybrid, onsite, or some mix — and how firm is that requirement? -- Any exceptions (occasional travel, specific regions)? -- Time zone expectations, and how flexible you actually are. - -**Example:** *Remote only — hard requirement. Up to 1-2 trips a year for training or off-sites is fine. Works Eastern time by default, some flexibility. Open to employers outside my home country as long as pay/benefits are competitive and there's no legal complication to working for them from here.* - -### Company size and stage - -Example prompts: -- Does company size actually predict whether you've been happy somewhere, based on your real history — or is something else the real filter (autonomy, culture, team structure)? -- What's the strongest evidence from your own past roles, not a guess about what *should* matter? - -**Example:** *No fixed headcount preference — history spans a 60-person startup to an 800-person enterprise product, and size itself never predicted whether the job was good. The real filter is autonomy: does this company need someone to own this function and hand over real decision-making power, or is it a seat on an existing team. The two strongest "liked" data points in my whole work history are both a version of building something from nothing with real ownership.* - -### Industries - -Example prompts: -- Any hard blocks — industries you refuse to work in, and why (values-based, knowledge-gap-based, something else)? -- Any industries you used to avoid out of a real gap that's since closed? Worth naming so an old instinct doesn't auto-reject a posting that's actually fine now. - -**Example:** *No hard preference by industry — judged on autonomy and treatment, not what the company sells. Hard blocks: [industry you refuse], [industry you refuse] — not preferences, real lines. One industry [name] used to be avoided out of a real knowledge gap; a past role proved that gap closable, so don't auto-reject a posting there on the old instinct.* - -### The pitch you're chasing - -A short paragraph naming, in your own words, what the ideal posting actually looks like — not a job title, but the shape of the opportunity you're actually hoping to find. - -**Example:** *The ideal posting isn't a job description — it's a company that needs [the thing you do] fixed and doesn't yet have anyone dedicated to it. The pitch: "Here's my system. I'll plug it in, tune it to your situation, and make this genuinely great." A role with real ownership over that outcome is the target.* - -### Culture red flags - -List the specific things that predict a bad fit, each backed by a real pattern from your own history — not a generic "good culture" wishlist. - -**Example:** -- **[Specific dealbreaker].** A dealbreaker, not a minor downside. -- **[Specific pattern you've lived through].** Lived directly at [company] ([what happened]) and [company] ([what happened]). Tolerated every time, never enjoyed. -- **Low employee satisfaction on Glassdoor** (or your local equivalent) — a signal worth checking every time, not noise. -- **[Anything else specific to you].** - ---- - -## Notes on writing your own version - -- **Ground every rule in real evidence.** "I don't want a bureaucratic company" is vague. "I lived through 9 reorgs in 7 years at one job and it wore me down every time" is a real, checkable pattern. -- **Separate hard blocks from soft preferences.** Some things are non-negotiable; most things are worth weighing case by case. Say which is which, in both this file and your structured criteria YAML. -- **Keep this file and your criteria YAML in sync.** When you learn something new about your own preferences, update both — the prose explains why, the YAML is what the tool actually checks. -- **This file is never read automatically by any script.** It's for you. Keep it wherever makes sense — a private notes app, a personal vault, a plain text file. It does not need to live inside this repo, and if you fork this tool publicly, keep your real version private the same way the real criteria YAML stays private. diff --git a/tools/job-fit-screen/test_example.py b/tools/job-fit-screen/test_example.py deleted file mode 100755 index ccbac29..0000000 --- a/tools/job-fit-screen/test_example.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -""" -Test fixture for job-fit-screen. -Creates a fake, fully genericized job posting (no real company) and runs the -scorer against criteria.example.yaml, so the tool can be verified without -needing a real criteria file or API costs beyond one test call. -""" - -import subprocess -import sys -from pathlib import Path - -FAKE_POSTING = """Senior Content Engineer (Remote) - -About the role - -We're a fictional example company looking for someone to own our documentation -end to end. You'll build our documentation strategy from scratch — we don't -have a dedicated writer today. You'll partner with engineers to gather source -material, and we don't micromanage: we hire people for their judgment. - -We use AI tooling throughout our workflow, including for drafting and -refining documentation content. - -What we're looking for - -5+ years of technical writing or content engineering experience. -Experience with docs-as-code workflows (Git, Markdown). -Comfortable working autonomously with minimal oversight. - -Nice to have - -Experience with structured content and information architecture. -Familiarity with AI-assisted content workflows. - -Location: Fully remote, US time zones. -Pay range: $110,000 - $140,000 USD. -""" - - -def main(): - fixture_dir = Path(__file__).parent - posting_path = fixture_dir / "_test_posting.txt" - posting_path.write_text(FAKE_POSTING, encoding="utf-8") - - criteria_path = fixture_dir / "criteria.example.yaml" - if not criteria_path.exists(): - print(f"❌ {criteria_path} not found — run this from the job-fit-screen directory.") - sys.exit(1) - - print("Running job_fit_screen.py against a fake test posting...") - print("=" * 60) - - result = subprocess.run( - [sys.executable, str(fixture_dir / "job_fit_screen.py"), - "--criteria", str(criteria_path), - "--posting", str(posting_path)], - cwd=fixture_dir, - ) - - posting_path.unlink(missing_ok=True) - - if result.returncode != 0: - print("\n❌ Test run failed — see output above.") - sys.exit(1) - - print("\n✅ Test run completed. Review the report above — it won't score") - print(" meaningfully well or poorly since criteria.example.yaml is all") - print(" placeholder text, but it confirms the tool runs end to end.") - - -if __name__ == "__main__": - main() From d863ec3fc37f1f65998198733e373eae9f1dfe91 Mon Sep 17 00:00:00 2001 From: darthrootbeer Date: Thu, 1 Oct 2026 17:47:08 -0400 Subject: [PATCH 2/2] ci(job-assessment): keep the clean-clone tree clean Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01XV3Ggh86cf2xwUyz28XaN6 --- .github/workflows/ci.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7663efc..af6ce84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,8 @@ jobs: clean-clone: name: clean-clone runs-on: ubuntu-latest + env: + PYTHONDONTWRITEBYTECODE: '1' steps: - uses: actions/setup-python@v5 with: @@ -79,15 +81,15 @@ jobs: - name: Install into a new virtual environment working-directory: fresh/pipelines/job-assessment run: | - python3 -m venv .venv - .venv/bin/pip install -r requirements.txt + python3 -m venv "$RUNNER_TEMP/venv" + "$RUNNER_TEMP/venv/bin/pip" install -r requirements.txt - name: The validator accepts the fictional person and rejects the broken file working-directory: fresh/pipelines/job-assessment shell: bash run: | - .venv/bin/python scripts/validate_profile.py --fixture fixtures/robin-sample/career-profile.yaml + "$RUNNER_TEMP/venv/bin/python" scripts/validate_profile.py --fixture fixtures/robin-sample/career-profile.yaml set +e - .venv/bin/python scripts/validate_profile.py --fixture fixtures/broken-profile.yaml + "$RUNNER_TEMP/venv/bin/python" scripts/validate_profile.py --fixture fixtures/broken-profile.yaml [ $? -eq 1 ] - name: Run the whole chain offline on all three fictional postings working-directory: fresh/pipelines/job-assessment @@ -95,14 +97,15 @@ jobs: run: | for id in 01-strong-fit 02-job-type-override 03-unlisted-pay-perks; do echo "=== $id" - .venv/bin/python scripts/assess_offline.py "fixtures/postings/$id.md" \ + "$RUNNER_TEMP/venv/bin/python" scripts/assess_offline.py "fixtures/postings/$id.md" \ --findings "fixtures/findings/$id.findings.json" \ --profile fixtures/robin-sample/career-profile.yaml \ --out "/tmp/ja-out/$id" --date 2026-10-01 2>/dev/null done - name: Run the full test suite, including the golden-file comparison working-directory: fresh/pipelines/job-assessment - run: .venv/bin/python -m pytest -q + run: | + "$RUNNER_TEMP/venv/bin/python" -m pytest -q -p no:cacheprovider - name: The run left the clone unchanged working-directory: fresh shell: bash