diff --git a/.claude/skills/job-application-assistant/01-candidate-profile.md b/.claude/skills/job-application-assistant/01-candidate-profile.md index a8a681a..873cc2d 100644 --- a/.claude/skills/job-application-assistant/01-candidate-profile.md +++ b/.claude/skills/job-application-assistant/01-candidate-profile.md @@ -1,3 +1,7 @@ +--- +framework_version: 1.0.0 +--- + # Candidate Profile diff --git a/.claude/skills/job-application-assistant/02-behavioral-profile.md b/.claude/skills/job-application-assistant/02-behavioral-profile.md index a52f4ad..0ed1af4 100644 --- a/.claude/skills/job-application-assistant/02-behavioral-profile.md +++ b/.claude/skills/job-application-assistant/02-behavioral-profile.md @@ -1,3 +1,7 @@ +--- +framework_version: 1.0.0 +--- + # Behavioral Profile diff --git a/.claude/skills/job-application-assistant/03-writing-style.md b/.claude/skills/job-application-assistant/03-writing-style.md index cf0764f..28dfb05 100644 --- a/.claude/skills/job-application-assistant/03-writing-style.md +++ b/.claude/skills/job-application-assistant/03-writing-style.md @@ -1,3 +1,7 @@ +--- +framework_version: 1.0.0 +--- + # Writing Style Guide ## Critical Rules diff --git a/.claude/skills/job-application-assistant/04-job-evaluation.md b/.claude/skills/job-application-assistant/04-job-evaluation.md index 9852338..5ee5b29 100644 --- a/.claude/skills/job-application-assistant/04-job-evaluation.md +++ b/.claude/skills/job-application-assistant/04-job-evaluation.md @@ -1,3 +1,7 @@ +--- +framework_version: 1.0.0 +--- + # Job Evaluation Framework diff --git a/.claude/skills/job-application-assistant/05-cv-templates.md b/.claude/skills/job-application-assistant/05-cv-templates.md index 96b22d9..3b43cc1 100644 --- a/.claude/skills/job-application-assistant/05-cv-templates.md +++ b/.claude/skills/job-application-assistant/05-cv-templates.md @@ -1,3 +1,7 @@ +--- +framework_version: 1.0.0 +--- + # CV Templates and Tailoring Guide diff --git a/.claude/skills/job-application-assistant/06-cover-letter-templates.md b/.claude/skills/job-application-assistant/06-cover-letter-templates.md index 02d46d5..0a0223c 100644 --- a/.claude/skills/job-application-assistant/06-cover-letter-templates.md +++ b/.claude/skills/job-application-assistant/06-cover-letter-templates.md @@ -1,3 +1,7 @@ +--- +framework_version: 1.0.0 +--- + # Cover Letter Templates and Tailoring Guide ## Template: Custom cover.cls (XeLaTeX) diff --git a/.claude/skills/job-application-assistant/07-interview-prep.md b/.claude/skills/job-application-assistant/07-interview-prep.md index 1c68adf..4fe8d79 100644 --- a/.claude/skills/job-application-assistant/07-interview-prep.md +++ b/.claude/skills/job-application-assistant/07-interview-prep.md @@ -1,3 +1,7 @@ +--- +framework_version: 1.0.0 +--- + # Interview Preparation Guide diff --git a/.claude/skills/job-application-assistant/SKILL.md b/.claude/skills/job-application-assistant/SKILL.md index 41db772..66109e9 100644 --- a/.claude/skills/job-application-assistant/SKILL.md +++ b/.claude/skills/job-application-assistant/SKILL.md @@ -5,6 +5,7 @@ description: > and preparing for interviews. Triggers on keywords like: job posting, job application, CV, cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion +framework_version: 1.0.0 --- # Job Application Assistant diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 826022e..fdb1358 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,11 +40,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - run: pip install pyyaml - run: python tools/lint_skills.py + - name: Framework version guard (upstream template only) + if: github.repository == 'MadsLorentzen/ai-job-search' + run: python tools/check_framework_version.py security-guards: name: Security guards (permissions, gitignore, manifests) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8397dea..91b5318 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,7 @@ Reviews here are empirical. Bug reports are reproduced on master before the fix - State the failing case and how to reproduce it. - Put CLI tests in `.agents/skills//cli/tests/` (bun test, network-free where possible); Python tool tests in `tests/`. -- Run what CI runs: `python3 tools/lint_skills.py` (or `python tools/lint_skills.py` if that is your Python 3 executable), `bun run typecheck` in touched CLIs, and the relevant test suites. +- Run what CI runs: `python3 tools/lint_skills.py`, `python3 tools/check_framework_version.py`, `bun run typecheck` in touched CLIs, and the relevant test suites. **Credit norm:** a change that incorporates your actual code gets a `Co-authored-by` trailer; a change written independently from your observation or report gets a named mention in the commit message and PR. Both happen unprompted. @@ -41,6 +41,7 @@ Reviews here are empirical. Bug reports are reproduced on master before the fix 1. Fork the repo and run `/add-portal` with your local job board - it scaffolds a portal skill matching the shipped contract, and `/scrape` picks it up automatically. 2. Announce your fork in the pinned [Community forks & adaptations](https://github.com/MadsLorentzen/ai-job-search/discussions/78) discussion so others can find it. +3. Run the framework update checker (`python3 tools/check_upstream_updates.py`) in your fork to check if upstream has updated any framework files and compare them with your personalized variants. Market-specific skills are genuinely valuable - they just live in forks, where their maintainers can test them and their users can find them. diff --git a/tools/check_framework_version.py b/tools/check_framework_version.py new file mode 100755 index 0000000..e532268 --- /dev/null +++ b/tools/check_framework_version.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""CI check: ensure that modified framework files have updated version markers. + +Fails if any markdown file under .claude/skills/job-application-assistant/ is +modified in git without a change/bump to its 'framework_version' frontmatter key. +Also ensures all framework files have a valid 'framework_version' frontmatter key. +""" + +from __future__ import annotations +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SKILL_DIR = ROOT / ".claude/skills/job-application-assistant" +FRAMEWORK_FILES = sorted(SKILL_DIR.glob("*.md")) + +def run_git(args: list[str]) -> tuple[int, str, str]: + res = subprocess.run(["git"] + args, cwd=str(ROOT), capture_output=True, text=True) + return res.returncode, res.stdout, res.stderr + +def get_base_commit() -> str | None: + # If in GitHub Actions PR, use the target branch's base ref + base_ref = os.environ.get("GITHUB_BASE_REF") + if base_ref: + # Check if origin/base_ref is fetched + rc, _, _ = run_git(["rev-parse", "--verify", f"origin/{base_ref}"]) + if rc == 0: + return f"origin/{base_ref}" + # Try without origin/ + rc, _, _ = run_git(["rev-parse", "--verify", base_ref]) + if rc == 0: + return base_ref + + # If running in GitHub Actions but not a PR (e.g. push to master) + if os.environ.get("GITHUB_ACTIONS"): + rc, _, _ = run_git(["rev-parse", "--verify", "HEAD~1"]) + if rc == 0: + return "HEAD~1" + + # Otherwise (running locally), check uncommitted changes against HEAD + rc, _, _ = run_git(["rev-parse", "--verify", "HEAD"]) + if rc == 0: + return "HEAD" + + return None + +def parse_frontmatter(path: Path) -> dict: + if not path.exists(): + return {} + text = path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + return {} + end = text.find("\n---", 4) + if end == -1: + return {} + + # Simple parser for YAML/frontmatter + data = {} + for line in text[4:end].splitlines(): + if ":" in line: + k, v = line.split(":", 1) + data[k.strip()] = v.strip().strip('"').strip("'") + return data + +def has_non_trivial_changes(file_path: Path, base_commit: str) -> bool: + # Get diff of the file from base_commit to HEAD + rel_path = str(file_path.relative_to(ROOT)) + rc, stdout, stderr = run_git(["diff", "-U0", base_commit, "--", rel_path]) + if rc != 0: + # If diff fails (e.g. file is new/untracked), it's a change + return True + + # Parse diff lines + # We want to count lines added/removed that: + # - do not match framework_version line + # - are not empty/whitespace only + meaningful_changes = 0 + version_changed = False + + for line in stdout.splitlines(): + if line.startswith("+++") or line.startswith("---") or line.startswith("@@"): + continue + if line.startswith("+") or line.startswith("-"): + content = line[1:].strip() + if not content: + continue + if re.match(r"^framework_version\s*:", content): + version_changed = True + continue + # Check if it's just frontmatter syntax (e.g. ---) + if content == "---": + continue + meaningful_changes += 1 + + # If the version key itself was modified, we don't fail, regardless of other changes + if version_changed: + return False + + # If there are meaningful changes but the version was not changed + return meaningful_changes > 0 + +def main() -> int: + errors = [] + + # 1. Lint: Check that all framework files have framework_version in frontmatter + for path in FRAMEWORK_FILES: + rel_path = str(path.relative_to(ROOT)) + fm = parse_frontmatter(path) + if "framework_version" not in fm: + errors.append(f"{rel_path}: missing 'framework_version' in frontmatter") + + # 2. Check for missing version bumps in modified files + base_commit = get_base_commit() + if base_commit: + print(f"Comparing HEAD against base commit: {base_commit}") + for path in FRAMEWORK_FILES: + rel_path = str(path.relative_to(ROOT)) + if "framework_version" not in parse_frontmatter(path): + # Skip checking changes if it doesn't even have frontmatter (already reported above) + continue + if has_non_trivial_changes(path, base_commit): + errors.append( + f"{rel_path}: modified without bumping 'framework_version'. " + f"Please update the version in the frontmatter." + ) + else: + print("No base commit found (e.g. initial commit or shallow clone without base branch). Skipping diff checks.") + + if errors: + print("Framework Version Check Failed:") + for err in errors: + print(f" - {err}") + return 1 + + print("Framework Version Check: OK") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_upstream_updates.py b/tools/check_upstream_updates.py new file mode 100755 index 0000000..7a816e5 --- /dev/null +++ b/tools/check_upstream_updates.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Check for framework updates in the upstream repository. + +Usage: python tools/check_upstream_updates.py [--remote ] [--branch ] + +This script: +1. Identifies the upstream remote (defaults to 'upstream', falls back to 'origin'). +2. Fetches the latest commits from the upstream remote. +3. Compares the 'framework_version' in your local files under + .claude/skills/job-application-assistant/ with those in the upstream remote. +4. Alerts you if a file has been updated upstream with a newer version. +""" + +from __future__ import annotations +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SKILL_DIR = ".claude/skills/job-application-assistant" +FRAMEWORK_FILES = [ + "01-candidate-profile.md", + "02-behavioral-profile.md", + "03-writing-style.md", + "04-job-evaluation.md", + "05-cv-templates.md", + "06-cover-letter-templates.md", + "07-interview-prep.md", + "SKILL.md", +] + +def run_git(args: list[str]) -> tuple[int, str, str]: + res = subprocess.run(["git"] + args, cwd=str(ROOT), capture_output=True, text=True) + return res.returncode, res.stdout, res.stderr + +def get_framework_version_from_text(text: str) -> str | None: + if not text.startswith("---\n"): + return None + end = text.find("\n---", 4) + if end == -1: + return None + for line in text[4:end].splitlines(): + if ":" in line: + k, v = line.split(":", 1) + if k.strip() == "framework_version": + return v.strip().strip('"').strip("'") + return None + +def parse_semver(version_str: str) -> tuple[int, int, int]: + # Clean version string (e.g. remove 'v' prefix) + match = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", version_str) + if not match: + return (0, 0, 0) + return tuple(int(x) for x in match.groups()) + +def main() -> int: + parser = argparse.ArgumentParser(description="Check for framework updates upstream.") + parser.add_argument("--remote", default="upstream", help="Name of the git remote for upstream (default: upstream)") + parser.add_argument("--branch", default="master", help="Branch name of the upstream repo (default: master)") + parser.add_argument("--no-fetch", action="store_true", help="Skip fetching from remote") + args = parser.parse_args() + + # Verify remote exists + rc, stdout, _ = run_git(["remote"]) + remotes = stdout.splitlines() + remote = args.remote + if remote not in remotes: + if "origin" in remotes: + print(f"Warning: Remote '{remote}' not found. Falling back to 'origin'.") + remote = "origin" + else: + print("Error: No git remotes found.") + return 1 + + if not args.no_fetch: + print(f"Fetching latest from remote '{remote}'...") + rc, _, stderr = run_git(["fetch", remote]) + if rc != 0: + print(f"Warning: Failed to fetch from remote '{remote}': {stderr.strip()}") + print("Proceeding with cached remote tracking branches.") + + ref = f"{remote}/{args.branch}" + # Verify ref exists + rc, _, _ = run_git(["rev-parse", "--verify", ref]) + if rc != 0: + print(f"Error: Ref '{ref}' does not exist. Make sure you fetched and specified the correct branch.") + return 1 + + print(f"Comparing local files against upstream '{ref}'...\n") + + updates_available = [] + errors = [] + + for filename in FRAMEWORK_FILES: + local_path = ROOT / SKILL_DIR / filename + if not local_path.exists(): + print(f"Local file missing: {SKILL_DIR}/{filename}") + continue + + # Get local version + local_text = local_path.read_text(encoding="utf-8") + local_ver = get_framework_version_from_text(local_text) + + # Get upstream version + rc, upstream_text, _ = run_git(["show", f"{ref}:{SKILL_DIR}/{filename}"]) + if rc != 0: + # File might not exist upstream yet + continue + + upstream_ver = get_framework_version_from_text(upstream_text) + + if not local_ver: + errors.append(f"Local file {filename} is missing 'framework_version' in frontmatter.") + continue + if not upstream_ver: + continue + + if parse_semver(upstream_ver) > parse_semver(local_ver): + updates_available.append({ + "filename": filename, + "local": local_ver, + "upstream": upstream_ver, + "path": f"{SKILL_DIR}/{filename}" + }) + + if errors: + print("Configuration errors:") + for err in errors: + print(f" - {err}") + print() + + if updates_available: + print("[UPDATE] Upstream updates available for framework methodology files:") + for up in updates_available: + print(f" - {up['filename']}: local {up['local']} < upstream {up['upstream']}") + print(f" Diff command: git diff {ref} -- {up['path']}") + print() + print("Review these changes to see if they fit your personalized fork!") + return 0 + else: + print("[OK] All framework files are up to date with upstream!") + return 0 + +if __name__ == "__main__": + sys.exit(main())