Files
claude-resume-kit/resume_builder/helpers/application_tracker.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

118 lines
6.3 KiB
Python

"""Record applications without quotas; preserve outcome events and interview history."""
from __future__ import annotations
import argparse
import hashlib
import application_records as records
FIT_CLASSES = ("core", "adjacent", "stretch", "no-go")
CHANNELS = ("strong", "moderate", "weak")
def summary(data: dict, all_applications: bool = False) -> None:
cohort_id = data["cohort_metadata"]["cohort_id"]
applications = [row for row in data["applications"] if all_applications or row.get("cohort_id") == cohort_id]
print(f"Application record: {'all' if all_applications else cohort_id} ({len(applications)} applications; no quota)")
for field, values in (("fit_class", FIT_CLASSES), ("channel", CHANNELS)):
for value in values:
print(f" {field} {value}: {sum(row.get(field) == value for row in applications)}")
unknown = sum(row.get(field) is None for row in applications)
if unknown:
print(f" {field} unrecorded: {unknown}")
outcomes = sorted({row["outcome"] for row in applications})
print(" outcomes: " + ", ".join(f"{value}={sum(row['outcome'] == value for row in applications)}" for value in outcomes))
print(f" interview reached (including later withdrawal/rejection): {sum(records.interview_reached(row) for row in applications)}")
def add_application(data: dict, args: argparse.Namespace) -> None:
records.valid_date(args.date)
identity = args.id or "app-" + hashlib.sha256(f"{args.company.casefold()}|{args.job_id or args.role.casefold()}|{args.date}".encode()).hexdigest()[:12]
candidate = {
"id": identity, "company": args.company, "role": args.role,
"label": f"{args.company} — {args.role}", "application_date": args.date,
"fit_class": args.fit_class, "evidence_fit": args.evidence_fit,
"hard_gate": args.hard_gate, "channel": args.channel, "outcome": args.outcome,
"cohort_id": data["cohort_metadata"]["cohort_id"], "job_id": args.job_id,
"url": args.url, "fit_override_reason": args.fit_override_reason,
"rubric_version": args.rubric_version,
"next_action": args.next_action, "current_summary": args.outcome,
"events": [{"type": args.outcome, "date": args.date}],
}
for existing in data["applications"]:
same_job = args.job_id and existing.get("job_id") == args.job_id and existing["company"].casefold() == args.company.casefold()
same_url = args.url and existing.get("url") == args.url
same_application = existing["company"].casefold() == args.company.casefold() and existing["role"].casefold() == args.role.casefold() and existing.get("application_date") == args.date
if existing["id"] == identity or same_job or same_url or same_application:
if all(existing.get(key) == value for key, value in candidate.items() if key not in {"events", "id", "current_summary", "next_action", "outcome"}):
return
raise ValueError(f"Application already exists ({existing['id']}); use update instead")
data["applications"].append(candidate)
records.validate(data)
def update_application(data: dict, args: argparse.Namespace) -> None:
records.valid_date(args.date)
matches = [row for row in data["applications"] if row["id"] == args.id]
if not matches:
raise ValueError(f"Unknown application ID: {args.id}")
row = matches[0]
if row.get("application_date") and args.date < row["application_date"]:
raise ValueError("event date precedes application date")
outcome_dates = [event["date"] for event in row.get("events", []) if event.get("outcome") and event.get("date")]
if args.outcome and outcome_dates and args.date < max(outcome_dates):
raise ValueError("older outcome cannot replace current state; record it as a historical note")
event = {"type": args.event or args.outcome or "note", "date": args.date}
if args.note:
event["note"] = args.note
if args.outcome:
event["outcome"] = args.outcome
if event not in row.setdefault("events", []):
row["events"].append(event)
if args.outcome:
row["outcome"] = args.outcome
row["current_summary"] = args.note or args.outcome
row["next_action"] = args.next_action or ("Await response." if args.outcome in {"applied", "interviewing", "open"} else "Review recorded outcome before further action.")
elif args.next_action:
row["next_action"] = args.next_action
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
view = commands.add_parser("summary")
view.add_argument("--all", action="store_true")
add = commands.add_parser("add")
for field in ("company", "role", "date"):
add.add_argument("--" + field, required=True)
add.add_argument("--id")
add.add_argument("--job-id")
add.add_argument("--url")
add.add_argument("--fit-class", choices=FIT_CLASSES, required=True)
add.add_argument("--evidence-fit", type=float, required=True)
add.add_argument("--channel", choices=CHANNELS, required=True)
add.add_argument("--hard-gate", choices=("pass", "fail", "unknown"), required=True)
add.add_argument("--outcome", choices=sorted(records.OUTCOMES), default="applied")
add.add_argument("--fit-override-reason")
add.add_argument("--rubric-version", default="critique-framework-2026-09-11")
add.add_argument("--next-action", default="Await response.")
update = commands.add_parser("update")
update.add_argument("--id", required=True)
update.add_argument("--date", required=True, help="Date of this event")
update.add_argument("--outcome", choices=sorted(records.OUTCOMES))
update.add_argument("--event", choices=("interview_invited", "interview_held", "assessment_passed", "contact_attempted", "contact_conversation", "referral_received", "note"))
update.add_argument("--note")
update.add_argument("--next-action")
args = parser.parse_args()
try:
if args.command == "summary":
summary(records.load(), args.all)
return
with records.transaction() as data:
(add_application if args.command == "add" else update_application)(data, args)
summary(data)
except (ValueError, OSError) as error:
parser.exit(2, f"ERROR: {error}\n")
if __name__ == "__main__":
main()