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
This commit is contained in:
1 parent
0d999ab21b
commit
afdef7eb94
33 files changed
+1558
-1768
No files matched your search
@@ -0,0 +1,140 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user