417 lines
17 KiB
Markdown
417 lines
17 KiB
Markdown
# 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.
|