The making of WorkWingman (technical)¶
A living record of how this app was built — from a Claude Design prototype to a running local-first Electron + Angular + .NET application — written so someone could learn to do the same. Updated as the project grows.
Plain-language narrative: ../plain/making-of.md
The tools and concepts in play¶
- Claude Code — the agentic coding environment that wrote and ran everything here.
- Claude Skills — packaged capabilities the agent invokes, e.g. the
/codex-reviewgate (a second-opinion review by Codex before every commit) andartifact-design. Skills are how repeatable workflows get standardized. - MCP servers (Model Context Protocol) — external tool servers the agent drives: the Chrome MCP (used for the design import), a DesignSync MCP (Claude Design), and the planned Semgrep MCP for security triage.
- RAG / grounded workspaces — Retrieval-Augmented Generation.
The
cgspectrum2notebooklmreference project feeds a NotebookLM knowledge base; WorkWingman's per-job "Claude study project" is the same pattern — a grounded workspace loaded with the job description, your resume, and gap analysis. - Agentic sub-agents — the
Exploreagent mined two reference repos in parallel; doc pairs in this very folder were drafted by parallel sub-agents against a shared facts brief. - Resilience / automation patterns — persistent-profile scraping, Polly retry pipelines, Page Object-style automation, judgement-call pausing.
Part A — the design conversation (Claude Design)¶
The prototype was built by iterative prompting in Claude Design. Each prompt is shown as what was asked → what it added.
| # | Asked | Added |
|---|---|---|
| A1 | Build an Angular/.NET app: take a resume, ask job-hunt questions, SSO into LinkedIn + Gmail, store data in Google Docs/Forms instead of a database | The core concept + the first screen set |
| A2 | (answers) first-4 deliverable, hi-fi with working interactions, 2–3 visual directions, explore aesthetics + automation-view layouts, trust-pause modal, real resume data | Six options across three board styles (1a calm SaaS, 1b dense table, 1c dark console) and three viewer layouts (1d browser+checklist, 1e reasoning+ranked calls, 1f run log) |
| A3 | "1a with light/dark, 1d viewer but rope in 1e and 1f, mix 1b's density into 1a's comfort" | The combined design language the app now uses |
| A4 | Add affirmations + reinforcement (popups, hovers); per-job study links (docs, YouTube, Udemy, Google Skills, AWS/Azure, Medium, LeetCode) | The encouragement system + the Study & prep hub |
| A5 | Offer to generate a Claude project / NotebookLM-style study plan per job | The "Generate Claude study project" action (a RAG workspace) |
| A6 | Google + Apple calendar integration for interviews | Calendar sync on the Applied screen |
| A7 | "This needs Apple SSO now" | Sign in with Apple as a connection |
| A8 | Learning-platform logins (Udemy, Google Skills, AWS) stored in KeePass | Learning platforms as a vault-backed connection |
| A9 | "Everything's stored locally — make it an Electron app, not cloud-hosted" | The local-first Electron pivot that defines the architecture |
Part B — the build conversation (Claude Code)¶
Board ledger: FND · Foundation
| # | Asked | Added |
|---|---|---|
| B1 | Build it for real: Electron + Angular + .NET Core, Flurl + Polly, git repo at WorkWingman, learn scraping from cgspectrum2notebooklm + FamilyRenewedScraper, folder/test structure from the USPTO repos, Google Forms/Sheets, LinkedIn/Workday, KeePass, Apple/LinkedIn SSO |
The whole scaffold: design import, .NET solution, Angular app, Electron shell, backend services, 9 screens, first CI, private repo |
| B2 | Swap Azure Pipelines for GitHub Actions (private repo) | .github/workflows/ci.yml |
| B3 | "Run it, but don't forget /codex-review" |
The Codex review gate became routine; live-verified the UI |
| B4 | How to grant the workflow scope |
Guided the gh auth refresh device flow to unblock the CI push |
| B5 | Update all NuGet + npm packages to latest without breaking anything | Bumped test SDK, Angular 22.0.5, vitest, jsdom, Electron 43, cross-env; enforced Node 22.12+ |
| B6 | "Don't forget /codex-review before pushing" |
Made review-before-push a saved rule |
| B7 | Add an auto-installer for required software (Claude, browser, KeePass…) with verify → notice → sign-off → install → verify | The dependency doctor (detect on launch, pinned versions, winget/npm/Playwright install with per-item approval, token-gated endpoint) |
| B8 | "I sign off on installing KeePassXC with winget" | Dogfooded the doctor's own endpoint to install KeePassXC 2.7.10 |
| B9 | "Execute workwingman-next-actions" | This two-track learning-doc set + Electron E2E automation |
The build cycle that repeated throughout: write → /codex-review → fix findings → commit →
push → watch CI. Codex caught real issues each round — a CORS gap that would break the packaged
app, an unauthenticated install endpoint, a Playwright installer that assumed a PATH CLI, a
token-path mismatch — all fixed before landing.
The design-import workaround (worth its own section)¶
Importing the Claude Design prototype into the repo was the trickiest early problem:
- The DesignSync MCP needs an interactive login unavailable in this environment.
- Worked around it via the Chrome MCP on the already-logged-in
claude.aisession. - Found the design app's internal ConnectRPC endpoint
(
OmeletteService/GetFile) and pulled each file as base64. - Hit DLP filters that blocked URL/cookie-shaped content in tool output.
- Solved it with a localhost fragment-transfer listener: a tiny local HTTP server received the base64 files via the URL fragment and wrote them to disk cleanly.
This is a good general lesson: when a managed integration isn't reachable, an authenticated browser session plus the app's own API is often a viable path — and content filters are navigated by moving data through channels that don't trip them.
Reference-repo learnings¶
cgspectrum2notebooklm+FamilyRenewedScraper→ the scraping stack: persistent Chromium profile, anti-automation flags, 900 ms pacing, Polly retry on null/5xx/429, Playwright→Flurl cookie handoff, fallback selector chains, log-and-continue.- USPTO gitfront repos → the solution shape:
src/projects + a dedicated test project +test.runsettings, models/services/controllers separation, CI-ready layout.
The prompt journey, at a glance¶
timeline
title WorkWingman — from prompt to product
section Design (Claude Design)
A1 Concept : Angular/.NET job assistant
A2 Directions : 6 options, 3 board styles
A3 Combine : 1a + 1b density + merged viewer
A4-A5 Encourage & study : affirmations · study hub · Claude study projects
A6-A8 Connect : Google + Apple calendars · Apple SSO · learning logins
A9 Pivot : local-first Electron
section Build (Claude Code)
B1 Scaffold : import · .NET · Angular · Electron · 9 screens · CI · repo
B2-B4 Ship : GitHub Actions · codex-review · workflow scope
B5-B6 Maintain : update all packages · review-before-push rule
B7-B8 Doctor : dependency doctor · install KeePassXC
B9 Document : two-track learning docs · Electron E2E
Overnight: the multi-agent build-out¶
Board ledger: LAB · ScraperLab
One night the build shifted from "one agent, one conversation" to orchestrating a fleet — several agents working concurrently on isolated slices of the same repo, supervised rather than hand-driven. This chapter is the story of that night, told so someone could reproduce the method.
The shape of the orchestration¶
The core trick: isolated git worktrees. Instead of one agent editing one working copy, the
orchestrator checked out a separate worktree per task, each on its own branch. One agent per
slice, working in parallel, with no risk of two agents clobbering each other's uncommitted edits —
they were never in the same directory. Every finished branch went through the same gate before it
was allowed near master: /codex-review (a second AI, Codex,
reviewing the diff for bugs, OWASP Top 10 issues, and quality) had to pass, and then CI had to be
green. The orchestrator batched merges to limit how many CI runs a busy night would burn.
That gate is what made unattended, overnight operation defensible: no branch reached master
without a second, independent set of eyes on it — AI eyes, but a genuinely different model with a
genuinely different failure mode than the one that wrote the code.
Two compute pools, not one¶
Partway through the night, the run split across two separate compute pools — Claude (on a Max
plan) and Codex (on a Pro plan) — instead of leaning on one. The reasoning was simple capacity
math: eleven-plus concurrent workstreams put real pressure on a single pool's rate limits, and two
pools in parallel roughly halve that pressure on either one. Codex, which had already proven itself
as the review gate, was extended to also write code directly in some of its own worktrees, once
a sandboxing snag was solved — codex exec from inside a plain directory hit a read-only sandbox
because a git worktree isn't auto-recognized as writable; the fix was running it from inside
the worktree with --sandbox workspace-write --skip-git-repo-check. With that unblocked, whole ATS
labs (Paycor, notably) were built end-to-end by the Codex pool, not just reviewed by it.
That created a second discipline worth naming: mutual review. Codex doesn't get a free pass just because it's also the reviewer elsewhere — code it writes gets reviewed by Claude as rigorously as Claude's code gets reviewed by Codex. This wasn't hypothetical: see "the over-suppression catch" below, where the discipline caught a real bug in the other direction.
Budget-tiering the agents¶
Not every task deserves the most expensive model. New agents were spawned on Sonnet by default, dropping to Haiku for genuinely trivial work, with the top-tier model reserved for the hardest problems. This is the same instinct as choosing instance sizes in cloud infra — match the tool to the job, not the other way around, especially when running a dozen-plus agents at once where the multiplier on any inefficiency is large.
Eleven ATS "scraper learning labs"¶
The single biggest chunk of the night was a series of offline learning labs, one per major Applicant Tracking System (ATS): Workday, UKG, Greenhouse, Lever, Oracle (Taleo + Oracle Recruiting Cloud), SAP SuccessFactors, iCIMS, Paycom, Dayforce, ADP, and Paycor.
Each lab followed the same shape, and the shape itself is the reusable pattern:
- A loopback-only fake site (an in-process
HttpListenerbound to127.0.0.1) that reproduces that ATS's publicly known markup patterns — never real proprietary markup, never a real tenant. - A per-iteration randomizer that mutates the fixture each run: dropped/renamed ids, missing fields, shuffled step order, slow/hydrating renders, localized labels — the same kind of fuzzing a real tenant's next redesign would throw at the adapter.
- A real automation engine (the actual production-shaped
XyzAutomationEngine, not a toy) driven against that fixture, iteration after iteration, for anywhere from 20 quick validation runs to an hour-plus soak. - A learnings log — every selector attempt, hit or miss, every iframe switch, every judgement call, every failure mode — rolled up into a written "what we learned" doc per base.
Nothing here ever touched a real site, created a real account, or submitted a real application — that boundary was enforced in code (asserted in tests, not just promised in prose): each fixture only binds to loopback, each lab's "Submit" is a client-side no-op, and every run asserts a submit-was-never-requested flag after the fact.
What each base actually taught, in one line apiece:
- Workday —
data-automation-idis stable and should always lead the fallback chain; the highest-severity bug the loop found was that.Firstmatches were grabbing stale hidden nodes from earlier wizard steps until every selector was scoped to>> visible=true(this took iteration failures from 20/20 to 0/20). - UKG — no universal automation attribute;
[name]is the real workhorse fallback once#idis dropped, andaria-labelis the safety net behind that. - Greenhouse — a flat single-page form with clean semantic
#ids, but custom per-posting questions must be resolved by their visible<label>text, never by index or id (the index shifts per posting). - Lever — the input
nameattribute (name=email, bracketedname="urls[LinkedIn]") is Lever's real backend-binding contract and the most durable anchor; custom questions are keyed by an unstable per-posting card UUID, so they're matched positionally, not by a fixed selector. - Oracle (Taleo + ORC) — two different products behind one label. On legacy Taleo, matching by
idhit 0 of 80 attempts — dead weight — whilenamecarried 77 of 80; on the modern ORC productidactually wins, 57 of 80. Same vendor, opposite lead anchor — proof that "pick a default and apply it everywhere" is the wrong instinct. - SAP SuccessFactors — no automation attribute either; ids are long and SAP-generated, so
matching is by substring token (
id*='firstName'), not exact id. Roughly a quarter of matched fields lived inside a same-origin iframe, and every step reuses the same generated id for its Next button, so — again — resolving to the first visible match (not just first match) was the fix that took a 20/20 timeout rate to a clean run. - iCIMS — the hardest base: forms sit one or two iframes deep, ids are long generated ASP.NET tokens, and layout is table-based. Frame resolution had to become its own fallback chain (outer iframe → wait for it to actually load → probe → descend into a nested iframe only if genuinely needed → fall back to the top document for inline tenants) — and it deliberately has no blind "just try any iframe" fallback, because that would wrongly descend into a phantom frame on single-frame tenants and lose the real fields.
- Paycom — no automation attribute and (unlike UKG)
aria-labelis inconsistent enough that it doesn't deserve to lead; plainidleads,nameis the fallback workhorse. The lab's own validation runs caught a real class of bug here: a probe meant to detect "is the EEO step on its own page or folded into Review?" was itself using a single fragile anchor and mis-routed under fuzzing — fixed by giving that routing decision the same multi-tier fallback discipline as an ordinary field. That fix then transferred cleanly to ADP's near-identical Voluntary Disclosures ambiguity. - Dayforce — the one base where the problem isn't selectors, it's timing. It's a client-rendered Angular SPA that hydrates fields in after the shell paints; the fix was a two-layer wait (a hydration gate that waits for the app's own busy signal to clear, then a per-field wait-for-visible, not just wait-for-attached). A concurrent-load stress run also surfaced that a stalled Playwright wait can hang past its own timeout under CPU contention — solved with a per-iteration watchdog that force-closes the browser context so log-and-continue covers hangs, not just exceptions.
- ADP — a genuine Angular SPA whose reactive-forms controls expose
formcontrolname, which turned out to be the most durable anchor of all eleven bases — more durable than plainid, which is actually the least durable here because it's framework-assigned and shifts across renders. This is the opposite priority from Paycom, and confirms the general rule: never assume the previous base's ordering transfers, always re-derive the lead anchor per ATS. - Paycor — built end-to-end by the Codex pool (not just reviewed by it) — the proof that Codex could drive real file-writing work autonomously once the sandbox flag was sorted out.
The cross-base selector law¶
Line them all up and one law falls out: there is no universal "best" selector — the right lead
anchor is a property of the ATS platform, not a fixed priority order to memorize. Workday leads
with data-automation-id; ADP leads with formcontrolname; UKG/Paycom/Lever/Greenhouse lead with
[name] or #id with aria-label as a weak trailing signal; iCIMS/SuccessFactors/Taleo need
iframe-aware resolution plus suffix/substring id matching because their ids are long and generated;
Dayforce's problem is hydration timing, not selector choice at all. The actionable version: detect
the ATS first, pick that platform's lead anchor, and always fall back through the full chain — a
selector chain that only tries the "right" anchor and gives up is exactly as fragile as hard-coding
one selector. The full table lives in
scraper-automation-learnings-summary.md.
Backend mutation: ~86% to 99.34%¶
Board ledger: TST · Testing foundation
Mutation testing (via Stryker.NET)
checks the checkers: it mutates the production code (flips a > to >=, deletes a line, negates a
condition) and confirms the test suite actually fails when it should. A backend score that
started around 86% was driven to 99.34%, with the test count growing from 91 to 200 along the
way. The biggest single-service jumps were dramatic — KeePassVaultService from 13.85% to 100%,
StudyPlanService from 20% to 100%, most other services landing at 100% outright. What's left: 3
survivors, all documented in-code as provably-equivalent mutants (a 60-second timeout option, a
> vs >= at the exact tick of UtcNow — mutations a human reviewer agreed cannot actually be
observed by any test) with // Stryker disable once plus the reasoning written next to it, not a
silent suppression.
That "provably equivalent" claim is exactly where the mutual-review discipline earned its keep:
Codex reviewed the 99.34% branch and found two mutants that had been marked equivalent but
weren't. One: skipping a seed SaveAsync call would regenerate application-record GUIDs on every
restart — a real behavior change, not a no-op. Two: dropping a .tmp suffix could move a file onto
itself, or truncate the real file on a failed write — a real data-loss path. Both were un-suppressed
and given real killing tests (GUID stability across reloads; a failed serialization leaves the
prior file intact). This is the case study for why mutual review matters: it's easy for the agent
that wrote the suppression to talk itself into "this one's fine" — a second, independent reviewer
catches what self-review doesn't.
Frontend StrykerJS: solved via Analog standalone vitest¶
The frontend mutation score had been logged as "blocked/deferred" earlier in the project — the
Angular CLI's test runner didn't expose what StrykerJS needed. The fix: stand up
@analogjs/vite-plugin-angular with a standalone vitest.config.ts and test-setup.ts, so vitest
runs outside the Angular builder entirely. With that in place, a standalone vitest run passed all
51 specs, and StrykerJS produced a 90.91% mutation score on a sample file — proof the pipeline
works, with the full frontend mutation push left as follow-on work using the same method.
The answer-mapping and outreach feature¶
Alongside the testing and lab work, a genuinely new feature landed: turning "the app knows your answers" into "the app can map those answers onto whatever an application form actually asks." The shape:
- A 12-category question taxonomy — the space of things application forms ask beyond core contact/work-history fields.
- An
AnswerResolverthat handles the taxonomy's edge cases explicitly rather than guessing: it fills "N/A" on a field that's only conditionally relevant (e.g. a follow-up that only applies after a "yes"), fills reference slots from profile data, and flags knockout questions (the ones that can silently disqualify a candidate) for the user to confirm rather than auto-answering them. - A
profile-data-gaps.mdproposing an additiveIntakeProfile.Extended— references, salary history, relocation preference, certifications, clearance, notice period, and other fields the taxonomy revealed the profile didn't have a home for yet. - A draft-only
OutreachDraftGenerator— a 300-character LinkedIn connection-note draft and an InMail draft — with a test that asserts noSend/Post/Scrapemethod exists anywhere in that class. The safety property isn't a comment, it's a compile-time-checked absence.
PageAgent research, and the hybrid pivot¶
The most consequential research thread of the night asked a genuinely open question: could the
per-ATS selector engineering above be replaced with a Claude-driven agent that just reads the page
and acts on it in natural language, the way a human would? A dedicated research-pageagent effort
plus a head-to-head prototype (feature-pageagent-swap, PageAgent+Claude vs. the selector engine on
the Workday fixture) went looking for the honest answer.
The honest answer was no, not wholesale — but yes, as a hybrid. The case for replacement was
real: Claude is genuinely wireable behind PageAgent's LLMClient interface (a small, ~150–250-line
AnthropicClient, reading the key only from ANTHROPIC_API_KEY, never hardcoded). The case against
full replacement was just as real and concrete:
- Cost and latency — an estimated 45–80 LLM calls per application, at tens-of-cents-to-a-dollar each and minutes of latency, against Playwright's near-zero marginal cost per field.
- No built-in never-submit guard — that safety property would have to be rebuilt from scratch for a PageAgent-driven flow; today it's structural in the Playwright engine.
- Cross-origin iframes — PageAgent, as evaluated, can't reach into a cross-origin iframe the way
Playwright's
FrameLocatordoes natively — exactly the iCIMS/SuccessFactors case documented above.
So the design that emerged is hybrid: Playwright keeps driving — navigation, cross-origin
iframes, the never-submit guard, deterministic fills via the selector chains this whole night was
spent hardening — and PageAgent becomes an advisor, consulted only when the deterministic path
hits genuine ambiguity (a field the chain can't confidently resolve, a tenant's novel custom
question, ranking best-match options). Because the advisor is only called on ambiguity, not on
every field, the token cost stays bounded rather than scaling with every keystroke of the
application. See
architecture.md § Hybrid automation
for the resulting seam (IPageAdvisor) and flow.
Things we tried and said no to: Google A2UI¶
A good making-of preserves the negative results too. A parallel thread evaluated Google's A2UI
(a generative-UI JSON protocol, at the time a v0.9 preview) as a way to render dynamic per-tenant
ATS screening questions on the fly — its strongest possible use case here. The evaluation
deferred/rejected it for both apps. The reasoning: that strongest candidate is already solved
better by the deterministic, offline AnswerResolver (roughly 50 known QuestionTypes) plus the
existing judgement-call routing — adopting A2UI would trade away exactly the predictability the rest
of the codebase invests so heavily in, in exchange for a dynamic-rendering capability the app
doesn't actually need. On top of the design objection there were two concrete blockers: no .NET
SDK for the protocol, and an Angular peer-dependency mismatch — @a2ui/angular wants Angular
^21 while the app is on 22. The full write-up lives in docs/technical/a2ui-assessment.md
(+ plain), committed on the research-a2ui branch. The general lesson worth keeping: a new protocol clearing a "does it work?" bar is not the same as clearing a
"does it beat what we already have, and is it worth the predictability we'd give up?" bar — and here
it didn't.
The overnight orchestration, visually¶
flowchart TB
O["Orchestrator\n(this session)"] -->|spawns| CP["Claude pool (Max)\nSonnet/Haiku budget-tiered"]
O -->|spawns| XP["Codex pool (x20 Pro)"]
CP --> WT1["Isolated worktrees\n8+ ATS labs, resilience,\nbackend-mutation, strykerjs,\nPageAgent research"]
XP --> WT2["Isolated worktrees\nOracle, Paycom, Paycor"]
WT1 --> BR["Per-task branches"]
WT2 --> BR
BR --> GATE["/codex-review gate"]
GATE -->|mutual review both ways| GATE
GATE --> CI["CI green"]
CI --> M["master\n(batched merges)"]
See overnight-orchestration.svg for the full
diagram, rendered from overnight-orchestration.d2.
Research pass: a portable study audio/video studio¶
Board ledger: INS · Job insights & interview prep
A research-only pass (no implementation) designed a feature to turn a job's study content into
files you can carry — MP3s for phone/car listening and for falling asleep, MP4s for a TV in the
background. The toolchain verdict landed on tools already free and local: ffmpeg (confirmed
already installed on the dev machine via winget) for mixing/panning/encoding, and Piper
(GPL-3.0, the maintained successor to the now-archived MIT rhasspy/piper) for offline narration.
Two guardrails were made structural, not just documented: the "bilateral" alternating-stereo
sleep variant is framed strictly as relaxation ambience — explicitly not EMDR, not a medical
treatment — and every asset (music, rain, video) must be CC0/royalty-free with its license
recorded, never copyrighted material. Meta's MusicGen was researched and rejected as the default
music source: its weights carry a non-commercial license and CPU generation is too slow for
on-demand use — curated CC0 loops are the pragmatic default instead. Full write-up:
study-audio.md / ../plain/study-audio.md.
Equity compensation valuation: a "no advice" feature, built like one¶
A later branch (feature-equity-comp, off master) tackled RSUs/stock options in job offers —
current value, vesting math, historical volatility. The governing constraint was set before any
code: factual data only, never advice, never a forecast. That constraint shaped the whole
design, not just the copy — EquityValuationService is pure and deterministic (no network, so
its output can't accidentally smuggle in a live "market sentiment" call), and
HistoricalVolatilityPct is explicitly documented and tested as backward-looking only.
The source research surfaced a genuinely useful negative result, in the same spirit as the A2UI
write-up above: Stooq, the intended free/primary source, turned out to have grown a
JavaScript proof-of-work bot-challenge in front of its CSV endpoints sometime between when it was
chosen and when the adapter was tested against the live site. Rather than treat that as a blocker,
the existing graceful-degradation pattern (already proven in CompanyLayoffService) absorbed it
for free — StooqSource fails closed exactly like any other unreachable source, and
StockQuoteService falls through to Alpha Vantage. The adapter and its CSV-parsing contract are
fully built and tested against fixture bodies; only live reachability is currently blocked
upstream, and the code needs no changes if that changes back.
Robinhood got the same scrutiny the LinkedIn-scraping question got in job-signals.md — and the same verdict: no official API exists, the only workarounds require the user's own brokerage login, and that crosses a ToS/security line for a feature that free, legitimate sources already serve. Documented and closed, not built.
See equity-comp.md (+ plain) for the full source
verdicts, math, and the IEquityValuation seam left for feature-financial-profile's
JobImpactService to pick up later.
Interview-prep company research¶
A feature-branch build on feature-company-research: before an interview, surface what a
company does, its public tech stack, and recent news — then feed the same facts into the
cover-letter drafter as talking points. Followed the CompanyLayoffService/ILayoffSourceAdapter
enrichment pattern from job-signals.md exactly, down to per-source try/catch graceful
degradation and cancellation propagation, so CompanyResearchService reads like a sibling of
CompanyLayoffService rather than a new pattern.
The research step mattered as much as the code: GDELT DOC 2.0 and the GitHub REST API were both verified live (real endpoints, real JSON shapes, fixture JSON in tests built from actual responses) before a line of adapter code was written, and Wikipedia's REST summary endpoint won out over querying Wikidata's SPARQL store directly once it was clear the summary endpoint already returns ready-to-read prose with no entity-resolution step. Paid options considered and explicitly rejected: NewsAPI, Bing News Search, BuiltWith, Wappalyzer, StackShare, Crunchbase — all keyed or paid, all out of step with the zero-account, zero-cost-to-run design. See company-research.md for the full source-by-source verdict table.
The cover-letter integration reused the existing IClaudeDrafter/TemplateDrafter seam rather
than adding a new one: CompanyTalkingPointSelector.SelectHooks(job.Research) is a pure,
independently-testable function that both today's template drafter and any future Claude-backed
drafter can call identically — no interface change needed, since every drafter already receives
the full JobPosting.
On the frontend, this is the first input()-signal standalone component in the app
(CompanyResearchCard under shared/) — built that way deliberately, since there's no
job-detail page yet to host it permanently; it's dropped onto the Documents page today and can
move to a future job-detail page without any rewrite.
Adding the optional self-assessment suite¶
On its own isolated branch (feature-self-assessment, worktree off master), an optional "know
yourself" suite: an in-app Big Five personality questionnaire, a self-entered MBTI-style field,
and short work/communication/learning-style quizzes. The research phase mattered as much as the
build here — three separate licensing questions had to be answered honestly before writing any
code:
- Big Five: the 50-item IPIP Big-Five Factor Markers (Goldberg, 1992) are genuinely public domain, confirmed straight from the IPIP project's own statement — the one case where a real, published, validated instrument could be shipped fully locally with zero licensing risk.
- MBTI: the actual Myers-Briggs instrument is commercial IP owned by The Myers-Briggs Company. Rather than approximate it, the feature links out to two verified-live free alternatives (16Personalities, Open Extended Jungian Type Scales) and lets the user self-enter whatever type they got.
- Learning style: inspired by the VARK sensory-modality idea, but VARK's actual questionnaire is copyrighted — its own copyright page explicitly forbids reproducing it on any website. So the learning-style quiz here is a small, self-authored item set built around the general concept, not a copy of VARK's items.
The privacy design mirrors that same honesty: the whole feature is local-only by default (one
LocalJsonStore key, guarded by an automated "never leaves the local directory" test), with the
one exception — an explicit, clearly-labeled "Export (leaves your machine)" button that mirrors
results into a Google Form via the same OAuth flow already used for onboarding. Making that path a
separate controller/route (AssessmentFormsController, distinct from the local
AssessmentController) was a deliberate choice: the routing itself makes it obvious which one call
in the whole feature ever touches the network.
The IAssessmentInsights seam (plain value objects — trait percentiles, style snapshots) is the
handoff point for the planned interview-coaching feature on its own branch — it never sees the
IPIP internals or the storage shape, just percentiles and short summaries, with null meaning "not
taken" rather than a fabricated neutral score.
See self-assessment.md (+ plain) for the full detail, including the exact licensing verdicts and citations.
Practice catalog + the LeetCode ToS question¶
A later ask extended Study & prep with a small, hand-verified catalog of practice
resources — LeetCode courses/study plans plus role platforms like Databricks, Snowflake, dbt
Learn, and Kaggle Learn — matched to a job's role/tech via PracticeCatalogService.RecommendFor.
Every URL was checked before it went in: most via a direct fetch, the LeetCode ones (which block
automated fetches with a 403) via independent search-index corroboration instead — same bar of
proof, different method.
The more interesting part was the optional half of the ask: should the app read a user's public
LeetCode solved-count stats to prioritize the catalog ("few Hards solved — start here")? The
honest research answer was no. LeetCode's own robots.txt explicitly disallows /graphql —
the exact endpoint the community's matchedUser trick uses — and its Terms of Service separately
and plainly prohibit scraping/crawling. That a lot of hobby projects use the endpoint anyway
doesn't make it sanctioned; it makes it tolerated-unofficial, and the hard rule set at the start
of this task was "if it's not clearly permissible, don't call it." So the shipped design is a
deep-link only: the user types their own public username, the app builds a link straight to
leetcode.com/u/{username}/, and the user reads their own progress on LeetCode's own site.
WorkWingman never calls out to LeetCode, never authenticates, never touches the endpoint. Full
verdict and reasoning: learning-paths.md.
Prep Studio — company LeetCode libraries, richer Claude projects, phone flashcards¶
Built in an isolated worktree (feature-prep-studio) off master. Three additions to the
Study & prep hub, all under the same never-auto-subscribe / local-first rules as everything
else: (A) a curated map of ~65 employers to their LeetCode company problem-list URL (pattern
verified via web search against a live leetcode.com/company/{slug}/ page), surfaced as a
"Practice {Company}'s problems" card that deep-links to LeetCode's own Premium subscribe page —
never auto-subscribing; (B) StudyPlanService.BuildClaudeProjectBrief assembles a richer,
purely-factual prep brief (gap keywords, detected ATS platform, company LeetCode link,
senior-role system-design nudge, behavioral prompts) that a generated Claude Project is seeded
with; (C) FlashcardService generates a deck from the job's own concepts and exports it as a
single self-contained HTML file with the browser-native Web Speech API for audio — verified via
web research that iOS Safari requires speech to start from a user-gesture click handler (handled:
Speak is wired directly to a click listener) — plus optional Anki/Quizlet TSV exports. The HTML
export's "zero external network calls" claim is enforced by a test that regexes the whole
rendered document for https?:// and asserts none exist; card-generation deliberately never puts
a URL into card text so that guarantee holds structurally. See
prep-studio.md for the full writeup.
The post-interview loop¶
Applying and interviewing generate their own exhaust — questions asked, gut feelings, small
personal connections, eventual feedback — and almost none of it gets written down before it fades.
This pass closes that loop: ThankYouDraftGenerator drafts an email + LinkedIn thank-you note
(same draft-only, never-auto-send boundary as OutreachDraftGenerator, personalized from the
user's own retro/question-log/common-ground entries and their StoryProfile/IntakeProfile —
never fabricated when those fields are blank), and InterviewRetroService gives the retro survey,
the asked-question bank, and the common-ground log their own local-only store keys, separate
from ApplicationRecord which does sync to Drive. The interesting design constraint was making
the question bank a genuine compounding asset rather than a per-job scratchpad: AskedQuestion
entries are never capped or scoped away from each other, so GetLearningsAsync can group every
question ever logged, across every interview, into one "Questions I've been asked" study surface
that gets more useful the more interviews happen. See
interview-retro.md (+ plain) for the full design.
Study videos, playlists, and podcasts — real results only¶
A later session extended Study & prep with YouTube study-video search and podcast
suggestions, built in an isolated worktree (feature-youtube-podcasts) off master. Two research
questions had to be answered before writing any code: what does search.list actually cost under
YouTube's 2026 granular-quota change (verdict: 100 units/call, roughly 100 searches/day on the
default project quota — cache aggressively), and which podcast API needs no config gate at all
(verdict: Apple's iTunes Search API — free, keyless, public — becomes the default; a keyed
alternative like Listen Notes would need the same config-gate treatment as the YouTube API key).
The interesting design decision was less "how do you call the API" and more "what happens when the
API call is allowed to write to the user's account." Creating a YouTube playlist isn't a
read — it puts a real, visible playlist in the user's own YouTube library. That got the same
two-gate treatment as the dependency doctor's shell-out installer: an explicit confirmed flag the
UI only sets after a plain-language confirm box, and the existing ApiToken.IsTrusted gate at
the controller, so a hostile page reaching the loopback API can't create content in someone's
account even if it forges the request body. The other standing rule enforced end-to-end (and
covered by dedicated "anti-hallucination" tests) is that every video and podcast shown is built
strictly from a parsed API response item — a malformed item (missing a video id or podcast url) is
dropped, never patched with an invented value. Verified live against the real iTunes API during
this session: JD keywords like "RAG" and "MCP" turned up genuinely matching real podcasts by real
publishers, not lookalike placeholders.
The apply-automation vertical — making "Start Run" actually apply¶
Board ledger: APL · The apply-automation vertical
The merge audit's headline finding was that the apply-to-a-job flow was scaffolded but never
connected: ApplicationRunService.StartRunAsync seeded a Workday checklist and logged "Run started"
without ever opening a browser. This session wired it end-to-end, ATS-agnostic and lab-first (proven
offline against fake sites before touching a real employer form).
The seam. IApplyAutomationEngine (Ats + StepLabels + RunAsync) with an ApplyEngineRouter
that picks one engine per detected AtsKind. AtsDetector grew to recognize twelve ATS families
and — critically — to extract a concrete apply URL from the same LinkedIn page HTML it already
scans, unwrapping LinkedIn's externalApply?url=<encoded> redirects (including relative wrappers and
multi-param encoded targets) while a host-validation guard rejects any URL whose host isn't actually
the ATS — an SSRF check two review models independently verified.
The drive. The Greenhouse engine was promoted from the offline ScraperLab and is the first
engine driven end-to-end in CI against FakeGreenhouseSite with a real headless browser: it
resolves the grnhse_app iframe (by element id, not the unreliable frame name), fills contact via
fallback selector chains, attaches the user's real resume (never a placeholder that would ship on
submit), answers custom questions it can confidently map, and LOCATES — never clicks — submit. A
fire-and-forget PlaywrightApplyRunDriver launches it from StartRunAsync; that endpoint (and
pause/resume/stop/resolve) is now token-gated, since starting a run drives a real browser and
/runs/active leaks the run id cross-origin.
Resumable pauses. A run parks — keeping its live page open — at a mandatory user pause and
continues from exactly where it stopped once the user acts, rather than re-driving from the top.
RunResumeGates is the primitive (a capped per-run semaphore; a signal only releases the pause it
was meant for, and can't accumulate to skip a later one), with a per-run cancellation token and a
page-close on Stop — including a Stop that races page creation — so a stopped run never leaks a
browser. Run status transitions are atomic under the run's lock (TryAdvanceToFinalReview /
RequestStop / TryMarkFailed / TryPause / TryResume) so a concurrent Stop is never clobbered
back into an active state. The judgement half is proven end-to-end: an unmappable choice question
raises a judgement the Live Run UI can resolve by its options, parks the drive, and resumes with the
user's pick; a free-text question is left blank for review rather than stranded on an unresolvable
modal. Every answer is filled only when confident (mapped, a saved rule still valid on this posting,
or the user's choice) — education from the applicant's highest degree, work authorization only when
the question is explicitly about the US, never a guess.
The Workday multi-phase drive. The account-wall half landed next: the production Workday engine's
RunAsync went from a single-phase stub to a five-phase resumable drive on the same seam — park at
the account wall (SignInAsync mints/offers the vault credential, never types a password), resume,
fill My Information, work history, education (parking again on a degree-dropdown judgement when no
option clears the confidence bar, and reusing a saved rule before parking so a resolved judgement
never re-prompts), skip past EEO without ever auto-answering a legally sensitive disclosure, and
locate — never click — submit. The review gate pushed one theme through about eight rounds:
honesty of reported progress. FieldsFilled now counts only fields actually written to the DOM
(not non-empty profile values); per-field Stop guards are re-checked across each selector-resolve
await so a Stop mid-resolve can't type one more field; a degree only counts as filled if the option
click actually landed; and the submit-located signal excludes the sign-in page's own
signInSubmitButton (which contains "submit") so "reached review" can't be faked by an early resume.
Coordinating the account wall (#25b). Enabling Workday background auto-launch required unifying
two account-wall paths: the engine had been provisioning vault credentials itself, bypassing
ApplicationRunService.CheckAccountWallAsync — the only path that serializes per tenant and joins
concurrent same-tenant runs onto one shared prompt. The fix mirrors the existing resume seam: an
ApplyDriveContext.CheckAccountWall delegate, supplied by the service at StartDriving (a closure
over the run id), carried by the driver, called first by SignInAsync. A subtle contract emerged in
review: the delegate returning null means only "no new account needs creating" — not "signed in" —
so a return visit still falls through to the email-fill + password-manager-handoff pause rather than
filling an application on a login page. The coordinator runs under the drive's cancellation token
(not the originating HTTP request's), and the service re-checks terminal status after the per-tenant
gate and before the provision mutation, so a user Stop landing mid-check can never resurrect a
stopped run into AwaitingAccount. A drive that ends without locating submit now rests at Paused
with an honest "a required field still needs you" log — never mislabeled final review, never left as
a zombie Running with no drive alive.
Teaching the engines to translate credentials¶
Real forms don't speak the applicant's language: no education-level picker offers "Bootcamp",
majors lists rarely carry "Game Programming", and certification sections demand expiry dates some
certs don't have. The WW-36 layer answers all of it from ONE place. OptionMatching unified the
two previously duplicated similarity matchers (the Workday and iCIMS labs had each evolved their
own — the extraction kept the union of both sides' hardening, and the review found Workday's "60%
bar" had been dead code all along). On top of it: alias-aware matching — the user's own "how
should sites read this degree?" translations from intake are tried after the primary value and win
honestly ("matched via your alias …" in the run log) — and CredentialTranslator, which maps a
credential KIND onto level-picker phrasing: a bootcamp certificate becomes "Certificate"/"Some
College", never a degree claim. Expected-graduation fields fill only for genuinely in-progress
credentials; certification sections fill name/issuer/dates with a ticked "does not expire" instead
of an invented date. Two review models pushed real fixes in: a probe-string approach whose shared
"science" token bridged B.S. to the master's family (replaced with distinctive-token detection),
a set-logic bug that would have fabricated associate degrees from stray "a" tokens, and
null-tolerance for the profiles that legitimately omit degree text.
Skills and language pickers — three add-mechanics, one rule¶
Skills and language sections are where ATS forms diverge most in mechanics: iCIMS renders a checkbox grid, Workday a select-and-add-chip control, Greenhouse a free-text field. The engines now drive all three from the same rule — tick/add/list ONLY what the user actually has on their profile, matched against the tenant's offered options through the shared matcher at a high bar, and say honestly which profile skills the tenant's list didn't offer ("left for you"). Nothing is ever invented onto an application. Language pairs (name + proficiency) fill from the WW-35 rows; programming-language questions are correctly routed to skills, not the spoken-language field.
"How did you hear about us?" — an honesty order, not a default¶
Every ATS asks how you found the role, and many add referral follow-ups. The engines answer from
one shared decision (ApplicationSource) with a strict order of honesty: a referral recorded for
this job makes "Referral" the true answer (and the referrer's name + work email fill the follow-up
fields); otherwise the user's own stated channel; otherwise LinkedIn — where WorkWingman scrapes the
jobs from — as the sensible default. The subtlety the review gate sharpened: LinkedIn is only a
candidate when nothing else is stated, so a stated "Company Website" a tenant doesn't offer is left
for the user rather than silently mis-filled as LinkedIn, and an acceptable referral option always
beats a later exact LinkedIn match. The per-job referral rides in through ApplyDriveContext, and
each engine matches these candidates against its real options — Greenhouse's custom-question
classifier, an iCIMS source select, a Workday prompt — never inventing a source.
Resume-parse intake — propose, never populate (WW-39a)¶
The intake survey is the source of truth, but a resume the user already has can seed it. The first
pass is deterministic and offline (ResumePreParser): it pulls the fields a regex/section scan can
extract with certainty — email, phone, name, LinkedIn/GitHub links, and a labelled skills section —
and reports everything else (education, work history) honestly as a gap for the Claude-CLI pass
that follows. Nothing is written: the endpoint returns a set of FieldProposals (value + provenance
+ confidence) that the onboarding review step asks the user to confirm. The conservative contract —
false gaps are fine, wrong values are not — took several review rounds to hold: the parser learned to
not merge a phone with the next line's ZIP, to strip glued Email:/LinkedIn: label prefixes, to
keep single acronym skills (SQL, AWS) out of the "is this a section header?" test, and to never
propose a section heading ("Professional Summary") as the user's name. Email/phone validity lives in
one shared FieldValidators — the same canonical basis the never-type-invalid guard (WW-47) will use.
The second pass (WW-39b) fills what a regex can't — education, work history — by asking the user's OWN
logged-in Claude CLI to extract them as JSON (no API key of ours, nothing written to the CLI's state).
It's strictly best-effort: gated on the CLI being installed + signed in, hard-timed-out, and every
ordinary failure (missing binary, non-zero exit, unparseable output) collapses to "no proposals" so
intake never breaks. The subprocess seam is a mockable IProcessRunner, so the whole path is unit-tested
without ever launching a real CLI. Two review findings shaped it: the enrichment is a SEPARATE
/propose-from-resume/enrich endpoint the UI calls in the background — the deterministic proposals render
instantly and a hung CLI can't freeze onboarding — and the runner arms its timeout before writing the
resume to stdin, so a child that never reads can't hang past the deadline. On Windows the CLI is a
claude.cmd shim, launched through cmd /c, with the whole prompt on stdin so there's no argument
quoting to get wrong.
The confirm step (WW-39c) is where "propose, never populate" becomes visible: an optional paste-your-
résumé card on the first onboarding step calls the instant deterministic endpoint, renders each
FieldProposal as a row (checkbox pre-checked only above the confidence bar, value editable inline),
and fires the background /enrich call whose education/work-history rows append when — or if — the CLI
answers. Only on Apply do accepted rows map into the typed profile (skills de-duped, education/work
entries created by index, a whitelist refusing any path that would clobber a list), and even then
nothing is saved until the existing review step. A request token discards stale parse/enrich callbacks
so a cancelled or already-applied flow can't be resurrected by a late response.
The Profile Gaps panel — surfacing what's missing before an application does (WW-40)¶
Rather than let the user discover a missing field mid-application, the Profile Gaps panel surfaces
them up front from two sources. A pure ProfileGapAnalyzer inspects the live intake for the
commonly-asked fields still empty — phone, a complete address (a lone city doesn't count), education,
work history, skills, LinkedIn, and optional extras — ignoring blank placeholder rows so an empty
Education[0] never masquerades as filled. These recompute every read, so filling the field anywhere
clears its gap on its own. The second source is the drives themselves: ProfileGapsService harvests a
finished run's "left for you" log entries into persistent gaps (recorded before the driver's own
status summary, so its "resume left for you" line isn't mistaken for a field gap), deduped by a stable
FNV hash. Dismissals persist and can't be resurrected; a SemaphoreSlim serialises the read-modify-
write so a run-completion record and a UI dismiss never lose each other; the record hook swallows every
exception so it can't break a run. The panel splits Recommended from Optional and flags which gaps came
from a real application — the store is registered in the backup registry as sensitive, local-only by
default.
The no-résumé start — a base résumé written from intake answers (WW-41)¶
WW-39 reads a résumé in to propose intake fields; WW-41 is its inverse twin — a user who never uploaded
a résumé generates a base one out of the intake answers they already gave. A pure ResumeComposer
projects an IntakeProfile into plain text — contact header, summary, skills, education, work history —
and invents nothing: it only writes what the profile holds, and any empty section drops out entirely
rather than emitting a hollow heading. ResumeGenerationService wraps that composer with the side
effects: it writes the text to a fixed leaf (base-resume.txt) inside a contained sub-directory
(%USERPROFILE%\Wingman\data\generated-resumes\) so the path can never be steered, points
IntakeProfile.SourceResumeFile at the file, and persists the intake. The IResumeGenerationService
interface and ResumeGenerationResult model live in Core; the endpoint is POST /api/profile/generate-resume
(RequireLocalToken), wired through DI in Program.cs. The insight that made it cheap: the automation
engines' AttachResumeAsync only File.Exists-checks the path it's handed, so a generated .txt attaches
to a real application exactly like a PDF would — no PDF/DOCX library needed at all. On the onboarding review
step (step 5) a "Generate from my answers" button carries the three states — offer, ready-with-preview, and
already-on-file — persisting the intake before it calls the endpoint and re-points SourceResumeFile.
Canonical field validation — a never-type-invalid engine guard (WW-47)¶
WW-39a promised it: the same shared FieldValidators the resume parser uses became the single canonical
basis for every field an engine is about to type. The class grew the checks a form actually cares about —
IsValidDate (the app's yyyy / yyyy-MM convention, real month, 1900–2099), an IsValid(value, FieldFormat)
router, and a DescribeInvalid that returns a short human reason ("not a valid email address") — all on top of
IsSafeText, the universal floor every open value must clear: non-empty, length-bounded, and free of
corrupting control characters. That floor deliberately allows tab, carriage return, and newline, because a
legitimate cover-letter or "why this role" answer spans multiple lines — the bar is "never type binary
garbage", not "single line only". A pure ProfileValidator sits on that basis: it scans only the present
intake values that have a known format (email, phone, the LinkedIn/GitHub/portfolio URLs, work/education dates)
and reports the malformed ones as FieldValidationIssues. A blank optional field stays a gap (WW-40), never an
issue — so the validator only ever flags what the user filled wrong, never nags them to fill something. The
endpoint is POST /api/profile/validate (RequireLocalToken); it returns the issues and never mutates or
rejects the profile.
The point of the ticket is the guard at the far end. Each ATS engine's fill choke point now consults the same
validators before it commits a keystroke — Greenhouse's FillCoreAsync and FillCustomAsync, iCIMS's
FillPresentAsync, Workday's FillAsync — and a present-but-malformed value is skipped with a "left for
you" Warn (harvested into Profile Gaps, WW-40) rather than typed into a real application as garbage. Secrets
are exempt on purpose: a password's contents are never inspected or logged. On the frontend the onboarding
review step calls /validate and shows a banner listing the malformed fields with a plain reason each —
advisory only, it never blocks the save. The backend gate ran short-handed: the Codex seat hung, so it was
committed on the Gemini seat with all its findings addressed — the multi-line allowance in IsSafeText, the
partial-JSON null-guards in ProfileValidator (a caller can POST explicit nulls for nested objects/lists), and
the date regex tightened to [0-9] so a Unicode digit can't sneak past a \d. The frontend gate had both
Codex (clean) and Gemini.
Importing your own LinkedIn profile as field proposals (WW-43)¶
WW-39 reads a résumé into intake proposals; WW-43 reuses that exact propose→confirm contract for a different
source — the user's OWN connected LinkedIn profile. Three pure/effectful pieces mirror the resume path. A pure
LinkedInProfileLdParser reads the durable schema.org Person ld+json block — name, headline, skills,
schools, employers — the same "parse the durable source, not the churning CSS" approach the saved-jobs
JobPosting parser already took. A pure LinkedInProfileProposalBuilder projects that ProfileLd into a
ResumeProposal on the exact field-path convention the confirm UI applies — TopSkills[],
Education[i].School, WorkHistory[i].Company, Extended.DesiredTitle — and any section the profile doesn't
have becomes a gap rather than a fabricated value. LinkedInProfileImporter drives the already-connected
session to /in/me/ (read-only, own profile, human-paced) and is best-effort by construction: it never throws,
and a login wall returns an empty proposal carrying a "Connect LinkedIn first" gap rather than forcing a
sign-in. The endpoint is POST /api/profile/propose-from-linkedin (RequireLocalToken). On the frontend an
"Import from your connected LinkedIn" button on the onboarding résumé card feeds the same confirm→apply rows
as the résumé paste; a proposal-source flag ensures a LinkedIn import never stamps SourceResumeFile, so the
WW-41 "Generate from my answers" fallback still offers itself.
The gate earned its keep. Gemini (High) caught the parser throwing on a jobTitle array that held a non-string
entry — one malformed field would have failed the whole import; it now tolerates it. Codex flagged two P2s: the
new Extended.DesiredTitle path wasn't in the confirm UI's applyProposal whitelist (so it silently dropped),
and applyResume stamped a bogus SourceResumeFile for a LinkedIn import (which would have hidden the WW-41
button). Both fixed, each with a regression test.
Resolving real US addresses via the Census geocoder (WW-46)¶
The address autocomplete had a blind spot a real user hit head-on: Nominatim (OpenStreetMap) couldn't resolve
their actual residential address, because OSM's US residential coverage is patchy. WW-46 keeps that type-ahead
but puts an authoritative US source in front of it. A pure CensusAddressSearchService queries the free,
no-key US Census Bureau onelineaddress geocoder — the source of record for US addresses — and maps the
standardized components it returns (street, city, USPS state, ZIP) into the same result shape Nominatim already
produced. Because Census matches whole addresses rather than prefixes, it only fires once the input looks
complete enough — a house number plus a ZIP, or ≥4 words — so every early keystroke skips straight past it; and
it fails soft to an empty list, never throwing. CompositeAddressSearchService is authority-first: it tries
each source in order and the first with results wins, later sources never consulted — Census resolves what
Nominatim misses, Nominatim stays the fallback, and a gated paid provider can slot in front later. The composite
registers as IAddressSearchService, so the frontend and the endpoint are byte-for-byte unchanged.
The gate paid for itself precisely. Codex and Gemini independently flagged the same high-confidence issue: the
composite serialized the Nominatim fallback behind Census's inherited default resilience (60s timeout + 3
retries), so a slow or unavailable Census would make the whole autocomplete look broken. Fixed both ways they
recommended — a new ResiliencePipelines.Autocomplete (4s timeout, no retry) now wraps both address
sources, so a dead source falls through in seconds instead of a minute-plus; and the complete-enough gate means
early keystrokes never wait on Census at all. Gemini's DI note was adopted too (registered singletons over
manual new). Live-verified against the real API: "1600 Pennsylvania Avenue NW Washington DC 20500" resolved to
a standardized address with the correct line 1, city, state, and ZIP. 14 address tests (Census parse / gate /
complete-enough / fail-soft, composite short-circuit / fall-through); full suite green.
A canonical ATS reference vocabulary — the words the forms speak (WW-45)¶
The engines spent this whole project matching the applicant's own values against whatever a tenant's form
offered; WW-45 gives them a curated vocabulary of their own to match toward. AtsReferenceVocab
(src/WorkWingman.Infrastructure/Reference/) is a set of static lists in the language ATS dropdowns actually
speak. EducationLevels is complete — the small, stable "highest education" set a level picker offers,
so it's meant to be exhaustive. Skills, Certifications, and Schools are honest seeds, not
exhaustive catalogs: a term absent from a seed is never rejected — it's kept as the user's own free text —
and each seed's size is queryable so nothing is ever silently capped or dropped without the caller knowing.
On top of the lists, AtsReferenceVocabService (behind IAtsReferenceVocab in Core) offers two operations.
Suggest is prefix-then-substring autocomplete — a prefix hit ranks ahead of a mid-string one — and is what
will seed the WW-44 picker. Normalize maps a free-text term onto the canonical vocabulary through the same
OptionMatching matcher the engines already use, so normalization and form-filling can never disagree about
what "close enough" means; it's symbol-aware for skills (so c# resolves to C# without colliding with C),
and it deliberately returns null below an 82 confidence threshold — when it isn't sure, the user's own term
survives rather than being snapped to a wrong-but-nearby canonical value. Two read-only, token-gated endpoints
expose it: GET /api/vocab/suggest and GET /api/vocab/{category}.
The scope was drawn on purpose. WW-45 ships the vocabulary and the normalization seam and stops there —
wiring it into the already-shipped WW-36/37/38 matchers is left to the follow-ons (WW-44 consumes suggest
first) precisely to avoid destabilizing those hardened engines while landing the new corpus. The gate was
light but real: Gemini added a [MaxLength(100)] on the suggest query (an unbounded query string has no
business being arbitrarily long), and its separate worry that returning the full list "serializes tens of
thousands of entries" was rejected on the facts — the corpus is curated seeds of at most ~150 entries per
category, not a scraped catalog. Codex found no issues.
The skill-graph onboarding picker — "pick one, and we'll suggest the rest" (WW-44)¶
WW-45 shipped the vocabulary and promised the picker would consume it first; WW-44 is that picker. The
familiar Spotify/X onboarding move — you pick one thing you like and it surfaces a handful of related ones to
add in a tap — needs a notion of "related," and the flat WW-45 Skills corpus doesn't carry one. SkillGraph
(src/WorkWingman.Infrastructure/Reference/) supplies it as a curated adjacency built from ~20 overlapping
clusters layered over that same corpus: two skills are related when they share any family (React and
Next.js sit in a frontend cluster; RxJS and Redux in a state/reactive one; a skill in several clusters bridges
them). Lookup unions a skill's neighbours across every cluster it belongs to, excludes itself, dedupes, and is
case-insensitive on the way in and out — and a test asserts every skill named in a cluster actually exists in
the WW-45 corpus, so the graph can never drift into referencing a term the vocabulary doesn't know.
IAtsReferenceVocab.RelatedSkills normalizes the free-text input the same WW-45 way before it hits the
graph, and GET /api/vocab/skills/related exposes it — a sibling to the suggest endpoint the search box
already uses.
On the frontend this closes a real gap: the onboarding Demographics step titled "skills" never actually had a
skills editor — TopSkills could only be populated indirectly, via résumé or LinkedIn proposals (WW-39/43).
WW-44 adds the missing picker inline: removable chips for the skills you've selected, a debounced search over
the corpus (/api/vocab/suggest), and a "Related to what you added" strip of one-tap adds fed by the new
related endpoint. Both the search results and the related strip exclude anything already selected, so the UI
never offers you a skill you've got. Live-verified against the real API: typing "react" returned
[React, React Native]; adding React surfaced related [JavaScript, TypeScript, Vue.js, Next.js, Redux,
RxJS, HTML] with the already-selected Angular correctly filtered out.
The gate found the async sharp edges, as it does with type-ahead. Gemini flagged three: route the related-skills
fetch through a switchMap over a Subject so a slow response for a since-removed skill can't clobber a newer
one; flush the pending debounced search when the user picks a result (so the query that just resolved a pick
doesn't re-fire); and make the chip remove case-insensitive to match the graph. Codex added a P2 — the suggest
subscribe could paint stale results into a search box the user had already cleared, flashing suggestions under
an empty query — now guarded. All fixed.
The security workflow — SAST, secrets, DAST, and an MCP¶
Board ledger: SEC · Security & scanning
The standing mandate (Fortify-style OSS scanning, per the USPTO reference repos) had only its CVE
half built. Two commits completed it. First, Semgrep as the SAST of record: a hard-gate CI job
(p/csharp p/typescript p/javascript p/security-audit p/secrets, --metrics=off) whose invocation
is byte-for-byte the local Docker command in docs/SECURITY.md, so local and CI can never disagree.
The first full scan (675 files) produced exactly two findings — a genuine path-traversal hazard in
StudyVisualStore.SaveAsync (fixed with backslash-normalized leaf-only writes plus the same
containment check Delete already documented; the backslash normalization matters because Linux's
Path.GetFileName doesn't treat \ as a separator) and a false positive on the Electron loopback
health check (suppressed with a justified inline nosemgrep; while there, each probe gained an
AbortController because Node's fetch has no default timeout and a deadlocked API would have hung
the launcher past its own deadline).
Second, the rest of the stack: gitleaks over the full git history (the two findings were both
reviewed false positives — C# enum identifiers matching a LinkedIn-secret rule, and a literal
YOUR_LAUNCH_TOKEN docs placeholder — allowlisted line-targeted and narrow, never whole files, so a
real credential pasted beside either still fails); OWASP ZAP baseline DAST against the real API
booted on loopback (0 FAIL / 66 PASS locally; its one warning became a real fix — the API now sends
Cache-Control: no-store on every response, since everything it serves is dynamic and much of it is
personal); an electron npm audit gate matching its new Dependabot coverage; and the Semgrep MCP
in .mcp.json for interactive in-session triage. That last one carried a lesson: the plan's
uvx semgrep-mcp / ghcr.io/semgrep/mcp forms turned out to be deprecated upstream (the old image
now serves nothing but a deprecation-notice tool) — discovered because a review finding about a
missing volume mount prompted an actual tools/list smoke test. The current semgrep mcp command
runs in the official container with the repo mounted read-only. CodeQL, SARIF upload, and SonarCloud
are deliberately absent — their licenses/free tiers cover public repos or GitHub Advanced Security,
and this repo is private without GHAS; docs/SECURITY.md records the reasoning so nobody
re-litigates it.
The whole vertical — and now the security stack — was built commit-by-commit behind a multi-model review gate (Codex chairing, Gemini and local open-model seats), each change iterated to a clean review before landing. The gate earned its keep both ways: Codex caught real concurrency and CI-portability bugs (the gitleaks safe.directory abort, the too-broad allowlist), while two of Gemini's three findings on the security diff were empirically refuted by the passing build and successful local runs — the model asserted a compile error in code that compiles and an invalid CLI command that had just run. Evidence beats assertion; the gate's job is to force the check.
Chapter: the business model — a council decides the money¶
Board ledger: BIZ · Business & strategy
At some point a working app has to answer the question it had been dodging: how does this make money without becoming the thing it was built to replace? The founder's instinct was big — partnerships with LinkedIn and Workday, enterprise licenses for headhunters, a hosted paid tier, a verification/trust layer, maybe an acqui-hire — and a conviction that this is eventually a billion-dollar idea. The risk was equally big: monetize wrong and WorkWingman becomes just another auto-apply spam cannon that gets users banned and sells their data.
So the decision was made the same way the code reviews are: a multi-model council. The question went
to Codex (gpt-5.5, with live web research), Gemini, and two local open-model councils on separate
machines (qwen3-coder:30b and gpt-oss:20b on the gaming PC; llama3.3:70b on the streaming PC),
with Claude chairing the synthesis. Getting all the seats to answer was itself a small saga — the
llm-council.exe chairman seat kept crashing (the nested claude CLI can't spawn from inside a
Claude session, and a mid-run model switch discarded the exe's in-memory answers twice), and the house
councils' heavyweight models exceeded the harness's 300-second MCP idle timeout. The workaround was to
call Codex and Gemini directly, and to reach the 70B streaming-PC model through a raw MCP JSON-RPC
curl call that bypassed the timeout entirely. The exe's discard-on-crash behaviour became a logged
fix: write partial answers to the report before the chairman step, so a chairman failure can't nuke
a ten-minute run.
The verdict was unanimous on the two load-bearing points and split on one. Unanimous: keep the free local core genuinely free, monetize convenience first (encrypted zero-knowledge sync, a managed-AI option for people who don't want to handle keys), and put LinkedIn/Workday partnerships last — they're a power move you make from leverage, not a seed-stage strategy, and LinkedIn's ToS bans exactly the automation surface WorkWingman would need. The split was over stage two: two of the smaller open models wanted the verification/trust layer early, but the chair ruled against them because verification is FCRA / consumer-reporting-agency legal territory — heavy compliance the smaller seats hadn't reckoned with — and put institutional seats (career coaches, bootcamps, universities, workforce boards, outplacement firms) second instead. Those seats don't churn when a single job seeker gets hired, which turns out to matter enormously: the load-bearing adversarial insight, verified against market data, is that job-seeker tools have brutal structural churn (users leave the day they land a job), so a pure prosumer model needs ~80–120k active users to sustain a small team, while a few dozen institutional orgs reach the same revenue with a fraction of the churn.
That synthesis, deepened by dedicated research agents on financials, go-to-market, and
legal/compliance, became the business/ doc track: a
monetization strategy, a staged roadmap,
a live financial model (with an editable
spreadsheet), a pitch deck (rendered to
.pptx), go-to-market & positioning,
a fully-sourced legal/compliance & risk analysis, and the
council decision record itself — each with a plain-language mirror.
The through-line the whole business is built to protect is the one the product started with: the job
seeker, not the recruiter, is the customer, and trust is the moat.
Chapter: the first green build — reading CI reds instead of guessing¶
The first push lit up the whole CI suite, and three jobs came back red. The lesson of this beat is that a red job's name lies as often as it tells the truth, so you read the log before you "fix" it.
- "Determinism (churn)" sounds like a flaky test. It wasn't. The log showed
playwright.ps1 install --with-deps chromiumaborting withpackages.microsoft.com … no longer signed / NOSPLIT— the runner's third-party Microsoft apt source intermittently fails its signature check, and--with-depsrunsapt-get updateagainst it. The backend job passed the same command that run, which is the tell: intermittent infra, not our code. Fix: drop that one apt source before the install (Chromium's system libs come only from Ubuntu's own repos), across all three--with-depssteps — deterministic, not a retry mask. - "Electron shell E2E … 60s timeout" sounds like the app failing to boot. It wasn't. The renderer
did mount — the smoke assertion
toHaveText('Work Wingman')on.brand-namekept failing because that span now also wraps the REAL/SANDBOX environment pill, so its text isWork Wingman REAL DATA. Playwright just retried the never-true exact match until the timeout. Fix: give the product name its own env-independentdata-testid="brand-product"hook and assert on that (the Angular unit test already used the tolerant form) — a stable selector, not a bigger timeout. - "Docs (two-track)" was real: business-plain companions sat in
docs/plainwith no technical sibling, and validation/reference records sat indocs/technicalwith no plain sibling. Rather than stub fake siblings or hollow the gate with an allowlist, the docs moved to honest homes —docs/reference/for the records, a newdocs/business-plain/for the plain business companions — so the gate stays strict over only the paired making-of tracks. (Council verdict: move, don't game.)
Chapter: July 8 — outage recovery, the demo, and consolidation under load¶
Board ledger: [WW-62 (token lock), WW-63 (accessibility), operations & council]*
The six-hour Claude outage and the session watchdog¶
On the night of July 7→8, a six-hour outage killed every running session mid-flight. Recovery swept and resumed all of them; same day, a standalone claude-session-watchdog (Windows Scheduled Task + PavlokPager wristband paging) was built and installed so failures now page the wristband within minutes — assuming the watchdog, scheduler, and Pavlok path are themselves healthy. Overnight missions arm the watchdog with a marker file; while armed, it detects dead Claude sessions and pages the user's wristband, enabling true unattended operation for overnight multi-agent runs.
9:30 a.m. demo and the installer rebuild¶
The demo landed that morning with a fresh 1.28 GB installer build. The build nearly failed: Defender was scanning the unpacked staging directory mid-build, locking files and aborting the NSIS pack step. Cleared Defender's quarantine, rebuilt, and shipped in time.
The installed app then hung on "Loading profile…" with the API returning 401s — a token issue, root cause not immediately obvious. The demo unblocked by stopping the test runners.
WW-62: The token lock bug and the fix¶
The detective story. The API writes a per-launch auth token to %LOCALAPPDATA%\WorkWingman\api-token.
The Electron preload reads it once at startup. Every single xUnit/Stryker test run constructing ApiToken
overwrote the real file — thousands of times per mutation run — corrupting the live app's stored token.
A second app launch made it catastrophically worse: the new API instance rewrote the token, then died on the port conflict (first instance still holding port 5000), stranding the first Electron window with a stale token.
The fix. Four-part (4b71ee7):
WORKWINGMAN_TOKEN_PATHenv override — test assembly passes a temp path viaModuleInitializer.Initialize(), keyed to the test's ownAppDomainso token writes never touch the user's real file.- Electron single-instance lock —
app.requestSingleInstanceLock()enforces one window per user session; second launches exit cleanly rather than spawning zombie processes fighting over the port. - Electron strips the override from spawned API env — the test-specific env var is scrubbed before the renderer spawns the API subprocess, so the API always reads the real token on real launches.
- Test assertions — newly-added test that confirms the API-token file is never the app's real file (a mock-only path assertion).
Full 5-seat council gate (Codex chairman; Codex/Gemini/local qwen3:8b/gaming-PC qwen3-coder:30b seats):
2 findings applied, 1 justified-skipped, 8 dropped. Final: 3,095/3,095 tests green.
WW-63: WCAG 2.2 AA accessibility suite¶
The requirement. A WCAG 2.2 AA-focused accessibility suite — automated gates enforcing, human screen-reader walkthrough still pending across the app, verified with live real-data backend.
The build. A structured three-part pass:
-
Semantic ARIA (244c3d1) — ~40 Angular templates annotated with ARIA labels, roles, and live regions; native HTML dialogs replace custom modals; skip-link on every page; contrast token floors enforced in the CSS layer (
--contrast-min: #000,--contrast-bg-min: #fff). -
Axe-core CI gate (same commit) — automated accessibility audit on 22 production routes, zero violations baseline, hard-gated before merge. Each violation is a failing test, not a logged warning.
-
Live-backend axe pass (WW-63b, b10e146) — the automated audit re-run over 25 routes with real job-application data (param routes from real ids) plus automated keyboard-behavior tests, behind a hard read-only firewall. The human screen-reader walkthrough (WW-63c) remains.
Why the gates matter. Accessibility is easy to defer because violations are invisible to sighted users. Automating it (axe-core) catches 60–70% of violations; the other 30% need human eyes. By putting both automated + human gates before shipping, the build rejects known machine-checkable violations; the human screen-reader review remains the final qualitative gate.
The usage crisis and the consolidation¶
Context: 22 concurrent sessions (CI, labs, overnight builds, business research) burning Claude API quota, with cache reads accounting for 68% of spend.
The usage governor. An always-on governor injects a per-turn directive into every session (GREEN/YELLOW/RED tiers): YELLOW caps concurrency at three sessions and forces cheap-model delegation; RED stops new Claude subagents entirely and offloads implementation to the Codex/Gemini fleet. The point is structural: cache-read burn scales with concurrent-session count × context size, so the fix is fewer, leaner orchestrators — not per-session thrift.
The consolidation. Three orchestrator tracks replaced ad-hoc multi-agent sprawl:
- Track A: WorkWingman build — nightly clean build, mutation suite, CI validation, Stryker report.
- Track B: Tools & infrastructure — ongoing skill delivery, MCP work, test infrastructure.
- Track C: Business — financial modeling, market research, council synthesis.
The model-escalation ladder. Default: Haiku for trivial work (single-file reads, simple searches), Sonnet for normal tasks, Opus/Fable for hard problems. Ceiling reviews everything before it ships.
CI moved to self-hosted warm pool. GitHub Actions cloud minutes ran out mid-month. Migrated to a
warm-pool runner group on the home org, keyed off github.token from the existing App installation
(b606a93). Runners are ephemeral (destroy after use), pinned to SHAs, and reject fork PRs entirely.
No workarounds, no rate-limited fallbacks — the pool is the single source of truth.
Council growth: 5-seat verified end-to-end¶
All five council seats (Codex + Gemini-via-agy + local Ollama + gaming-PC + streaming-PC open councils) verified healthy same night (WW-62's review gate). Two more model families announced for July 9.
The council is the standing gate for every commit: code can't land without independent review from another model in another process with a different failure mode. That's the one rule that changed when the scale exploded.
Related docs¶
- Plain narrative: ../plain/making-of.md
- Business & strategy: ../business/README.md
- architecture.md · testing.md · security.md · references.md
- scraper-automation-learnings-summary.md
- equity-comp.md
- self-assessment.md
- learning-paths.md
- prep-studio.md
- interview-retro.md · outreach-drafting.md
- study-media.md — the YouTube/podcast feature this beat describes
- Doc index: ../README.md