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
141 lines
6.0 KiB
Python
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())
|