feat(input): add codex automation stack

This commit is contained in:
2025-10-15 16:32:26 -05:00
parent e0816486cb
commit 8bf8784d52
10 changed files with 625 additions and 3 deletions

45
input/AGENTS.md Normal file
View File

@@ -0,0 +1,45 @@
# Input Agent Guide
## Mission
Automate the upstream resume customization workflow. Monitor the job-description inbox, combine the base resume and prompt template into a Codex-friendly request, invoke the Codex CLI, and deposit the generated Markdown in a timestamped outbox for human review.
## Directory Responsibilities
- `resume/` contains exactly one Markdown resume. Any other count is a fatal configuration error.
- `ForCustomizing/inbox/` drop one job-description Markdown at a time to trigger processing.
- `ForCustomizing/outbox/YYYY/MM/DD/HHMM/` timestamped folders containing Codex output Markdown (and a copy of the prompt used).
- `ForCustomizing/processed/YYYY/MM/DD/HHMM/` archives of job descriptions that Codex processed successfully.
- `ForCustomizing/failed/` captures job descriptions when Codex errors or a recoverable issue occurs. Fatal configuration errors still exit the container.
- `templates/ResumeCustomizerPrompt.md.example` default instruction block; copy to `ResumeCustomizerPrompt.md` (same folder) to override locally.
## Running the Input Processor
Launch the stack with the wrapper so files inherit your UID/GID and your local Codex credentials mount in:
```bash
cd input/Docker
./run-input-processor.sh up -d
```
Environment variables you can pass before the command:
- `CODEX_COMMAND_TEMPLATE` override the Codex CLI invocation (defaults to `codex prompt --input {prompt} --output {output} --format markdown`).
- `POLL_INTERVAL_SECONDS` watcher polling cadence (default `5`).
- `CODEX_TIMEOUT_SECONDS` hard timeout for Codex calls (default `600`).
- `CODEX_CONFIG_DIR` optional override for the host directory that should mount into `/home/codex/.codex`.
Stop or inspect the stack with:
```bash
cd input/Docker
./run-input-processor.sh down
./run-input-processor.sh logs -f
```
## Guardrails
- Ensure only one job description resides in `ForCustomizing/inbox/`. Multiple files cause the container to exit with a fatal error.
- Keep exactly one resume Markdown in `resume/`. Missing or multiple resumes also terminate the watcher.
- The container runs as a non-root user matching the callers UID/GID. Avoid changing permissions manually inside the mounted directories.
- Do not edit anything under `output/` from this agent session; treat the downstream pipeline as read-only reference material.
## Troubleshooting
- If Codex CLI fails, the job description moves to `ForCustomizing/failed/`. Check container logs, adjust the Markdown, then requeue it.
- Fatal errors (multiple resumes, multiple job descriptions, missing template, or missing Codex binary) stop the container. Resolve the issue and restart via the wrapper.
- To change the Codex command format, pass a quoted template (e.g., `CODEX_COMMAND_TEMPLATE='codex run --input {prompt} --output {output}' ./run-input-processor.sh up -d'`). The template must include `{prompt}` and `{output}` placeholders.

29
input/Docker/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:20-bookworm
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PUID=1000 \
PGID=1000 \
CODEX_HOME=/home/codex
RUN apt-get update \
&& apt-get install --yes --no-install-recommends \
python3 \
python3-venv \
gosu \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --location=global codex-cli || true
RUN groupadd -r codex && \
useradd -r -m -g codex -s /bin/bash codex
WORKDIR /app
COPY watch_and_customize.py entrypoint.sh ./
RUN chmod +x /app/watch_and_customize.py /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -0,0 +1,23 @@
name: RCEO-AI-ResumeCustomizer-Input
services:
rceo-ai-resumecustomizer-inputprocessor:
build:
context: .
dockerfile: Dockerfile
container_name: RCEO-AI-ResumeCustomizer-InputProcessor
restart: "no"
environment:
PUID: "${LOCAL_UID:-1000}"
PGID: "${LOCAL_GID:-1000}"
POLL_INTERVAL_SECONDS: "${POLL_INTERVAL_SECONDS:-5}"
CODEX_TIMEOUT_SECONDS: "${CODEX_TIMEOUT_SECONDS:-600}"
CODEX_COMMAND_TEMPLATE: "${CODEX_COMMAND_TEMPLATE:-codex prompt --input {prompt} --output {output} --format markdown}"
volumes:
- ../ForCustomizing/inbox:/workspace/inbox
- ../ForCustomizing/outbox:/workspace/outbox
- ../ForCustomizing/processed:/workspace/processed
- ../ForCustomizing/failed:/workspace/failed
- ../resume:/workspace/resume:ro
- ../templates:/templates:ro
- ${CODEX_CONFIG_DIR:-/workspace/.codex}:/home/codex/.codex

58
input/Docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
USER_NAME=codex
PUID=${PUID:-1000}
PGID=${PGID:-1000}
ensure_group() {
local desired_gid=$1
local group_name
if getent group "${desired_gid}" >/dev/null 2>&1; then
group_name=$(getent group "${desired_gid}" | cut -d: -f1)
echo "${group_name}"
return 0
fi
if getent group "${USER_NAME}" >/dev/null 2>&1; then
groupmod -o -g "${desired_gid}" "${USER_NAME}"
echo "${USER_NAME}"
return 0
fi
groupadd -o -g "${desired_gid}" "${USER_NAME}"
echo "${USER_NAME}"
}
ensure_user() {
local desired_uid=$1
local primary_group=$2
if getent passwd "${USER_NAME}" >/dev/null 2>&1; then
usermod -o -u "${desired_uid}" -g "${primary_group}" -d "/home/${USER_NAME}" -s /bin/bash "${USER_NAME}"
else
useradd -o -m -u "${desired_uid}" -g "${primary_group}" -s /bin/bash "${USER_NAME}"
fi
}
GROUP_NAME=$(ensure_group "${PGID}")
ensure_user "${PUID}" "${GROUP_NAME}"
USER_HOME=$(eval echo "~${USER_NAME}")
mkdir -p /workspace/inbox /workspace/outbox /workspace/processed /workspace/failed
mkdir -p "${USER_HOME}/.codex"
for path in /workspace/inbox /workspace/outbox /workspace/processed /workspace/failed "${USER_HOME}" "${USER_HOME}/.codex"; do
if [ -e "${path}" ]; then
chown -R "${PUID}:${PGID}" "${path}"
fi
done
export HOME="${USER_HOME}"
export XDG_CACHE_HOME="${USER_HOME}/.cache"
mkdir -p "${XDG_CACHE_HOME}"
chown -R "${PUID}:${PGID}" "${XDG_CACHE_HOME}"
exec gosu "${PUID}:${PGID}" python3 /app/watch_and_customize.py

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Wrapper to run docker compose with the caller's UID/GID so generated files stay writable.
set -euo pipefail
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker is not installed or not on PATH." >&2
exit 1
fi
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
else
echo "Error: docker compose plugin or docker-compose binary is required." >&2
exit 1
fi
CALLER_UID=$(id -u)
CALLER_GID=$(id -g)
DEFAULT_CODEX_CONFIG="${HOME}/.codex"
CODEX_CONFIG_DIR=${CODEX_CONFIG_DIR:-${DEFAULT_CODEX_CONFIG}}
mkdir -p "${CODEX_CONFIG_DIR}"
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
(
cd "${SCRIPT_DIR}"
LOCAL_UID="${CALLER_UID}" \
LOCAL_GID="${CALLER_GID}" \
CODEX_CONFIG_DIR="${CODEX_CONFIG_DIR}" \
"${COMPOSE_CMD[@]}" "$@"
)

View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""
Monitor the customization inbox for job description Markdown files and run the Codex CLI
to produce tailored resumes.
The script expects exactly one base resume Markdown file and processes one job file at a
time. After a successful Codex run, the generated resume is written into a timestamped
outbox folder and the job description is archived under processed/. Failures move the
job description into failed/.
"""
from __future__ import annotations
import logging
import os
import shlex
import shutil
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Sequence
INBOX = Path("/workspace/inbox")
OUTBOX = Path("/workspace/outbox")
PROCESSED = Path("/workspace/processed")
FAILED = Path("/workspace/failed")
RESUME_DIR = Path("/workspace/resume")
TEMPLATES_DIR = Path("/templates")
TEMPLATE_CACHE = Path("/tmp/templates")
PROMPT_TEMPLATE = TEMPLATES_DIR / "ResumeCustomizerPrompt.md"
PROMPT_TEMPLATE_EXAMPLE = TEMPLATES_DIR / "ResumeCustomizerPrompt.md.example"
POLL_INTERVAL_SECONDS = int(os.environ.get("POLL_INTERVAL_SECONDS", "5"))
CODEX_COMMAND_TEMPLATE = os.environ.get(
"CODEX_COMMAND_TEMPLATE",
"codex prompt --input {prompt} --output {output} --format markdown",
)
CODEX_TIMEOUT_SECONDS = int(os.environ.get("CODEX_TIMEOUT_SECONDS", "600"))
RESOLVED_PROMPT_TEMPLATE: Path | None = None
class FatalConfigurationError(RuntimeError):
"""Raised when the watcher encounters a non-recoverable configuration problem."""
@dataclass(frozen=True)
class MarkdownInputs:
resume: Path
job_description: Path
prompt_template: Path
def ensure_environment() -> None:
"""Verify required directories and template assets exist."""
global RESOLVED_PROMPT_TEMPLATE
missing = [
str(path)
for path in (
INBOX,
OUTBOX,
PROCESSED,
FAILED,
RESUME_DIR,
TEMPLATES_DIR,
)
if not path.exists()
]
if missing:
raise FatalConfigurationError(
"Input pipeline is missing required paths: " + ", ".join(missing)
)
RESOLVED_PROMPT_TEMPLATE = resolve_prompt_template(
PROMPT_TEMPLATE,
PROMPT_TEMPLATE_EXAMPLE,
TEMPLATE_CACHE,
)
def resolve_prompt_template(primary: Path, example: Path, cache_dir: Path) -> Path:
"""Return the prompt template path, copying the example if needed."""
if primary.exists():
return primary
if example.exists():
cache_dir.mkdir(parents=True, exist_ok=True)
cached = cache_dir / primary.name
shutil.copy(example, cached)
return cached
raise FatalConfigurationError(
f"Prompt template missing: {primary} (no example found at {example})"
)
def ensure_single_resume() -> Path:
"""Return the single resume markdown file or raise if conditions are not met."""
resumes = sorted(RESUME_DIR.glob("*.md"))
if len(resumes) == 0:
raise FatalConfigurationError(
f"No resume Markdown file found in {RESUME_DIR}. Exactly one is required."
)
if len(resumes) > 1:
raise FatalConfigurationError(
f"Multiple resume Markdown files found in {RESUME_DIR}: "
+ ", ".join(r.name for r in resumes)
)
return resumes[0]
def ensure_single_job(md_files: Sequence[Path]) -> Path | None:
"""Validate there is at most one job description file."""
if not md_files:
return None
if len(md_files) > 1:
names = ", ".join(p.name for p in md_files)
raise FatalConfigurationError(
f"Multiple job description files detected in inbox: {names} "
"— expected exactly one."
)
return md_files[0]
def read_inputs(job_file: Path) -> MarkdownInputs:
"""Gather and return all markdown inputs required for the prompt."""
resume = ensure_single_resume()
missing = [str(path) for path in (job_file,) if not path.exists()]
if missing:
raise FatalConfigurationError(
"Required files disappeared before processing: " + ", ".join(missing)
)
if RESOLVED_PROMPT_TEMPLATE is None:
raise FatalConfigurationError("Prompt template was not resolved during startup.")
return MarkdownInputs(
resume=resume,
job_description=job_file,
prompt_template=RESOLVED_PROMPT_TEMPLATE,
)
def build_prompt_text(inputs: MarkdownInputs) -> str:
"""Return the combined prompt string fed to the Codex CLI."""
resume_text = inputs.resume.read_text(encoding="utf-8").strip()
jd_text = inputs.job_description.read_text(encoding="utf-8").strip()
instructions_text = inputs.prompt_template.read_text(encoding="utf-8").strip()
return (
"# Resume Customization Request\n\n"
"## Instructions\n"
f"{instructions_text}\n\n"
"---\n\n"
"## Job Description\n"
f"{jd_text}\n\n"
"---\n\n"
"## Current Resume\n"
f"{resume_text}\n"
)
def build_timestamp_dir(base: Path, timestamp: datetime) -> Path:
"""Create (if missing) and return the timestamped directory path."""
path = (
base
/ timestamp.strftime("%Y")
/ timestamp.strftime("%m")
/ timestamp.strftime("%d")
/ timestamp.strftime("%H%M")
)
path.mkdir(parents=True, exist_ok=True)
return path
def sanitize_stem(stem: str) -> str:
"""Replace characters that could interfere with filesystem operations."""
return "".join(ch if ch.isalnum() else "_" for ch in stem) or "resume"
def run_codex(prompt_path: Path, output_path: Path) -> None:
"""Execute the Codex CLI using the configured command template."""
command_text = CODEX_COMMAND_TEMPLATE.format(
prompt=str(prompt_path),
output=str(output_path),
)
logging.info("Running Codex CLI command: %s", command_text)
try:
command = shlex.split(command_text)
except ValueError as exc:
raise FatalConfigurationError(
f"Unable to parse CODEX_COMMAND_TEMPLATE into arguments: {exc}"
) from exc
try:
subprocess.run(
command,
check=True,
timeout=CODEX_TIMEOUT_SECONDS,
env=os.environ.copy(),
)
except FileNotFoundError as exc:
raise FatalConfigurationError(
f"Executable not found while running Codex CLI command: {command[0]}"
) from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("Codex CLI timed out") from exc
if not output_path.exists():
raise RuntimeError(
f"Codex CLI completed but expected output file {output_path} is missing."
)
def move_with_unique_target(source: Path, destination_dir: Path) -> Path:
"""Move source into destination_dir, avoiding collisions with numeric suffixes."""
destination_dir.mkdir(parents=True, exist_ok=True)
target = destination_dir / source.name
stem = source.stem
suffix = source.suffix
counter = 1
while target.exists():
target = destination_dir / f"{stem}_{counter}{suffix}"
counter += 1
shutil.move(str(source), target)
return target
def process_job(job_file: Path) -> None:
"""Combine inputs, run Codex, and archive outputs."""
timestamp = datetime.now().astimezone()
out_dir = build_timestamp_dir(OUTBOX, timestamp)
processed_dir = build_timestamp_dir(PROCESSED, timestamp)
inputs = read_inputs(job_file)
prompt_text = build_prompt_text(inputs)
safe_resume_stem = sanitize_stem(inputs.resume.stem)
safe_job_stem = sanitize_stem(job_file.stem)
output_filename = f"{safe_resume_stem}-for-{safe_job_stem}.md"
with TemporaryDirectory() as tmp_dir_str:
tmp_dir = Path(tmp_dir_str)
prompt_path = tmp_dir / "prompt.md"
prompt_path.write_text(prompt_text, encoding="utf-8")
output_path = tmp_dir / "codex_output.md"
run_codex(prompt_path, output_path)
generated_output = out_dir / output_filename
counter = 1
while generated_output.exists():
generated_output = out_dir / f"{safe_resume_stem}-for-{safe_job_stem}_{counter}.md"
counter += 1
shutil.move(str(output_path), generated_output)
logging.info("Generated customized resume at %s", generated_output)
prompt_archive = out_dir / f"prompt-{safe_job_stem}.md"
prompt_archive.write_text(prompt_text, encoding="utf-8")
processed_target = move_with_unique_target(job_file, processed_dir)
logging.info(
"Archived job description %s to %s",
job_file.name,
processed_target,
)
def move_job_to_failed(job_file: Path) -> None:
"""Move the job description into the failed directory."""
if not job_file.exists():
return
failed_target = move_with_unique_target(job_file, FAILED)
logging.info(
"Moved job description %s into failed directory at %s",
job_file.name,
failed_target,
)
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
try:
ensure_environment()
except FatalConfigurationError as exc:
logging.error("Fatal configuration error: %s", exc)
raise SystemExit(2) from exc
logging.info("Resume customizer watcher started")
while True:
job_files = sorted(INBOX.glob("*.md"))
try:
job_file = ensure_single_job(job_files)
except FatalConfigurationError as exc:
logging.error("Fatal configuration error: %s", exc)
raise SystemExit(2) from exc
if job_file is None:
time.sleep(POLL_INTERVAL_SECONDS)
continue
logging.info("Processing job description %s", job_file.name)
try:
process_job(job_file)
except FatalConfigurationError as exc:
logging.error("Fatal configuration error: %s", exc)
move_job_to_failed(job_file)
raise SystemExit(2) from exc
except subprocess.CalledProcessError as exc:
logging.error("Codex CLI failed with non-zero exit status: %s", exc)
move_job_to_failed(job_file)
except Exception as exc: # noqa: BLE001
logging.exception("Unexpected error while processing %s: %s", job_file.name, exc)
move_job_to_failed(job_file)
time.sleep(POLL_INTERVAL_SECONDS)
if __name__ == "__main__":
main()

41
input/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Input Pipeline Overview
The input side of ResumeCustomizer prepares job-specific Markdown resumes by stitching together the base resume, a job-description Markdown file, and the shared instruction prompt, then invoking the Codex CLI inside a containerized watcher.
## Workflow Recap
1. Ensure `input/resume/` contains exactly one Markdown resume.
2. Drop a single job-description Markdown into `input/ForCustomizing/inbox/`.
3. Start the watcher stack (`input/Docker/run-input-processor.sh up -d`).
4. The watcher combines the resume, job description, and the resolved instruction prompt (defaulting to `templates/ResumeCustomizerPrompt.md.example`) into a prompt, runs the Codex CLI, and writes the generated resume to `ForCustomizing/outbox/YYYY/MM/DD/HHMM/`.
5. Successful runs archive the job description under `ForCustomizing/processed/` and copy the prompt used into the same outbox folder. Failures move the job description into `ForCustomizing/failed/` for review.
The human operator reviews the Codex output Markdown, makes any edits, and then manually hands it off to the output pipeline for document rendering.
## Container Stack
The watcher lives in `input/Docker/`:
- `Dockerfile` builds a Node/Python base image, installs gosu, and prepares a non-root `codex` user.
- `watch_and_customize.py` polls the inbox, validates preconditions, resolves the prompt template (`ResumeCustomizerPrompt.md` or its `.example` fallback), constructs prompts, runs Codex, and routes files.
- `entrypoint.sh` maps the container user to the callers UID/GID and ensures shared directories exist.
- `run-input-processor.sh` wrapper around `docker compose` that mounts your `~/.codex` directory and forwards CLI arguments.
- `docker-compose.yml` defines the container, volumes, environment variables, and restart policy (`no` so fatal errors halt the stack).
### Templates
- `templates/ResumeCustomizerPrompt.md.example` ships with default Codex instructions.
- To customize, copy the `.example` file to `templates/ResumeCustomizerPrompt.md` (the `.gitignore` keeps your local overrides out of version control).
### Key Environment Variables
- `CODEX_COMMAND_TEMPLATE` format string for invoking Codex (placeholders: `{prompt}`, `{output}`).
- `POLL_INTERVAL_SECONDS` watch loop delay (defaults to 5).
- `CODEX_TIMEOUT_SECONDS` wall-clock timeout for each Codex call (defaults to 600).
- `CODEX_CONFIG_DIR` host path to mount as `/home/codex/.codex` (defaults to `${HOME}/.codex` via the wrapper).
### Prerequisites
- Docker Engine with the Compose plugin (`docker compose`) or the standalone `docker-compose` binary.
- A working Codex CLI and credentials in `~/.codex`. The Docker build attempts `npm install --location=global codex-cli`; override or update as needed if packages change.
## Failure Modes
- **Multiple resumes or job descriptions** watcher exits immediately with a fatal configuration error. Fix the files and restart.
- **Codex CLI missing or failing** job description moves to `ForCustomizing/failed/`; inspect container logs, resolve, and requeue.
- **Timeouts** treat as failures; adjust `CODEX_TIMEOUT_SECONDS` if Codex regularly needs more time.
For day-to-day operating guidelines, see `input/AGENTS.md`.

View File

@@ -0,0 +1,46 @@
# Input Pipeline Clarifications
Please add answers inline after each question.
1. **Codex execution:** Should the input-side container actually run the `codex` CLI to generate the customized resume, or simply assemble the combined prompt file for manual execution? If automated, what exact command, flags, and expected output path should it use?
Yes please invoke the codex cli .
I want it to run non interactively and in an automated fashion. I am open to you building a cli / process todo that . I am sure we will need to iterate a bit.
It should output the returned markdown file to ForCustomizing/outbox/YYYY/MM/DD/HHMM (using local system time) and move the .md file from ForCustomizing/inbox to ForCustomizing/processed/YYYY/MMD/DD/HHMM (if succesful)
On failure it should put the .md file into failed
2. **Resume selection:** How should the watcher choose the base resume when multiple Markdown files live in `input/resume/`? Will there always be exactly one current resume, or should job-description filenames reference a specific variant?
Only one .md file should exist. Any other condition is a fatal error and the input stack should halt and inform the user of multiple input resumes.
Same for if multiple .md files ever exist in ForCustomizing/inbox .
3. **Post-processing job descriptions:** After a job description is handled, where should its source Markdown move? Should successes land in `input/ForCustomizing/processed/` and failures in `input/ForCustomizing/failed/`, mirroring the output pipeline?
Yes exactly
4. **Parallel jobs:** Do you want the watcher to enforce “exactly one job file at a time” in `input/ForCustomizing/inbox`, or process multiple sequentially if several appear?
Only one file and one job at a time. Anything else is a fatal error and should halt the line.
5. **Customized resume location:** Where should the generated Codex output (or combined prompt) be saved for human review—timestamped folders under `input/ForCustomizing/outbox/`, a flat directory, or another structure?
See my answer to question 1.
To recap outbox/YYYY/MM/DD/HHMM (using local system time)
6. **Container environment:** Is there a preferred base image or existing Codex CLI installation to assume for the input container? When mounting `~/.codex`, should it be attached to `/root/.codex` or a different path the CLI expects?
Great question.
I guess a generic debian image that can npm install codex however the official codex docs say to install it? It will need to be able to update the cli as well, as the cli is frequently updated. I'm fine with this being a "fat" container.
I do not want any root user anywhere. Look at the output stack for how it has a little bash wrapper script to handle uid/gid mapping?
I guess create a dedicated non root user in the container to run the watcher and invoke codex? Open to suggestions here?

2
input/templates/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
ResumeCustomizerPrompt.md
!ResumeCustomizerPrompt.md.example

View File

@@ -1,3 +1,7 @@
# Default instructions for Codex-driven resume customization. Copy this file to
# `ResumeCustomizerPrompt.md` (without the `.example` suffix) and edit it to
# override the defaults for your workflow.
I need you to customize my resume for this job description: I need you to customize my resume for this job description:
. “Act like a recruiter in information technology. Whats missing from this résumé that would stop you from reaching out?” . “Act like a recruiter in information technology. Whats missing from this résumé that would stop you from reaching out?”
@@ -7,6 +11,3 @@ I need you to customize my resume for this job description:
. “Add ATS keywords from this job post without sounding robotic.” . “Add ATS keywords from this job post without sounding robotic.”
. “Format this résumé so its easy to scan, clean to read, and works on any system.” . “Format this résumé so its easy to scan, clean to read, and works on any system.”
. “Write a short, punchy message I can DM a hiring manager. No desperation, just value.” . “Write a short, punchy message I can DM a hiring manager. No desperation, just value.”