{"owner":"openai","repo":"symphony","hasSkills":true,"totalSkillsCount":10,"totalTokensCount":13321,"categories":["anthropic-skill","root-instruction","codex-rule"],"hasMcp":false,"mcpConfig":null,"found":[".codex/skills/push/SKILL.md",".codex/skills/debug/SKILL.md",".codex/skills/release/SKILL.md",".codex/skills/commit/SKILL.md","elixir/AGENTS.md",".codex/skills/linear/SKILL.md",".codex/skills/pull/SKILL.md",".codex/skills/land/SKILL.md",".codex/worktree_init.sh",".codex/skills/land/land_watch.py"],"skills":{".codex/skills/push/SKILL.md":"---\nname: push\ndescription:\n  Push current branch changes to origin and create or update the corresponding\n  pull request; use when asked to push, publish updates, or create pull request.\n---\n\n# Push\n\n## Prerequisites\n\n- `gh` CLI is installed and available in `PATH`.\n- `gh auth status` succeeds for GitHub operations in this repo.\n\n## Goals\n\n- Push current branch changes to `origin` safely.\n- Create a PR if none exists for the branch, otherwise update the existing PR.\n- Keep branch history clean when remote has moved.\n\n## Related Skills\n\n- `pull`: use this when push is rejected or sync is not clean (non-fast-forward,\n  merge conflict risk, or stale branch).\n\n## Steps\n\n1. Identify current branch and confirm remote state.\n2. Run local validation (`make -C elixir all`) before pushing.\n3. Push branch to `origin` with upstream tracking if needed, using whatever\n   remote URL is already configured.\n4. If push is not clean/rejected:\n   - If the failure is a non-fast-forward or sync problem, run the `pull`\n     skill to merge `origin/main`, resolve conflicts, and rerun validation.\n   - Push again; use `--force-with-lease` only when history was rewritten.\n   - If the failure is due to auth, permissions, or workflow restrictions on\n     the configured remote, stop and surface the exact error instead of\n     rewriting remotes or switching protocols as a workaround.\n\n5. Ensure a PR exists for the branch:\n   - If no PR exists, create one.\n   - If a PR exists and is open, update it.\n   - If branch is tied to a closed/merged PR, create a new branch + PR.\n   - Write a proper PR title that clearly describes the change outcome\n   - For branch updates, explicitly reconsider whether current PR title still\n     matches the latest scope; update it if it no longer does.\n6. Write/update PR body explicitly using `.github/pull_request_template.md`:\n   - Fill every section with concrete content for this change.\n   - Replace all placeholder comments (`<!-- ... -->`).\n   - Keep bullets/checkboxes where template expects them.\n   - If PR already exists, refresh body content so it reflects the total PR\n     scope (all intended work on the branch), not just the newest commits,\n     including newly added work, removed work, or changed approach.\n   - Do not reuse stale description text from earlier iterations.\n7. Validate PR body with `mix pr_body.check` and fix all reported issues.\n8. Reply with the PR URL from `gh pr view`.\n\n## Commands\n\n```sh\n# Identify branch\nbranch=$(git branch --show-current)\n\n# Minimal validation gate\nmake -C elixir all\n\n# Initial push: respect the current origin remote.\ngit push -u origin HEAD\n\n# If that failed because the remote moved, use the pull skill. After\n# pull-skill resolution and re-validation, retry the normal push:\ngit push -u origin HEAD\n\n# If the configured remote rejects the push for auth, permissions, or workflow\n# restrictions, stop and surface the exact error.\n\n# Only if history was rewritten locally:\ngit push --force-with-lease origin HEAD\n\n# Ensure a PR exists (create only if missing)\npr_state=$(gh pr view --json state -q .state 2>/dev/null || true)\nif [ \"$pr_state\" = \"MERGED\" ] || [ \"$pr_state\" = \"CLOSED\" ]; then\n  echo \"Current branch is tied to a closed PR; create a new branch + PR.\" >&2\n  exit 1\nfi\n\n# Write a clear, human-friendly title that summarizes the shipped change.\npr_title=\"<clear PR title written for this change>\"\nif [ -z \"$pr_state\" ]; then\n  gh pr create --title \"$pr_title\"\nelse\n  # Reconsider title on every branch update; edit if scope shifted.\n  gh pr edit --title \"$pr_title\"\nfi\n\n# Write/edit PR body to match .github/pull_request_template.md before validation.\n# Example workflow:\n# 1) open the template and draft body content for this PR\n# 2) gh pr edit --body-file /tmp/pr_body.md\n# 3) for branch updates, re-check that title/body still match current diff\n\ntmp_pr_body=$(mktemp)\ngh pr view --json body -q .body > \"$tmp_pr_body\"\n(cd elixir && mix pr_body.check --file \"$tmp_pr_body\")\nrm -f \"$tmp_pr_body\"\n\n# Show PR URL for the reply\ngh pr view --json url -q .url\n```\n\n## Notes\n\n- Do not use `--force`; only use `--force-with-lease` as the last resort.\n- Distinguish sync problems from remote auth/permission problems:\n  - Use the `pull` skill for non-fast-forward or stale-branch issues.\n  - Surface auth, permissions, or workflow restrictions directly instead of\n    changing remotes or protocols.\n",".codex/skills/debug/SKILL.md":"---\nname: debug\ndescription:\n  Investigate stuck runs and execution failures by tracing Symphony and Codex\n  logs with issue/session identifiers; use when runs stall, retry repeatedly, or\n  fail unexpectedly.\n---\n\n# Debug\n\n## Goals\n\n- Find why a run is stuck, retrying, or failing.\n- Correlate Linear issue identity to a Codex session quickly.\n- Read the right logs in the right order to isolate root cause.\n\n## Log Sources\n\n- Primary runtime log: `log/symphony.log`\n  - Default comes from `SymphonyElixir.LogFile` (`log/symphony.log`).\n  - Includes orchestrator, agent runner, and Codex app-server lifecycle logs.\n- Rotated runtime logs: `log/symphony.log*`\n  - Check these when the relevant run is older.\n\n## Correlation Keys\n\n- `issue_identifier`: human ticket key (example: `MT-625`)\n- `issue_id`: Linear UUID (stable internal ID)\n- `session_id`: Codex thread-turn pair (`<thread_id>-<turn_id>`)\n\n`elixir/docs/logging.md` requires these fields for issue/session lifecycle logs. Use\nthem as your join keys during debugging.\n\n## Quick Triage (Stuck Run)\n\n1. Confirm scheduler/worker symptoms for the ticket.\n2. Find recent lines for the ticket (`issue_identifier` first).\n3. Extract `session_id` from matching lines.\n4. Trace that `session_id` across start, stream, completion/failure, and stall\n   handling logs.\n5. Decide class of failure: timeout/stall, app-server startup failure, turn\n   failure, or orchestrator retry loop.\n\n## Commands\n\n```bash\n# 1) Narrow by ticket key (fastest entry point)\nrg -n \"issue_identifier=MT-625\" log/symphony.log*\n\n# 2) If needed, narrow by Linear UUID\nrg -n \"issue_id=<linear-uuid>\" log/symphony.log*\n\n# 3) Pull session IDs seen for that ticket\nrg -o \"session_id=[^ ;]+\" log/symphony.log* | sort -u\n\n# 4) Trace one session end-to-end\nrg -n \"session_id=<thread>-<turn>\" log/symphony.log*\n\n# 5) Focus on stuck/retry signals\nrg -n \"Issue stalled|scheduling retry|turn_timeout|turn_failed|Codex session failed|Codex session ended with error\" log/symphony.log*\n```\n\n## Investigation Flow\n\n1. Locate the ticket slice:\n    - Search by `issue_identifier=<KEY>`.\n    - If noise is high, add `issue_id=<UUID>`.\n2. Establish timeline:\n    - Identify first `Codex session started ... session_id=...`.\n    - Follow with `Codex session completed`, `ended with error`, or worker exit\n      lines.\n3. Classify the problem:\n    - Stall loop: `Issue stalled ... restarting with backoff`.\n    - App-server startup: `Codex session failed ...`.\n    - Turn execution failure: `turn_failed`, `turn_cancelled`, `turn_timeout`, or\n      `ended with error`.\n    - Worker crash: `Agent task exited ... reason=...`.\n4. Validate scope:\n    - Check whether failures are isolated to one issue/session or repeating across\n      multiple tickets.\n5. Capture evidence:\n    - Save key log lines with timestamps, `issue_identifier`, `issue_id`, and\n      `session_id`.\n    - Record probable root cause and the exact failing stage.\n\n## Reading Codex Session Logs\n\nIn Symphony, Codex session diagnostics are emitted into `log/symphony.log` and\nkeyed by `session_id`. Read them as a lifecycle:\n\n1. `Codex session started ... session_id=...`\n2. Session stream/lifecycle events for the same `session_id`\n3. Terminal event:\n    - `Codex session completed ...`, or\n    - `Codex session ended with error ...`, or\n    - `Issue stalled ... restarting with backoff`\n\nFor one specific session investigation, keep the trace narrow:\n\n1. Capture one `session_id` for the ticket.\n2. Build a timestamped slice for only that session:\n    - `rg -n \"session_id=<thread>-<turn>\" log/symphony.log*`\n3. Mark the exact failing stage:\n    - Startup failure before stream events (`Codex session failed ...`).\n    - Turn/runtime failure after stream events (`turn_*` / `ended with error`).\n    - Stall recovery (`Issue stalled ... restarting with backoff`).\n4. Pair findings with `issue_identifier` and `issue_id` from nearby lines to\n   confirm you are not mixing concurrent retries.\n\nAlways pair session findings with `issue_identifier`/`issue_id` to avoid mixing\nconcurrent runs.\n\n## Notes\n\n- Prefer `rg` over `grep` for speed on large logs.\n- Check rotated logs (`log/symphony.log*`) before concluding data is missing.\n- If required context fields are missing in new log statements, align with\n  `elixir/docs/logging.md` conventions.\n",".codex/skills/release/SKILL.md":"---\nname: release\ndescription: Cut a Symphony release by bumping the committed version, landing it, tagging the merged commit, and verifying the Burrito release workflow. Use when asked to release, tag, or retag Symphony.\n---\n\n# Release\n\n1. Start from fresh `origin/main` in a clean worktree. Never disturb unrelated\n   local changes.\n2. Pick the requested version. If none is given, use the next patch after the\n   latest `vX.Y.Z` tag.\n3. Update `elixir/mix.exs` so `version: \"X.Y.Z\"` matches the intended tag.\n   Search the old version and change other files only when they are true\n   release-version sources, not examples.\n4. Run `make -C elixir all`, then commit, push, create a PR, and land it.\n5. Fetch the merged `main` commit. Verify its `mix.exs` version, then create an\n   annotated tag on that exact commit:\n\n   ```sh\n   git tag -a vX.Y.Z <merged-commit> -m \"Symphony vX.Y.Z\"\n   git push origin vX.Y.Z\n   ```\n\n6. Watch `burrito-release` until it finishes. Verify the build, smoke, and\n   release jobs pass and that the GitHub release has all expected assets.\n\nDo not tag an uncommitted or unmerged revision. Do not move a published tag\nwithout explicit user approval.\n",".codex/skills/commit/SKILL.md":"---\nname: commit\ndescription:\n  Create a well-formed git commit from current changes using session history for\n  rationale and summary; use when asked to commit, prepare a commit message, or\n  finalize staged work.\n---\n\n# Commit\n\n## Goals\n\n- Produce a commit that reflects the actual code changes and the session\n  context.\n- Follow common git conventions (type prefix, short subject, wrapped body).\n- Include both summary and rationale in the body.\n\n## Inputs\n\n- Codex session history for intent and rationale.\n- `git status`, `git diff`, and `git diff --staged` for actual changes.\n- Repo-specific commit conventions if documented.\n\n## Steps\n\n1. Read session history to identify scope, intent, and rationale.\n2. Inspect the working tree and staged changes (`git status`, `git diff`,\n   `git diff --staged`).\n3. Stage intended changes, including new files (`git add -A`) after confirming\n   scope.\n4. Sanity-check newly added files; if anything looks random or likely ignored\n   (build artifacts, logs, temp files), flag it to the user before committing.\n5. If staging is incomplete or includes unrelated files, fix the index or ask\n   for confirmation.\n6. Choose a conventional type and optional scope that match the change (e.g.,\n   `feat(scope): ...`, `fix(scope): ...`, `refactor(scope): ...`).\n7. Write a subject line in imperative mood, <= 72 characters, no trailing\n   period.\n8. Write a body that includes:\n   - Summary of key changes (what changed).\n   - Rationale and trade-offs (why it changed).\n   - Tests or validation run (or explicit note if not run).\n9. Append a `Co-authored-by` trailer for Codex using `Codex <codex@openai.com>`\n   unless the user explicitly requests a different identity.\n10. Wrap body lines at 72 characters.\n11. Create the commit message with a here-doc or temp file and use\n    `git commit -F <file>` so newlines are literal (avoid `-m` with `\\n`).\n12. Commit only when the message matches the staged changes: if the staged diff\n    includes unrelated files or the message describes work that isn't staged,\n    fix the index or revise the message before committing.\n\n## Output\n\n- A single commit created with `git commit` whose message reflects the session.\n\n## Template\n\nType and scope are examples only; adjust to fit the repo and changes.\n\n```\n<type>(<scope>): <short summary>\n\nSummary:\n- <what changed>\n- <what changed>\n\nRationale:\n- <why>\n- <why>\n\nTests:\n- <command or \"not run (reason)\">\n\nCo-authored-by: Codex <codex@openai.com>\n```\n","elixir/AGENTS.md":"# Symphony Elixir\n\nThis directory contains the Elixir agent orchestration service that polls Linear, creates per-issue workspaces, and runs Codex in app-server mode.\n\n## Environment\n\n- Elixir: `1.19.x` (OTP 28) via `mise`.\n- Install deps: `mix setup`.\n- Main quality gate: `make all` (format check, lint, coverage, dialyzer).\n\n\n## Codebase-Specific Conventions\n\n- Runtime config is loaded from `WORKFLOW.md` front matter via `SymphonyElixir.Workflow` and `SymphonyElixir.Config`.\n- Keep the implementation aligned with [`../SPEC.md`](../SPEC.md) where practical.\n  - The implementation may be a superset of the spec.\n  - The implementation must not conflict with the spec.\n  - If implementation changes meaningfully alter the intended behavior, update the spec in the same\n    change where practical so the spec stays current.\n- Prefer adding config access through `SymphonyElixir.Config` instead of ad-hoc env reads.\n- Workspace safety is critical:\n  - Never run Codex turn cwd in source repo.\n  - Workspaces must stay under configured workspace root.\n- Orchestrator behavior is stateful and concurrency-sensitive; preserve retry, reconciliation, and cleanup semantics.\n- Simplicity is a project constraint: prefer the smallest coherent design with one clear owner and\n  invariant. Push back on extra abstractions, duplicated policy, and speculative flexibility.\n- For stateful changes, check startup, reload, restart, and failure recovery together before editing.\n- Follow `docs/logging.md` for logging conventions and required issue/session context fields.\n\n## Tests and Validation\n\nRun targeted tests while iterating, then run full gates before handoff.\n\n- Prefer narrow tests that exercise real OTP processes and observable behavior over mock-only or\n  broad end-to-end coverage; prove health with a synchronous call or stable effect, not only a PID.\n- For non-trivial changes, use an adversarial review early to challenge complexity and try to break\n  adjacent lifecycle paths; a reproducible failure blocks landing even if other reviews are clean.\n- If tests need repeated global restarts or bespoke cleanup, first fix the shared harness or\n  ownership boundary.\n\n```bash\nmake all\n```\n\n## Required Rules\n\n- Public functions (`def`) in `lib/` must have an adjacent `@spec`.\n- `defp` specs are optional.\n- `@impl` callback implementations are exempt from local `@spec` requirement.\n- Evaluate proposed directions instead of agreeing reflexively; surface simpler designs and material\n  trade-offs early.\n- Keep changes narrowly scoped; avoid unrelated refactors.\n- Follow existing module/style patterns in `lib/symphony_elixir/*`.\n\nValidation command:\n\n```bash\nmix specs.check\n```\n\n## PR Requirements\n\n- PR body must follow `../.github/pull_request_template.md` exactly.\n- Validate PR body locally when needed:\n\n```bash\nmix pr_body.check --file /path/to/pr_body.md\n```\n\n## Docs Update Policy\n\nIf behavior/config changes, update docs in the same PR:\n\n- `../README.md` for project concept and goals.\n- `README.md` for Elixir implementation and run instructions.\n- `WORKFLOW.md` for workflow/config contract changes.\n",".codex/skills/linear/SKILL.md":"---\nname: linear\ndescription: |\n  Use Symphony's `linear_graphql` client tool for raw Linear GraphQL\n  operations such as comment editing and upload flows.\n---\n\n# Linear GraphQL\n\nUse this skill for raw Linear GraphQL work during Symphony app-server sessions.\n\n## Primary tool\n\nUse the `linear_graphql` client tool exposed by Symphony's app-server session.\nIt reuses Symphony's configured Linear auth for the session.\n\nTool input:\n\n```json\n{\n  \"query\": \"query or mutation document\",\n  \"variables\": {\n    \"optional\": \"graphql variables object\"\n  }\n}\n```\n\nTool behavior:\n\n- Send one GraphQL operation per tool call.\n- Treat a top-level `errors` array as a failed GraphQL operation even if the\n  tool call itself completed.\n- Keep queries/mutations narrowly scoped; ask only for the fields you need.\n\n## Discovering unfamiliar operations\n\nWhen you need an unfamiliar mutation, input type, or object field, use targeted\nintrospection through `linear_graphql`.\n\nList mutation names:\n\n```graphql\nquery ListMutations {\n  __type(name: \"Mutation\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\nInspect a specific input object:\n\n```graphql\nquery CommentCreateInputShape {\n  __type(name: \"CommentCreateInput\") {\n    inputFields {\n      name\n      type {\n        kind\n        name\n        ofType {\n          kind\n          name\n        }\n      }\n    }\n  }\n}\n```\n\n## Common workflows\n\n### Query an issue by key, identifier, or id\n\nUse these progressively:\n\n- Start with `issue(id: $key)` when you have a ticket key such as `MT-686`.\n- Fall back to `issues(filter: ...)` when you need identifier search semantics.\n- Once you have the internal issue id, prefer `issue(id: $id)` for narrower reads.\n\nLookup by issue key:\n\n```graphql\nquery IssueByKey($key: String!) {\n  issue(id: $key) {\n    id\n    identifier\n    title\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    branchName\n    url\n    description\n    updatedAt\n    links {\n      nodes {\n        id\n        url\n        title\n      }\n    }\n  }\n}\n```\n\nLookup by identifier filter:\n\n```graphql\nquery IssueByIdentifier($identifier: String!) {\n  issues(filter: { identifier: { eq: $identifier } }, first: 1) {\n    nodes {\n      id\n      identifier\n      title\n      state {\n        id\n        name\n        type\n      }\n      project {\n        id\n        name\n      }\n      branchName\n      url\n      description\n      updatedAt\n    }\n  }\n}\n```\n\nResolve a key to an internal id:\n\n```graphql\nquery IssueByIdOrKey($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n  }\n}\n```\n\nRead the issue once the internal id is known:\n\n```graphql\nquery IssueDetails($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n    url\n    description\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    attachments {\n      nodes {\n        id\n        title\n        url\n        sourceType\n      }\n    }\n  }\n}\n```\n\n### Query team workflow states for an issue\n\nUse this before changing issue state when you need the exact `stateId`:\n\n```graphql\nquery IssueTeamStates($id: String!) {\n  issue(id: $id) {\n    id\n    team {\n      id\n      key\n      name\n      states {\n        nodes {\n          id\n          name\n          type\n        }\n      }\n    }\n  }\n}\n```\n\n### Edit an existing comment\n\nUse `commentUpdate` through `linear_graphql`:\n\n```graphql\nmutation UpdateComment($id: String!, $body: String!) {\n  commentUpdate(id: $id, input: { body: $body }) {\n    success\n    comment {\n      id\n      body\n    }\n  }\n}\n```\n\n### Create a comment\n\nUse `commentCreate` through `linear_graphql`:\n\n```graphql\nmutation CreateComment($issueId: String!, $body: String!) {\n  commentCreate(input: { issueId: $issueId, body: $body }) {\n    success\n    comment {\n      id\n      url\n    }\n  }\n}\n```\n\n### Move an issue to a different state\n\nUse `issueUpdate` with the destination `stateId`:\n\n```graphql\nmutation MoveIssueToState($id: String!, $stateId: String!) {\n  issueUpdate(id: $id, input: { stateId: $stateId }) {\n    success\n    issue {\n      id\n      identifier\n      state {\n        id\n        name\n      }\n    }\n  }\n}\n```\n\n### Attach a GitHub PR to an issue\n\nUse the GitHub-specific attachment mutation when linking a PR:\n\n```graphql\nmutation AttachGitHubPR($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkGitHubPR(\n    issueId: $issueId\n    url: $url\n    title: $title\n    linkKind: links\n  ) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\nIf you only need a plain URL attachment and do not care about GitHub-specific\nlink metadata, use:\n\n```graphql\nmutation AttachURL($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkURL(issueId: $issueId, url: $url, title: $title) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\n### Introspection patterns used during schema discovery\n\nUse these when the exact field or mutation shape is unclear:\n\n```graphql\nquery QueryFields {\n  __type(name: \"Query\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\n```graphql\nquery IssueFieldArgs {\n  __type(name: \"Query\") {\n    fields {\n      name\n      args {\n        name\n        type {\n          kind\n          name\n          ofType {\n            kind\n            name\n            ofType {\n              kind\n              name\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n### Upload a video to a comment\n\nDo this in three steps:\n\n1. Call `linear_graphql` with `fileUpload` to get `uploadUrl`, `assetUrl`, and\n   any required upload headers.\n2. Upload the local file bytes to `uploadUrl` with `curl -X PUT` and the exact\n   headers returned by `fileUpload`.\n3. Call `linear_graphql` again with `commentCreate` (or `commentUpdate`) and\n   include the resulting `assetUrl` in the comment body.\n\nUseful mutations:\n\n```graphql\nmutation FileUpload(\n  $filename: String!\n  $contentType: String!\n  $size: Int!\n  $makePublic: Boolean\n) {\n  fileUpload(\n    filename: $filename\n    contentType: $contentType\n    size: $size\n    makePublic: $makePublic\n  ) {\n    success\n    uploadFile {\n      uploadUrl\n      assetUrl\n      headers {\n        key\n        value\n      }\n    }\n  }\n}\n```\n\n## Usage rules\n\n- Use `linear_graphql` for comment edits, uploads, and ad-hoc Linear API\n  queries.\n- Prefer the narrowest issue lookup that matches what you already know:\n  key -> identifier search -> internal id.\n- For state transitions, fetch team states first and use the exact `stateId`\n  instead of hardcoding names inside mutations.\n- Prefer `attachmentLinkGitHubPR` over a generic URL attachment when linking a\n  GitHub PR to a Linear issue.\n- Do not introduce new raw-token shell helpers for GraphQL access.\n- If you need shell work for uploads, only use it for signed upload URLs\n  returned by `fileUpload`; those URLs already carry the needed authorization.\n",".codex/skills/pull/SKILL.md":"---\nname: pull\ndescription:\n  Pull latest origin/main into the current local branch and resolve merge\n  conflicts (aka update-branch). Use when Codex needs to sync a feature branch\n  with origin, perform a merge-based update (not rebase), and guide conflict\n  resolution best practices.\n---\n\n# Pull\n\n## Workflow\n\n1. Verify git status is clean or commit/stash changes before merging.\n2. Ensure rerere is enabled locally:\n   - `git config rerere.enabled true`\n   - `git config rerere.autoupdate true`\n3. Confirm remotes and branches:\n   - Ensure the `origin` remote exists.\n   - Ensure the current branch is the one to receive the merge.\n4. Fetch latest refs:\n   - `git fetch origin`\n5. Sync the remote feature branch first:\n   - `git pull --ff-only origin $(git branch --show-current)`\n   - This pulls branch updates made remotely (for example, a GitHub auto-commit)\n     before merging `origin/main`.\n6. Merge in order:\n   - Prefer `git -c merge.conflictstyle=zdiff3 merge origin/main` for clearer\n     conflict context.\n7. If conflicts appear, resolve them (see conflict guidance below), then:\n   - `git add <files>`\n   - `git commit` (or `git merge --continue` if the merge is paused)\n8. Verify with project checks (follow repo policy in `AGENTS.md`).\n9. Summarize the merge:\n   - Call out the most challenging conflicts/files and how they were resolved.\n   - Note any assumptions or follow-ups.\n\n## Conflict Resolution Guidance (Best Practices)\n\n- Inspect context before editing:\n  - Use `git status` to list conflicted files.\n  - Use `git diff` or `git diff --merge` to see conflict hunks.\n  - Use `git diff :1:path/to/file :2:path/to/file` and\n    `git diff :1:path/to/file :3:path/to/file` to compare base vs ours/theirs\n    for a file-level view of intent.\n  - With `merge.conflictstyle=zdiff3`, conflict markers include:\n    - `<<<<<<<` ours, `|||||||` base, `=======` split, `>>>>>>>` theirs.\n    - Matching lines near the start/end are trimmed out of the conflict region,\n      so focus on the differing core.\n  - Summarize the intent of both changes, decide the semantically correct\n    outcome, then edit:\n    - State what each side is trying to achieve (bug fix, refactor, rename,\n      behavior change).\n    - Identify the shared goal, if any, and whether one side supersedes the\n      other.\n    - Decide the final behavior first; only then craft the code to match that\n      decision.\n    - Prefer preserving invariants, API contracts, and user-visible behavior\n      unless the conflict clearly indicates a deliberate change.\n  - Open files and understand intent on both sides before choosing a resolution.\n- Prefer minimal, intention-preserving edits:\n  - Keep behavior consistent with the branch’s purpose.\n  - Avoid accidental deletions or silent behavior changes.\n- Resolve one file at a time and rerun tests after each logical batch.\n- Use `ours/theirs` only when you are certain one side should win entirely.\n- For complex conflicts, search for related files or definitions to align with\n  the rest of the codebase.\n- For generated files, resolve non-generated conflicts first, then regenerate:\n  - Prefer resolving source files and handwritten logic before touching\n    generated artifacts.\n  - Run the CLI/tooling command that produced the generated file to recreate it\n    cleanly, then stage the regenerated output.\n- For import conflicts where intent is unclear, accept both sides first:\n  - Keep all candidate imports temporarily, finish the merge, then run lint/type\n    checks to remove unused or incorrect imports safely.\n- After resolving, ensure no conflict markers remain:\n  - `git diff --check`\n- When unsure, note assumptions and ask for confirmation before finalizing the\n  merge.\n\n## When To Ask The User (Keep To A Minimum)\n\nDo not ask for input unless there is no safe, reversible alternative. Prefer\nmaking a best-effort decision, documenting the rationale, and proceeding.\n\nAsk the user only when:\n\n- The correct resolution depends on product intent or behavior not inferable\n  from code, tests, or nearby documentation.\n- The conflict crosses a user-visible contract, API surface, or migration where\n  choosing incorrectly could break external consumers.\n- A conflict requires selecting between two mutually exclusive designs with\n  equivalent technical merit and no clear local signal.\n- The merge introduces data loss, schema changes, or irreversible side effects\n  without an obvious safe default.\n- The branch is not the intended target, or the remote/branch names do not exist\n  and cannot be determined locally.\n\nOtherwise, proceed with the merge, explain the decision briefly in notes, and\nleave a clear, reviewable commit history.\n",".codex/skills/land/SKILL.md":"---\nname: land\ndescription:\n  Land a PR by monitoring conflicts, resolving them, waiting for checks, and\n  squash-merging when green; use when asked to land, merge, or shepherd a PR to\n  completion.\n---\n\n# Land\n\n## Goals\n\n- Ensure the PR is conflict-free with main.\n- Keep CI green and fix failures when they occur.\n- Squash-merge the PR once checks pass.\n- Do not yield to the user until the PR is merged; keep the watcher loop running\n  unless blocked.\n- No need to delete remote branches after merge; the repo auto-deletes head\n  branches.\n\n## Preconditions\n\n- `gh` CLI is authenticated.\n- You are on the PR branch with a clean working tree.\n\n## Steps\n\n1. Locate the PR for the current branch.\n2. Confirm the full gauntlet is green locally before any push.\n3. If the working tree has uncommitted changes, commit with the `commit` skill\n   and push with the `push` skill before proceeding.\n4. Check mergeability and conflicts against main.\n5. If conflicts exist, use the `pull` skill to fetch/merge `origin/main` and\n   resolve conflicts, then use the `push` skill to publish the updated branch.\n6. Ensure Codex review comments (if present) are acknowledged and any required\n   fixes are handled before merging.\n7. Watch checks until complete.\n8. If checks fail, pull logs, fix the issue, commit with the `commit` skill,\n   push with the `push` skill, and re-run checks.\n9. When all checks are green and review feedback is addressed, squash-merge and\n   delete the branch using the PR title/body for the merge subject/body.\n10. **Context guard:** Before implementing review feedback, confirm it does not\n    conflict with the user’s stated intent or task context. If it conflicts,\n    respond inline with a justification and ask the user before changing code.\n11. **Pushback template:** When disagreeing, reply inline with: acknowledge +\n    rationale + offer alternative.\n12. **Ambiguity gate:** When ambiguity blocks progress, use the clarification\n    flow (assign PR to current GH user, mention them, wait for response). Do not\n    implement until ambiguity is resolved.\n    - If you are confident you know better than the reviewer, you may proceed\n      without asking the user, but reply inline with your rationale.\n13. **Per-comment mode:** For each review comment, choose one of: accept,\n    clarify, or push back. Reply inline (or in the issue thread for Codex\n    reviews) stating the mode before changing code.\n14. **Reply before change:** Always respond with intended action before pushing\n    code changes (inline for review comments, issue thread for Codex reviews).\n\n## Commands\n\n```\n# Ensure branch and PR context\nbranch=$(git branch --show-current)\npr_number=$(gh pr view --json number -q .number)\npr_title=$(gh pr view --json title -q .title)\npr_body=$(gh pr view --json body -q .body)\n\n# Check mergeability and conflicts\nmergeable=$(gh pr view --json mergeable -q .mergeable)\n\nif [ \"$mergeable\" = \"CONFLICTING\" ]; then\n  # Run the `pull` skill to handle fetch + merge + conflict resolution.\n  # Then run the `push` skill to publish the updated branch.\nfi\n\n# Preferred: use the Async Watch Helper below. The manual loop is a fallback\n# when Python cannot run or the helper script is unavailable.\n# Wait for review feedback: Codex reviews arrive as issue comments that start\n# with \"## Codex Review — <persona>\". Treat them like reviewer feedback: reply\n# with a `[codex]` issue comment acknowledging the findings and whether you're\n# addressing or deferring them.\nwhile true; do\n  gh api repos/{owner}/{repo}/issues/\"$pr_number\"/comments \\\n    --jq '.[] | select(.body | startswith(\"## Codex Review\")) | .id' | rg -q '.' \\\n    && break\n  sleep 10\ndone\n\n# Watch checks\nif ! gh pr checks --watch; then\n  gh pr checks\n  # Identify failing run and inspect logs\n  # gh run list --branch \"$branch\"\n  # gh run view <run-id> --log\n  exit 1\nfi\n\n# Squash-merge (remote branches auto-delete on merge in this repo)\ngh pr merge --squash --subject \"$pr_title\" --body \"$pr_body\"\n```\n\n## Async Watch Helper\n\nPreferred: use the asyncio watcher to monitor review comments, CI, and head\nupdates in parallel:\n\n```\npython3 .codex/skills/land/land_watch.py\n```\n\nExit codes:\n\n- 2: Review comments detected (address feedback)\n- 3: CI checks failed\n- 4: PR head updated (autofix commit detected)\n\n## Failure Handling\n\n- If checks fail, pull details with `gh pr checks` and `gh run view --log`, then\n  fix locally, commit with the `commit` skill, push with the `push` skill, and\n  re-run the watch.\n- Use judgment to identify flaky failures. If a failure is a flake (e.g., a\n  timeout on only one platform), you may proceed without fixing it.\n- If CI pushes an auto-fix commit (authored by GitHub Actions), it does not\n  trigger a fresh CI run. Detect the updated PR head, pull locally, merge\n  `origin/main` if needed, add a real author commit, and force-push to retrigger\n  CI, then restart the checks loop.\n- If all jobs fail with corrupted pnpm lockfile errors on the merge commit, the\n  remediation is to fetch latest `origin/main`, merge, force-push, and rerun CI.\n- If mergeability is `UNKNOWN`, wait and re-check.\n- Do not merge while review comments (human or Codex review) are outstanding.\n- Codex review jobs retry on failure and are non-blocking; use the presence of\n  `## Codex Review — <persona>` issue comments (not job status) as the signal\n  that review feedback is available.\n- Do not enable auto-merge; this repo has no required checks so auto-merge can\n  skip tests.\n- If the remote PR branch advanced due to your own prior force-push or merge,\n  avoid redundant merges; re-run the formatter locally if needed and\n  `git push --force-with-lease`.\n\n## Review Handling\n\n- Codex reviews now arrive as issue comments posted by GitHub Actions. They\n  start with `## Codex Review — <persona>` and include the reviewer’s\n  methodology + guardrails used. Treat these as feedback that must be\n  acknowledged before merge.\n- Human review comments are blocking and must be addressed (responded to and\n  resolved) before requesting a new review or merging.\n- If multiple reviewers comment in the same thread, respond to each comment\n  (batching is fine) before closing the thread.\n- Fetch review comments via `gh api` and reply with a prefixed comment.\n- Use review comment endpoints (not issue comments) to find inline feedback:\n  - List PR review comments:\n    ```\n    gh api repos/{owner}/{repo}/pulls/<pr_number>/comments\n    ```\n  - PR issue comments (top-level discussion):\n    ```\n    gh api repos/{owner}/{repo}/issues/<pr_number>/comments\n    ```\n  - Reply to a specific review comment:\n    ```\n    gh api -X POST /repos/{owner}/{repo}/pulls/<pr_number>/comments \\\n      -f body='[codex] <response>' -F in_reply_to=<comment_id>\n    ```\n- `in_reply_to` must be the numeric review comment id (e.g., `2710521800`), not\n  the GraphQL node id (e.g., `PRRC_...`), and the endpoint must include the PR\n  number (`/pulls/<pr_number>/comments`).\n- If GraphQL review reply mutation is forbidden, use REST.\n- A 404 on reply typically means the wrong endpoint (missing PR number) or\n  insufficient scope; verify by listing comments first.\n- All GitHub comments generated by this agent must be prefixed with `[codex]`.\n- For Codex review issue comments, reply in the issue thread (not a review\n  thread) with `[codex]` and state whether you will address the feedback now or\n  defer it (include rationale).\n- If feedback requires changes:\n  - For inline review comments (human), reply with intended fixes\n    (`[codex] ...`) **as an inline reply to the original review comment** using\n    the review comment endpoint and `in_reply_to` (do not use issue comments for\n    this).\n  - Implement fixes, commit, push.\n  - Reply with the fix details and commit sha (`[codex] ...`) in the same place\n    you acknowledged the feedback (issue comment for Codex reviews, inline reply\n    for review comments).\n  - The land watcher treats Codex review issue comments as unresolved until a\n    newer `[codex]` issue comment is posted acknowledging the findings.\n- Only request a new Codex review when you need a rerun (e.g., after new\n  commits). Do not request one without changes since the last review.\n  - Before requesting a new Codex review, re-run the land watcher and ensure\n    there are zero outstanding review comments (all have `[codex]` inline\n    replies).\n  - After pushing new commits, the Codex review workflow will rerun on PR\n    synchronization (or you can re-run the workflow manually). Post a concise\n    root-level summary comment so reviewers have the latest delta:\n    ```\n    [codex] Changes since last review:\n    - <short bullets of deltas>\n    Commits: <sha>, <sha>\n    Tests: <commands run>\n    ```\n  - Only request a new review if there is at least one new commit since the\n    previous request.\n  - Wait for the next Codex review comment before merging.\n\n## Scope + PR Metadata\n\n- The PR title and description should reflect the full scope of the change, not\n  just the most recent fix.\n- If review feedback expands scope, decide whether to include it now or defer\n  it. You can accept, defer, or decline feedback. If deferring or declining,\n  call it out in the root-level `[codex]` update with a brief reason (e.g.,\n  out-of-scope, conflicts with intent, unnecessary).\n- Correctness issues raised in review comments should be addressed. If you plan\n  to defer or decline a correctness concern, validate first and explain why the\n  concern does not apply.\n- Classify each review comment as one of: correctness, design, style,\n  clarification, scope.\n- For correctness feedback, provide concrete validation (test, log, or\n  reasoning) before closing it.\n- When accepting feedback, include a one-line rationale in the root-level\n  update.\n- When declining feedback, offer a brief alternative or follow-up trigger.\n- Prefer a single consolidated \"review addressed\" root-level comment after a\n  batch of fixes instead of many small updates.\n- For doc feedback, confirm the doc change matches behavior (no doc-only edits\n  to appease review).\n",".codex/worktree_init.sh":"#!/usr/bin/env bash\nset -eo pipefail\n\nscript_dir=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nrepo_root=\"$(cd \"$script_dir/..\" && pwd)\"\nproject_root=\"$repo_root/elixir\"\n\nif ! command -v mise >/dev/null 2>&1; then\n  echo \"mise is required. Install it from https://mise.jdx.dev/getting-started.html\" >&2\n  exit 1\nfi\n\ncd \"$project_root\"\nmise trust\n\nmake setup\n",".codex/skills/land/land_watch.py":"#!/usr/bin/env python3\nimport asyncio\nimport json\nimport random\nimport re\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Any\n\nPOLL_SECONDS = 10\nCHECKS_APPEAR_TIMEOUT_SECONDS = 120\nCODEX_BOTS = {\n    \"chatgpt-codex-connector[bot]\",\n    \"github-actions[bot]\",\n    \"codex-gc-app[bot]\",\n    \"app/codex-gc-app\",\n}\nMAX_GH_RETRIES = 5\nBASE_GH_BACKOFF_SECONDS = 2\n\n\n@dataclass\nclass PrInfo:\n    number: int\n    url: str\n    head_sha: str\n    mergeable: str | None\n    merge_state: str | None\n\n\nclass RateLimitError(RuntimeError):\n    pass\n\n\ndef is_rate_limit_error(error: str) -> bool:\n    return \"HTTP 429\" in error or \"rate limit\" in error.lower()\n\n\nasync def run_gh(*args: str) -> str:\n    max_delay = BASE_GH_BACKOFF_SECONDS * (2 ** (MAX_GH_RETRIES - 1))\n    delay_seconds = BASE_GH_BACKOFF_SECONDS\n    last_error = \"gh command failed\"\n    for attempt in range(1, MAX_GH_RETRIES + 1):\n        proc = await asyncio.create_subprocess_exec(\n            \"gh\",\n            *args,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await proc.communicate()\n        if proc.returncode == 0:\n            return stdout.decode()\n        error = stderr.decode().strip() or \"gh command failed\"\n        if not is_rate_limit_error(error):\n            raise RuntimeError(error)\n        last_error = error\n        if attempt >= MAX_GH_RETRIES:\n            break\n        jitter = random.uniform(0, delay_seconds)\n        await asyncio.sleep(min(delay_seconds + jitter, max_delay))\n        delay_seconds = min(delay_seconds * 2, max_delay)\n    raise RateLimitError(last_error)\n\n\nasync def get_pr_info() -> PrInfo:\n    data = await run_gh(\n        \"pr\",\n        \"view\",\n        \"--json\",\n        \"number,url,headRefOid,mergeable,mergeStateStatus\",\n    )\n    parsed = json.loads(data)\n    return PrInfo(\n        number=parsed[\"number\"],\n        url=parsed[\"url\"],\n        head_sha=parsed[\"headRefOid\"],\n        mergeable=parsed.get(\"mergeable\"),\n        merge_state=parsed.get(\"mergeStateStatus\"),\n    )\n\n\nasync def get_paginated_list(endpoint: str) -> list[dict[str, Any]]:\n    page = 1\n    items: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            endpoint,\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        items.extend(batch)\n        page += 1\n    return items\n\n\nasync def get_issue_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/issues/{pr_number}/comments\",\n    )\n\n\nasync def get_review_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments\",\n    )\n\n\nasync def get_reviews(pr_number: int) -> list[dict[str, Any]]:\n    page = 1\n    reviews: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        reviews.extend(batch)\n        page += 1\n    return reviews\n\n\nasync def get_check_runs(head_sha: str) -> list[dict[str, Any]]:\n    page = 1\n    check_runs: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/commits/{head_sha}/check-runs\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        payload = json.loads(data)\n        batch = payload.get(\"check_runs\", [])\n        if not batch:\n            break\n        check_runs.extend(batch)\n        total_count = payload.get(\"total_count\")\n        if total_count is not None and len(check_runs) >= total_count:\n            break\n        page += 1\n    return check_runs\n\n\ndef parse_time(value: str) -> datetime:\n    normalized = value.replace(\"Z\", \"+00:00\")\n    return datetime.fromisoformat(normalized)\n\n\nCONTROL_CHARS_RE = re.compile(r\"[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f]\")\n\n\ndef sanitize_terminal_output(value: str) -> str:\n    return CONTROL_CHARS_RE.sub(\"\", value)\n\n\ndef check_timestamp(check: dict[str, Any]) -> datetime | None:\n    for key in (\"completed_at\", \"started_at\", \"run_started_at\", \"created_at\"):\n        value = check.get(key)\n        if value:\n            return parse_time(value)\n    return None\n\n\ndef dedupe_check_runs(check_runs: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_name: dict[str, dict[str, Any]] = {}\n    for check in check_runs:\n        name = check.get(\"name\", \"unknown\")\n        timestamp = check_timestamp(check)\n        if name not in latest_by_name:\n            latest_by_name[name] = check\n            continue\n        existing = latest_by_name[name]\n        existing_timestamp = check_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_name[name] = check\n    return list(latest_by_name.values())\n\n\ndef summarize_checks(check_runs: list[dict[str, Any]]) -> tuple[bool, bool, list[str]]:\n    if not check_runs:\n        return True, False, [\"no checks reported\"]\n    check_runs = dedupe_check_runs(check_runs)\n    pending = False\n    failed = False\n    failures: list[str] = []\n    for check in check_runs:\n        status = check.get(\"status\")\n        conclusion = check.get(\"conclusion\")\n        name = check.get(\"name\", \"unknown\")\n        if status != \"completed\":\n            pending = True\n            continue\n        if conclusion not in (\"success\", \"skipped\", \"neutral\"):\n            failed = True\n            failures.append(f\"{name}: {conclusion}\")\n    return pending, failed, failures\n\n\ndef latest_review_request_at(comments: list[dict[str, Any]]) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        if is_codex_bot_user(comment.get(\"user\", {})):\n            continue\n        body = comment.get(\"body\") or \"\"\n        if \"@codex review\" not in body:\n            continue\n        timestamp = comment_time(comment)\n        if timestamp is None:\n            continue\n        if latest is None or timestamp > latest:\n            latest = timestamp\n    return latest\n\n\ndef filter_codex_comments(\n    comments: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    latest_issue_ack = latest_codex_issue_reply_time(comments)\n    codex_comments = [c for c in comments if is_codex_bot_user(c.get(\"user\", {}))]\n    filtered: list[dict[str, Any]] = []\n    for comment in codex_comments:\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if review_requested_at is not None and created_time <= review_requested_at:\n            continue\n        is_threaded = bool(\n            comment.get(\"in_reply_to_id\") or comment.get(\"pull_request_review_id\")\n        )\n        if not is_threaded:\n            if latest_issue_ack is not None and created_time <= latest_issue_ack:\n                continue\n        else:\n            thread_root = thread_root_id(comment)\n            last_reply = None\n            if thread_root is not None:\n                last_reply = latest_codex_reply.get(thread_root)\n            if last_reply and last_reply > created_time:\n                continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_codex_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    return login in CODEX_BOTS\n\n\ndef is_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    if is_codex_bot_user(user):\n        return True\n    if user.get(\"type\") == \"Bot\":\n        return True\n    return login.endswith(\"[bot]\")\n\n\ndef is_codex_reply_body(body: str) -> bool:\n    return body.startswith(\"[codex]\")\n\n\ndef is_codex_review_body(body: str) -> bool:\n    return body.startswith(\"## Codex Review\")\n\n\ndef latest_codex_issue_reply_time(\n    comments: list[dict[str, Any]],\n) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if latest is None or created_time > latest:\n            latest = created_time\n    return latest\n\n\ndef filter_human_issue_comments(comments: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        if is_codex_review_body(body):\n            continue\n        if \"@codex review\" in body:\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef filter_codex_review_issue_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_review_body(body):\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef thread_root_id(comment: dict[str, Any]) -> int | None:\n    return comment.get(\"in_reply_to_id\") or comment.get(\"id\")\n\n\ndef comment_time(comment: dict[str, Any]) -> datetime | None:\n    timestamp = comment.get(\"updated_at\") or comment.get(\"created_at\")\n    if not timestamp:\n        return None\n    return parse_time(timestamp)\n\n\ndef latest_codex_reply_by_thread(\n    comments: list[dict[str, Any]],\n) -> dict[int, datetime]:\n    latest: dict[int, datetime] = {}\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        if thread_root is None or created_time is None:\n            continue\n        existing = latest.get(thread_root)\n        if existing is None or created_time > existing:\n            latest[thread_root] = created_time\n    return latest\n\n\ndef filter_human_review_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        last_codex_reply = None\n        if thread_root is not None:\n            last_codex_reply = latest_codex_reply.get(thread_root)\n        if last_codex_reply and created_time and created_time <= last_codex_reply:\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_blocking_review(\n    review: dict[str, Any],\n    review_requested_at: datetime | None,\n) -> bool:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return False\n    user_login = review.get(\"user\", {}).get(\"login\")\n    created_time = parse_time(created_at)\n    if (\n        user_login in CODEX_BOTS\n        and review_requested_at is not None\n        and created_time <= review_requested_at\n    ):\n        return False\n    body = (review.get(\"body\") or \"\").strip()\n    state = review.get(\"state\")\n    if user_login in CODEX_BOTS:\n        return state == \"CHANGES_REQUESTED\"\n    if body.startswith(\"[codex]\") or state in (\"APPROVED\", \"DISMISSED\"):\n        return False\n    blocking = False\n    if body or state == \"CHANGES_REQUESTED\":\n        blocking = True\n    elif state == \"COMMENTED\":\n        blocking = False\n    elif state:\n        blocking = state not in (\"APPROVED\", \"DISMISSED\")\n    return blocking\n\n\ndef review_timestamp(review: dict[str, Any]) -> datetime | None:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return None\n    return parse_time(created_at)\n\n\ndef dedupe_reviews(reviews: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_user: dict[str, dict[str, Any]] = {}\n    for review in reviews:\n        user_login = review.get(\"user\", {}).get(\"login\")\n        if not user_login:\n            continue\n        timestamp = review_timestamp(review)\n        if user_login not in latest_by_user:\n            latest_by_user[user_login] = review\n            continue\n        existing = latest_by_user[user_login]\n        existing_timestamp = review_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_user[user_login] = review\n    return list(latest_by_user.values())\n\n\ndef filter_blocking_reviews(\n    reviews: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    return [\n        review\n        for review in dedupe_reviews(reviews)\n        if is_blocking_review(review, review_requested_at)\n    ]\n\n\ndef is_merge_conflicting(pr: PrInfo) -> bool:\n    return pr.mergeable == \"CONFLICTING\" or pr.merge_state == \"DIRTY\"\n\n\nasync def fetch_review_context(\n    pr_number: int,\n) -> tuple[\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    datetime | None,\n]:\n    issue_comments = await get_issue_comments(pr_number)\n    review_request_at = latest_review_request_at(issue_comments)\n    review_comments = await get_review_comments(pr_number)\n    reviews = await get_reviews(pr_number)\n    return issue_comments, review_comments, reviews, review_request_at\n\n\ndef raise_on_human_feedback(\n    issue_comments: list[dict[str, Any]],\n    review_comments: list[dict[str, Any]],\n    reviews: list[dict[str, Any]],\n    review_request_at: datetime | None,\n) -> None:\n    human_issue_comments = filter_human_issue_comments(issue_comments)\n    codex_review_comments = filter_codex_review_issue_comments(issue_comments)\n    human_review_comments = filter_human_review_comments(review_comments)\n    if human_issue_comments or human_review_comments or codex_review_comments:\n        print(\"Review comments detected. Address before merge.\")\n        print(\n            \"Reminder: decide whether feedback stays in scope; defer if needed \"\n            \"and note in your root-level update.\",\n        )\n        raise SystemExit(2)\n    blocking_reviews = filter_blocking_reviews(reviews, review_request_at)\n    if blocking_reviews:\n        print(\"Review states/comments detected. Address before merge.\")\n        print(\n            \"Reminder: keep PR title/description aligned with the full scope \"\n            \"when changes expand.\",\n        )\n        raise SystemExit(2)\n\n\nasync def wait_for_codex(pr_number: int, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for review feedback...\", flush=True)\n    while True:\n        (\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        ) = await fetch_review_context(pr_number)\n        bot_issue_comments = filter_codex_comments(issue_comments, review_request_at)\n        bot_review_comments = filter_codex_comments(review_comments, review_request_at)\n        bot_comments = bot_issue_comments + bot_review_comments\n        raise_on_human_feedback(\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        )\n        if bot_comments:\n            latest = max(\n                bot_comments,\n                key=lambda comment: parse_time(comment[\"created_at\"]),\n            )\n            body = sanitize_terminal_output(latest.get(\"body\") or \"\").strip()\n            if body:\n                print(\"Codex left comments. Address feedback before merge.\")\n                print(body)\n                raise SystemExit(2)\n        if checks_done.is_set():\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def wait_for_checks(head_sha: str, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for CI checks...\", flush=True)\n    empty_seconds = 0\n    while True:\n        check_runs = await get_check_runs(head_sha)\n        if not check_runs:\n            empty_seconds += POLL_SECONDS\n            if empty_seconds >= CHECKS_APPEAR_TIMEOUT_SECONDS:\n                print(\n                    \"No checks detected after 120s; check CI configuration\",\n                )\n                raise SystemExit(3)\n            await asyncio.sleep(POLL_SECONDS)\n            continue\n        empty_seconds = 0\n        pending, failed, failures = summarize_checks(check_runs)\n        if failed:\n            print(\"Checks failed:\")\n            for failure in failures:\n                print(f\"- {failure}\")\n            raise SystemExit(3)\n        if not pending:\n            print(\"Checks passed\")\n            checks_done.set()\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def watch_pr() -> None:\n    pr = await get_pr_info()\n    if is_merge_conflicting(pr):\n        print(\n            \"PR has merge conflicts. Resolve/rebase against main and push before \"\n            \"running land_watch again.\",\n        )\n        raise SystemExit(5)\n    head_sha = pr.head_sha\n    checks_done = asyncio.Event()\n    codex_task = asyncio.create_task(wait_for_codex(pr.number, checks_done))\n    checks_task = asyncio.create_task(wait_for_checks(head_sha, checks_done))\n\n    async def head_monitor() -> None:\n        while True:\n            current = await get_pr_info()\n            if is_merge_conflicting(current):\n                print(\n                    \"PR has merge conflicts. Resolve/rebase against main and push \"\n                    \"before running land_watch again.\",\n                )\n                raise SystemExit(5)\n            if current.head_sha != head_sha:\n                print(\"PR head updated; pull/amend/force-push to retrigger CI\")\n                raise SystemExit(4)\n            await asyncio.sleep(POLL_SECONDS)\n\n    monitor_task = asyncio.create_task(head_monitor())\n    success_task = asyncio.gather(codex_task, checks_task)\n\n    done, pending = await asyncio.wait(\n        [monitor_task, success_task],\n        return_when=asyncio.FIRST_COMPLETED,\n    )\n    for task in pending:\n        task.cancel()\n    for task in done:\n        exc = task.exception()\n        if exc:\n            raise exc\n\n\nif __name__ == \"__main__\":\n    try:\n        asyncio.run(watch_pr())\n    except SystemExit as exc:\n        raise SystemExit(exc.code) from None\n"},"files":{".codex/skills/push/SKILL.md":"---\nname: push\ndescription:\n  Push current branch changes to origin and create or update the corresponding\n  pull request; use when asked to push, publish updates, or create pull request.\n---\n\n# Push\n\n## Prerequisites\n\n- `gh` CLI is installed and available in `PATH`.\n- `gh auth status` succeeds for GitHub operations in this repo.\n\n## Goals\n\n- Push current branch changes to `origin` safely.\n- Create a PR if none exists for the branch, otherwise update the existing PR.\n- Keep branch history clean when remote has moved.\n\n## Related Skills\n\n- `pull`: use this when push is rejected or sync is not clean (non-fast-forward,\n  merge conflict risk, or stale branch).\n\n## Steps\n\n1. Identify current branch and confirm remote state.\n2. Run local validation (`make -C elixir all`) before pushing.\n3. Push branch to `origin` with upstream tracking if needed, using whatever\n   remote URL is already configured.\n4. If push is not clean/rejected:\n   - If the failure is a non-fast-forward or sync problem, run the `pull`\n     skill to merge `origin/main`, resolve conflicts, and rerun validation.\n   - Push again; use `--force-with-lease` only when history was rewritten.\n   - If the failure is due to auth, permissions, or workflow restrictions on\n     the configured remote, stop and surface the exact error instead of\n     rewriting remotes or switching protocols as a workaround.\n\n5. Ensure a PR exists for the branch:\n   - If no PR exists, create one.\n   - If a PR exists and is open, update it.\n   - If branch is tied to a closed/merged PR, create a new branch + PR.\n   - Write a proper PR title that clearly describes the change outcome\n   - For branch updates, explicitly reconsider whether current PR title still\n     matches the latest scope; update it if it no longer does.\n6. Write/update PR body explicitly using `.github/pull_request_template.md`:\n   - Fill every section with concrete content for this change.\n   - Replace all placeholder comments (`<!-- ... -->`).\n   - Keep bullets/checkboxes where template expects them.\n   - If PR already exists, refresh body content so it reflects the total PR\n     scope (all intended work on the branch), not just the newest commits,\n     including newly added work, removed work, or changed approach.\n   - Do not reuse stale description text from earlier iterations.\n7. Validate PR body with `mix pr_body.check` and fix all reported issues.\n8. Reply with the PR URL from `gh pr view`.\n\n## Commands\n\n```sh\n# Identify branch\nbranch=$(git branch --show-current)\n\n# Minimal validation gate\nmake -C elixir all\n\n# Initial push: respect the current origin remote.\ngit push -u origin HEAD\n\n# If that failed because the remote moved, use the pull skill. After\n# pull-skill resolution and re-validation, retry the normal push:\ngit push -u origin HEAD\n\n# If the configured remote rejects the push for auth, permissions, or workflow\n# restrictions, stop and surface the exact error.\n\n# Only if history was rewritten locally:\ngit push --force-with-lease origin HEAD\n\n# Ensure a PR exists (create only if missing)\npr_state=$(gh pr view --json state -q .state 2>/dev/null || true)\nif [ \"$pr_state\" = \"MERGED\" ] || [ \"$pr_state\" = \"CLOSED\" ]; then\n  echo \"Current branch is tied to a closed PR; create a new branch + PR.\" >&2\n  exit 1\nfi\n\n# Write a clear, human-friendly title that summarizes the shipped change.\npr_title=\"<clear PR title written for this change>\"\nif [ -z \"$pr_state\" ]; then\n  gh pr create --title \"$pr_title\"\nelse\n  # Reconsider title on every branch update; edit if scope shifted.\n  gh pr edit --title \"$pr_title\"\nfi\n\n# Write/edit PR body to match .github/pull_request_template.md before validation.\n# Example workflow:\n# 1) open the template and draft body content for this PR\n# 2) gh pr edit --body-file /tmp/pr_body.md\n# 3) for branch updates, re-check that title/body still match current diff\n\ntmp_pr_body=$(mktemp)\ngh pr view --json body -q .body > \"$tmp_pr_body\"\n(cd elixir && mix pr_body.check --file \"$tmp_pr_body\")\nrm -f \"$tmp_pr_body\"\n\n# Show PR URL for the reply\ngh pr view --json url -q .url\n```\n\n## Notes\n\n- Do not use `--force`; only use `--force-with-lease` as the last resort.\n- Distinguish sync problems from remote auth/permission problems:\n  - Use the `pull` skill for non-fast-forward or stale-branch issues.\n  - Surface auth, permissions, or workflow restrictions directly instead of\n    changing remotes or protocols.\n",".codex/skills/debug/SKILL.md":"---\nname: debug\ndescription:\n  Investigate stuck runs and execution failures by tracing Symphony and Codex\n  logs with issue/session identifiers; use when runs stall, retry repeatedly, or\n  fail unexpectedly.\n---\n\n# Debug\n\n## Goals\n\n- Find why a run is stuck, retrying, or failing.\n- Correlate Linear issue identity to a Codex session quickly.\n- Read the right logs in the right order to isolate root cause.\n\n## Log Sources\n\n- Primary runtime log: `log/symphony.log`\n  - Default comes from `SymphonyElixir.LogFile` (`log/symphony.log`).\n  - Includes orchestrator, agent runner, and Codex app-server lifecycle logs.\n- Rotated runtime logs: `log/symphony.log*`\n  - Check these when the relevant run is older.\n\n## Correlation Keys\n\n- `issue_identifier`: human ticket key (example: `MT-625`)\n- `issue_id`: Linear UUID (stable internal ID)\n- `session_id`: Codex thread-turn pair (`<thread_id>-<turn_id>`)\n\n`elixir/docs/logging.md` requires these fields for issue/session lifecycle logs. Use\nthem as your join keys during debugging.\n\n## Quick Triage (Stuck Run)\n\n1. Confirm scheduler/worker symptoms for the ticket.\n2. Find recent lines for the ticket (`issue_identifier` first).\n3. Extract `session_id` from matching lines.\n4. Trace that `session_id` across start, stream, completion/failure, and stall\n   handling logs.\n5. Decide class of failure: timeout/stall, app-server startup failure, turn\n   failure, or orchestrator retry loop.\n\n## Commands\n\n```bash\n# 1) Narrow by ticket key (fastest entry point)\nrg -n \"issue_identifier=MT-625\" log/symphony.log*\n\n# 2) If needed, narrow by Linear UUID\nrg -n \"issue_id=<linear-uuid>\" log/symphony.log*\n\n# 3) Pull session IDs seen for that ticket\nrg -o \"session_id=[^ ;]+\" log/symphony.log* | sort -u\n\n# 4) Trace one session end-to-end\nrg -n \"session_id=<thread>-<turn>\" log/symphony.log*\n\n# 5) Focus on stuck/retry signals\nrg -n \"Issue stalled|scheduling retry|turn_timeout|turn_failed|Codex session failed|Codex session ended with error\" log/symphony.log*\n```\n\n## Investigation Flow\n\n1. Locate the ticket slice:\n    - Search by `issue_identifier=<KEY>`.\n    - If noise is high, add `issue_id=<UUID>`.\n2. Establish timeline:\n    - Identify first `Codex session started ... session_id=...`.\n    - Follow with `Codex session completed`, `ended with error`, or worker exit\n      lines.\n3. Classify the problem:\n    - Stall loop: `Issue stalled ... restarting with backoff`.\n    - App-server startup: `Codex session failed ...`.\n    - Turn execution failure: `turn_failed`, `turn_cancelled`, `turn_timeout`, or\n      `ended with error`.\n    - Worker crash: `Agent task exited ... reason=...`.\n4. Validate scope:\n    - Check whether failures are isolated to one issue/session or repeating across\n      multiple tickets.\n5. Capture evidence:\n    - Save key log lines with timestamps, `issue_identifier`, `issue_id`, and\n      `session_id`.\n    - Record probable root cause and the exact failing stage.\n\n## Reading Codex Session Logs\n\nIn Symphony, Codex session diagnostics are emitted into `log/symphony.log` and\nkeyed by `session_id`. Read them as a lifecycle:\n\n1. `Codex session started ... session_id=...`\n2. Session stream/lifecycle events for the same `session_id`\n3. Terminal event:\n    - `Codex session completed ...`, or\n    - `Codex session ended with error ...`, or\n    - `Issue stalled ... restarting with backoff`\n\nFor one specific session investigation, keep the trace narrow:\n\n1. Capture one `session_id` for the ticket.\n2. Build a timestamped slice for only that session:\n    - `rg -n \"session_id=<thread>-<turn>\" log/symphony.log*`\n3. Mark the exact failing stage:\n    - Startup failure before stream events (`Codex session failed ...`).\n    - Turn/runtime failure after stream events (`turn_*` / `ended with error`).\n    - Stall recovery (`Issue stalled ... restarting with backoff`).\n4. Pair findings with `issue_identifier` and `issue_id` from nearby lines to\n   confirm you are not mixing concurrent retries.\n\nAlways pair session findings with `issue_identifier`/`issue_id` to avoid mixing\nconcurrent runs.\n\n## Notes\n\n- Prefer `rg` over `grep` for speed on large logs.\n- Check rotated logs (`log/symphony.log*`) before concluding data is missing.\n- If required context fields are missing in new log statements, align with\n  `elixir/docs/logging.md` conventions.\n",".codex/skills/release/SKILL.md":"---\nname: release\ndescription: Cut a Symphony release by bumping the committed version, landing it, tagging the merged commit, and verifying the Burrito release workflow. Use when asked to release, tag, or retag Symphony.\n---\n\n# Release\n\n1. Start from fresh `origin/main` in a clean worktree. Never disturb unrelated\n   local changes.\n2. Pick the requested version. If none is given, use the next patch after the\n   latest `vX.Y.Z` tag.\n3. Update `elixir/mix.exs` so `version: \"X.Y.Z\"` matches the intended tag.\n   Search the old version and change other files only when they are true\n   release-version sources, not examples.\n4. Run `make -C elixir all`, then commit, push, create a PR, and land it.\n5. Fetch the merged `main` commit. Verify its `mix.exs` version, then create an\n   annotated tag on that exact commit:\n\n   ```sh\n   git tag -a vX.Y.Z <merged-commit> -m \"Symphony vX.Y.Z\"\n   git push origin vX.Y.Z\n   ```\n\n6. Watch `burrito-release` until it finishes. Verify the build, smoke, and\n   release jobs pass and that the GitHub release has all expected assets.\n\nDo not tag an uncommitted or unmerged revision. Do not move a published tag\nwithout explicit user approval.\n",".codex/skills/commit/SKILL.md":"---\nname: commit\ndescription:\n  Create a well-formed git commit from current changes using session history for\n  rationale and summary; use when asked to commit, prepare a commit message, or\n  finalize staged work.\n---\n\n# Commit\n\n## Goals\n\n- Produce a commit that reflects the actual code changes and the session\n  context.\n- Follow common git conventions (type prefix, short subject, wrapped body).\n- Include both summary and rationale in the body.\n\n## Inputs\n\n- Codex session history for intent and rationale.\n- `git status`, `git diff`, and `git diff --staged` for actual changes.\n- Repo-specific commit conventions if documented.\n\n## Steps\n\n1. Read session history to identify scope, intent, and rationale.\n2. Inspect the working tree and staged changes (`git status`, `git diff`,\n   `git diff --staged`).\n3. Stage intended changes, including new files (`git add -A`) after confirming\n   scope.\n4. Sanity-check newly added files; if anything looks random or likely ignored\n   (build artifacts, logs, temp files), flag it to the user before committing.\n5. If staging is incomplete or includes unrelated files, fix the index or ask\n   for confirmation.\n6. Choose a conventional type and optional scope that match the change (e.g.,\n   `feat(scope): ...`, `fix(scope): ...`, `refactor(scope): ...`).\n7. Write a subject line in imperative mood, <= 72 characters, no trailing\n   period.\n8. Write a body that includes:\n   - Summary of key changes (what changed).\n   - Rationale and trade-offs (why it changed).\n   - Tests or validation run (or explicit note if not run).\n9. Append a `Co-authored-by` trailer for Codex using `Codex <codex@openai.com>`\n   unless the user explicitly requests a different identity.\n10. Wrap body lines at 72 characters.\n11. Create the commit message with a here-doc or temp file and use\n    `git commit -F <file>` so newlines are literal (avoid `-m` with `\\n`).\n12. Commit only when the message matches the staged changes: if the staged diff\n    includes unrelated files or the message describes work that isn't staged,\n    fix the index or revise the message before committing.\n\n## Output\n\n- A single commit created with `git commit` whose message reflects the session.\n\n## Template\n\nType and scope are examples only; adjust to fit the repo and changes.\n\n```\n<type>(<scope>): <short summary>\n\nSummary:\n- <what changed>\n- <what changed>\n\nRationale:\n- <why>\n- <why>\n\nTests:\n- <command or \"not run (reason)\">\n\nCo-authored-by: Codex <codex@openai.com>\n```\n","elixir/AGENTS.md":"# Symphony Elixir\n\nThis directory contains the Elixir agent orchestration service that polls Linear, creates per-issue workspaces, and runs Codex in app-server mode.\n\n## Environment\n\n- Elixir: `1.19.x` (OTP 28) via `mise`.\n- Install deps: `mix setup`.\n- Main quality gate: `make all` (format check, lint, coverage, dialyzer).\n\n\n## Codebase-Specific Conventions\n\n- Runtime config is loaded from `WORKFLOW.md` front matter via `SymphonyElixir.Workflow` and `SymphonyElixir.Config`.\n- Keep the implementation aligned with [`../SPEC.md`](../SPEC.md) where practical.\n  - The implementation may be a superset of the spec.\n  - The implementation must not conflict with the spec.\n  - If implementation changes meaningfully alter the intended behavior, update the spec in the same\n    change where practical so the spec stays current.\n- Prefer adding config access through `SymphonyElixir.Config` instead of ad-hoc env reads.\n- Workspace safety is critical:\n  - Never run Codex turn cwd in source repo.\n  - Workspaces must stay under configured workspace root.\n- Orchestrator behavior is stateful and concurrency-sensitive; preserve retry, reconciliation, and cleanup semantics.\n- Simplicity is a project constraint: prefer the smallest coherent design with one clear owner and\n  invariant. Push back on extra abstractions, duplicated policy, and speculative flexibility.\n- For stateful changes, check startup, reload, restart, and failure recovery together before editing.\n- Follow `docs/logging.md` for logging conventions and required issue/session context fields.\n\n## Tests and Validation\n\nRun targeted tests while iterating, then run full gates before handoff.\n\n- Prefer narrow tests that exercise real OTP processes and observable behavior over mock-only or\n  broad end-to-end coverage; prove health with a synchronous call or stable effect, not only a PID.\n- For non-trivial changes, use an adversarial review early to challenge complexity and try to break\n  adjacent lifecycle paths; a reproducible failure blocks landing even if other reviews are clean.\n- If tests need repeated global restarts or bespoke cleanup, first fix the shared harness or\n  ownership boundary.\n\n```bash\nmake all\n```\n\n## Required Rules\n\n- Public functions (`def`) in `lib/` must have an adjacent `@spec`.\n- `defp` specs are optional.\n- `@impl` callback implementations are exempt from local `@spec` requirement.\n- Evaluate proposed directions instead of agreeing reflexively; surface simpler designs and material\n  trade-offs early.\n- Keep changes narrowly scoped; avoid unrelated refactors.\n- Follow existing module/style patterns in `lib/symphony_elixir/*`.\n\nValidation command:\n\n```bash\nmix specs.check\n```\n\n## PR Requirements\n\n- PR body must follow `../.github/pull_request_template.md` exactly.\n- Validate PR body locally when needed:\n\n```bash\nmix pr_body.check --file /path/to/pr_body.md\n```\n\n## Docs Update Policy\n\nIf behavior/config changes, update docs in the same PR:\n\n- `../README.md` for project concept and goals.\n- `README.md` for Elixir implementation and run instructions.\n- `WORKFLOW.md` for workflow/config contract changes.\n",".codex/skills/linear/SKILL.md":"---\nname: linear\ndescription: |\n  Use Symphony's `linear_graphql` client tool for raw Linear GraphQL\n  operations such as comment editing and upload flows.\n---\n\n# Linear GraphQL\n\nUse this skill for raw Linear GraphQL work during Symphony app-server sessions.\n\n## Primary tool\n\nUse the `linear_graphql` client tool exposed by Symphony's app-server session.\nIt reuses Symphony's configured Linear auth for the session.\n\nTool input:\n\n```json\n{\n  \"query\": \"query or mutation document\",\n  \"variables\": {\n    \"optional\": \"graphql variables object\"\n  }\n}\n```\n\nTool behavior:\n\n- Send one GraphQL operation per tool call.\n- Treat a top-level `errors` array as a failed GraphQL operation even if the\n  tool call itself completed.\n- Keep queries/mutations narrowly scoped; ask only for the fields you need.\n\n## Discovering unfamiliar operations\n\nWhen you need an unfamiliar mutation, input type, or object field, use targeted\nintrospection through `linear_graphql`.\n\nList mutation names:\n\n```graphql\nquery ListMutations {\n  __type(name: \"Mutation\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\nInspect a specific input object:\n\n```graphql\nquery CommentCreateInputShape {\n  __type(name: \"CommentCreateInput\") {\n    inputFields {\n      name\n      type {\n        kind\n        name\n        ofType {\n          kind\n          name\n        }\n      }\n    }\n  }\n}\n```\n\n## Common workflows\n\n### Query an issue by key, identifier, or id\n\nUse these progressively:\n\n- Start with `issue(id: $key)` when you have a ticket key such as `MT-686`.\n- Fall back to `issues(filter: ...)` when you need identifier search semantics.\n- Once you have the internal issue id, prefer `issue(id: $id)` for narrower reads.\n\nLookup by issue key:\n\n```graphql\nquery IssueByKey($key: String!) {\n  issue(id: $key) {\n    id\n    identifier\n    title\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    branchName\n    url\n    description\n    updatedAt\n    links {\n      nodes {\n        id\n        url\n        title\n      }\n    }\n  }\n}\n```\n\nLookup by identifier filter:\n\n```graphql\nquery IssueByIdentifier($identifier: String!) {\n  issues(filter: { identifier: { eq: $identifier } }, first: 1) {\n    nodes {\n      id\n      identifier\n      title\n      state {\n        id\n        name\n        type\n      }\n      project {\n        id\n        name\n      }\n      branchName\n      url\n      description\n      updatedAt\n    }\n  }\n}\n```\n\nResolve a key to an internal id:\n\n```graphql\nquery IssueByIdOrKey($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n  }\n}\n```\n\nRead the issue once the internal id is known:\n\n```graphql\nquery IssueDetails($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n    url\n    description\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    attachments {\n      nodes {\n        id\n        title\n        url\n        sourceType\n      }\n    }\n  }\n}\n```\n\n### Query team workflow states for an issue\n\nUse this before changing issue state when you need the exact `stateId`:\n\n```graphql\nquery IssueTeamStates($id: String!) {\n  issue(id: $id) {\n    id\n    team {\n      id\n      key\n      name\n      states {\n        nodes {\n          id\n          name\n          type\n        }\n      }\n    }\n  }\n}\n```\n\n### Edit an existing comment\n\nUse `commentUpdate` through `linear_graphql`:\n\n```graphql\nmutation UpdateComment($id: String!, $body: String!) {\n  commentUpdate(id: $id, input: { body: $body }) {\n    success\n    comment {\n      id\n      body\n    }\n  }\n}\n```\n\n### Create a comment\n\nUse `commentCreate` through `linear_graphql`:\n\n```graphql\nmutation CreateComment($issueId: String!, $body: String!) {\n  commentCreate(input: { issueId: $issueId, body: $body }) {\n    success\n    comment {\n      id\n      url\n    }\n  }\n}\n```\n\n### Move an issue to a different state\n\nUse `issueUpdate` with the destination `stateId`:\n\n```graphql\nmutation MoveIssueToState($id: String!, $stateId: String!) {\n  issueUpdate(id: $id, input: { stateId: $stateId }) {\n    success\n    issue {\n      id\n      identifier\n      state {\n        id\n        name\n      }\n    }\n  }\n}\n```\n\n### Attach a GitHub PR to an issue\n\nUse the GitHub-specific attachment mutation when linking a PR:\n\n```graphql\nmutation AttachGitHubPR($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkGitHubPR(\n    issueId: $issueId\n    url: $url\n    title: $title\n    linkKind: links\n  ) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\nIf you only need a plain URL attachment and do not care about GitHub-specific\nlink metadata, use:\n\n```graphql\nmutation AttachURL($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkURL(issueId: $issueId, url: $url, title: $title) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\n### Introspection patterns used during schema discovery\n\nUse these when the exact field or mutation shape is unclear:\n\n```graphql\nquery QueryFields {\n  __type(name: \"Query\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\n```graphql\nquery IssueFieldArgs {\n  __type(name: \"Query\") {\n    fields {\n      name\n      args {\n        name\n        type {\n          kind\n          name\n          ofType {\n            kind\n            name\n            ofType {\n              kind\n              name\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n### Upload a video to a comment\n\nDo this in three steps:\n\n1. Call `linear_graphql` with `fileUpload` to get `uploadUrl`, `assetUrl`, and\n   any required upload headers.\n2. Upload the local file bytes to `uploadUrl` with `curl -X PUT` and the exact\n   headers returned by `fileUpload`.\n3. Call `linear_graphql` again with `commentCreate` (or `commentUpdate`) and\n   include the resulting `assetUrl` in the comment body.\n\nUseful mutations:\n\n```graphql\nmutation FileUpload(\n  $filename: String!\n  $contentType: String!\n  $size: Int!\n  $makePublic: Boolean\n) {\n  fileUpload(\n    filename: $filename\n    contentType: $contentType\n    size: $size\n    makePublic: $makePublic\n  ) {\n    success\n    uploadFile {\n      uploadUrl\n      assetUrl\n      headers {\n        key\n        value\n      }\n    }\n  }\n}\n```\n\n## Usage rules\n\n- Use `linear_graphql` for comment edits, uploads, and ad-hoc Linear API\n  queries.\n- Prefer the narrowest issue lookup that matches what you already know:\n  key -> identifier search -> internal id.\n- For state transitions, fetch team states first and use the exact `stateId`\n  instead of hardcoding names inside mutations.\n- Prefer `attachmentLinkGitHubPR` over a generic URL attachment when linking a\n  GitHub PR to a Linear issue.\n- Do not introduce new raw-token shell helpers for GraphQL access.\n- If you need shell work for uploads, only use it for signed upload URLs\n  returned by `fileUpload`; those URLs already carry the needed authorization.\n",".codex/skills/pull/SKILL.md":"---\nname: pull\ndescription:\n  Pull latest origin/main into the current local branch and resolve merge\n  conflicts (aka update-branch). Use when Codex needs to sync a feature branch\n  with origin, perform a merge-based update (not rebase), and guide conflict\n  resolution best practices.\n---\n\n# Pull\n\n## Workflow\n\n1. Verify git status is clean or commit/stash changes before merging.\n2. Ensure rerere is enabled locally:\n   - `git config rerere.enabled true`\n   - `git config rerere.autoupdate true`\n3. Confirm remotes and branches:\n   - Ensure the `origin` remote exists.\n   - Ensure the current branch is the one to receive the merge.\n4. Fetch latest refs:\n   - `git fetch origin`\n5. Sync the remote feature branch first:\n   - `git pull --ff-only origin $(git branch --show-current)`\n   - This pulls branch updates made remotely (for example, a GitHub auto-commit)\n     before merging `origin/main`.\n6. Merge in order:\n   - Prefer `git -c merge.conflictstyle=zdiff3 merge origin/main` for clearer\n     conflict context.\n7. If conflicts appear, resolve them (see conflict guidance below), then:\n   - `git add <files>`\n   - `git commit` (or `git merge --continue` if the merge is paused)\n8. Verify with project checks (follow repo policy in `AGENTS.md`).\n9. Summarize the merge:\n   - Call out the most challenging conflicts/files and how they were resolved.\n   - Note any assumptions or follow-ups.\n\n## Conflict Resolution Guidance (Best Practices)\n\n- Inspect context before editing:\n  - Use `git status` to list conflicted files.\n  - Use `git diff` or `git diff --merge` to see conflict hunks.\n  - Use `git diff :1:path/to/file :2:path/to/file` and\n    `git diff :1:path/to/file :3:path/to/file` to compare base vs ours/theirs\n    for a file-level view of intent.\n  - With `merge.conflictstyle=zdiff3`, conflict markers include:\n    - `<<<<<<<` ours, `|||||||` base, `=======` split, `>>>>>>>` theirs.\n    - Matching lines near the start/end are trimmed out of the conflict region,\n      so focus on the differing core.\n  - Summarize the intent of both changes, decide the semantically correct\n    outcome, then edit:\n    - State what each side is trying to achieve (bug fix, refactor, rename,\n      behavior change).\n    - Identify the shared goal, if any, and whether one side supersedes the\n      other.\n    - Decide the final behavior first; only then craft the code to match that\n      decision.\n    - Prefer preserving invariants, API contracts, and user-visible behavior\n      unless the conflict clearly indicates a deliberate change.\n  - Open files and understand intent on both sides before choosing a resolution.\n- Prefer minimal, intention-preserving edits:\n  - Keep behavior consistent with the branch’s purpose.\n  - Avoid accidental deletions or silent behavior changes.\n- Resolve one file at a time and rerun tests after each logical batch.\n- Use `ours/theirs` only when you are certain one side should win entirely.\n- For complex conflicts, search for related files or definitions to align with\n  the rest of the codebase.\n- For generated files, resolve non-generated conflicts first, then regenerate:\n  - Prefer resolving source files and handwritten logic before touching\n    generated artifacts.\n  - Run the CLI/tooling command that produced the generated file to recreate it\n    cleanly, then stage the regenerated output.\n- For import conflicts where intent is unclear, accept both sides first:\n  - Keep all candidate imports temporarily, finish the merge, then run lint/type\n    checks to remove unused or incorrect imports safely.\n- After resolving, ensure no conflict markers remain:\n  - `git diff --check`\n- When unsure, note assumptions and ask for confirmation before finalizing the\n  merge.\n\n## When To Ask The User (Keep To A Minimum)\n\nDo not ask for input unless there is no safe, reversible alternative. Prefer\nmaking a best-effort decision, documenting the rationale, and proceeding.\n\nAsk the user only when:\n\n- The correct resolution depends on product intent or behavior not inferable\n  from code, tests, or nearby documentation.\n- The conflict crosses a user-visible contract, API surface, or migration where\n  choosing incorrectly could break external consumers.\n- A conflict requires selecting between two mutually exclusive designs with\n  equivalent technical merit and no clear local signal.\n- The merge introduces data loss, schema changes, or irreversible side effects\n  without an obvious safe default.\n- The branch is not the intended target, or the remote/branch names do not exist\n  and cannot be determined locally.\n\nOtherwise, proceed with the merge, explain the decision briefly in notes, and\nleave a clear, reviewable commit history.\n",".codex/skills/land/SKILL.md":"---\nname: land\ndescription:\n  Land a PR by monitoring conflicts, resolving them, waiting for checks, and\n  squash-merging when green; use when asked to land, merge, or shepherd a PR to\n  completion.\n---\n\n# Land\n\n## Goals\n\n- Ensure the PR is conflict-free with main.\n- Keep CI green and fix failures when they occur.\n- Squash-merge the PR once checks pass.\n- Do not yield to the user until the PR is merged; keep the watcher loop running\n  unless blocked.\n- No need to delete remote branches after merge; the repo auto-deletes head\n  branches.\n\n## Preconditions\n\n- `gh` CLI is authenticated.\n- You are on the PR branch with a clean working tree.\n\n## Steps\n\n1. Locate the PR for the current branch.\n2. Confirm the full gauntlet is green locally before any push.\n3. If the working tree has uncommitted changes, commit with the `commit` skill\n   and push with the `push` skill before proceeding.\n4. Check mergeability and conflicts against main.\n5. If conflicts exist, use the `pull` skill to fetch/merge `origin/main` and\n   resolve conflicts, then use the `push` skill to publish the updated branch.\n6. Ensure Codex review comments (if present) are acknowledged and any required\n   fixes are handled before merging.\n7. Watch checks until complete.\n8. If checks fail, pull logs, fix the issue, commit with the `commit` skill,\n   push with the `push` skill, and re-run checks.\n9. When all checks are green and review feedback is addressed, squash-merge and\n   delete the branch using the PR title/body for the merge subject/body.\n10. **Context guard:** Before implementing review feedback, confirm it does not\n    conflict with the user’s stated intent or task context. If it conflicts,\n    respond inline with a justification and ask the user before changing code.\n11. **Pushback template:** When disagreeing, reply inline with: acknowledge +\n    rationale + offer alternative.\n12. **Ambiguity gate:** When ambiguity blocks progress, use the clarification\n    flow (assign PR to current GH user, mention them, wait for response). Do not\n    implement until ambiguity is resolved.\n    - If you are confident you know better than the reviewer, you may proceed\n      without asking the user, but reply inline with your rationale.\n13. **Per-comment mode:** For each review comment, choose one of: accept,\n    clarify, or push back. Reply inline (or in the issue thread for Codex\n    reviews) stating the mode before changing code.\n14. **Reply before change:** Always respond with intended action before pushing\n    code changes (inline for review comments, issue thread for Codex reviews).\n\n## Commands\n\n```\n# Ensure branch and PR context\nbranch=$(git branch --show-current)\npr_number=$(gh pr view --json number -q .number)\npr_title=$(gh pr view --json title -q .title)\npr_body=$(gh pr view --json body -q .body)\n\n# Check mergeability and conflicts\nmergeable=$(gh pr view --json mergeable -q .mergeable)\n\nif [ \"$mergeable\" = \"CONFLICTING\" ]; then\n  # Run the `pull` skill to handle fetch + merge + conflict resolution.\n  # Then run the `push` skill to publish the updated branch.\nfi\n\n# Preferred: use the Async Watch Helper below. The manual loop is a fallback\n# when Python cannot run or the helper script is unavailable.\n# Wait for review feedback: Codex reviews arrive as issue comments that start\n# with \"## Codex Review — <persona>\". Treat them like reviewer feedback: reply\n# with a `[codex]` issue comment acknowledging the findings and whether you're\n# addressing or deferring them.\nwhile true; do\n  gh api repos/{owner}/{repo}/issues/\"$pr_number\"/comments \\\n    --jq '.[] | select(.body | startswith(\"## Codex Review\")) | .id' | rg -q '.' \\\n    && break\n  sleep 10\ndone\n\n# Watch checks\nif ! gh pr checks --watch; then\n  gh pr checks\n  # Identify failing run and inspect logs\n  # gh run list --branch \"$branch\"\n  # gh run view <run-id> --log\n  exit 1\nfi\n\n# Squash-merge (remote branches auto-delete on merge in this repo)\ngh pr merge --squash --subject \"$pr_title\" --body \"$pr_body\"\n```\n\n## Async Watch Helper\n\nPreferred: use the asyncio watcher to monitor review comments, CI, and head\nupdates in parallel:\n\n```\npython3 .codex/skills/land/land_watch.py\n```\n\nExit codes:\n\n- 2: Review comments detected (address feedback)\n- 3: CI checks failed\n- 4: PR head updated (autofix commit detected)\n\n## Failure Handling\n\n- If checks fail, pull details with `gh pr checks` and `gh run view --log`, then\n  fix locally, commit with the `commit` skill, push with the `push` skill, and\n  re-run the watch.\n- Use judgment to identify flaky failures. If a failure is a flake (e.g., a\n  timeout on only one platform), you may proceed without fixing it.\n- If CI pushes an auto-fix commit (authored by GitHub Actions), it does not\n  trigger a fresh CI run. Detect the updated PR head, pull locally, merge\n  `origin/main` if needed, add a real author commit, and force-push to retrigger\n  CI, then restart the checks loop.\n- If all jobs fail with corrupted pnpm lockfile errors on the merge commit, the\n  remediation is to fetch latest `origin/main`, merge, force-push, and rerun CI.\n- If mergeability is `UNKNOWN`, wait and re-check.\n- Do not merge while review comments (human or Codex review) are outstanding.\n- Codex review jobs retry on failure and are non-blocking; use the presence of\n  `## Codex Review — <persona>` issue comments (not job status) as the signal\n  that review feedback is available.\n- Do not enable auto-merge; this repo has no required checks so auto-merge can\n  skip tests.\n- If the remote PR branch advanced due to your own prior force-push or merge,\n  avoid redundant merges; re-run the formatter locally if needed and\n  `git push --force-with-lease`.\n\n## Review Handling\n\n- Codex reviews now arrive as issue comments posted by GitHub Actions. They\n  start with `## Codex Review — <persona>` and include the reviewer’s\n  methodology + guardrails used. Treat these as feedback that must be\n  acknowledged before merge.\n- Human review comments are blocking and must be addressed (responded to and\n  resolved) before requesting a new review or merging.\n- If multiple reviewers comment in the same thread, respond to each comment\n  (batching is fine) before closing the thread.\n- Fetch review comments via `gh api` and reply with a prefixed comment.\n- Use review comment endpoints (not issue comments) to find inline feedback:\n  - List PR review comments:\n    ```\n    gh api repos/{owner}/{repo}/pulls/<pr_number>/comments\n    ```\n  - PR issue comments (top-level discussion):\n    ```\n    gh api repos/{owner}/{repo}/issues/<pr_number>/comments\n    ```\n  - Reply to a specific review comment:\n    ```\n    gh api -X POST /repos/{owner}/{repo}/pulls/<pr_number>/comments \\\n      -f body='[codex] <response>' -F in_reply_to=<comment_id>\n    ```\n- `in_reply_to` must be the numeric review comment id (e.g., `2710521800`), not\n  the GraphQL node id (e.g., `PRRC_...`), and the endpoint must include the PR\n  number (`/pulls/<pr_number>/comments`).\n- If GraphQL review reply mutation is forbidden, use REST.\n- A 404 on reply typically means the wrong endpoint (missing PR number) or\n  insufficient scope; verify by listing comments first.\n- All GitHub comments generated by this agent must be prefixed with `[codex]`.\n- For Codex review issue comments, reply in the issue thread (not a review\n  thread) with `[codex]` and state whether you will address the feedback now or\n  defer it (include rationale).\n- If feedback requires changes:\n  - For inline review comments (human), reply with intended fixes\n    (`[codex] ...`) **as an inline reply to the original review comment** using\n    the review comment endpoint and `in_reply_to` (do not use issue comments for\n    this).\n  - Implement fixes, commit, push.\n  - Reply with the fix details and commit sha (`[codex] ...`) in the same place\n    you acknowledged the feedback (issue comment for Codex reviews, inline reply\n    for review comments).\n  - The land watcher treats Codex review issue comments as unresolved until a\n    newer `[codex]` issue comment is posted acknowledging the findings.\n- Only request a new Codex review when you need a rerun (e.g., after new\n  commits). Do not request one without changes since the last review.\n  - Before requesting a new Codex review, re-run the land watcher and ensure\n    there are zero outstanding review comments (all have `[codex]` inline\n    replies).\n  - After pushing new commits, the Codex review workflow will rerun on PR\n    synchronization (or you can re-run the workflow manually). Post a concise\n    root-level summary comment so reviewers have the latest delta:\n    ```\n    [codex] Changes since last review:\n    - <short bullets of deltas>\n    Commits: <sha>, <sha>\n    Tests: <commands run>\n    ```\n  - Only request a new review if there is at least one new commit since the\n    previous request.\n  - Wait for the next Codex review comment before merging.\n\n## Scope + PR Metadata\n\n- The PR title and description should reflect the full scope of the change, not\n  just the most recent fix.\n- If review feedback expands scope, decide whether to include it now or defer\n  it. You can accept, defer, or decline feedback. If deferring or declining,\n  call it out in the root-level `[codex]` update with a brief reason (e.g.,\n  out-of-scope, conflicts with intent, unnecessary).\n- Correctness issues raised in review comments should be addressed. If you plan\n  to defer or decline a correctness concern, validate first and explain why the\n  concern does not apply.\n- Classify each review comment as one of: correctness, design, style,\n  clarification, scope.\n- For correctness feedback, provide concrete validation (test, log, or\n  reasoning) before closing it.\n- When accepting feedback, include a one-line rationale in the root-level\n  update.\n- When declining feedback, offer a brief alternative or follow-up trigger.\n- Prefer a single consolidated \"review addressed\" root-level comment after a\n  batch of fixes instead of many small updates.\n- For doc feedback, confirm the doc change matches behavior (no doc-only edits\n  to appease review).\n",".codex/worktree_init.sh":"#!/usr/bin/env bash\nset -eo pipefail\n\nscript_dir=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nrepo_root=\"$(cd \"$script_dir/..\" && pwd)\"\nproject_root=\"$repo_root/elixir\"\n\nif ! command -v mise >/dev/null 2>&1; then\n  echo \"mise is required. Install it from https://mise.jdx.dev/getting-started.html\" >&2\n  exit 1\nfi\n\ncd \"$project_root\"\nmise trust\n\nmake setup\n",".codex/skills/land/land_watch.py":"#!/usr/bin/env python3\nimport asyncio\nimport json\nimport random\nimport re\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Any\n\nPOLL_SECONDS = 10\nCHECKS_APPEAR_TIMEOUT_SECONDS = 120\nCODEX_BOTS = {\n    \"chatgpt-codex-connector[bot]\",\n    \"github-actions[bot]\",\n    \"codex-gc-app[bot]\",\n    \"app/codex-gc-app\",\n}\nMAX_GH_RETRIES = 5\nBASE_GH_BACKOFF_SECONDS = 2\n\n\n@dataclass\nclass PrInfo:\n    number: int\n    url: str\n    head_sha: str\n    mergeable: str | None\n    merge_state: str | None\n\n\nclass RateLimitError(RuntimeError):\n    pass\n\n\ndef is_rate_limit_error(error: str) -> bool:\n    return \"HTTP 429\" in error or \"rate limit\" in error.lower()\n\n\nasync def run_gh(*args: str) -> str:\n    max_delay = BASE_GH_BACKOFF_SECONDS * (2 ** (MAX_GH_RETRIES - 1))\n    delay_seconds = BASE_GH_BACKOFF_SECONDS\n    last_error = \"gh command failed\"\n    for attempt in range(1, MAX_GH_RETRIES + 1):\n        proc = await asyncio.create_subprocess_exec(\n            \"gh\",\n            *args,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await proc.communicate()\n        if proc.returncode == 0:\n            return stdout.decode()\n        error = stderr.decode().strip() or \"gh command failed\"\n        if not is_rate_limit_error(error):\n            raise RuntimeError(error)\n        last_error = error\n        if attempt >= MAX_GH_RETRIES:\n            break\n        jitter = random.uniform(0, delay_seconds)\n        await asyncio.sleep(min(delay_seconds + jitter, max_delay))\n        delay_seconds = min(delay_seconds * 2, max_delay)\n    raise RateLimitError(last_error)\n\n\nasync def get_pr_info() -> PrInfo:\n    data = await run_gh(\n        \"pr\",\n        \"view\",\n        \"--json\",\n        \"number,url,headRefOid,mergeable,mergeStateStatus\",\n    )\n    parsed = json.loads(data)\n    return PrInfo(\n        number=parsed[\"number\"],\n        url=parsed[\"url\"],\n        head_sha=parsed[\"headRefOid\"],\n        mergeable=parsed.get(\"mergeable\"),\n        merge_state=parsed.get(\"mergeStateStatus\"),\n    )\n\n\nasync def get_paginated_list(endpoint: str) -> list[dict[str, Any]]:\n    page = 1\n    items: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            endpoint,\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        items.extend(batch)\n        page += 1\n    return items\n\n\nasync def get_issue_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/issues/{pr_number}/comments\",\n    )\n\n\nasync def get_review_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments\",\n    )\n\n\nasync def get_reviews(pr_number: int) -> list[dict[str, Any]]:\n    page = 1\n    reviews: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        reviews.extend(batch)\n        page += 1\n    return reviews\n\n\nasync def get_check_runs(head_sha: str) -> list[dict[str, Any]]:\n    page = 1\n    check_runs: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/commits/{head_sha}/check-runs\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        payload = json.loads(data)\n        batch = payload.get(\"check_runs\", [])\n        if not batch:\n            break\n        check_runs.extend(batch)\n        total_count = payload.get(\"total_count\")\n        if total_count is not None and len(check_runs) >= total_count:\n            break\n        page += 1\n    return check_runs\n\n\ndef parse_time(value: str) -> datetime:\n    normalized = value.replace(\"Z\", \"+00:00\")\n    return datetime.fromisoformat(normalized)\n\n\nCONTROL_CHARS_RE = re.compile(r\"[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f]\")\n\n\ndef sanitize_terminal_output(value: str) -> str:\n    return CONTROL_CHARS_RE.sub(\"\", value)\n\n\ndef check_timestamp(check: dict[str, Any]) -> datetime | None:\n    for key in (\"completed_at\", \"started_at\", \"run_started_at\", \"created_at\"):\n        value = check.get(key)\n        if value:\n            return parse_time(value)\n    return None\n\n\ndef dedupe_check_runs(check_runs: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_name: dict[str, dict[str, Any]] = {}\n    for check in check_runs:\n        name = check.get(\"name\", \"unknown\")\n        timestamp = check_timestamp(check)\n        if name not in latest_by_name:\n            latest_by_name[name] = check\n            continue\n        existing = latest_by_name[name]\n        existing_timestamp = check_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_name[name] = check\n    return list(latest_by_name.values())\n\n\ndef summarize_checks(check_runs: list[dict[str, Any]]) -> tuple[bool, bool, list[str]]:\n    if not check_runs:\n        return True, False, [\"no checks reported\"]\n    check_runs = dedupe_check_runs(check_runs)\n    pending = False\n    failed = False\n    failures: list[str] = []\n    for check in check_runs:\n        status = check.get(\"status\")\n        conclusion = check.get(\"conclusion\")\n        name = check.get(\"name\", \"unknown\")\n        if status != \"completed\":\n            pending = True\n            continue\n        if conclusion not in (\"success\", \"skipped\", \"neutral\"):\n            failed = True\n            failures.append(f\"{name}: {conclusion}\")\n    return pending, failed, failures\n\n\ndef latest_review_request_at(comments: list[dict[str, Any]]) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        if is_codex_bot_user(comment.get(\"user\", {})):\n            continue\n        body = comment.get(\"body\") or \"\"\n        if \"@codex review\" not in body:\n            continue\n        timestamp = comment_time(comment)\n        if timestamp is None:\n            continue\n        if latest is None or timestamp > latest:\n            latest = timestamp\n    return latest\n\n\ndef filter_codex_comments(\n    comments: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    latest_issue_ack = latest_codex_issue_reply_time(comments)\n    codex_comments = [c for c in comments if is_codex_bot_user(c.get(\"user\", {}))]\n    filtered: list[dict[str, Any]] = []\n    for comment in codex_comments:\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if review_requested_at is not None and created_time <= review_requested_at:\n            continue\n        is_threaded = bool(\n            comment.get(\"in_reply_to_id\") or comment.get(\"pull_request_review_id\")\n        )\n        if not is_threaded:\n            if latest_issue_ack is not None and created_time <= latest_issue_ack:\n                continue\n        else:\n            thread_root = thread_root_id(comment)\n            last_reply = None\n            if thread_root is not None:\n                last_reply = latest_codex_reply.get(thread_root)\n            if last_reply and last_reply > created_time:\n                continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_codex_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    return login in CODEX_BOTS\n\n\ndef is_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    if is_codex_bot_user(user):\n        return True\n    if user.get(\"type\") == \"Bot\":\n        return True\n    return login.endswith(\"[bot]\")\n\n\ndef is_codex_reply_body(body: str) -> bool:\n    return body.startswith(\"[codex]\")\n\n\ndef is_codex_review_body(body: str) -> bool:\n    return body.startswith(\"## Codex Review\")\n\n\ndef latest_codex_issue_reply_time(\n    comments: list[dict[str, Any]],\n) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if latest is None or created_time > latest:\n            latest = created_time\n    return latest\n\n\ndef filter_human_issue_comments(comments: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        if is_codex_review_body(body):\n            continue\n        if \"@codex review\" in body:\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef filter_codex_review_issue_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_review_body(body):\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef thread_root_id(comment: dict[str, Any]) -> int | None:\n    return comment.get(\"in_reply_to_id\") or comment.get(\"id\")\n\n\ndef comment_time(comment: dict[str, Any]) -> datetime | None:\n    timestamp = comment.get(\"updated_at\") or comment.get(\"created_at\")\n    if not timestamp:\n        return None\n    return parse_time(timestamp)\n\n\ndef latest_codex_reply_by_thread(\n    comments: list[dict[str, Any]],\n) -> dict[int, datetime]:\n    latest: dict[int, datetime] = {}\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        if thread_root is None or created_time is None:\n            continue\n        existing = latest.get(thread_root)\n        if existing is None or created_time > existing:\n            latest[thread_root] = created_time\n    return latest\n\n\ndef filter_human_review_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        last_codex_reply = None\n        if thread_root is not None:\n            last_codex_reply = latest_codex_reply.get(thread_root)\n        if last_codex_reply and created_time and created_time <= last_codex_reply:\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_blocking_review(\n    review: dict[str, Any],\n    review_requested_at: datetime | None,\n) -> bool:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return False\n    user_login = review.get(\"user\", {}).get(\"login\")\n    created_time = parse_time(created_at)\n    if (\n        user_login in CODEX_BOTS\n        and review_requested_at is not None\n        and created_time <= review_requested_at\n    ):\n        return False\n    body = (review.get(\"body\") or \"\").strip()\n    state = review.get(\"state\")\n    if user_login in CODEX_BOTS:\n        return state == \"CHANGES_REQUESTED\"\n    if body.startswith(\"[codex]\") or state in (\"APPROVED\", \"DISMISSED\"):\n        return False\n    blocking = False\n    if body or state == \"CHANGES_REQUESTED\":\n        blocking = True\n    elif state == \"COMMENTED\":\n        blocking = False\n    elif state:\n        blocking = state not in (\"APPROVED\", \"DISMISSED\")\n    return blocking\n\n\ndef review_timestamp(review: dict[str, Any]) -> datetime | None:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return None\n    return parse_time(created_at)\n\n\ndef dedupe_reviews(reviews: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_user: dict[str, dict[str, Any]] = {}\n    for review in reviews:\n        user_login = review.get(\"user\", {}).get(\"login\")\n        if not user_login:\n            continue\n        timestamp = review_timestamp(review)\n        if user_login not in latest_by_user:\n            latest_by_user[user_login] = review\n            continue\n        existing = latest_by_user[user_login]\n        existing_timestamp = review_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_user[user_login] = review\n    return list(latest_by_user.values())\n\n\ndef filter_blocking_reviews(\n    reviews: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    return [\n        review\n        for review in dedupe_reviews(reviews)\n        if is_blocking_review(review, review_requested_at)\n    ]\n\n\ndef is_merge_conflicting(pr: PrInfo) -> bool:\n    return pr.mergeable == \"CONFLICTING\" or pr.merge_state == \"DIRTY\"\n\n\nasync def fetch_review_context(\n    pr_number: int,\n) -> tuple[\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    datetime | None,\n]:\n    issue_comments = await get_issue_comments(pr_number)\n    review_request_at = latest_review_request_at(issue_comments)\n    review_comments = await get_review_comments(pr_number)\n    reviews = await get_reviews(pr_number)\n    return issue_comments, review_comments, reviews, review_request_at\n\n\ndef raise_on_human_feedback(\n    issue_comments: list[dict[str, Any]],\n    review_comments: list[dict[str, Any]],\n    reviews: list[dict[str, Any]],\n    review_request_at: datetime | None,\n) -> None:\n    human_issue_comments = filter_human_issue_comments(issue_comments)\n    codex_review_comments = filter_codex_review_issue_comments(issue_comments)\n    human_review_comments = filter_human_review_comments(review_comments)\n    if human_issue_comments or human_review_comments or codex_review_comments:\n        print(\"Review comments detected. Address before merge.\")\n        print(\n            \"Reminder: decide whether feedback stays in scope; defer if needed \"\n            \"and note in your root-level update.\",\n        )\n        raise SystemExit(2)\n    blocking_reviews = filter_blocking_reviews(reviews, review_request_at)\n    if blocking_reviews:\n        print(\"Review states/comments detected. Address before merge.\")\n        print(\n            \"Reminder: keep PR title/description aligned with the full scope \"\n            \"when changes expand.\",\n        )\n        raise SystemExit(2)\n\n\nasync def wait_for_codex(pr_number: int, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for review feedback...\", flush=True)\n    while True:\n        (\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        ) = await fetch_review_context(pr_number)\n        bot_issue_comments = filter_codex_comments(issue_comments, review_request_at)\n        bot_review_comments = filter_codex_comments(review_comments, review_request_at)\n        bot_comments = bot_issue_comments + bot_review_comments\n        raise_on_human_feedback(\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        )\n        if bot_comments:\n            latest = max(\n                bot_comments,\n                key=lambda comment: parse_time(comment[\"created_at\"]),\n            )\n            body = sanitize_terminal_output(latest.get(\"body\") or \"\").strip()\n            if body:\n                print(\"Codex left comments. Address feedback before merge.\")\n                print(body)\n                raise SystemExit(2)\n        if checks_done.is_set():\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def wait_for_checks(head_sha: str, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for CI checks...\", flush=True)\n    empty_seconds = 0\n    while True:\n        check_runs = await get_check_runs(head_sha)\n        if not check_runs:\n            empty_seconds += POLL_SECONDS\n            if empty_seconds >= CHECKS_APPEAR_TIMEOUT_SECONDS:\n                print(\n                    \"No checks detected after 120s; check CI configuration\",\n                )\n                raise SystemExit(3)\n            await asyncio.sleep(POLL_SECONDS)\n            continue\n        empty_seconds = 0\n        pending, failed, failures = summarize_checks(check_runs)\n        if failed:\n            print(\"Checks failed:\")\n            for failure in failures:\n                print(f\"- {failure}\")\n            raise SystemExit(3)\n        if not pending:\n            print(\"Checks passed\")\n            checks_done.set()\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def watch_pr() -> None:\n    pr = await get_pr_info()\n    if is_merge_conflicting(pr):\n        print(\n            \"PR has merge conflicts. Resolve/rebase against main and push before \"\n            \"running land_watch again.\",\n        )\n        raise SystemExit(5)\n    head_sha = pr.head_sha\n    checks_done = asyncio.Event()\n    codex_task = asyncio.create_task(wait_for_codex(pr.number, checks_done))\n    checks_task = asyncio.create_task(wait_for_checks(head_sha, checks_done))\n\n    async def head_monitor() -> None:\n        while True:\n            current = await get_pr_info()\n            if is_merge_conflicting(current):\n                print(\n                    \"PR has merge conflicts. Resolve/rebase against main and push \"\n                    \"before running land_watch again.\",\n                )\n                raise SystemExit(5)\n            if current.head_sha != head_sha:\n                print(\"PR head updated; pull/amend/force-push to retrigger CI\")\n                raise SystemExit(4)\n            await asyncio.sleep(POLL_SECONDS)\n\n    monitor_task = asyncio.create_task(head_monitor())\n    success_task = asyncio.gather(codex_task, checks_task)\n\n    done, pending = await asyncio.wait(\n        [monitor_task, success_task],\n        return_when=asyncio.FIRST_COMPLETED,\n    )\n    for task in pending:\n        task.cancel()\n    for task in done:\n        exc = task.exception()\n        if exc:\n            raise exc\n\n\nif __name__ == \"__main__\":\n    try:\n        asyncio.run(watch_pr())\n    except SystemExit as exc:\n        raise SystemExit(exc.code) from None\n"},"items":[{"name":"AGENTS.md","path":"elixir/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/elixir/AGENTS.md","title":"elixir - AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# Symphony Elixir\n\nThis directory contains the Elixir agent orchestration service that polls Linear, creates per-issue workspaces, and runs Codex in app-server mode.\n\n## Environment\n\n- Elixir: `1.19.x` (OTP 28) via `mise`.\n- Install deps: `mix setup`.\n- Main quality gate: `make all` (format check, lint, coverage, dialyzer).\n\n\n## Codebase-Specific Conventions\n\n- Runtime config is loaded from `WORKFLOW.md` front matter via `SymphonyElixir.Workflow` and `SymphonyElixir.Config`.\n- Keep the implementation aligned with [`../SPEC.md`](../SPEC.md) where practical.\n  - The implementation may be a superset of the spec.\n  - The implementation must not conflict with the spec.\n  - If implementation changes meaningfully alter the intended behavior, update the spec in the same\n    change where practical so the spec stays current.\n- Prefer adding config access through `SymphonyElixir.Config` instead of ad-hoc env reads.\n- Workspace safety is critical:\n  - Never run Codex turn cwd in source repo.\n  - Workspaces must stay under configured workspace root.\n- Orchestrator behavior is stateful and concurrency-sensitive; preserve retry, reconciliation, and cleanup semantics.\n- Simplicity is a project constraint: prefer the smallest coherent design with one clear owner and\n  invariant. Push back on extra abstractions, duplicated policy, and speculative flexibility.\n- For stateful changes, check startup, reload, restart, and failure recovery together before editing.\n- Follow `docs/logging.md` for logging conventions and required issue/session context fields.\n\n## Tests and Validation\n\nRun targeted tests while iterating, then run full gates before handoff.\n\n- Prefer narrow tests that exercise real OTP processes and observable behavior over mock-only or\n  broad end-to-end coverage; prove health with a synchronous call or stable effect, not only a PID.\n- For non-trivial changes, use an adversarial review early to challenge complexity and try to break\n  adjacent lifecycle paths; a reproducible failure blocks landing even if other reviews are clean.\n- If tests need repeated global restarts or bespoke cleanup, first fix the shared harness or\n  ownership boundary.\n\n```bash\nmake all\n```\n\n## Required Rules\n\n- Public functions (`def`) in `lib/` must have an adjacent `@spec`.\n- `defp` specs are optional.\n- `@impl` callback implementations are exempt from local `@spec` requirement.\n- Evaluate proposed directions instead of agreeing reflexively; surface simpler designs and material\n  trade-offs early.\n- Keep changes narrowly scoped; avoid unrelated refactors.\n- Follow existing module/style patterns in `lib/symphony_elixir/*`.\n\nValidation command:\n\n```bash\nmix specs.check\n```\n\n## PR Requirements\n\n- PR body must follow `../.github/pull_request_template.md` exactly.\n- Validate PR body locally when needed:\n\n```bash\nmix pr_body.check --file /path/to/pr_body.md\n```\n\n## Docs Update Policy\n\nIf behavior/config changes, update docs in the same PR:\n\n- `../README.md` for project concept and goals.\n- `README.md` for Elixir implementation and run instructions.\n- `WORKFLOW.md` for workflow/config contract changes.\n","isInternal":false,"tokens":678,"sizeBytes":3118},{"name":"SKILL.md","path":".codex/skills/commit/SKILL.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/commit/SKILL.md","title":"commit","category":"anthropic-skill","format":"markdown","content":"---\nname: commit\ndescription:\n  Create a well-formed git commit from current changes using session history for\n  rationale and summary; use when asked to commit, prepare a commit message, or\n  finalize staged work.\n---\n\n# Commit\n\n## Goals\n\n- Produce a commit that reflects the actual code changes and the session\n  context.\n- Follow common git conventions (type prefix, short subject, wrapped body).\n- Include both summary and rationale in the body.\n\n## Inputs\n\n- Codex session history for intent and rationale.\n- `git status`, `git diff`, and `git diff --staged` for actual changes.\n- Repo-specific commit conventions if documented.\n\n## Steps\n\n1. Read session history to identify scope, intent, and rationale.\n2. Inspect the working tree and staged changes (`git status`, `git diff`,\n   `git diff --staged`).\n3. Stage intended changes, including new files (`git add -A`) after confirming\n   scope.\n4. Sanity-check newly added files; if anything looks random or likely ignored\n   (build artifacts, logs, temp files), flag it to the user before committing.\n5. If staging is incomplete or includes unrelated files, fix the index or ask\n   for confirmation.\n6. Choose a conventional type and optional scope that match the change (e.g.,\n   `feat(scope): ...`, `fix(scope): ...`, `refactor(scope): ...`).\n7. Write a subject line in imperative mood, <= 72 characters, no trailing\n   period.\n8. Write a body that includes:\n   - Summary of key changes (what changed).\n   - Rationale and trade-offs (why it changed).\n   - Tests or validation run (or explicit note if not run).\n9. Append a `Co-authored-by` trailer for Codex using `Codex <codex@openai.com>`\n   unless the user explicitly requests a different identity.\n10. Wrap body lines at 72 characters.\n11. Create the commit message with a here-doc or temp file and use\n    `git commit -F <file>` so newlines are literal (avoid `-m` with `\\n`).\n12. Commit only when the message matches the staged changes: if the staged diff\n    includes unrelated files or the message describes work that isn't staged,\n    fix the index or revise the message before committing.\n\n## Output\n\n- A single commit created with `git commit` whose message reflects the session.\n\n## Template\n\nType and scope are examples only; adjust to fit the repo and changes.\n\n```\n<type>(<scope>): <short summary>\n\nSummary:\n- <what changed>\n- <what changed>\n\nRationale:\n- <why>\n- <why>\n\nTests:\n- <command or \"not run (reason)\">\n\nCo-authored-by: Codex <codex@openai.com>\n```\n","frontmatter":{"name":"commit","description":"Create a well-formed git commit from current changes using session history for rationale and summary; use when asked to commit, prepare a commit message, or finalize staged work."},"isInternal":false,"tokens":587,"sizeBytes":2480},{"name":"SKILL.md","path":".codex/skills/debug/SKILL.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/debug/SKILL.md","title":"debug","category":"anthropic-skill","format":"markdown","content":"---\nname: debug\ndescription:\n  Investigate stuck runs and execution failures by tracing Symphony and Codex\n  logs with issue/session identifiers; use when runs stall, retry repeatedly, or\n  fail unexpectedly.\n---\n\n# Debug\n\n## Goals\n\n- Find why a run is stuck, retrying, or failing.\n- Correlate Linear issue identity to a Codex session quickly.\n- Read the right logs in the right order to isolate root cause.\n\n## Log Sources\n\n- Primary runtime log: `log/symphony.log`\n  - Default comes from `SymphonyElixir.LogFile` (`log/symphony.log`).\n  - Includes orchestrator, agent runner, and Codex app-server lifecycle logs.\n- Rotated runtime logs: `log/symphony.log*`\n  - Check these when the relevant run is older.\n\n## Correlation Keys\n\n- `issue_identifier`: human ticket key (example: `MT-625`)\n- `issue_id`: Linear UUID (stable internal ID)\n- `session_id`: Codex thread-turn pair (`<thread_id>-<turn_id>`)\n\n`elixir/docs/logging.md` requires these fields for issue/session lifecycle logs. Use\nthem as your join keys during debugging.\n\n## Quick Triage (Stuck Run)\n\n1. Confirm scheduler/worker symptoms for the ticket.\n2. Find recent lines for the ticket (`issue_identifier` first).\n3. Extract `session_id` from matching lines.\n4. Trace that `session_id` across start, stream, completion/failure, and stall\n   handling logs.\n5. Decide class of failure: timeout/stall, app-server startup failure, turn\n   failure, or orchestrator retry loop.\n\n## Commands\n\n```bash\n# 1) Narrow by ticket key (fastest entry point)\nrg -n \"issue_identifier=MT-625\" log/symphony.log*\n\n# 2) If needed, narrow by Linear UUID\nrg -n \"issue_id=<linear-uuid>\" log/symphony.log*\n\n# 3) Pull session IDs seen for that ticket\nrg -o \"session_id=[^ ;]+\" log/symphony.log* | sort -u\n\n# 4) Trace one session end-to-end\nrg -n \"session_id=<thread>-<turn>\" log/symphony.log*\n\n# 5) Focus on stuck/retry signals\nrg -n \"Issue stalled|scheduling retry|turn_timeout|turn_failed|Codex session failed|Codex session ended with error\" log/symphony.log*\n```\n\n## Investigation Flow\n\n1. Locate the ticket slice:\n    - Search by `issue_identifier=<KEY>`.\n    - If noise is high, add `issue_id=<UUID>`.\n2. Establish timeline:\n    - Identify first `Codex session started ... session_id=...`.\n    - Follow with `Codex session completed`, `ended with error`, or worker exit\n      lines.\n3. Classify the problem:\n    - Stall loop: `Issue stalled ... restarting with backoff`.\n    - App-server startup: `Codex session failed ...`.\n    - Turn execution failure: `turn_failed`, `turn_cancelled`, `turn_timeout`, or\n      `ended with error`.\n    - Worker crash: `Agent task exited ... reason=...`.\n4. Validate scope:\n    - Check whether failures are isolated to one issue/session or repeating across\n      multiple tickets.\n5. Capture evidence:\n    - Save key log lines with timestamps, `issue_identifier`, `issue_id`, and\n      `session_id`.\n    - Record probable root cause and the exact failing stage.\n\n## Reading Codex Session Logs\n\nIn Symphony, Codex session diagnostics are emitted into `log/symphony.log` and\nkeyed by `session_id`. Read them as a lifecycle:\n\n1. `Codex session started ... session_id=...`\n2. Session stream/lifecycle events for the same `session_id`\n3. Terminal event:\n    - `Codex session completed ...`, or\n    - `Codex session ended with error ...`, or\n    - `Issue stalled ... restarting with backoff`\n\nFor one specific session investigation, keep the trace narrow:\n\n1. Capture one `session_id` for the ticket.\n2. Build a timestamped slice for only that session:\n    - `rg -n \"session_id=<thread>-<turn>\" log/symphony.log*`\n3. Mark the exact failing stage:\n    - Startup failure before stream events (`Codex session failed ...`).\n    - Turn/runtime failure after stream events (`turn_*` / `ended with error`).\n    - Stall recovery (`Issue stalled ... restarting with backoff`).\n4. Pair findings with `issue_identifier` and `issue_id` from nearby lines to\n   confirm you are not mixing concurrent retries.\n\nAlways pair session findings with `issue_identifier`/`issue_id` to avoid mixing\nconcurrent runs.\n\n## Notes\n\n- Prefer `rg` over `grep` for speed on large logs.\n- Check rotated logs (`log/symphony.log*`) before concluding data is missing.\n- If required context fields are missing in new log statements, align with\n  `elixir/docs/logging.md` conventions.\n","frontmatter":{"name":"debug","description":"Investigate stuck runs and execution failures by tracing Symphony and Codex logs with issue/session identifiers; use when runs stall, retry repeatedly, or fail unexpectedly."},"isInternal":false,"tokens":1071,"sizeBytes":4314},{"name":"SKILL.md","path":".codex/skills/land/SKILL.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/land/SKILL.md","title":"land","category":"anthropic-skill","format":"markdown","content":"---\nname: land\ndescription:\n  Land a PR by monitoring conflicts, resolving them, waiting for checks, and\n  squash-merging when green; use when asked to land, merge, or shepherd a PR to\n  completion.\n---\n\n# Land\n\n## Goals\n\n- Ensure the PR is conflict-free with main.\n- Keep CI green and fix failures when they occur.\n- Squash-merge the PR once checks pass.\n- Do not yield to the user until the PR is merged; keep the watcher loop running\n  unless blocked.\n- No need to delete remote branches after merge; the repo auto-deletes head\n  branches.\n\n## Preconditions\n\n- `gh` CLI is authenticated.\n- You are on the PR branch with a clean working tree.\n\n## Steps\n\n1. Locate the PR for the current branch.\n2. Confirm the full gauntlet is green locally before any push.\n3. If the working tree has uncommitted changes, commit with the `commit` skill\n   and push with the `push` skill before proceeding.\n4. Check mergeability and conflicts against main.\n5. If conflicts exist, use the `pull` skill to fetch/merge `origin/main` and\n   resolve conflicts, then use the `push` skill to publish the updated branch.\n6. Ensure Codex review comments (if present) are acknowledged and any required\n   fixes are handled before merging.\n7. Watch checks until complete.\n8. If checks fail, pull logs, fix the issue, commit with the `commit` skill,\n   push with the `push` skill, and re-run checks.\n9. When all checks are green and review feedback is addressed, squash-merge and\n   delete the branch using the PR title/body for the merge subject/body.\n10. **Context guard:** Before implementing review feedback, confirm it does not\n    conflict with the user’s stated intent or task context. If it conflicts,\n    respond inline with a justification and ask the user before changing code.\n11. **Pushback template:** When disagreeing, reply inline with: acknowledge +\n    rationale + offer alternative.\n12. **Ambiguity gate:** When ambiguity blocks progress, use the clarification\n    flow (assign PR to current GH user, mention them, wait for response). Do not\n    implement until ambiguity is resolved.\n    - If you are confident you know better than the reviewer, you may proceed\n      without asking the user, but reply inline with your rationale.\n13. **Per-comment mode:** For each review comment, choose one of: accept,\n    clarify, or push back. Reply inline (or in the issue thread for Codex\n    reviews) stating the mode before changing code.\n14. **Reply before change:** Always respond with intended action before pushing\n    code changes (inline for review comments, issue thread for Codex reviews).\n\n## Commands\n\n```\n# Ensure branch and PR context\nbranch=$(git branch --show-current)\npr_number=$(gh pr view --json number -q .number)\npr_title=$(gh pr view --json title -q .title)\npr_body=$(gh pr view --json body -q .body)\n\n# Check mergeability and conflicts\nmergeable=$(gh pr view --json mergeable -q .mergeable)\n\nif [ \"$mergeable\" = \"CONFLICTING\" ]; then\n  # Run the `pull` skill to handle fetch + merge + conflict resolution.\n  # Then run the `push` skill to publish the updated branch.\nfi\n\n# Preferred: use the Async Watch Helper below. The manual loop is a fallback\n# when Python cannot run or the helper script is unavailable.\n# Wait for review feedback: Codex reviews arrive as issue comments that start\n# with \"## Codex Review — <persona>\". Treat them like reviewer feedback: reply\n# with a `[codex]` issue comment acknowledging the findings and whether you're\n# addressing or deferring them.\nwhile true; do\n  gh api repos/{owner}/{repo}/issues/\"$pr_number\"/comments \\\n    --jq '.[] | select(.body | startswith(\"## Codex Review\")) | .id' | rg -q '.' \\\n    && break\n  sleep 10\ndone\n\n# Watch checks\nif ! gh pr checks --watch; then\n  gh pr checks\n  # Identify failing run and inspect logs\n  # gh run list --branch \"$branch\"\n  # gh run view <run-id> --log\n  exit 1\nfi\n\n# Squash-merge (remote branches auto-delete on merge in this repo)\ngh pr merge --squash --subject \"$pr_title\" --body \"$pr_body\"\n```\n\n## Async Watch Helper\n\nPreferred: use the asyncio watcher to monitor review comments, CI, and head\nupdates in parallel:\n\n```\npython3 .codex/skills/land/land_watch.py\n```\n\nExit codes:\n\n- 2: Review comments detected (address feedback)\n- 3: CI checks failed\n- 4: PR head updated (autofix commit detected)\n\n## Failure Handling\n\n- If checks fail, pull details with `gh pr checks` and `gh run view --log`, then\n  fix locally, commit with the `commit` skill, push with the `push` skill, and\n  re-run the watch.\n- Use judgment to identify flaky failures. If a failure is a flake (e.g., a\n  timeout on only one platform), you may proceed without fixing it.\n- If CI pushes an auto-fix commit (authored by GitHub Actions), it does not\n  trigger a fresh CI run. Detect the updated PR head, pull locally, merge\n  `origin/main` if needed, add a real author commit, and force-push to retrigger\n  CI, then restart the checks loop.\n- If all jobs fail with corrupted pnpm lockfile errors on the merge commit, the\n  remediation is to fetch latest `origin/main`, merge, force-push, and rerun CI.\n- If mergeability is `UNKNOWN`, wait and re-check.\n- Do not merge while review comments (human or Codex review) are outstanding.\n- Codex review jobs retry on failure and are non-blocking; use the presence of\n  `## Codex Review — <persona>` issue comments (not job status) as the signal\n  that review feedback is available.\n- Do not enable auto-merge; this repo has no required checks so auto-merge can\n  skip tests.\n- If the remote PR branch advanced due to your own prior force-push or merge,\n  avoid redundant merges; re-run the formatter locally if needed and\n  `git push --force-with-lease`.\n\n## Review Handling\n\n- Codex reviews now arrive as issue comments posted by GitHub Actions. They\n  start with `## Codex Review — <persona>` and include the reviewer’s\n  methodology + guardrails used. Treat these as feedback that must be\n  acknowledged before merge.\n- Human review comments are blocking and must be addressed (responded to and\n  resolved) before requesting a new review or merging.\n- If multiple reviewers comment in the same thread, respond to each comment\n  (batching is fine) before closing the thread.\n- Fetch review comments via `gh api` and reply with a prefixed comment.\n- Use review comment endpoints (not issue comments) to find inline feedback:\n  - List PR review comments:\n    ```\n    gh api repos/{owner}/{repo}/pulls/<pr_number>/comments\n    ```\n  - PR issue comments (top-level discussion):\n    ```\n    gh api repos/{owner}/{repo}/issues/<pr_number>/comments\n    ```\n  - Reply to a specific review comment:\n    ```\n    gh api -X POST /repos/{owner}/{repo}/pulls/<pr_number>/comments \\\n      -f body='[codex] <response>' -F in_reply_to=<comment_id>\n    ```\n- `in_reply_to` must be the numeric review comment id (e.g., `2710521800`), not\n  the GraphQL node id (e.g., `PRRC_...`), and the endpoint must include the PR\n  number (`/pulls/<pr_number>/comments`).\n- If GraphQL review reply mutation is forbidden, use REST.\n- A 404 on reply typically means the wrong endpoint (missing PR number) or\n  insufficient scope; verify by listing comments first.\n- All GitHub comments generated by this agent must be prefixed with `[codex]`.\n- For Codex review issue comments, reply in the issue thread (not a review\n  thread) with `[codex]` and state whether you will address the feedback now or\n  defer it (include rationale).\n- If feedback requires changes:\n  - For inline review comments (human), reply with intended fixes\n    (`[codex] ...`) **as an inline reply to the original review comment** using\n    the review comment endpoint and `in_reply_to` (do not use issue comments for\n    this).\n  - Implement fixes, commit, push.\n  - Reply with the fix details and commit sha (`[codex] ...`) in the same place\n    you acknowledged the feedback (issue comment for Codex reviews, inline reply\n    for review comments).\n  - The land watcher treats Codex review issue comments as unresolved until a\n    newer `[codex]` issue comment is posted acknowledging the findings.\n- Only request a new Codex review when you need a rerun (e.g., after new\n  commits). Do not request one without changes since the last review.\n  - Before requesting a new Codex review, re-run the land watcher and ensure\n    there are zero outstanding review comments (all have `[codex]` inline\n    replies).\n  - After pushing new commits, the Codex review workflow will rerun on PR\n    synchronization (or you can re-run the workflow manually). Post a concise\n    root-level summary comment so reviewers have the latest delta:\n    ```\n    [codex] Changes since last review:\n    - <short bullets of deltas>\n    Commits: <sha>, <sha>\n    Tests: <commands run>\n    ```\n  - Only request a new review if there is at least one new commit since the\n    previous request.\n  - Wait for the next Codex review comment before merging.\n\n## Scope + PR Metadata\n\n- The PR title and description should reflect the full scope of the change, not\n  just the most recent fix.\n- If review feedback expands scope, decide whether to include it now or defer\n  it. You can accept, defer, or decline feedback. If deferring or declining,\n  call it out in the root-level `[codex]` update with a brief reason (e.g.,\n  out-of-scope, conflicts with intent, unnecessary).\n- Correctness issues raised in review comments should be addressed. If you plan\n  to defer or decline a correctness concern, validate first and explain why the\n  concern does not apply.\n- Classify each review comment as one of: correctness, design, style,\n  clarification, scope.\n- For correctness feedback, provide concrete validation (test, log, or\n  reasoning) before closing it.\n- When accepting feedback, include a one-line rationale in the root-level\n  update.\n- When declining feedback, offer a brief alternative or follow-up trigger.\n- Prefer a single consolidated \"review addressed\" root-level comment after a\n  batch of fixes instead of many small updates.\n- For doc feedback, confirm the doc change matches behavior (no doc-only edits\n  to appease review).\n","frontmatter":{"name":"land","description":"Land a PR by monitoring conflicts, resolving them, waiting for checks, and squash-merging when green; use when asked to land, merge, or shepherd a PR to completion."},"isInternal":false,"tokens":2397,"sizeBytes":10064},{"name":"SKILL.md","path":".codex/skills/linear/SKILL.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/linear/SKILL.md","title":"linear","category":"anthropic-skill","format":"markdown","content":"---\nname: linear\ndescription: |\n  Use Symphony's `linear_graphql` client tool for raw Linear GraphQL\n  operations such as comment editing and upload flows.\n---\n\n# Linear GraphQL\n\nUse this skill for raw Linear GraphQL work during Symphony app-server sessions.\n\n## Primary tool\n\nUse the `linear_graphql` client tool exposed by Symphony's app-server session.\nIt reuses Symphony's configured Linear auth for the session.\n\nTool input:\n\n```json\n{\n  \"query\": \"query or mutation document\",\n  \"variables\": {\n    \"optional\": \"graphql variables object\"\n  }\n}\n```\n\nTool behavior:\n\n- Send one GraphQL operation per tool call.\n- Treat a top-level `errors` array as a failed GraphQL operation even if the\n  tool call itself completed.\n- Keep queries/mutations narrowly scoped; ask only for the fields you need.\n\n## Discovering unfamiliar operations\n\nWhen you need an unfamiliar mutation, input type, or object field, use targeted\nintrospection through `linear_graphql`.\n\nList mutation names:\n\n```graphql\nquery ListMutations {\n  __type(name: \"Mutation\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\nInspect a specific input object:\n\n```graphql\nquery CommentCreateInputShape {\n  __type(name: \"CommentCreateInput\") {\n    inputFields {\n      name\n      type {\n        kind\n        name\n        ofType {\n          kind\n          name\n        }\n      }\n    }\n  }\n}\n```\n\n## Common workflows\n\n### Query an issue by key, identifier, or id\n\nUse these progressively:\n\n- Start with `issue(id: $key)` when you have a ticket key such as `MT-686`.\n- Fall back to `issues(filter: ...)` when you need identifier search semantics.\n- Once you have the internal issue id, prefer `issue(id: $id)` for narrower reads.\n\nLookup by issue key:\n\n```graphql\nquery IssueByKey($key: String!) {\n  issue(id: $key) {\n    id\n    identifier\n    title\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    branchName\n    url\n    description\n    updatedAt\n    links {\n      nodes {\n        id\n        url\n        title\n      }\n    }\n  }\n}\n```\n\nLookup by identifier filter:\n\n```graphql\nquery IssueByIdentifier($identifier: String!) {\n  issues(filter: { identifier: { eq: $identifier } }, first: 1) {\n    nodes {\n      id\n      identifier\n      title\n      state {\n        id\n        name\n        type\n      }\n      project {\n        id\n        name\n      }\n      branchName\n      url\n      description\n      updatedAt\n    }\n  }\n}\n```\n\nResolve a key to an internal id:\n\n```graphql\nquery IssueByIdOrKey($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n  }\n}\n```\n\nRead the issue once the internal id is known:\n\n```graphql\nquery IssueDetails($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n    url\n    description\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    attachments {\n      nodes {\n        id\n        title\n        url\n        sourceType\n      }\n    }\n  }\n}\n```\n\n### Query team workflow states for an issue\n\nUse this before changing issue state when you need the exact `stateId`:\n\n```graphql\nquery IssueTeamStates($id: String!) {\n  issue(id: $id) {\n    id\n    team {\n      id\n      key\n      name\n      states {\n        nodes {\n          id\n          name\n          type\n        }\n      }\n    }\n  }\n}\n```\n\n### Edit an existing comment\n\nUse `commentUpdate` through `linear_graphql`:\n\n```graphql\nmutation UpdateComment($id: String!, $body: String!) {\n  commentUpdate(id: $id, input: { body: $body }) {\n    success\n    comment {\n      id\n      body\n    }\n  }\n}\n```\n\n### Create a comment\n\nUse `commentCreate` through `linear_graphql`:\n\n```graphql\nmutation CreateComment($issueId: String!, $body: String!) {\n  commentCreate(input: { issueId: $issueId, body: $body }) {\n    success\n    comment {\n      id\n      url\n    }\n  }\n}\n```\n\n### Move an issue to a different state\n\nUse `issueUpdate` with the destination `stateId`:\n\n```graphql\nmutation MoveIssueToState($id: String!, $stateId: String!) {\n  issueUpdate(id: $id, input: { stateId: $stateId }) {\n    success\n    issue {\n      id\n      identifier\n      state {\n        id\n        name\n      }\n    }\n  }\n}\n```\n\n### Attach a GitHub PR to an issue\n\nUse the GitHub-specific attachment mutation when linking a PR:\n\n```graphql\nmutation AttachGitHubPR($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkGitHubPR(\n    issueId: $issueId\n    url: $url\n    title: $title\n    linkKind: links\n  ) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\nIf you only need a plain URL attachment and do not care about GitHub-specific\nlink metadata, use:\n\n```graphql\nmutation AttachURL($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkURL(issueId: $issueId, url: $url, title: $title) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\n### Introspection patterns used during schema discovery\n\nUse these when the exact field or mutation shape is unclear:\n\n```graphql\nquery QueryFields {\n  __type(name: \"Query\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\n```graphql\nquery IssueFieldArgs {\n  __type(name: \"Query\") {\n    fields {\n      name\n      args {\n        name\n        type {\n          kind\n          name\n          ofType {\n            kind\n            name\n            ofType {\n              kind\n              name\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n### Upload a video to a comment\n\nDo this in three steps:\n\n1. Call `linear_graphql` with `fileUpload` to get `uploadUrl`, `assetUrl`, and\n   any required upload headers.\n2. Upload the local file bytes to `uploadUrl` with `curl -X PUT` and the exact\n   headers returned by `fileUpload`.\n3. Call `linear_graphql` again with `commentCreate` (or `commentUpdate`) and\n   include the resulting `assetUrl` in the comment body.\n\nUseful mutations:\n\n```graphql\nmutation FileUpload(\n  $filename: String!\n  $contentType: String!\n  $size: Int!\n  $makePublic: Boolean\n) {\n  fileUpload(\n    filename: $filename\n    contentType: $contentType\n    size: $size\n    makePublic: $makePublic\n  ) {\n    success\n    uploadFile {\n      uploadUrl\n      assetUrl\n      headers {\n        key\n        value\n      }\n    }\n  }\n}\n```\n\n## Usage rules\n\n- Use `linear_graphql` for comment edits, uploads, and ad-hoc Linear API\n  queries.\n- Prefer the narrowest issue lookup that matches what you already know:\n  key -> identifier search -> internal id.\n- For state transitions, fetch team states first and use the exact `stateId`\n  instead of hardcoding names inside mutations.\n- Prefer `attachmentLinkGitHubPR` over a generic URL attachment when linking a\n  GitHub PR to a Linear issue.\n- Do not introduce new raw-token shell helpers for GraphQL access.\n- If you need shell work for uploads, only use it for signed upload URLs\n  returned by `fileUpload`; those URLs already carry the needed authorization.\n","frontmatter":{"name":"linear","description":"Use Symphony's `linear_graphql` client tool for raw Linear GraphQL\noperations such as comment editing and upload flows.\n"},"isInternal":false,"tokens":1709,"sizeBytes":6838},{"name":"SKILL.md","path":".codex/skills/pull/SKILL.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/pull/SKILL.md","title":"pull","category":"anthropic-skill","format":"markdown","content":"---\nname: pull\ndescription:\n  Pull latest origin/main into the current local branch and resolve merge\n  conflicts (aka update-branch). Use when Codex needs to sync a feature branch\n  with origin, perform a merge-based update (not rebase), and guide conflict\n  resolution best practices.\n---\n\n# Pull\n\n## Workflow\n\n1. Verify git status is clean or commit/stash changes before merging.\n2. Ensure rerere is enabled locally:\n   - `git config rerere.enabled true`\n   - `git config rerere.autoupdate true`\n3. Confirm remotes and branches:\n   - Ensure the `origin` remote exists.\n   - Ensure the current branch is the one to receive the merge.\n4. Fetch latest refs:\n   - `git fetch origin`\n5. Sync the remote feature branch first:\n   - `git pull --ff-only origin $(git branch --show-current)`\n   - This pulls branch updates made remotely (for example, a GitHub auto-commit)\n     before merging `origin/main`.\n6. Merge in order:\n   - Prefer `git -c merge.conflictstyle=zdiff3 merge origin/main` for clearer\n     conflict context.\n7. If conflicts appear, resolve them (see conflict guidance below), then:\n   - `git add <files>`\n   - `git commit` (or `git merge --continue` if the merge is paused)\n8. Verify with project checks (follow repo policy in `AGENTS.md`).\n9. Summarize the merge:\n   - Call out the most challenging conflicts/files and how they were resolved.\n   - Note any assumptions or follow-ups.\n\n## Conflict Resolution Guidance (Best Practices)\n\n- Inspect context before editing:\n  - Use `git status` to list conflicted files.\n  - Use `git diff` or `git diff --merge` to see conflict hunks.\n  - Use `git diff :1:path/to/file :2:path/to/file` and\n    `git diff :1:path/to/file :3:path/to/file` to compare base vs ours/theirs\n    for a file-level view of intent.\n  - With `merge.conflictstyle=zdiff3`, conflict markers include:\n    - `<<<<<<<` ours, `|||||||` base, `=======` split, `>>>>>>>` theirs.\n    - Matching lines near the start/end are trimmed out of the conflict region,\n      so focus on the differing core.\n  - Summarize the intent of both changes, decide the semantically correct\n    outcome, then edit:\n    - State what each side is trying to achieve (bug fix, refactor, rename,\n      behavior change).\n    - Identify the shared goal, if any, and whether one side supersedes the\n      other.\n    - Decide the final behavior first; only then craft the code to match that\n      decision.\n    - Prefer preserving invariants, API contracts, and user-visible behavior\n      unless the conflict clearly indicates a deliberate change.\n  - Open files and understand intent on both sides before choosing a resolution.\n- Prefer minimal, intention-preserving edits:\n  - Keep behavior consistent with the branch’s purpose.\n  - Avoid accidental deletions or silent behavior changes.\n- Resolve one file at a time and rerun tests after each logical batch.\n- Use `ours/theirs` only when you are certain one side should win entirely.\n- For complex conflicts, search for related files or definitions to align with\n  the rest of the codebase.\n- For generated files, resolve non-generated conflicts first, then regenerate:\n  - Prefer resolving source files and handwritten logic before touching\n    generated artifacts.\n  - Run the CLI/tooling command that produced the generated file to recreate it\n    cleanly, then stage the regenerated output.\n- For import conflicts where intent is unclear, accept both sides first:\n  - Keep all candidate imports temporarily, finish the merge, then run lint/type\n    checks to remove unused or incorrect imports safely.\n- After resolving, ensure no conflict markers remain:\n  - `git diff --check`\n- When unsure, note assumptions and ask for confirmation before finalizing the\n  merge.\n\n## When To Ask The User (Keep To A Minimum)\n\nDo not ask for input unless there is no safe, reversible alternative. Prefer\nmaking a best-effort decision, documenting the rationale, and proceeding.\n\nAsk the user only when:\n\n- The correct resolution depends on product intent or behavior not inferable\n  from code, tests, or nearby documentation.\n- The conflict crosses a user-visible contract, API surface, or migration where\n  choosing incorrectly could break external consumers.\n- A conflict requires selecting between two mutually exclusive designs with\n  equivalent technical merit and no clear local signal.\n- The merge introduces data loss, schema changes, or irreversible side effects\n  without an obvious safe default.\n- The branch is not the intended target, or the remote/branch names do not exist\n  and cannot be determined locally.\n\nOtherwise, proceed with the merge, explain the decision briefly in notes, and\nleave a clear, reviewable commit history.\n","frontmatter":{"name":"pull","description":"Pull latest origin/main into the current local branch and resolve merge conflicts (aka update-branch). Use when Codex needs to sync a feature branch with origin, perform a merge-based update (not rebase), and guide conflict resolution best practices."},"isInternal":false,"tokens":1036,"sizeBytes":4685},{"name":"SKILL.md","path":".codex/skills/push/SKILL.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/push/SKILL.md","title":"push","category":"anthropic-skill","format":"markdown","content":"---\nname: push\ndescription:\n  Push current branch changes to origin and create or update the corresponding\n  pull request; use when asked to push, publish updates, or create pull request.\n---\n\n# Push\n\n## Prerequisites\n\n- `gh` CLI is installed and available in `PATH`.\n- `gh auth status` succeeds for GitHub operations in this repo.\n\n## Goals\n\n- Push current branch changes to `origin` safely.\n- Create a PR if none exists for the branch, otherwise update the existing PR.\n- Keep branch history clean when remote has moved.\n\n## Related Skills\n\n- `pull`: use this when push is rejected or sync is not clean (non-fast-forward,\n  merge conflict risk, or stale branch).\n\n## Steps\n\n1. Identify current branch and confirm remote state.\n2. Run local validation (`make -C elixir all`) before pushing.\n3. Push branch to `origin` with upstream tracking if needed, using whatever\n   remote URL is already configured.\n4. If push is not clean/rejected:\n   - If the failure is a non-fast-forward or sync problem, run the `pull`\n     skill to merge `origin/main`, resolve conflicts, and rerun validation.\n   - Push again; use `--force-with-lease` only when history was rewritten.\n   - If the failure is due to auth, permissions, or workflow restrictions on\n     the configured remote, stop and surface the exact error instead of\n     rewriting remotes or switching protocols as a workaround.\n\n5. Ensure a PR exists for the branch:\n   - If no PR exists, create one.\n   - If a PR exists and is open, update it.\n   - If branch is tied to a closed/merged PR, create a new branch + PR.\n   - Write a proper PR title that clearly describes the change outcome\n   - For branch updates, explicitly reconsider whether current PR title still\n     matches the latest scope; update it if it no longer does.\n6. Write/update PR body explicitly using `.github/pull_request_template.md`:\n   - Fill every section with concrete content for this change.\n   - Replace all placeholder comments (`<!-- ... -->`).\n   - Keep bullets/checkboxes where template expects them.\n   - If PR already exists, refresh body content so it reflects the total PR\n     scope (all intended work on the branch), not just the newest commits,\n     including newly added work, removed work, or changed approach.\n   - Do not reuse stale description text from earlier iterations.\n7. Validate PR body with `mix pr_body.check` and fix all reported issues.\n8. Reply with the PR URL from `gh pr view`.\n\n## Commands\n\n```sh\n# Identify branch\nbranch=$(git branch --show-current)\n\n# Minimal validation gate\nmake -C elixir all\n\n# Initial push: respect the current origin remote.\ngit push -u origin HEAD\n\n# If that failed because the remote moved, use the pull skill. After\n# pull-skill resolution and re-validation, retry the normal push:\ngit push -u origin HEAD\n\n# If the configured remote rejects the push for auth, permissions, or workflow\n# restrictions, stop and surface the exact error.\n\n# Only if history was rewritten locally:\ngit push --force-with-lease origin HEAD\n\n# Ensure a PR exists (create only if missing)\npr_state=$(gh pr view --json state -q .state 2>/dev/null || true)\nif [ \"$pr_state\" = \"MERGED\" ] || [ \"$pr_state\" = \"CLOSED\" ]; then\n  echo \"Current branch is tied to a closed PR; create a new branch + PR.\" >&2\n  exit 1\nfi\n\n# Write a clear, human-friendly title that summarizes the shipped change.\npr_title=\"<clear PR title written for this change>\"\nif [ -z \"$pr_state\" ]; then\n  gh pr create --title \"$pr_title\"\nelse\n  # Reconsider title on every branch update; edit if scope shifted.\n  gh pr edit --title \"$pr_title\"\nfi\n\n# Write/edit PR body to match .github/pull_request_template.md before validation.\n# Example workflow:\n# 1) open the template and draft body content for this PR\n# 2) gh pr edit --body-file /tmp/pr_body.md\n# 3) for branch updates, re-check that title/body still match current diff\n\ntmp_pr_body=$(mktemp)\ngh pr view --json body -q .body > \"$tmp_pr_body\"\n(cd elixir && mix pr_body.check --file \"$tmp_pr_body\")\nrm -f \"$tmp_pr_body\"\n\n# Show PR URL for the reply\ngh pr view --json url -q .url\n```\n\n## Notes\n\n- Do not use `--force`; only use `--force-with-lease` as the last resort.\n- Distinguish sync problems from remote auth/permission problems:\n  - Use the `pull` skill for non-fast-forward or stale-branch issues.\n  - Surface auth, permissions, or workflow restrictions directly instead of\n    changing remotes or protocols.\n","frontmatter":{"name":"push","description":"Push current branch changes to origin and create or update the corresponding pull request; use when asked to push, publish updates, or create pull request."},"isInternal":false,"tokens":1050,"sizeBytes":4393},{"name":"SKILL.md","path":".codex/skills/release/SKILL.md","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/release/SKILL.md","title":"release","category":"anthropic-skill","format":"markdown","content":"---\nname: release\ndescription: Cut a Symphony release by bumping the committed version, landing it, tagging the merged commit, and verifying the Burrito release workflow. Use when asked to release, tag, or retag Symphony.\n---\n\n# Release\n\n1. Start from fresh `origin/main` in a clean worktree. Never disturb unrelated\n   local changes.\n2. Pick the requested version. If none is given, use the next patch after the\n   latest `vX.Y.Z` tag.\n3. Update `elixir/mix.exs` so `version: \"X.Y.Z\"` matches the intended tag.\n   Search the old version and change other files only when they are true\n   release-version sources, not examples.\n4. Run `make -C elixir all`, then commit, push, create a PR, and land it.\n5. Fetch the merged `main` commit. Verify its `mix.exs` version, then create an\n   annotated tag on that exact commit:\n\n   ```sh\n   git tag -a vX.Y.Z <merged-commit> -m \"Symphony vX.Y.Z\"\n   git push origin vX.Y.Z\n   ```\n\n6. Watch `burrito-release` until it finishes. Verify the build, smoke, and\n   release jobs pass and that the GitHub release has all expected assets.\n\nDo not tag an uncommitted or unmerged revision. Do not move a published tag\nwithout explicit user approval.\n","frontmatter":{"name":"release","description":"Cut a Symphony release by bumping the committed version, landing it, tagging the merged commit, and verifying the Burrito release workflow. Use when asked to release, tag, or retag Symphony."},"isInternal":false,"tokens":300,"sizeBytes":1180},{"name":"land_watch.py","path":".codex/skills/land/land_watch.py","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/skills/land/land_watch.py","title":"Codex Directive: land_watch.py","category":"codex-rule","format":"markdown","content":"#!/usr/bin/env python3\nimport asyncio\nimport json\nimport random\nimport re\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Any\n\nPOLL_SECONDS = 10\nCHECKS_APPEAR_TIMEOUT_SECONDS = 120\nCODEX_BOTS = {\n    \"chatgpt-codex-connector[bot]\",\n    \"github-actions[bot]\",\n    \"codex-gc-app[bot]\",\n    \"app/codex-gc-app\",\n}\nMAX_GH_RETRIES = 5\nBASE_GH_BACKOFF_SECONDS = 2\n\n\n@dataclass\nclass PrInfo:\n    number: int\n    url: str\n    head_sha: str\n    mergeable: str | None\n    merge_state: str | None\n\n\nclass RateLimitError(RuntimeError):\n    pass\n\n\ndef is_rate_limit_error(error: str) -> bool:\n    return \"HTTP 429\" in error or \"rate limit\" in error.lower()\n\n\nasync def run_gh(*args: str) -> str:\n    max_delay = BASE_GH_BACKOFF_SECONDS * (2 ** (MAX_GH_RETRIES - 1))\n    delay_seconds = BASE_GH_BACKOFF_SECONDS\n    last_error = \"gh command failed\"\n    for attempt in range(1, MAX_GH_RETRIES + 1):\n        proc = await asyncio.create_subprocess_exec(\n            \"gh\",\n            *args,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await proc.communicate()\n        if proc.returncode == 0:\n            return stdout.decode()\n        error = stderr.decode().strip() or \"gh command failed\"\n        if not is_rate_limit_error(error):\n            raise RuntimeError(error)\n        last_error = error\n        if attempt >= MAX_GH_RETRIES:\n            break\n        jitter = random.uniform(0, delay_seconds)\n        await asyncio.sleep(min(delay_seconds + jitter, max_delay))\n        delay_seconds = min(delay_seconds * 2, max_delay)\n    raise RateLimitError(last_error)\n\n\nasync def get_pr_info() -> PrInfo:\n    data = await run_gh(\n        \"pr\",\n        \"view\",\n        \"--json\",\n        \"number,url,headRefOid,mergeable,mergeStateStatus\",\n    )\n    parsed = json.loads(data)\n    return PrInfo(\n        number=parsed[\"number\"],\n        url=parsed[\"url\"],\n        head_sha=parsed[\"headRefOid\"],\n        mergeable=parsed.get(\"mergeable\"),\n        merge_state=parsed.get(\"mergeStateStatus\"),\n    )\n\n\nasync def get_paginated_list(endpoint: str) -> list[dict[str, Any]]:\n    page = 1\n    items: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            endpoint,\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        items.extend(batch)\n        page += 1\n    return items\n\n\nasync def get_issue_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/issues/{pr_number}/comments\",\n    )\n\n\nasync def get_review_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments\",\n    )\n\n\nasync def get_reviews(pr_number: int) -> list[dict[str, Any]]:\n    page = 1\n    reviews: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        reviews.extend(batch)\n        page += 1\n    return reviews\n\n\nasync def get_check_runs(head_sha: str) -> list[dict[str, Any]]:\n    page = 1\n    check_runs: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/commits/{head_sha}/check-runs\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        payload = json.loads(data)\n        batch = payload.get(\"check_runs\", [])\n        if not batch:\n            break\n        check_runs.extend(batch)\n        total_count = payload.get(\"total_count\")\n        if total_count is not None and len(check_runs) >= total_count:\n            break\n        page += 1\n    return check_runs\n\n\ndef parse_time(value: str) -> datetime:\n    normalized = value.replace(\"Z\", \"+00:00\")\n    return datetime.fromisoformat(normalized)\n\n\nCONTROL_CHARS_RE = re.compile(r\"[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f]\")\n\n\ndef sanitize_terminal_output(value: str) -> str:\n    return CONTROL_CHARS_RE.sub(\"\", value)\n\n\ndef check_timestamp(check: dict[str, Any]) -> datetime | None:\n    for key in (\"completed_at\", \"started_at\", \"run_started_at\", \"created_at\"):\n        value = check.get(key)\n        if value:\n            return parse_time(value)\n    return None\n\n\ndef dedupe_check_runs(check_runs: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_name: dict[str, dict[str, Any]] = {}\n    for check in check_runs:\n        name = check.get(\"name\", \"unknown\")\n        timestamp = check_timestamp(check)\n        if name not in latest_by_name:\n            latest_by_name[name] = check\n            continue\n        existing = latest_by_name[name]\n        existing_timestamp = check_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_name[name] = check\n    return list(latest_by_name.values())\n\n\ndef summarize_checks(check_runs: list[dict[str, Any]]) -> tuple[bool, bool, list[str]]:\n    if not check_runs:\n        return True, False, [\"no checks reported\"]\n    check_runs = dedupe_check_runs(check_runs)\n    pending = False\n    failed = False\n    failures: list[str] = []\n    for check in check_runs:\n        status = check.get(\"status\")\n        conclusion = check.get(\"conclusion\")\n        name = check.get(\"name\", \"unknown\")\n        if status != \"completed\":\n            pending = True\n            continue\n        if conclusion not in (\"success\", \"skipped\", \"neutral\"):\n            failed = True\n            failures.append(f\"{name}: {conclusion}\")\n    return pending, failed, failures\n\n\ndef latest_review_request_at(comments: list[dict[str, Any]]) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        if is_codex_bot_user(comment.get(\"user\", {})):\n            continue\n        body = comment.get(\"body\") or \"\"\n        if \"@codex review\" not in body:\n            continue\n        timestamp = comment_time(comment)\n        if timestamp is None:\n            continue\n        if latest is None or timestamp > latest:\n            latest = timestamp\n    return latest\n\n\ndef filter_codex_comments(\n    comments: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    latest_issue_ack = latest_codex_issue_reply_time(comments)\n    codex_comments = [c for c in comments if is_codex_bot_user(c.get(\"user\", {}))]\n    filtered: list[dict[str, Any]] = []\n    for comment in codex_comments:\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if review_requested_at is not None and created_time <= review_requested_at:\n            continue\n        is_threaded = bool(\n            comment.get(\"in_reply_to_id\") or comment.get(\"pull_request_review_id\")\n        )\n        if not is_threaded:\n            if latest_issue_ack is not None and created_time <= latest_issue_ack:\n                continue\n        else:\n            thread_root = thread_root_id(comment)\n            last_reply = None\n            if thread_root is not None:\n                last_reply = latest_codex_reply.get(thread_root)\n            if last_reply and last_reply > created_time:\n                continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_codex_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    return login in CODEX_BOTS\n\n\ndef is_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    if is_codex_bot_user(user):\n        return True\n    if user.get(\"type\") == \"Bot\":\n        return True\n    return login.endswith(\"[bot]\")\n\n\ndef is_codex_reply_body(body: str) -> bool:\n    return body.startswith(\"[codex]\")\n\n\ndef is_codex_review_body(body: str) -> bool:\n    return body.startswith(\"## Codex Review\")\n\n\ndef latest_codex_issue_reply_time(\n    comments: list[dict[str, Any]],\n) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if latest is None or created_time > latest:\n            latest = created_time\n    return latest\n\n\ndef filter_human_issue_comments(comments: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        if is_codex_review_body(body):\n            continue\n        if \"@codex review\" in body:\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef filter_codex_review_issue_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_review_body(body):\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef thread_root_id(comment: dict[str, Any]) -> int | None:\n    return comment.get(\"in_reply_to_id\") or comment.get(\"id\")\n\n\ndef comment_time(comment: dict[str, Any]) -> datetime | None:\n    timestamp = comment.get(\"updated_at\") or comment.get(\"created_at\")\n    if not timestamp:\n        return None\n    return parse_time(timestamp)\n\n\ndef latest_codex_reply_by_thread(\n    comments: list[dict[str, Any]],\n) -> dict[int, datetime]:\n    latest: dict[int, datetime] = {}\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        if thread_root is None or created_time is None:\n            continue\n        existing = latest.get(thread_root)\n        if existing is None or created_time > existing:\n            latest[thread_root] = created_time\n    return latest\n\n\ndef filter_human_review_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        last_codex_reply = None\n        if thread_root is not None:\n            last_codex_reply = latest_codex_reply.get(thread_root)\n        if last_codex_reply and created_time and created_time <= last_codex_reply:\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_blocking_review(\n    review: dict[str, Any],\n    review_requested_at: datetime | None,\n) -> bool:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return False\n    user_login = review.get(\"user\", {}).get(\"login\")\n    created_time = parse_time(created_at)\n    if (\n        user_login in CODEX_BOTS\n        and review_requested_at is not None\n        and created_time <= review_requested_at\n    ):\n        return False\n    body = (review.get(\"body\") or \"\").strip()\n    state = review.get(\"state\")\n    if user_login in CODEX_BOTS:\n        return state == \"CHANGES_REQUESTED\"\n    if body.startswith(\"[codex]\") or state in (\"APPROVED\", \"DISMISSED\"):\n        return False\n    blocking = False\n    if body or state == \"CHANGES_REQUESTED\":\n        blocking = True\n    elif state == \"COMMENTED\":\n        blocking = False\n    elif state:\n        blocking = state not in (\"APPROVED\", \"DISMISSED\")\n    return blocking\n\n\ndef review_timestamp(review: dict[str, Any]) -> datetime | None:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return None\n    return parse_time(created_at)\n\n\ndef dedupe_reviews(reviews: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_user: dict[str, dict[str, Any]] = {}\n    for review in reviews:\n        user_login = review.get(\"user\", {}).get(\"login\")\n        if not user_login:\n            continue\n        timestamp = review_timestamp(review)\n        if user_login not in latest_by_user:\n            latest_by_user[user_login] = review\n            continue\n        existing = latest_by_user[user_login]\n        existing_timestamp = review_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_user[user_login] = review\n    return list(latest_by_user.values())\n\n\ndef filter_blocking_reviews(\n    reviews: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    return [\n        review\n        for review in dedupe_reviews(reviews)\n        if is_blocking_review(review, review_requested_at)\n    ]\n\n\ndef is_merge_conflicting(pr: PrInfo) -> bool:\n    return pr.mergeable == \"CONFLICTING\" or pr.merge_state == \"DIRTY\"\n\n\nasync def fetch_review_context(\n    pr_number: int,\n) -> tuple[\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    datetime | None,\n]:\n    issue_comments = await get_issue_comments(pr_number)\n    review_request_at = latest_review_request_at(issue_comments)\n    review_comments = await get_review_comments(pr_number)\n    reviews = await get_reviews(pr_number)\n    return issue_comments, review_comments, reviews, review_request_at\n\n\ndef raise_on_human_feedback(\n    issue_comments: list[dict[str, Any]],\n    review_comments: list[dict[str, Any]],\n    reviews: list[dict[str, Any]],\n    review_request_at: datetime | None,\n) -> None:\n    human_issue_comments = filter_human_issue_comments(issue_comments)\n    codex_review_comments = filter_codex_review_issue_comments(issue_comments)\n    human_review_comments = filter_human_review_comments(review_comments)\n    if human_issue_comments or human_review_comments or codex_review_comments:\n        print(\"Review comments detected. Address before merge.\")\n        print(\n            \"Reminder: decide whether feedback stays in scope; defer if needed \"\n            \"and note in your root-level update.\",\n        )\n        raise SystemExit(2)\n    blocking_reviews = filter_blocking_reviews(reviews, review_request_at)\n    if blocking_reviews:\n        print(\"Review states/comments detected. Address before merge.\")\n        print(\n            \"Reminder: keep PR title/description aligned with the full scope \"\n            \"when changes expand.\",\n        )\n        raise SystemExit(2)\n\n\nasync def wait_for_codex(pr_number: int, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for review feedback...\", flush=True)\n    while True:\n        (\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        ) = await fetch_review_context(pr_number)\n        bot_issue_comments = filter_codex_comments(issue_comments, review_request_at)\n        bot_review_comments = filter_codex_comments(review_comments, review_request_at)\n        bot_comments = bot_issue_comments + bot_review_comments\n        raise_on_human_feedback(\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        )\n        if bot_comments:\n            latest = max(\n                bot_comments,\n                key=lambda comment: parse_time(comment[\"created_at\"]),\n            )\n            body = sanitize_terminal_output(latest.get(\"body\") or \"\").strip()\n            if body:\n                print(\"Codex left comments. Address feedback before merge.\")\n                print(body)\n                raise SystemExit(2)\n        if checks_done.is_set():\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def wait_for_checks(head_sha: str, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for CI checks...\", flush=True)\n    empty_seconds = 0\n    while True:\n        check_runs = await get_check_runs(head_sha)\n        if not check_runs:\n            empty_seconds += POLL_SECONDS\n            if empty_seconds >= CHECKS_APPEAR_TIMEOUT_SECONDS:\n                print(\n                    \"No checks detected after 120s; check CI configuration\",\n                )\n                raise SystemExit(3)\n            await asyncio.sleep(POLL_SECONDS)\n            continue\n        empty_seconds = 0\n        pending, failed, failures = summarize_checks(check_runs)\n        if failed:\n            print(\"Checks failed:\")\n            for failure in failures:\n                print(f\"- {failure}\")\n            raise SystemExit(3)\n        if not pending:\n            print(\"Checks passed\")\n            checks_done.set()\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def watch_pr() -> None:\n    pr = await get_pr_info()\n    if is_merge_conflicting(pr):\n        print(\n            \"PR has merge conflicts. Resolve/rebase against main and push before \"\n            \"running land_watch again.\",\n        )\n        raise SystemExit(5)\n    head_sha = pr.head_sha\n    checks_done = asyncio.Event()\n    codex_task = asyncio.create_task(wait_for_codex(pr.number, checks_done))\n    checks_task = asyncio.create_task(wait_for_checks(head_sha, checks_done))\n\n    async def head_monitor() -> None:\n        while True:\n            current = await get_pr_info()\n            if is_merge_conflicting(current):\n                print(\n                    \"PR has merge conflicts. Resolve/rebase against main and push \"\n                    \"before running land_watch again.\",\n                )\n                raise SystemExit(5)\n            if current.head_sha != head_sha:\n                print(\"PR head updated; pull/amend/force-push to retrigger CI\")\n                raise SystemExit(4)\n            await asyncio.sleep(POLL_SECONDS)\n\n    monitor_task = asyncio.create_task(head_monitor())\n    success_task = asyncio.gather(codex_task, checks_task)\n\n    done, pending = await asyncio.wait(\n        [monitor_task, success_task],\n        return_when=asyncio.FIRST_COMPLETED,\n    )\n    for task in pending:\n        task.cancel()\n    for task in done:\n        exc = task.exception()\n        if exc:\n            raise exc\n\n\nif __name__ == \"__main__\":\n    try:\n        asyncio.run(watch_pr())\n    except SystemExit as exc:\n        raise SystemExit(exc.code) from None\n","isInternal":false,"tokens":4388,"sizeBytes":19585},{"name":"worktree_init.sh","path":".codex/worktree_init.sh","rawUrl":"https://raw.githubusercontent.com/openai/symphony/HEAD/.codex/worktree_init.sh","title":"Codex Directive: worktree_init.sh","category":"codex-rule","format":"markdown","content":"#!/usr/bin/env bash\nset -eo pipefail\n\nscript_dir=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nrepo_root=\"$(cd \"$script_dir/..\" && pwd)\"\nproject_root=\"$repo_root/elixir\"\n\nif ! command -v mise >/dev/null 2>&1; then\n  echo \"mise is required. Install it from https://mise.jdx.dev/getting-started.html\" >&2\n  exit 1\nfi\n\ncd \"$project_root\"\nmise trust\n\nmake setup\n","isInternal":false,"tokens":105,"sizeBytes":345}],"systemPromptSnippet":"<yakaai_skills repo=\"openai/symphony\">\n<!-- File: elixir/AGENTS.md (Tokens: ~678 | Category: root-instruction) -->\n# Symphony Elixir\n\nThis directory contains the Elixir agent orchestration service that polls Linear, creates per-issue workspaces, and runs Codex in app-server mode.\n\n## Environment\n\n- Elixir: `1.19.x` (OTP 28) via `mise`.\n- Install deps: `mix setup`.\n- Main quality gate: `make all` (format check, lint, coverage, dialyzer).\n\n\n## Codebase-Specific Conventions\n\n- Runtime config is loaded from `WORKFLOW.md` front matter via `SymphonyElixir.Workflow` and `SymphonyElixir.Config`.\n- Keep the implementation aligned with [`../SPEC.md`](../SPEC.md) where practical.\n  - The implementation may be a superset of the spec.\n  - The implementation must not conflict with the spec.\n  - If implementation changes meaningfully alter the intended behavior, update the spec in the same\n    change where practical so the spec stays current.\n- Prefer adding config access through `SymphonyElixir.Config` instead of ad-hoc env reads.\n- Workspace safety is critical:\n  - Never run Codex turn cwd in source repo.\n  - Workspaces must stay under configured workspace root.\n- Orchestrator behavior is stateful and concurrency-sensitive; preserve retry, reconciliation, and cleanup semantics.\n- Simplicity is a project constraint: prefer the smallest coherent design with one clear owner and\n  invariant. Push back on extra abstractions, duplicated policy, and speculative flexibility.\n- For stateful changes, check startup, reload, restart, and failure recovery together before editing.\n- Follow `docs/logging.md` for logging conventions and required issue/session context fields.\n\n## Tests and Validation\n\nRun targeted tests while iterating, then run full gates before handoff.\n\n- Prefer narrow tests that exercise real OTP processes and observable behavior over mock-only or\n  broad end-to-end coverage; prove health with a synchronous call or stable effect, not only a PID.\n- For non-trivial changes, use an adversarial review early to challenge complexity and try to break\n  adjacent lifecycle paths; a reproducible failure blocks landing even if other reviews are clean.\n- If tests need repeated global restarts or bespoke cleanup, first fix the shared harness or\n  ownership boundary.\n\n```bash\nmake all\n```\n\n## Required Rules\n\n- Public functions (`def`) in `lib/` must have an adjacent `@spec`.\n- `defp` specs are optional.\n- `@impl` callback implementations are exempt from local `@spec` requirement.\n- Evaluate proposed directions instead of agreeing reflexively; surface simpler designs and material\n  trade-offs early.\n- Keep changes narrowly scoped; avoid unrelated refactors.\n- Follow existing module/style patterns in `lib/symphony_elixir/*`.\n\nValidation command:\n\n```bash\nmix specs.check\n```\n\n## PR Requirements\n\n- PR body must follow `../.github/pull_request_template.md` exactly.\n- Validate PR body locally when needed:\n\n```bash\nmix pr_body.check --file /path/to/pr_body.md\n```\n\n## Docs Update Policy\n\nIf behavior/config changes, update docs in the same PR:\n\n- `../README.md` for project concept and goals.\n- `README.md` for Elixir implementation and run instructions.\n- `WORKFLOW.md` for workflow/config contract changes.\n\n<!-- File: .codex/skills/commit/SKILL.md (Tokens: ~587 | Category: anthropic-skill) -->\n---\nname: commit\ndescription:\n  Create a well-formed git commit from current changes using session history for\n  rationale and summary; use when asked to commit, prepare a commit message, or\n  finalize staged work.\n---\n\n# Commit\n\n## Goals\n\n- Produce a commit that reflects the actual code changes and the session\n  context.\n- Follow common git conventions (type prefix, short subject, wrapped body).\n- Include both summary and rationale in the body.\n\n## Inputs\n\n- Codex session history for intent and rationale.\n- `git status`, `git diff`, and `git diff --staged` for actual changes.\n- Repo-specific commit conventions if documented.\n\n## Steps\n\n1. Read session history to identify scope, intent, and rationale.\n2. Inspect the working tree and staged changes (`git status`, `git diff`,\n   `git diff --staged`).\n3. Stage intended changes, including new files (`git add -A`) after confirming\n   scope.\n4. Sanity-check newly added files; if anything looks random or likely ignored\n   (build artifacts, logs, temp files), flag it to the user before committing.\n5. If staging is incomplete or includes unrelated files, fix the index or ask\n   for confirmation.\n6. Choose a conventional type and optional scope that match the change (e.g.,\n   `feat(scope): ...`, `fix(scope): ...`, `refactor(scope): ...`).\n7. Write a subject line in imperative mood, <= 72 characters, no trailing\n   period.\n8. Write a body that includes:\n   - Summary of key changes (what changed).\n   - Rationale and trade-offs (why it changed).\n   - Tests or validation run (or explicit note if not run).\n9. Append a `Co-authored-by` trailer for Codex using `Codex <codex@openai.com>`\n   unless the user explicitly requests a different identity.\n10. Wrap body lines at 72 characters.\n11. Create the commit message with a here-doc or temp file and use\n    `git commit -F <file>` so newlines are literal (avoid `-m` with `\\n`).\n12. Commit only when the message matches the staged changes: if the staged diff\n    includes unrelated files or the message describes work that isn't staged,\n    fix the index or revise the message before committing.\n\n## Output\n\n- A single commit created with `git commit` whose message reflects the session.\n\n## Template\n\nType and scope are examples only; adjust to fit the repo and changes.\n\n```\n<type>(<scope>): <short summary>\n\nSummary:\n- <what changed>\n- <what changed>\n\nRationale:\n- <why>\n- <why>\n\nTests:\n- <command or \"not run (reason)\">\n\nCo-authored-by: Codex <codex@openai.com>\n```\n\n<!-- File: .codex/skills/debug/SKILL.md (Tokens: ~1071 | Category: anthropic-skill) -->\n---\nname: debug\ndescription:\n  Investigate stuck runs and execution failures by tracing Symphony and Codex\n  logs with issue/session identifiers; use when runs stall, retry repeatedly, or\n  fail unexpectedly.\n---\n\n# Debug\n\n## Goals\n\n- Find why a run is stuck, retrying, or failing.\n- Correlate Linear issue identity to a Codex session quickly.\n- Read the right logs in the right order to isolate root cause.\n\n## Log Sources\n\n- Primary runtime log: `log/symphony.log`\n  - Default comes from `SymphonyElixir.LogFile` (`log/symphony.log`).\n  - Includes orchestrator, agent runner, and Codex app-server lifecycle logs.\n- Rotated runtime logs: `log/symphony.log*`\n  - Check these when the relevant run is older.\n\n## Correlation Keys\n\n- `issue_identifier`: human ticket key (example: `MT-625`)\n- `issue_id`: Linear UUID (stable internal ID)\n- `session_id`: Codex thread-turn pair (`<thread_id>-<turn_id>`)\n\n`elixir/docs/logging.md` requires these fields for issue/session lifecycle logs. Use\nthem as your join keys during debugging.\n\n## Quick Triage (Stuck Run)\n\n1. Confirm scheduler/worker symptoms for the ticket.\n2. Find recent lines for the ticket (`issue_identifier` first).\n3. Extract `session_id` from matching lines.\n4. Trace that `session_id` across start, stream, completion/failure, and stall\n   handling logs.\n5. Decide class of failure: timeout/stall, app-server startup failure, turn\n   failure, or orchestrator retry loop.\n\n## Commands\n\n```bash\n# 1) Narrow by ticket key (fastest entry point)\nrg -n \"issue_identifier=MT-625\" log/symphony.log*\n\n# 2) If needed, narrow by Linear UUID\nrg -n \"issue_id=<linear-uuid>\" log/symphony.log*\n\n# 3) Pull session IDs seen for that ticket\nrg -o \"session_id=[^ ;]+\" log/symphony.log* | sort -u\n\n# 4) Trace one session end-to-end\nrg -n \"session_id=<thread>-<turn>\" log/symphony.log*\n\n# 5) Focus on stuck/retry signals\nrg -n \"Issue stalled|scheduling retry|turn_timeout|turn_failed|Codex session failed|Codex session ended with error\" log/symphony.log*\n```\n\n## Investigation Flow\n\n1. Locate the ticket slice:\n    - Search by `issue_identifier=<KEY>`.\n    - If noise is high, add `issue_id=<UUID>`.\n2. Establish timeline:\n    - Identify first `Codex session started ... session_id=...`.\n    - Follow with `Codex session completed`, `ended with error`, or worker exit\n      lines.\n3. Classify the problem:\n    - Stall loop: `Issue stalled ... restarting with backoff`.\n    - App-server startup: `Codex session failed ...`.\n    - Turn execution failure: `turn_failed`, `turn_cancelled`, `turn_timeout`, or\n      `ended with error`.\n    - Worker crash: `Agent task exited ... reason=...`.\n4. Validate scope:\n    - Check whether failures are isolated to one issue/session or repeating across\n      multiple tickets.\n5. Capture evidence:\n    - Save key log lines with timestamps, `issue_identifier`, `issue_id`, and\n      `session_id`.\n    - Record probable root cause and the exact failing stage.\n\n## Reading Codex Session Logs\n\nIn Symphony, Codex session diagnostics are emitted into `log/symphony.log` and\nkeyed by `session_id`. Read them as a lifecycle:\n\n1. `Codex session started ... session_id=...`\n2. Session stream/lifecycle events for the same `session_id`\n3. Terminal event:\n    - `Codex session completed ...`, or\n    - `Codex session ended with error ...`, or\n    - `Issue stalled ... restarting with backoff`\n\nFor one specific session investigation, keep the trace narrow:\n\n1. Capture one `session_id` for the ticket.\n2. Build a timestamped slice for only that session:\n    - `rg -n \"session_id=<thread>-<turn>\" log/symphony.log*`\n3. Mark the exact failing stage:\n    - Startup failure before stream events (`Codex session failed ...`).\n    - Turn/runtime failure after stream events (`turn_*` / `ended with error`).\n    - Stall recovery (`Issue stalled ... restarting with backoff`).\n4. Pair findings with `issue_identifier` and `issue_id` from nearby lines to\n   confirm you are not mixing concurrent retries.\n\nAlways pair session findings with `issue_identifier`/`issue_id` to avoid mixing\nconcurrent runs.\n\n## Notes\n\n- Prefer `rg` over `grep` for speed on large logs.\n- Check rotated logs (`log/symphony.log*`) before concluding data is missing.\n- If required context fields are missing in new log statements, align with\n  `elixir/docs/logging.md` conventions.\n\n<!-- File: .codex/skills/land/SKILL.md (Tokens: ~2397 | Category: anthropic-skill) -->\n---\nname: land\ndescription:\n  Land a PR by monitoring conflicts, resolving them, waiting for checks, and\n  squash-merging when green; use when asked to land, merge, or shepherd a PR to\n  completion.\n---\n\n# Land\n\n## Goals\n\n- Ensure the PR is conflict-free with main.\n- Keep CI green and fix failures when they occur.\n- Squash-merge the PR once checks pass.\n- Do not yield to the user until the PR is merged; keep the watcher loop running\n  unless blocked.\n- No need to delete remote branches after merge; the repo auto-deletes head\n  branches.\n\n## Preconditions\n\n- `gh` CLI is authenticated.\n- You are on the PR branch with a clean working tree.\n\n## Steps\n\n1. Locate the PR for the current branch.\n2. Confirm the full gauntlet is green locally before any push.\n3. If the working tree has uncommitted changes, commit with the `commit` skill\n   and push with the `push` skill before proceeding.\n4. Check mergeability and conflicts against main.\n5. If conflicts exist, use the `pull` skill to fetch/merge `origin/main` and\n   resolve conflicts, then use the `push` skill to publish the updated branch.\n6. Ensure Codex review comments (if present) are acknowledged and any required\n   fixes are handled before merging.\n7. Watch checks until complete.\n8. If checks fail, pull logs, fix the issue, commit with the `commit` skill,\n   push with the `push` skill, and re-run checks.\n9. When all checks are green and review feedback is addressed, squash-merge and\n   delete the branch using the PR title/body for the merge subject/body.\n10. **Context guard:** Before implementing review feedback, confirm it does not\n    conflict with the user’s stated intent or task context. If it conflicts,\n    respond inline with a justification and ask the user before changing code.\n11. **Pushback template:** When disagreeing, reply inline with: acknowledge +\n    rationale + offer alternative.\n12. **Ambiguity gate:** When ambiguity blocks progress, use the clarification\n    flow (assign PR to current GH user, mention them, wait for response). Do not\n    implement until ambiguity is resolved.\n    - If you are confident you know better than the reviewer, you may proceed\n      without asking the user, but reply inline with your rationale.\n13. **Per-comment mode:** For each review comment, choose one of: accept,\n    clarify, or push back. Reply inline (or in the issue thread for Codex\n    reviews) stating the mode before changing code.\n14. **Reply before change:** Always respond with intended action before pushing\n    code changes (inline for review comments, issue thread for Codex reviews).\n\n## Commands\n\n```\n# Ensure branch and PR context\nbranch=$(git branch --show-current)\npr_number=$(gh pr view --json number -q .number)\npr_title=$(gh pr view --json title -q .title)\npr_body=$(gh pr view --json body -q .body)\n\n# Check mergeability and conflicts\nmergeable=$(gh pr view --json mergeable -q .mergeable)\n\nif [ \"$mergeable\" = \"CONFLICTING\" ]; then\n  # Run the `pull` skill to handle fetch + merge + conflict resolution.\n  # Then run the `push` skill to publish the updated branch.\nfi\n\n# Preferred: use the Async Watch Helper below. The manual loop is a fallback\n# when Python cannot run or the helper script is unavailable.\n# Wait for review feedback: Codex reviews arrive as issue comments that start\n# with \"## Codex Review — <persona>\". Treat them like reviewer feedback: reply\n# with a `[codex]` issue comment acknowledging the findings and whether you're\n# addressing or deferring them.\nwhile true; do\n  gh api repos/{owner}/{repo}/issues/\"$pr_number\"/comments \\\n    --jq '.[] | select(.body | startswith(\"## Codex Review\")) | .id' | rg -q '.' \\\n    && break\n  sleep 10\ndone\n\n# Watch checks\nif ! gh pr checks --watch; then\n  gh pr checks\n  # Identify failing run and inspect logs\n  # gh run list --branch \"$branch\"\n  # gh run view <run-id> --log\n  exit 1\nfi\n\n# Squash-merge (remote branches auto-delete on merge in this repo)\ngh pr merge --squash --subject \"$pr_title\" --body \"$pr_body\"\n```\n\n## Async Watch Helper\n\nPreferred: use the asyncio watcher to monitor review comments, CI, and head\nupdates in parallel:\n\n```\npython3 .codex/skills/land/land_watch.py\n```\n\nExit codes:\n\n- 2: Review comments detected (address feedback)\n- 3: CI checks failed\n- 4: PR head updated (autofix commit detected)\n\n## Failure Handling\n\n- If checks fail, pull details with `gh pr checks` and `gh run view --log`, then\n  fix locally, commit with the `commit` skill, push with the `push` skill, and\n  re-run the watch.\n- Use judgment to identify flaky failures. If a failure is a flake (e.g., a\n  timeout on only one platform), you may proceed without fixing it.\n- If CI pushes an auto-fix commit (authored by GitHub Actions), it does not\n  trigger a fresh CI run. Detect the updated PR head, pull locally, merge\n  `origin/main` if needed, add a real author commit, and force-push to retrigger\n  CI, then restart the checks loop.\n- If all jobs fail with corrupted pnpm lockfile errors on the merge commit, the\n  remediation is to fetch latest `origin/main`, merge, force-push, and rerun CI.\n- If mergeability is `UNKNOWN`, wait and re-check.\n- Do not merge while review comments (human or Codex review) are outstanding.\n- Codex review jobs retry on failure and are non-blocking; use the presence of\n  `## Codex Review — <persona>` issue comments (not job status) as the signal\n  that review feedback is available.\n- Do not enable auto-merge; this repo has no required checks so auto-merge can\n  skip tests.\n- If the remote PR branch advanced due to your own prior force-push or merge,\n  avoid redundant merges; re-run the formatter locally if needed and\n  `git push --force-with-lease`.\n\n## Review Handling\n\n- Codex reviews now arrive as issue comments posted by GitHub Actions. They\n  start with `## Codex Review — <persona>` and include the reviewer’s\n  methodology + guardrails used. Treat these as feedback that must be\n  acknowledged before merge.\n- Human review comments are blocking and must be addressed (responded to and\n  resolved) before requesting a new review or merging.\n- If multiple reviewers comment in the same thread, respond to each comment\n  (batching is fine) before closing the thread.\n- Fetch review comments via `gh api` and reply with a prefixed comment.\n- Use review comment endpoints (not issue comments) to find inline feedback:\n  - List PR review comments:\n    ```\n    gh api repos/{owner}/{repo}/pulls/<pr_number>/comments\n    ```\n  - PR issue comments (top-level discussion):\n    ```\n    gh api repos/{owner}/{repo}/issues/<pr_number>/comments\n    ```\n  - Reply to a specific review comment:\n    ```\n    gh api -X POST /repos/{owner}/{repo}/pulls/<pr_number>/comments \\\n      -f body='[codex] <response>' -F in_reply_to=<comment_id>\n    ```\n- `in_reply_to` must be the numeric review comment id (e.g., `2710521800`), not\n  the GraphQL node id (e.g., `PRRC_...`), and the endpoint must include the PR\n  number (`/pulls/<pr_number>/comments`).\n- If GraphQL review reply mutation is forbidden, use REST.\n- A 404 on reply typically means the wrong endpoint (missing PR number) or\n  insufficient scope; verify by listing comments first.\n- All GitHub comments generated by this agent must be prefixed with `[codex]`.\n- For Codex review issue comments, reply in the issue thread (not a review\n  thread) with `[codex]` and state whether you will address the feedback now or\n  defer it (include rationale).\n- If feedback requires changes:\n  - For inline review comments (human), reply with intended fixes\n    (`[codex] ...`) **as an inline reply to the original review comment** using\n    the review comment endpoint and `in_reply_to` (do not use issue comments for\n    this).\n  - Implement fixes, commit, push.\n  - Reply with the fix details and commit sha (`[codex] ...`) in the same place\n    you acknowledged the feedback (issue comment for Codex reviews, inline reply\n    for review comments).\n  - The land watcher treats Codex review issue comments as unresolved until a\n    newer `[codex]` issue comment is posted acknowledging the findings.\n- Only request a new Codex review when you need a rerun (e.g., after new\n  commits). Do not request one without changes since the last review.\n  - Before requesting a new Codex review, re-run the land watcher and ensure\n    there are zero outstanding review comments (all have `[codex]` inline\n    replies).\n  - After pushing new commits, the Codex review workflow will rerun on PR\n    synchronization (or you can re-run the workflow manually). Post a concise\n    root-level summary comment so reviewers have the latest delta:\n    ```\n    [codex] Changes since last review:\n    - <short bullets of deltas>\n    Commits: <sha>, <sha>\n    Tests: <commands run>\n    ```\n  - Only request a new review if there is at least one new commit since the\n    previous request.\n  - Wait for the next Codex review comment before merging.\n\n## Scope + PR Metadata\n\n- The PR title and description should reflect the full scope of the change, not\n  just the most recent fix.\n- If review feedback expands scope, decide whether to include it now or defer\n  it. You can accept, defer, or decline feedback. If deferring or declining,\n  call it out in the root-level `[codex]` update with a brief reason (e.g.,\n  out-of-scope, conflicts with intent, unnecessary).\n- Correctness issues raised in review comments should be addressed. If you plan\n  to defer or decline a correctness concern, validate first and explain why the\n  concern does not apply.\n- Classify each review comment as one of: correctness, design, style,\n  clarification, scope.\n- For correctness feedback, provide concrete validation (test, log, or\n  reasoning) before closing it.\n- When accepting feedback, include a one-line rationale in the root-level\n  update.\n- When declining feedback, offer a brief alternative or follow-up trigger.\n- Prefer a single consolidated \"review addressed\" root-level comment after a\n  batch of fixes instead of many small updates.\n- For doc feedback, confirm the doc change matches behavior (no doc-only edits\n  to appease review).\n\n<!-- File: .codex/skills/linear/SKILL.md (Tokens: ~1709 | Category: anthropic-skill) -->\n---\nname: linear\ndescription: |\n  Use Symphony's `linear_graphql` client tool for raw Linear GraphQL\n  operations such as comment editing and upload flows.\n---\n\n# Linear GraphQL\n\nUse this skill for raw Linear GraphQL work during Symphony app-server sessions.\n\n## Primary tool\n\nUse the `linear_graphql` client tool exposed by Symphony's app-server session.\nIt reuses Symphony's configured Linear auth for the session.\n\nTool input:\n\n```json\n{\n  \"query\": \"query or mutation document\",\n  \"variables\": {\n    \"optional\": \"graphql variables object\"\n  }\n}\n```\n\nTool behavior:\n\n- Send one GraphQL operation per tool call.\n- Treat a top-level `errors` array as a failed GraphQL operation even if the\n  tool call itself completed.\n- Keep queries/mutations narrowly scoped; ask only for the fields you need.\n\n## Discovering unfamiliar operations\n\nWhen you need an unfamiliar mutation, input type, or object field, use targeted\nintrospection through `linear_graphql`.\n\nList mutation names:\n\n```graphql\nquery ListMutations {\n  __type(name: \"Mutation\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\nInspect a specific input object:\n\n```graphql\nquery CommentCreateInputShape {\n  __type(name: \"CommentCreateInput\") {\n    inputFields {\n      name\n      type {\n        kind\n        name\n        ofType {\n          kind\n          name\n        }\n      }\n    }\n  }\n}\n```\n\n## Common workflows\n\n### Query an issue by key, identifier, or id\n\nUse these progressively:\n\n- Start with `issue(id: $key)` when you have a ticket key such as `MT-686`.\n- Fall back to `issues(filter: ...)` when you need identifier search semantics.\n- Once you have the internal issue id, prefer `issue(id: $id)` for narrower reads.\n\nLookup by issue key:\n\n```graphql\nquery IssueByKey($key: String!) {\n  issue(id: $key) {\n    id\n    identifier\n    title\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    branchName\n    url\n    description\n    updatedAt\n    links {\n      nodes {\n        id\n        url\n        title\n      }\n    }\n  }\n}\n```\n\nLookup by identifier filter:\n\n```graphql\nquery IssueByIdentifier($identifier: String!) {\n  issues(filter: { identifier: { eq: $identifier } }, first: 1) {\n    nodes {\n      id\n      identifier\n      title\n      state {\n        id\n        name\n        type\n      }\n      project {\n        id\n        name\n      }\n      branchName\n      url\n      description\n      updatedAt\n    }\n  }\n}\n```\n\nResolve a key to an internal id:\n\n```graphql\nquery IssueByIdOrKey($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n  }\n}\n```\n\nRead the issue once the internal id is known:\n\n```graphql\nquery IssueDetails($id: String!) {\n  issue(id: $id) {\n    id\n    identifier\n    title\n    url\n    description\n    state {\n      id\n      name\n      type\n    }\n    project {\n      id\n      name\n    }\n    attachments {\n      nodes {\n        id\n        title\n        url\n        sourceType\n      }\n    }\n  }\n}\n```\n\n### Query team workflow states for an issue\n\nUse this before changing issue state when you need the exact `stateId`:\n\n```graphql\nquery IssueTeamStates($id: String!) {\n  issue(id: $id) {\n    id\n    team {\n      id\n      key\n      name\n      states {\n        nodes {\n          id\n          name\n          type\n        }\n      }\n    }\n  }\n}\n```\n\n### Edit an existing comment\n\nUse `commentUpdate` through `linear_graphql`:\n\n```graphql\nmutation UpdateComment($id: String!, $body: String!) {\n  commentUpdate(id: $id, input: { body: $body }) {\n    success\n    comment {\n      id\n      body\n    }\n  }\n}\n```\n\n### Create a comment\n\nUse `commentCreate` through `linear_graphql`:\n\n```graphql\nmutation CreateComment($issueId: String!, $body: String!) {\n  commentCreate(input: { issueId: $issueId, body: $body }) {\n    success\n    comment {\n      id\n      url\n    }\n  }\n}\n```\n\n### Move an issue to a different state\n\nUse `issueUpdate` with the destination `stateId`:\n\n```graphql\nmutation MoveIssueToState($id: String!, $stateId: String!) {\n  issueUpdate(id: $id, input: { stateId: $stateId }) {\n    success\n    issue {\n      id\n      identifier\n      state {\n        id\n        name\n      }\n    }\n  }\n}\n```\n\n### Attach a GitHub PR to an issue\n\nUse the GitHub-specific attachment mutation when linking a PR:\n\n```graphql\nmutation AttachGitHubPR($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkGitHubPR(\n    issueId: $issueId\n    url: $url\n    title: $title\n    linkKind: links\n  ) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\nIf you only need a plain URL attachment and do not care about GitHub-specific\nlink metadata, use:\n\n```graphql\nmutation AttachURL($issueId: String!, $url: String!, $title: String) {\n  attachmentLinkURL(issueId: $issueId, url: $url, title: $title) {\n    success\n    attachment {\n      id\n      title\n      url\n    }\n  }\n}\n```\n\n### Introspection patterns used during schema discovery\n\nUse these when the exact field or mutation shape is unclear:\n\n```graphql\nquery QueryFields {\n  __type(name: \"Query\") {\n    fields {\n      name\n    }\n  }\n}\n```\n\n```graphql\nquery IssueFieldArgs {\n  __type(name: \"Query\") {\n    fields {\n      name\n      args {\n        name\n        type {\n          kind\n          name\n          ofType {\n            kind\n            name\n            ofType {\n              kind\n              name\n            }\n          }\n        }\n      }\n    }\n  }\n}\n```\n\n### Upload a video to a comment\n\nDo this in three steps:\n\n1. Call `linear_graphql` with `fileUpload` to get `uploadUrl`, `assetUrl`, and\n   any required upload headers.\n2. Upload the local file bytes to `uploadUrl` with `curl -X PUT` and the exact\n   headers returned by `fileUpload`.\n3. Call `linear_graphql` again with `commentCreate` (or `commentUpdate`) and\n   include the resulting `assetUrl` in the comment body.\n\nUseful mutations:\n\n```graphql\nmutation FileUpload(\n  $filename: String!\n  $contentType: String!\n  $size: Int!\n  $makePublic: Boolean\n) {\n  fileUpload(\n    filename: $filename\n    contentType: $contentType\n    size: $size\n    makePublic: $makePublic\n  ) {\n    success\n    uploadFile {\n      uploadUrl\n      assetUrl\n      headers {\n        key\n        value\n      }\n    }\n  }\n}\n```\n\n## Usage rules\n\n- Use `linear_graphql` for comment edits, uploads, and ad-hoc Linear API\n  queries.\n- Prefer the narrowest issue lookup that matches what you already know:\n  key -> identifier search -> internal id.\n- For state transitions, fetch team states first and use the exact `stateId`\n  instead of hardcoding names inside mutations.\n- Prefer `attachmentLinkGitHubPR` over a generic URL attachment when linking a\n  GitHub PR to a Linear issue.\n- Do not introduce new raw-token shell helpers for GraphQL access.\n- If you need shell work for uploads, only use it for signed upload URLs\n  returned by `fileUpload`; those URLs already carry the needed authorization.\n\n<!-- File: .codex/skills/pull/SKILL.md (Tokens: ~1036 | Category: anthropic-skill) -->\n---\nname: pull\ndescription:\n  Pull latest origin/main into the current local branch and resolve merge\n  conflicts (aka update-branch). Use when Codex needs to sync a feature branch\n  with origin, perform a merge-based update (not rebase), and guide conflict\n  resolution best practices.\n---\n\n# Pull\n\n## Workflow\n\n1. Verify git status is clean or commit/stash changes before merging.\n2. Ensure rerere is enabled locally:\n   - `git config rerere.enabled true`\n   - `git config rerere.autoupdate true`\n3. Confirm remotes and branches:\n   - Ensure the `origin` remote exists.\n   - Ensure the current branch is the one to receive the merge.\n4. Fetch latest refs:\n   - `git fetch origin`\n5. Sync the remote feature branch first:\n   - `git pull --ff-only origin $(git branch --show-current)`\n   - This pulls branch updates made remotely (for example, a GitHub auto-commit)\n     before merging `origin/main`.\n6. Merge in order:\n   - Prefer `git -c merge.conflictstyle=zdiff3 merge origin/main` for clearer\n     conflict context.\n7. If conflicts appear, resolve them (see conflict guidance below), then:\n   - `git add <files>`\n   - `git commit` (or `git merge --continue` if the merge is paused)\n8. Verify with project checks (follow repo policy in `AGENTS.md`).\n9. Summarize the merge:\n   - Call out the most challenging conflicts/files and how they were resolved.\n   - Note any assumptions or follow-ups.\n\n## Conflict Resolution Guidance (Best Practices)\n\n- Inspect context before editing:\n  - Use `git status` to list conflicted files.\n  - Use `git diff` or `git diff --merge` to see conflict hunks.\n  - Use `git diff :1:path/to/file :2:path/to/file` and\n    `git diff :1:path/to/file :3:path/to/file` to compare base vs ours/theirs\n    for a file-level view of intent.\n  - With `merge.conflictstyle=zdiff3`, conflict markers include:\n    - `<<<<<<<` ours, `|||||||` base, `=======` split, `>>>>>>>` theirs.\n    - Matching lines near the start/end are trimmed out of the conflict region,\n      so focus on the differing core.\n  - Summarize the intent of both changes, decide the semantically correct\n    outcome, then edit:\n    - State what each side is trying to achieve (bug fix, refactor, rename,\n      behavior change).\n    - Identify the shared goal, if any, and whether one side supersedes the\n      other.\n    - Decide the final behavior first; only then craft the code to match that\n      decision.\n    - Prefer preserving invariants, API contracts, and user-visible behavior\n      unless the conflict clearly indicates a deliberate change.\n  - Open files and understand intent on both sides before choosing a resolution.\n- Prefer minimal, intention-preserving edits:\n  - Keep behavior consistent with the branch’s purpose.\n  - Avoid accidental deletions or silent behavior changes.\n- Resolve one file at a time and rerun tests after each logical batch.\n- Use `ours/theirs` only when you are certain one side should win entirely.\n- For complex conflicts, search for related files or definitions to align with\n  the rest of the codebase.\n- For generated files, resolve non-generated conflicts first, then regenerate:\n  - Prefer resolving source files and handwritten logic before touching\n    generated artifacts.\n  - Run the CLI/tooling command that produced the generated file to recreate it\n    cleanly, then stage the regenerated output.\n- For import conflicts where intent is unclear, accept both sides first:\n  - Keep all candidate imports temporarily, finish the merge, then run lint/type\n    checks to remove unused or incorrect imports safely.\n- After resolving, ensure no conflict markers remain:\n  - `git diff --check`\n- When unsure, note assumptions and ask for confirmation before finalizing the\n  merge.\n\n## When To Ask The User (Keep To A Minimum)\n\nDo not ask for input unless there is no safe, reversible alternative. Prefer\nmaking a best-effort decision, documenting the rationale, and proceeding.\n\nAsk the user only when:\n\n- The correct resolution depends on product intent or behavior not inferable\n  from code, tests, or nearby documentation.\n- The conflict crosses a user-visible contract, API surface, or migration where\n  choosing incorrectly could break external consumers.\n- A conflict requires selecting between two mutually exclusive designs with\n  equivalent technical merit and no clear local signal.\n- The merge introduces data loss, schema changes, or irreversible side effects\n  without an obvious safe default.\n- The branch is not the intended target, or the remote/branch names do not exist\n  and cannot be determined locally.\n\nOtherwise, proceed with the merge, explain the decision briefly in notes, and\nleave a clear, reviewable commit history.\n\n<!-- File: .codex/skills/push/SKILL.md (Tokens: ~1050 | Category: anthropic-skill) -->\n---\nname: push\ndescription:\n  Push current branch changes to origin and create or update the corresponding\n  pull request; use when asked to push, publish updates, or create pull request.\n---\n\n# Push\n\n## Prerequisites\n\n- `gh` CLI is installed and available in `PATH`.\n- `gh auth status` succeeds for GitHub operations in this repo.\n\n## Goals\n\n- Push current branch changes to `origin` safely.\n- Create a PR if none exists for the branch, otherwise update the existing PR.\n- Keep branch history clean when remote has moved.\n\n## Related Skills\n\n- `pull`: use this when push is rejected or sync is not clean (non-fast-forward,\n  merge conflict risk, or stale branch).\n\n## Steps\n\n1. Identify current branch and confirm remote state.\n2. Run local validation (`make -C elixir all`) before pushing.\n3. Push branch to `origin` with upstream tracking if needed, using whatever\n   remote URL is already configured.\n4. If push is not clean/rejected:\n   - If the failure is a non-fast-forward or sync problem, run the `pull`\n     skill to merge `origin/main`, resolve conflicts, and rerun validation.\n   - Push again; use `--force-with-lease` only when history was rewritten.\n   - If the failure is due to auth, permissions, or workflow restrictions on\n     the configured remote, stop and surface the exact error instead of\n     rewriting remotes or switching protocols as a workaround.\n\n5. Ensure a PR exists for the branch:\n   - If no PR exists, create one.\n   - If a PR exists and is open, update it.\n   - If branch is tied to a closed/merged PR, create a new branch + PR.\n   - Write a proper PR title that clearly describes the change outcome\n   - For branch updates, explicitly reconsider whether current PR title still\n     matches the latest scope; update it if it no longer does.\n6. Write/update PR body explicitly using `.github/pull_request_template.md`:\n   - Fill every section with concrete content for this change.\n   - Replace all placeholder comments (`<!-- ... -->`).\n   - Keep bullets/checkboxes where template expects them.\n   - If PR already exists, refresh body content so it reflects the total PR\n     scope (all intended work on the branch), not just the newest commits,\n     including newly added work, removed work, or changed approach.\n   - Do not reuse stale description text from earlier iterations.\n7. Validate PR body with `mix pr_body.check` and fix all reported issues.\n8. Reply with the PR URL from `gh pr view`.\n\n## Commands\n\n```sh\n# Identify branch\nbranch=$(git branch --show-current)\n\n# Minimal validation gate\nmake -C elixir all\n\n# Initial push: respect the current origin remote.\ngit push -u origin HEAD\n\n# If that failed because the remote moved, use the pull skill. After\n# pull-skill resolution and re-validation, retry the normal push:\ngit push -u origin HEAD\n\n# If the configured remote rejects the push for auth, permissions, or workflow\n# restrictions, stop and surface the exact error.\n\n# Only if history was rewritten locally:\ngit push --force-with-lease origin HEAD\n\n# Ensure a PR exists (create only if missing)\npr_state=$(gh pr view --json state -q .state 2>/dev/null || true)\nif [ \"$pr_state\" = \"MERGED\" ] || [ \"$pr_state\" = \"CLOSED\" ]; then\n  echo \"Current branch is tied to a closed PR; create a new branch + PR.\" >&2\n  exit 1\nfi\n\n# Write a clear, human-friendly title that summarizes the shipped change.\npr_title=\"<clear PR title written for this change>\"\nif [ -z \"$pr_state\" ]; then\n  gh pr create --title \"$pr_title\"\nelse\n  # Reconsider title on every branch update; edit if scope shifted.\n  gh pr edit --title \"$pr_title\"\nfi\n\n# Write/edit PR body to match .github/pull_request_template.md before validation.\n# Example workflow:\n# 1) open the template and draft body content for this PR\n# 2) gh pr edit --body-file /tmp/pr_body.md\n# 3) for branch updates, re-check that title/body still match current diff\n\ntmp_pr_body=$(mktemp)\ngh pr view --json body -q .body > \"$tmp_pr_body\"\n(cd elixir && mix pr_body.check --file \"$tmp_pr_body\")\nrm -f \"$tmp_pr_body\"\n\n# Show PR URL for the reply\ngh pr view --json url -q .url\n```\n\n## Notes\n\n- Do not use `--force`; only use `--force-with-lease` as the last resort.\n- Distinguish sync problems from remote auth/permission problems:\n  - Use the `pull` skill for non-fast-forward or stale-branch issues.\n  - Surface auth, permissions, or workflow restrictions directly instead of\n    changing remotes or protocols.\n\n<!-- File: .codex/skills/release/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n---\nname: release\ndescription: Cut a Symphony release by bumping the committed version, landing it, tagging the merged commit, and verifying the Burrito release workflow. Use when asked to release, tag, or retag Symphony.\n---\n\n# Release\n\n1. Start from fresh `origin/main` in a clean worktree. Never disturb unrelated\n   local changes.\n2. Pick the requested version. If none is given, use the next patch after the\n   latest `vX.Y.Z` tag.\n3. Update `elixir/mix.exs` so `version: \"X.Y.Z\"` matches the intended tag.\n   Search the old version and change other files only when they are true\n   release-version sources, not examples.\n4. Run `make -C elixir all`, then commit, push, create a PR, and land it.\n5. Fetch the merged `main` commit. Verify its `mix.exs` version, then create an\n   annotated tag on that exact commit:\n\n   ```sh\n   git tag -a vX.Y.Z <merged-commit> -m \"Symphony vX.Y.Z\"\n   git push origin vX.Y.Z\n   ```\n\n6. Watch `burrito-release` until it finishes. Verify the build, smoke, and\n   release jobs pass and that the GitHub release has all expected assets.\n\nDo not tag an uncommitted or unmerged revision. Do not move a published tag\nwithout explicit user approval.\n\n<!-- File: .codex/skills/land/land_watch.py (Tokens: ~4388 | Category: codex-rule) -->\n#!/usr/bin/env python3\nimport asyncio\nimport json\nimport random\nimport re\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import Any\n\nPOLL_SECONDS = 10\nCHECKS_APPEAR_TIMEOUT_SECONDS = 120\nCODEX_BOTS = {\n    \"chatgpt-codex-connector[bot]\",\n    \"github-actions[bot]\",\n    \"codex-gc-app[bot]\",\n    \"app/codex-gc-app\",\n}\nMAX_GH_RETRIES = 5\nBASE_GH_BACKOFF_SECONDS = 2\n\n\n@dataclass\nclass PrInfo:\n    number: int\n    url: str\n    head_sha: str\n    mergeable: str | None\n    merge_state: str | None\n\n\nclass RateLimitError(RuntimeError):\n    pass\n\n\ndef is_rate_limit_error(error: str) -> bool:\n    return \"HTTP 429\" in error or \"rate limit\" in error.lower()\n\n\nasync def run_gh(*args: str) -> str:\n    max_delay = BASE_GH_BACKOFF_SECONDS * (2 ** (MAX_GH_RETRIES - 1))\n    delay_seconds = BASE_GH_BACKOFF_SECONDS\n    last_error = \"gh command failed\"\n    for attempt in range(1, MAX_GH_RETRIES + 1):\n        proc = await asyncio.create_subprocess_exec(\n            \"gh\",\n            *args,\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, stderr = await proc.communicate()\n        if proc.returncode == 0:\n            return stdout.decode()\n        error = stderr.decode().strip() or \"gh command failed\"\n        if not is_rate_limit_error(error):\n            raise RuntimeError(error)\n        last_error = error\n        if attempt >= MAX_GH_RETRIES:\n            break\n        jitter = random.uniform(0, delay_seconds)\n        await asyncio.sleep(min(delay_seconds + jitter, max_delay))\n        delay_seconds = min(delay_seconds * 2, max_delay)\n    raise RateLimitError(last_error)\n\n\nasync def get_pr_info() -> PrInfo:\n    data = await run_gh(\n        \"pr\",\n        \"view\",\n        \"--json\",\n        \"number,url,headRefOid,mergeable,mergeStateStatus\",\n    )\n    parsed = json.loads(data)\n    return PrInfo(\n        number=parsed[\"number\"],\n        url=parsed[\"url\"],\n        head_sha=parsed[\"headRefOid\"],\n        mergeable=parsed.get(\"mergeable\"),\n        merge_state=parsed.get(\"mergeStateStatus\"),\n    )\n\n\nasync def get_paginated_list(endpoint: str) -> list[dict[str, Any]]:\n    page = 1\n    items: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            endpoint,\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        items.extend(batch)\n        page += 1\n    return items\n\n\nasync def get_issue_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/issues/{pr_number}/comments\",\n    )\n\n\nasync def get_review_comments(pr_number: int) -> list[dict[str, Any]]:\n    return await get_paginated_list(\n        f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/comments\",\n    )\n\n\nasync def get_reviews(pr_number: int) -> list[dict[str, Any]]:\n    page = 1\n    reviews: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        batch = json.loads(data)\n        if not batch:\n            break\n        reviews.extend(batch)\n        page += 1\n    return reviews\n\n\nasync def get_check_runs(head_sha: str) -> list[dict[str, Any]]:\n    page = 1\n    check_runs: list[dict[str, Any]] = []\n    while True:\n        data = await run_gh(\n            \"api\",\n            \"--method\",\n            \"GET\",\n            f\"repos/{{owner}}/{{repo}}/commits/{head_sha}/check-runs\",\n            \"-f\",\n            \"per_page=100\",\n            \"-f\",\n            f\"page={page}\",\n        )\n        payload = json.loads(data)\n        batch = payload.get(\"check_runs\", [])\n        if not batch:\n            break\n        check_runs.extend(batch)\n        total_count = payload.get(\"total_count\")\n        if total_count is not None and len(check_runs) >= total_count:\n            break\n        page += 1\n    return check_runs\n\n\ndef parse_time(value: str) -> datetime:\n    normalized = value.replace(\"Z\", \"+00:00\")\n    return datetime.fromisoformat(normalized)\n\n\nCONTROL_CHARS_RE = re.compile(r\"[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f]\")\n\n\ndef sanitize_terminal_output(value: str) -> str:\n    return CONTROL_CHARS_RE.sub(\"\", value)\n\n\ndef check_timestamp(check: dict[str, Any]) -> datetime | None:\n    for key in (\"completed_at\", \"started_at\", \"run_started_at\", \"created_at\"):\n        value = check.get(key)\n        if value:\n            return parse_time(value)\n    return None\n\n\ndef dedupe_check_runs(check_runs: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_name: dict[str, dict[str, Any]] = {}\n    for check in check_runs:\n        name = check.get(\"name\", \"unknown\")\n        timestamp = check_timestamp(check)\n        if name not in latest_by_name:\n            latest_by_name[name] = check\n            continue\n        existing = latest_by_name[name]\n        existing_timestamp = check_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_name[name] = check\n    return list(latest_by_name.values())\n\n\ndef summarize_checks(check_runs: list[dict[str, Any]]) -> tuple[bool, bool, list[str]]:\n    if not check_runs:\n        return True, False, [\"no checks reported\"]\n    check_runs = dedupe_check_runs(check_runs)\n    pending = False\n    failed = False\n    failures: list[str] = []\n    for check in check_runs:\n        status = check.get(\"status\")\n        conclusion = check.get(\"conclusion\")\n        name = check.get(\"name\", \"unknown\")\n        if status != \"completed\":\n            pending = True\n            continue\n        if conclusion not in (\"success\", \"skipped\", \"neutral\"):\n            failed = True\n            failures.append(f\"{name}: {conclusion}\")\n    return pending, failed, failures\n\n\ndef latest_review_request_at(comments: list[dict[str, Any]]) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        if is_codex_bot_user(comment.get(\"user\", {})):\n            continue\n        body = comment.get(\"body\") or \"\"\n        if \"@codex review\" not in body:\n            continue\n        timestamp = comment_time(comment)\n        if timestamp is None:\n            continue\n        if latest is None or timestamp > latest:\n            latest = timestamp\n    return latest\n\n\ndef filter_codex_comments(\n    comments: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    latest_issue_ack = latest_codex_issue_reply_time(comments)\n    codex_comments = [c for c in comments if is_codex_bot_user(c.get(\"user\", {}))]\n    filtered: list[dict[str, Any]] = []\n    for comment in codex_comments:\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if review_requested_at is not None and created_time <= review_requested_at:\n            continue\n        is_threaded = bool(\n            comment.get(\"in_reply_to_id\") or comment.get(\"pull_request_review_id\")\n        )\n        if not is_threaded:\n            if latest_issue_ack is not None and created_time <= latest_issue_ack:\n                continue\n        else:\n            thread_root = thread_root_id(comment)\n            last_reply = None\n            if thread_root is not None:\n                last_reply = latest_codex_reply.get(thread_root)\n            if last_reply and last_reply > created_time:\n                continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_codex_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    return login in CODEX_BOTS\n\n\ndef is_bot_user(user: dict[str, Any]) -> bool:\n    login = user.get(\"login\") or \"\"\n    if is_codex_bot_user(user):\n        return True\n    if user.get(\"type\") == \"Bot\":\n        return True\n    return login.endswith(\"[bot]\")\n\n\ndef is_codex_reply_body(body: str) -> bool:\n    return body.startswith(\"[codex]\")\n\n\ndef is_codex_review_body(body: str) -> bool:\n    return body.startswith(\"## Codex Review\")\n\n\ndef latest_codex_issue_reply_time(\n    comments: list[dict[str, Any]],\n) -> datetime | None:\n    latest: datetime | None = None\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        created_time = comment_time(comment)\n        if created_time is None:\n            continue\n        if latest is None or created_time > latest:\n            latest = created_time\n    return latest\n\n\ndef filter_human_issue_comments(comments: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        if is_codex_review_body(body):\n            continue\n        if \"@codex review\" in body:\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef filter_codex_review_issue_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_ack = latest_codex_issue_reply_time(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_review_body(body):\n            continue\n        created_time = comment_time(comment)\n        if (\n            latest_ack is not None\n            and created_time is not None\n            and created_time <= latest_ack\n        ):\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef thread_root_id(comment: dict[str, Any]) -> int | None:\n    return comment.get(\"in_reply_to_id\") or comment.get(\"id\")\n\n\ndef comment_time(comment: dict[str, Any]) -> datetime | None:\n    timestamp = comment.get(\"updated_at\") or comment.get(\"created_at\")\n    if not timestamp:\n        return None\n    return parse_time(timestamp)\n\n\ndef latest_codex_reply_by_thread(\n    comments: list[dict[str, Any]],\n) -> dict[int, datetime]:\n    latest: dict[int, datetime] = {}\n    for comment in comments:\n        body = (comment.get(\"body\") or \"\").strip()\n        if not is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        if thread_root is None or created_time is None:\n            continue\n        existing = latest.get(thread_root)\n        if existing is None or created_time > existing:\n            latest[thread_root] = created_time\n    return latest\n\n\ndef filter_human_review_comments(\n    comments: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n    latest_codex_reply = latest_codex_reply_by_thread(comments)\n    filtered: list[dict[str, Any]] = []\n    for comment in comments:\n        if is_bot_user(comment.get(\"user\", {})):\n            continue\n        body = (comment.get(\"body\") or \"\").strip()\n        if is_codex_reply_body(body):\n            continue\n        thread_root = thread_root_id(comment)\n        created_time = comment_time(comment)\n        last_codex_reply = None\n        if thread_root is not None:\n            last_codex_reply = latest_codex_reply.get(thread_root)\n        if last_codex_reply and created_time and created_time <= last_codex_reply:\n            continue\n        filtered.append(comment)\n    return filtered\n\n\ndef is_blocking_review(\n    review: dict[str, Any],\n    review_requested_at: datetime | None,\n) -> bool:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return False\n    user_login = review.get(\"user\", {}).get(\"login\")\n    created_time = parse_time(created_at)\n    if (\n        user_login in CODEX_BOTS\n        and review_requested_at is not None\n        and created_time <= review_requested_at\n    ):\n        return False\n    body = (review.get(\"body\") or \"\").strip()\n    state = review.get(\"state\")\n    if user_login in CODEX_BOTS:\n        return state == \"CHANGES_REQUESTED\"\n    if body.startswith(\"[codex]\") or state in (\"APPROVED\", \"DISMISSED\"):\n        return False\n    blocking = False\n    if body or state == \"CHANGES_REQUESTED\":\n        blocking = True\n    elif state == \"COMMENTED\":\n        blocking = False\n    elif state:\n        blocking = state not in (\"APPROVED\", \"DISMISSED\")\n    return blocking\n\n\ndef review_timestamp(review: dict[str, Any]) -> datetime | None:\n    created_at = review.get(\"submitted_at\") or review.get(\"created_at\")\n    if not created_at:\n        return None\n    return parse_time(created_at)\n\n\ndef dedupe_reviews(reviews: list[dict[str, Any]]) -> list[dict[str, Any]]:\n    latest_by_user: dict[str, dict[str, Any]] = {}\n    for review in reviews:\n        user_login = review.get(\"user\", {}).get(\"login\")\n        if not user_login:\n            continue\n        timestamp = review_timestamp(review)\n        if user_login not in latest_by_user:\n            latest_by_user[user_login] = review\n            continue\n        existing = latest_by_user[user_login]\n        existing_timestamp = review_timestamp(existing)\n        if timestamp is None:\n            continue\n        if existing_timestamp is None or timestamp > existing_timestamp:\n            latest_by_user[user_login] = review\n    return list(latest_by_user.values())\n\n\ndef filter_blocking_reviews(\n    reviews: list[dict[str, Any]],\n    review_requested_at: datetime | None,\n) -> list[dict[str, Any]]:\n    return [\n        review\n        for review in dedupe_reviews(reviews)\n        if is_blocking_review(review, review_requested_at)\n    ]\n\n\ndef is_merge_conflicting(pr: PrInfo) -> bool:\n    return pr.mergeable == \"CONFLICTING\" or pr.merge_state == \"DIRTY\"\n\n\nasync def fetch_review_context(\n    pr_number: int,\n) -> tuple[\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    list[dict[str, Any]],\n    datetime | None,\n]:\n    issue_comments = await get_issue_comments(pr_number)\n    review_request_at = latest_review_request_at(issue_comments)\n    review_comments = await get_review_comments(pr_number)\n    reviews = await get_reviews(pr_number)\n    return issue_comments, review_comments, reviews, review_request_at\n\n\ndef raise_on_human_feedback(\n    issue_comments: list[dict[str, Any]],\n    review_comments: list[dict[str, Any]],\n    reviews: list[dict[str, Any]],\n    review_request_at: datetime | None,\n) -> None:\n    human_issue_comments = filter_human_issue_comments(issue_comments)\n    codex_review_comments = filter_codex_review_issue_comments(issue_comments)\n    human_review_comments = filter_human_review_comments(review_comments)\n    if human_issue_comments or human_review_comments or codex_review_comments:\n        print(\"Review comments detected. Address before merge.\")\n        print(\n            \"Reminder: decide whether feedback stays in scope; defer if needed \"\n            \"and note in your root-level update.\",\n        )\n        raise SystemExit(2)\n    blocking_reviews = filter_blocking_reviews(reviews, review_request_at)\n    if blocking_reviews:\n        print(\"Review states/comments detected. Address before merge.\")\n        print(\n            \"Reminder: keep PR title/description aligned with the full scope \"\n            \"when changes expand.\",\n        )\n        raise SystemExit(2)\n\n\nasync def wait_for_codex(pr_number: int, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for review feedback...\", flush=True)\n    while True:\n        (\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        ) = await fetch_review_context(pr_number)\n        bot_issue_comments = filter_codex_comments(issue_comments, review_request_at)\n        bot_review_comments = filter_codex_comments(review_comments, review_request_at)\n        bot_comments = bot_issue_comments + bot_review_comments\n        raise_on_human_feedback(\n            issue_comments,\n            review_comments,\n            reviews,\n            review_request_at,\n        )\n        if bot_comments:\n            latest = max(\n                bot_comments,\n                key=lambda comment: parse_time(comment[\"created_at\"]),\n            )\n            body = sanitize_terminal_output(latest.get(\"body\") or \"\").strip()\n            if body:\n                print(\"Codex left comments. Address feedback before merge.\")\n                print(body)\n                raise SystemExit(2)\n        if checks_done.is_set():\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def wait_for_checks(head_sha: str, checks_done: asyncio.Event) -> None:\n    print(\"Waiting for CI checks...\", flush=True)\n    empty_seconds = 0\n    while True:\n        check_runs = await get_check_runs(head_sha)\n        if not check_runs:\n            empty_seconds += POLL_SECONDS\n            if empty_seconds >= CHECKS_APPEAR_TIMEOUT_SECONDS:\n                print(\n                    \"No checks detected after 120s; check CI configuration\",\n                )\n                raise SystemExit(3)\n            await asyncio.sleep(POLL_SECONDS)\n            continue\n        empty_seconds = 0\n        pending, failed, failures = summarize_checks(check_runs)\n        if failed:\n            print(\"Checks failed:\")\n            for failure in failures:\n                print(f\"- {failure}\")\n            raise SystemExit(3)\n        if not pending:\n            print(\"Checks passed\")\n            checks_done.set()\n            return\n        await asyncio.sleep(POLL_SECONDS)\n\n\nasync def watch_pr() -> None:\n    pr = await get_pr_info()\n    if is_merge_conflicting(pr):\n        print(\n            \"PR has merge conflicts. Resolve/rebase against main and push before \"\n            \"running land_watch again.\",\n        )\n        raise SystemExit(5)\n    head_sha = pr.head_sha\n    checks_done = asyncio.Event()\n    codex_task = asyncio.create_task(wait_for_codex(pr.number, checks_done))\n    checks_task = asyncio.create_task(wait_for_checks(head_sha, checks_done))\n\n    async def head_monitor() -> None:\n        while True:\n            current = await get_pr_info()\n            if is_merge_conflicting(current):\n                print(\n                    \"PR has merge conflicts. Resolve/rebase against main and push \"\n                    \"before running land_watch again.\",\n                )\n                raise SystemExit(5)\n            if current.head_sha != head_sha:\n                print(\"PR head updated; pull/amend/force-push to retrigger CI\")\n                raise SystemExit(4)\n            await asyncio.sleep(POLL_SECONDS)\n\n    monitor_task = asyncio.create_task(head_monitor())\n    success_task = asyncio.gather(codex_task, checks_task)\n\n    done, pending = await asyncio.wait(\n        [monitor_task, success_task],\n        return_when=asyncio.FIRST_COMPLETED,\n    )\n    for task in pending:\n        task.cancel()\n    for task in done:\n        exc = task.exception()\n        if exc:\n            raise exc\n\n\nif __name__ == \"__main__\":\n    try:\n        asyncio.run(watch_pr())\n    except SystemExit as exc:\n        raise SystemExit(exc.code) from None\n\n<!-- File: .codex/worktree_init.sh (Tokens: ~105 | Category: codex-rule) -->\n#!/usr/bin/env bash\nset -eo pipefail\n\nscript_dir=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nrepo_root=\"$(cd \"$script_dir/..\" && pwd)\"\nproject_root=\"$repo_root/elixir\"\n\nif ! command -v mise >/dev/null 2>&1; then\n  echo \"mise is required. Install it from https://mise.jdx.dev/getting-started.html\" >&2\n  exit 1\nfi\n\ncd \"$project_root\"\nmise trust\n\nmake setup\n</yakaai_skills>"}