Files
claude-resume-kit/resume_builder/helpers/application_records.py
T
dennisthiessenandClaude Opus 5 afdef7eb94 refactor: shared agent workflows (F01) and one application registry (F02)
Checkpoint for the 2026-09-11 project review remediation. Work by a previous
agent that ran out of context; committed unchanged as a rollback point before
the remaining findings land on top of it.

F01 — the two agent directories had drifted into different processes
- Six shared workflows under `resume_builder/reference/workflows/`; both
  `.agents/skills/` and `.claude/skills/` are now thin entrypoints that route
  to the same file instead of carrying full copies.
- The `.claude` copies were the stale side: fixed bullet line variants and
  character budgets that the newer process had already dropped. Removing them
  is the point of the change, not collateral.
- Parity/link regression test added; corrected the top-level file map, which
  pointed at a directory that does not exist.

F02 — application status was stored in four places and disagreed
- New registry `job_scout/state/applications.json` (26 records) with
  `application_records.py` (schema, validation, transactional write) and
  `application_tracker.py` (add/update, no quota).
- AGENTS.md and CLAUDE.md no longer keep their own status tables; both point
  at `current_applications.md`, generated from the registry.
- Interview-reached is now independent of outcome, so a withdrawal after an
  interview can record both facts. The old tables are preserved verbatim in
  `job_scout/state/history/application_status_before_2026-09-11.json`.

Verification: `python -m unittest discover -s tests -v` — 18/18 pass.
Spot-checked that the detailed SBB and Kdo Cy notes survived the migration
into the registry rather than being flattened.

Known unresolved, deliberately not invented (see FIX_PROGRESS.md):
Aker BP's application date and its Adjacent-despite-79 classification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qej5SBvhcbGXHwLqNWiskM
2026-09-11 19:23:13 +02:00

141 lines
6.0 KiB
Python

"""Authoritative application state and generated, read-only projections.
Run with --check to detect stale views, or --render after editing the registry.
Outcome events describe what happened; current outcome never erases an interview.
"""
from __future__ import annotations
import argparse
from contextlib import contextmanager
from datetime import date
import json
import math
import os
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
REGISTRY = ROOT / "job_scout/state/applications.json"
VIEW = ROOT / "resume_builder/reference/current_applications.md"
COHORT = ROOT / "job_scout/state/application_cohort.json"
OUTCOMES = {"open", "applied", "paused", "not_proceeding", "role_closed", "closed_no_response", "withdrawn_by_candidate", "rejected_no_interview", "rejected_after_interview", "interviewing", "offer", "accepted"}
def valid_date(value: str) -> str:
if not isinstance(value, str) or date.fromisoformat(value).isoformat() != value:
raise ValueError("expected ISO date YYYY-MM-DD")
return value
def validate(data: dict) -> None:
ids = set()
for record in data["applications"]:
identifier = record.get("id")
if not identifier or identifier in ids:
raise ValueError("application IDs must be nonempty and unique")
ids.add(identifier)
if record.get("outcome") not in OUTCOMES:
raise ValueError(f"{identifier}: invalid outcome")
for key in ("application_date", "rejection_date", "withdrawn"):
if record.get(key):
valid_date(record[key])
for event in record.get("events", []):
if event.get("date"):
valid_date(event["date"])
score = record.get("evidence_fit")
if score is not None and (isinstance(score, bool) or not isinstance(score, (float, int)) or not math.isfinite(score) or not 0 <= score <= 100):
raise ValueError(f"{identifier}: evidence_fit must be finite and between 0 and 100")
if record.get("hard_gate") == "fail" and record.get("fit_class") not in {"stretch", "no-go"}:
raise ValueError(f"{identifier}: failed gate requires Stretch or No-go")
if score is not None and record.get("hard_gate") == "pass" and record.get("fit_class") in {"core", "adjacent"}:
expected = "core" if score >= 75 else "adjacent" if score >= 60 else "stretch"
if record["fit_class"] != expected and not record.get("fit_override_reason"):
raise ValueError(f"{identifier}: classification differs from score; record fit_override_reason")
@contextmanager
def transaction():
"""Serialize local writers; fail clearly rather than overwrite another update."""
lock = REGISTRY.with_suffix(".lock")
try:
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError as exc:
raise ValueError(f"Application registry is locked: {lock}. Check for an active writer before removing a stale lock.") from exc
try:
with os.fdopen(descriptor, "w") as stream:
stream.write(str(os.getpid()))
data = load()
yield data
validate(data)
atomic_write(REGISTRY, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
render(data)
finally:
lock.unlink(missing_ok=True)
def atomic_write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
stream.write(text)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
Path(temporary).unlink(missing_ok=True)
def load() -> dict:
return json.loads(REGISTRY.read_text(encoding="utf-8"))
def interview_reached(record: dict) -> bool:
return bool(record.get("interview")) or any(
event.get("type") in {"interview_invited", "interview_held"}
for event in record.get("events", [])
)
def projections(data: dict) -> dict[Path, str]:
lines = ["# Current applications", "", "Generated from `job_scout/state/applications.json`; do not edit this view.",
"Use current outcome and next action below. Imported legacy notes are historical, not current instructions.", "",
"| Application | Current outcome | Interview reached | Next action |", "|---|---|---|---|"]
for record in data["applications"]:
cells = [record["label"], record["outcome"], "yes" if interview_reached(record) else "not recorded", record["next_action"]]
lines.append("| " + " | ".join(str(cell).replace("|", " / ").replace("\n", " ") for cell in cells) + " |")
lines.extend(["", "## Current targeting notes", "", *["- " + note for note in data.get("targeting_notes", [])], ""])
cohort = dict(data["cohort_metadata"])
cohort["generated_from"] = "job_scout/state/applications.json"
cohort["applications"] = [record for record in data["applications"] if record.get("cohort_id") == cohort["cohort_id"]]
return {VIEW: "\n".join(lines), COHORT: json.dumps(cohort, indent=2, ensure_ascii=False) + "\n"}
def render(data: dict, check: bool = False) -> list[str]:
validate(data)
stale = []
for path, expected in projections(data).items():
if not path.exists() or path.read_text(encoding="utf-8") != expected:
stale.append(str(path.relative_to(ROOT)))
if not check:
atomic_write(path, expected)
return stale
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--check", action="store_true")
mode.add_argument("--render", action="store_true")
args = parser.parse_args()
stale = render(load(), check=args.check)
if args.check and stale:
print("STALE: " + ", ".join(stale))
return 1
print("Application views are current.")
return 0
if __name__ == "__main__":
raise SystemExit(main())