jobbsok/docs/build-brief.md
Kjell Tore Guttormsen 05a7aa1d6f feat: initial commit — plugin manifest and build brief
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:27:23 +02:00

417 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Build brief — `jobbsok` plugin
A local-first, read-only job search operating system for a single operator.
Written for Claude Code as the implementing agent. Read the whole brief before
writing any file. Build in milestone order; do not skip ahead.
**Language convention:** code, identifiers, file names and this brief are
English. All operator-facing content — skill output, `sak.md` bodies,
application drafts — is Norwegian (bokmål). The job market being served is
Norwegian.
---
## 1. Mission
Compress the job-search loop from scattered manual work into one auditable local
workspace: find candidate roles, score them against a maintained candidate
profile, record accept/reject decisions with reasons, learn from the pattern,
draft applications, keep every case's full correspondence in one folder, prepare
interviews, and never lose a thread to silence.
The operator is a single person searching for their own job. Not a recruiting
tool, not multi-tenant, not a SaaS.
---
## 2. Non-negotiable constraints
These are architectural, not preferences. Violating any of them is a defect.
1. **Read-only against external sites.** The browser layer navigates and reads.
It never submits a form, never sends a message, never applies. Applications
are prepared as files; the operator sends them by hand.
2. **No credential handling.** The plugin attaches to an already-authenticated
browser session over CDP. It never sees, stores, or prompts for a password.
3. **Human approval before any profile mutation.** Learned changes to the
candidate profile are proposed as diffs and applied only on explicit approval.
4. **Local-only data.** Everything lives in the operator's workspace. Nothing is
transmitted to a third party beyond the model call itself. No telemetry.
5. **Untrusted content stays untrusted.** Every byte read from a job listing or
an inbound email passes the ingestion guard before it is persisted into a
file that a later skill will read as context. See §9.
6. **Append-only decision and event logs.** Corrections are new entries, never
edits.
7. **No autonomous scheduling.** Every skill runs because the operator invoked
it. Nothing polls, nothing runs in the background.
---
## 3. Target surface
Build **one plugin** that installs into both Claude Code and Claude Cowork —
they share the plugin schema. Cowork is the intended day-to-day operator
surface; Claude Code is the development and maintenance surface.
Consequences for implementation:
- Use `skills/*/SKILL.md` for every component. Do **not** create a `commands/`
directory — the legacy single-file command format is deprecated for new
plugins, and Cowork presents skills and commands as one concept anyway.
- Skill bodies are instructions **for Claude**, written imperatively
("Parse the snapshot", not "You should parse the snapshot"). They are not
documentation for the operator to read.
- Frontmatter descriptions are third person and contain concrete Norwegian
trigger phrases the operator would actually type.
- Keep each `SKILL.md` body under ~3000 words. Detailed schemas, enums and
worked examples go in `references/` inside the skill directory.
- Use `${CLAUDE_PLUGIN_ROOT}` for every intra-plugin path. Never hardcode.
---
## 4. Plugin layout
```
jobbsok/
├── .claude-plugin/
│ └── plugin.json
├── skills/
│ ├── kandidatprofil/
│ ├── annonse-uttrekk/
│ ├── kandidatvurdering/
│ ├── beslutning/
│ ├── sak/
│ ├── korrespondanse/
│ ├── soknad/
│ ├── intervju/
│ ├── referanser/
│ ├── forhandling/
│ ├── dagens/
│ ├── utfall/
│ ├── laering/
│ └── datahygiene/
├── scripts/
│ ├── guard_ingest.py # wraps llm-ingestion-guard
│ ├── sak_status.py # deterministic status/SLA computation
│ └── dashboard.py # renders HTML dashboard
├── templates/
├── .mcp.json
├── README.md
└── CONNECTORS.md # only if tool categories are genericised
```
`plugin.json`:
```json
{
"name": "jobbsok",
"version": "0.1.0",
"description": "Local-first job search workspace: sourcing, scoring, case tracking, applications and interview prep.",
"author": { "name": "<operator>" },
"license": "MIT"
}
```
---
## 5. Workspace data model
The plugin operates on a workspace directory chosen by the operator, default
`~/jobbsok-workspace`. The plugin never writes outside it.
```
workspace/
├── profil/
│ ├── kandidat.md # single source of truth
│ └── cv/
│ ├── cv-generell-v3.pdf
│ └── varianter.jsonl # which variant went where
├── saker/
│ └── 2026-09-eksempel-as-ai-radgiver/
│ ├── sak.md
│ ├── annonse.md
│ ├── soknad-v1.md
│ ├── korrespondanse/
│ ├── kontakter.md
│ ├── intervju/
│ └── logg.jsonl
├── beslutninger.jsonl
├── referanser.md
├── arkiv/
└── .gitignore # workspace is never committed
```
### 5.1 `profil/kandidat.md`
YAML frontmatter carries hard filters, read mechanically by scoring. Body is
prose, read by the model. The boundary is: **frontmatter is absolute, body is
judgement.**
Frontmatter keys: `oppdatert`, `geografi` (base, maks_reisetid_min,
hjemmekontor_min_dager, vurderer_flytting), `lonn` (gulv_nok, onsket_nok,
kommentar), `ansettelsesform` (aksepterer, avviser), `absolutte_nei`,
`senioritet` (min, maks).
Body sections, in order: Kjerne (one sentence), Kompetanse (Dybde with evidence
per item / Bredde / Under oppbygging), Retning (Mot / Bort fra),
Signaturprosjekter (situasjon, bidrag, målbart utfall, hvilke krav det dekker),
Kjente svakheter (table: svakhet, kompensasjon, tiltak), Formuleringer som
virker, Profilgap.
The operator will supply an existing `kandidat.md`. Treat its structure as the
contract and validate against it rather than regenerating it.
### 5.2 `saker/<sak-id>/sak.md`
Case front page. Every skill reads it; several write to it. `sak-id` format:
`YYYY-MM-<arbeidsgiver-slug>-<rolle-slug>`.
Frontmatter: `sak_id`, `arbeidsgiver`, `rolle`, `kilde`, `url`, `opprettet`,
`status`, `neste_frist`, `ventende_part` (`meg` | `dem` | `ingen`),
`sist_aktivitet`, `score`, `cv_variant`, `soknad_versjon`.
Body: Hvorfor denne (from the decision reason), Status nå, Neste handling,
Åpne spørsmål, Tidslinje (human-readable, generated from `logg.jsonl`).
### 5.3 `saker/<sak-id>/logg.jsonl`
Append-only events. `{ts, hendelse, kilde, ref, notat}`. Event vocabulary is a
closed enum: `opprettet`, `soknad_sendt`, `bekreftelse_mottatt`,
`henvendelse_mottatt`, `svar_sendt`, `intervju_avtalt`, `intervju_gjennomfort`,
`tilbud_mottatt`, `avslag`, `trukket`, `stille`.
### 5.4 `beslutninger.jsonl`
Append-only, one JSON object per sourcing decision.
Fields: `id`, `dato`, `kilde`, `url`, `tittel`, `arbeidsgiver`, `beslutning`
(`ja`|`nei`), `arsak` (1–3 values from closed enum), `notat` (max 15 words),
`score_da` (integer, the score before the human decided), `korrigerer`
(nullable id).
`arsak` enum: `lonn`, `geografi`, `arbeidsform`, `fagomrade`, `oppgavetype`,
`teknologi`, `senioritetsniva`, `selskapstype`, `arbeidsgiverrykte`,
`tidspunkt`, `konkurranse`, `annet`.
`score_da` is the learning signal. High score with `nei` means the profile is
wrong about something; that divergence is what §5.7 mines.
### 5.5 `profil/cv/varianter.jsonl`
`{variant_id, fil, sak_id, sendt_dato, tilpasninger}`. The operator must be able
to answer "which CV did they actually receive" before walking into an interview.
### 5.6 `referanser.md`
Per referee: name, relation, what they can vouch for, last asked, cases used on,
consent status. Skills warn before proposing the same referee a third time
inside six months.
### 5.7 Outcome records
Outcomes are appended to the case log **and** mirrored into
`beslutninger.jsonl` as an outcome line so that learning sees entry and exit
together. Rejection stage is captured explicitly: `screening`, `forste_intervju`,
`andre_intervju`, `referansesjekk`, `tilbud_avslatt`. Screening rejections and
post-interview rejections are different diagnoses — the first says the profile
targets wrong, the second says the presentation does.
---
## 6. Status machine
Deterministic. Implement in `scripts/sak_status.py`, not in prose, so status is
reproducible and testable.
States: `vurderer` → `soker` → `sendt` → `dialog` → `intervju` → `tilbud` →
`avsluttet`. Terminal side-states: `avslag`, `trukket`.
Rules:
- Every state change appends to `logg.jsonl` and updates `sak.md` frontmatter.
- `ventende_part` is recomputed on every transition. It drives the daily view.
- Silence thresholds: `sendt` with no inbound for 14 days → flag. `dialog` with
`ventende_part: dem` and no inbound for 7 days → flag. `intervju`
gjennomført with no outcome after 10 days → flag.
- Flags surface in `dagens`. Nothing auto-escalates, auto-sends, or auto-closes.
- Never invent a transition from inferred email sentiment. State changes on
explicit operator confirmation or on an unambiguous structural signal.
---
## 7. Skills
Each skill directory gets `SKILL.md` plus `references/` where the schema is
non-trivial. For each, the frontmatter `description` must name the trigger
phrases in Norwegian.
| Skill | Purpose | Writes |
|---|---|---|
| `kandidatprofil` | Read, validate, edit `kandidat.md`. Enforces frontmatter schema. | `profil/kandidat.md` |
| `annonse-uttrekk` | The **only** skill that touches the browser. Reads an open listing, guards it, writes `annonse.md` snapshot. | snapshot |
| `kandidatvurdering` | Score a snapshot against `kandidat.md`. Hard filters first, then weighted judgement. Outputs score + reasoning + concerns. | nothing |
| `beslutning` | Record ja/nei with enum reason. On `ja`, hands off to `sak`. | `beslutninger.jsonl` |
| `sak` | Create and maintain case folders. Owns the status machine. | case folder |
| `korrespondanse` | Ingest inbound email, guard it, match to case, file it, append event. | `korrespondanse/`, `logg.jsonl` |
| `soknad` | Draft application from snapshot + profile + signature projects. Versioned, never sent. | `soknad-vN.md` |
| `intervju` | Produce likely questions, three weakest points with prepared answers, questions to ask them. | `intervju/` |
| `referanser` | Maintain referee ledger, warn on overuse. | `referanser.md` |
| `forhandling` | Prepare negotiation using floor from profile and BATNA from live pipeline. | `intervju/forhandling.md` |
| `dagens` | Daily operating view: what needs action, what has gone silent, upcoming deadlines. | nothing |
| `utfall` | Record outcome with rejection stage. | logs |
| `laering` | The profile-update loop. Explicit invocation only. | proposes diffs |
| `datahygiene` | Export, archive, and delete case data including third-party personal data. | archive/delete |
### 7.1 `annonse-uttrekk` specifics
Extract: title, employer, org number if present, location, deadline, employment
type, stated requirements (split must-have / nice-to-have), full listing body,
contact person, listing URL, capture timestamp.
The snapshot is essential because listings are deleted — usually right when
interview prep needs them.
DOM selectors are the only brittle surface in the system. Isolate every selector
in `references/selectors.md` with a fallback strategy, so a site change is a
one-file fix and never touches scoring or drafting.
### 7.2 `laering` specifics
- Never runs automatically.
- Requires ≥10 total decisions before proposing anything.
- Reads the last 20 decisions plus all recorded outcomes.
- Proposes only on clear signal: same `arsak` in ≥4 of 20, or ≥3 cases of
`score_da` > 70 paired with `nei`.
- **Never tightens hard filters automatically.** Salary floor, geography and
absolute nos change only by direct operator edit. A run of salary rejections
is as likely to mean a weak market as a wrong floor.
- If the last 20 contain fewer than 2 `ja`, propose widening the search rather
than narrowing the profile. A loop that learns only from rejection converges
on a profile that rejects everything.
- Every proposal lists the decision ids it rests on.
---
## 8. MCP servers
`.mcp.json` declares two, both local, both least-privilege.
1. **Browser** — Playwright MCP attached over CDP to an existing Chrome instance
started with `--remote-debugging-port=9222` against the operator's normal
profile. Do not launch a fresh browser; do not manage profiles. Document the
launch command in `README.md`. Configure with navigation and read tools only.
2. **Mail** — read-only IMAP against a dedicated folder the operator filters
into. Credentials via environment variables, never in the repo. Matching is
by employer domain plus thread key. Read scope only: the plugin must be
incapable of sending.
Both are optional at install time. Every skill degrades to manual paste when its
server is absent, and the plugin must be fully usable that way.
---
## 9. Ingestion security
Wire `llm-ingestion-guard`
(`git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git`)
into `scripts/guard_ingest.py`. Pin an exact release tag; do not vendor the
source.
Guard placement — the trust boundary is the **write**, because a poisoned
`annonse.md` is read weeks later by `intervju` as trusted context, and that
reader has no idea where the text came from:
```
untrusted text → prepare_input() → tool-less model transform → screen_output() → persist
```
Requirements:
- The extraction/transform step runs **tool-less**. A successful injection then
has nothing to act with.
- `FAIL_SECURE` halts the write and alerts with a gate code only, never content.
- Apply to both listing bodies and inbound email bodies. Email is the higher-risk
channel: arbitrary senders, and the operator is primed to trust it.
- Expect false positives. Job listings are dense in the constructs the gate
grades. Route non-WARN dispositions to a review queue that shows the operator
the flagged region and asks for a decision — do not silently drop, and do not
silently persist.
- Capability isolation: the skill that reads the browser holds no mail
credential, and vice versa.
---
## 10. Visual output
Cowork renders rich output well; use it, but keep files as the source of truth.
- `dagens` renders a self-contained HTML dashboard: pipeline by state, actions
due, silence flags, deadlines. Regenerated on each run from the case files.
It is a view, never a store.
- Optional xlsx export of the pipeline for operators who want it in a
spreadsheet.
- Every visual must be reproducible from the workspace files alone.
---
## 11. Build order
Ship each milestone working before starting the next.
**M1 — Spine.** `plugin.json`, workspace scaffold, `kandidatprofil`,
`kandidatvurdering` against pasted listing text. No browser, no mail. Proves
scoring works before any automation exists.
**M2 — Case folders.** `sak`, status machine in `sak_status.py`, `beslutning`,
`dagens` as text output. The system now remembers.
**M3 — Ingestion.** `annonse-uttrekk` with browser MCP, `guard_ingest.py`,
snapshotting. First untrusted input enters the system, already guarded.
**M4 — Communication.** `korrespondanse`, mail MCP, silence flags wired into
`dagens`. "Everything in one place" becomes true here.
**M5 — Output.** `soknad`, `intervju`, `referanser`, CV variant tracking.
**M6 — Learning.** `utfall`, `laering`, HTML dashboard, `forhandling`,
`datahygiene`.
---
## 12. Testing
- Fixture corpus of anonymised listings and emails under `tests/fixtures/`.
Every skill except `annonse-uttrekk` must be testable with zero network.
- `sak_status.py` gets real unit tests: every transition, every silence
threshold, every recomputation of `ventende_part`.
- Adversarial fixtures: a listing with an embedded instruction override, an
email with a zero-click exfil carrier, a listing with hidden Unicode. Assert
the guard halts and nothing is persisted.
- Golden-file tests for `kandidat.md` frontmatter validation.
- No test may require the operator's real data.
---
## 13. Non-goals
Submitting applications. Sending email. Any write to Finn.no or LinkedIn.
Credential storage. Background jobs. Multi-user support. Recruiter-side
features. Bulk listing harvest — the system reads listings the operator is
looking at, not the database behind them.
---
## 14. Decisions to confirm with the operator before M1
Ask these; do not assume defaults.
1. Workspace path, and whether it is a git repository (if yes, `.gitignore`
must exclude case folders containing third-party personal data).
2. Which mail account and folder, or whether M4 is deferred entirely.
3. Whether the scoring weights are fixed in the skill or configurable in
`kandidat.md` frontmatter.
4. Retention: how long archived cases are kept before `datahygiene` proposes
deletion.
5. Whether the plugin repo ships with a redacted example workspace for other
users, or workspace-shaped documentation only.