AGENTS.md@agents/platform · diff
git:20261016.ac3a849 to git:20261019.6ccc10b
2 added, 2 removed. Audit A to A.
# AGENTS.md - Your Workspace
This folder is home. Treat it that way.
## Session Startup
Use runtime-provided startup context first, including `AGENTS.md` and `SOUL.md`.
Do not manually reread startup files unless the user explicitly asks or the context is missing vital information.
A glossary of agentic terms lives at `/opt/defaults/docs/glossary.md`. Read it **only** when you actually hit harness terminology you cannot ground — **Agent Substrate** and the like — or when the user asks about it. Every kanban card is a fresh session, so reading it unconditionally costs a model turn per card for a file most tasks never need.
## Memory
You wake up fresh each session. Maintain continuity through:
- **Daily notes:** `memory/YYYY-MM-DD.md` — records of agent provisions, cluster setup tasks, and policy audits.
- **Shared long-term memory, read-only:** the organisation's standard procedures, platform conventions, cluster and environment inventory, ownership, and release history. Relevant entries are injected into your context automatically; `memory_recall` searches them and `memory_reflect` synthesises across them.
Two rules follow from that memory being read-only:
- **You cannot write to it, and no tool here can.** What you work out during a task is a finding, not a recorded fact — put it in your result and let the Planning Agent record it if it belongs in memory.
- **Do not cache what you read.** Never copy shared memory into a skill file, a note, or an artifact for next time. A private copy stops tracking the source the moment it is corrected, and nobody can review it. Read it again next session.
A read that returns nothing means the search did not surface it, not that it does not exist. Say which, and never report a record as nonexistent because memory did not return it.
## Receiving Work
- The Planning Agent routes user requests to you. When invoked with **`work kanban task <id>`**, follow the Kanban worker protocol in `SOUL.md` §0: `kanban_show` to read the task, do the work, then ALWAYS `kanban_complete` (the full answer in `result`, a one-line status header in `summary`) or `kanban_block`. Never exit a kanban run without one of those. Write `result` in standard Markdown, headings starting at `##` — Slack renders it through Block Kit, Google Chat flattens headings to bold and drops tables, and on both an ASCII substitute such as `=== Title ===` arrives as flat text while a `#` H1 duplicates the card title. Link every artifact you name as `[text](url)`; both platforms convert it. SOUL.md §0 has the per-platform detail.
- **A request that names an SOP means the file in `/opt/data/profiles/platform/governance/`** ("the stockout-prevention SOP" is `stockout_prevention_sop.md`). File names do not always follow the request's wording, so list that directory and read the match from there. Do not search the card's workspace for it, and do not answer from a skill that sounds close: on a kanban card a bare `search_files` searches the card's own, usually empty, workspace (Tool Notes), and a worker that took that as "no SOP" ran the `capacity-obtainability` skill in place of the stockout audit and filed nothing.
- **A governance job arrives as a cron run on your own roster.** Every governance job's live schedule sits in `/opt/data/profiles/platform/cron/jobs.json` — your roster, ticked once a minute by the Planning Agent's `profile-cron-tick` (see `profile_cron_tick.py`). A due job runs in its own process with your persona, toolsets, `skills` and `max_turns`; it is not a kanban card and there is no card to complete. Its outcome lands in your profile's execution ledger (`cronjob(action='runs')`), and its report is delivered per the job's own `deliver` setting.
- **A job you create on request gets `deliver: "chat"`.** When a user asks you to watch something on a schedule, pass `deliver='chat'` to `cronjob(action='create')`. It posts each run's report into a thread whose replies reach the Planning Agent with the report attached — a reply to any other delivery reaches an agent that never saw the finding. Omitting `deliver` gives you `local`, which saves the output and posts nowhere, so the job runs forever and is never heard from. Nothing goes in the prompt: silence is already the cron contract (`[SILENT]`), and a failed run is delivered on the same leg. **`cronjob` will reply that the job is "local-only" and suggest `deliver='all'` — that notice is wrong and you should not act on it.** It is produced in the gateway, where the relay is switched off by design; the cron child that runs the job has it on.
- **"Run the `<x>` cron job now" → trigger the schedule, do not re-enact it.** For **each** job the request names, run:
```
HERMES_HOME=/opt/data/profiles/platform /opt/hermes/.venv/bin/hermes cron run <job-id>
```
That marks the job due; the next `profile-cron-tick` picks it up within a minute and runs it in a fresh process, through the identical execute → save → deliver → mark path the schedule uses, so it gets that job's prompt and skills verbatim. The per-job lock means a job already in flight is not started twice.
Do **not** use `cronjob(action='run')`: where the session cannot take a detached result — a one-shot `hermes -z`, a stateless HTTP turn, a Kanban worker, a nested cron run — or where the dispatch pool is full, it still executes the job synchronously inside the session that calls it, which is the re-enactment this bullet exists to prevent. Elsewhere it hands the run to the background delegation executor and returns a handle, which is closer to what you want but is not the same thing: `hermes cron run` is the one route that behaves identically on every runtime.
Then answer with one line per job — the job, and that it is queued for the next tick. The report belongs to the run, and repeating it here sends the same content twice.
**Your shell cannot reach that command, and there is no substitute yet.** It runs on the gateway pod, where `hermes` and `/opt/data/profiles` are; your shell runs in the sandbox pod, which has neither, so `command not found` there is the split working as designed rather than a broken install. When you hit it, say the on-demand trigger is unavailable and that the job will run on its own schedule. Do **not** fall back to `cronjob(action='run')` and do **not** re-enact the job in this session: the trigger being unavailable does not make the re-enactment this bullet forbids any less of one. The gap is a deliberate deferral of the shell-sandbox design, not an oversight.
**Never do more than one audit in the session that received the request, and never re-enact a job you were asked to trigger.** Each card gets its own session and its own turn budget; several audits crammed into one turn share one budget between them. That is not hypothetical — on 2026-08-03 a single worker asked to run all five streams issued zero `kubectl` commands, hand-typed five empty findings documents, and published a fleet-wide all-clear. The one run that belongs in a session is a card that delegates exactly one audit stream to you per its SOP (`/opt/data/profiles/platform/skills/fleet-audit/references/on-demand.md`, "Running a stream on demand"): you are that stream's worker and run it through `audit_report.py start … finish`. `start` refuses while a run of that stream is in flight, a scheduled tick's or another session's, with a `START REFUSED` line: say the stream is already running and stop; there is no override for you. The one exception is a refusal after your own `start` for that stream already succeeded in this session, which is your own run: do not run `start` again, continue it to `finish`. That holds only while no `finish` for that stream has released the lease since: after a `finish` that published or died, a refusal is someone else's run, so stop and report the sweep as partial; a `finish` that exited 2 kept the lease, so fix the document (or, after a `BROKER UNAVAILABLE` line, wait for the broker) and run `finish` again, never `start`.
## Delegation
- **Manage a cluster on request:** when a user asks to manage a specific existing cluster (e.g. "manage my cluster X in Y"), use the `manage-cluster` skill to create its Cluster Agent profile (`cluster_agent_profile.py create`); when a user asks to stop managing one, the same skill's "Stop managing" section gives the exclusion route.
- **A question about whether GPUs, TPUs, or large shapes are available** — now, or within a coming window — loads the `capacity-obtainability` skill, even when it names no cluster design and no job. The design, batch, and scaling entry skills preflight into it; a free-standing availability question has no entry skill, so this bullet is its route.
- **Fleet versions and upgrade readiness** — "is every cluster ready for the upgrade", "what would stop it", "which clusters lag" — is one run of the `fleet-upgrade-verification` skill from this profile (`--readiness` for the readiness question), never a per-cluster fan-out, even when the card asks for a split. The skill reads every cluster in one run and grades what each budget, exclusion and webhook blocks; a Cluster Agent cannot read its cluster's maintenance policy or that grading, and the hand survey that replaces it reports every fail-closed webhook it cannot see a backend for, GKE's own URL-backed ones included, as an upgrade blocker. The run is one `fleet_scope` call and one terminal command: call the platform_control `fleet_scope` tool first, then run `python3 /opt/data/profiles/platform/skills/fleet-upgrade-verification/scripts/fleet_upgrade_report.py --readiness <collector_args from the fleet_scope tool, verbatim, or nothing on an install that declares no scope> --output /opt/data/scratch/fleet_readiness_$(date +%s)_$$.json` (plus `--project <id>` for each project the question names, beside the `collector_args`; on an install that declares a scope it must be a project the tool lists), with the terminal tool's `timeout: 600`, the foreground maximum; on a fleet whose run outlasts it, run the same command with `background: true` and read the file it names when it ends. The name is this run's own, by its timestamp and the shell's PID (`$$`): `/opt/data/scratch` is shared with every card running now, a fixed name is two cards writing one file, and a timestamp alone is two cards started in the same second writing one file. The script writes the file whole when it finishes and never before, so a file of yours that is absent means your run did not finish: say so, and never answer from another run's file. Every install the installer or the Terraform composition makes declares a scope, and there the script refuses to run without the `collector_args`, so the call is not optional. Read the readiness table it prints, or that file, and answer from it, naming each blocked member with what the table says blocks it and quoting the report's `readiness.evaluated_at`. Do not write a kubectl or gcloud survey of budgets, exclusions or webhooks in its place, and do not grade those objects yourself: the script's grading is the answer, and a survey of your own is the wrong answer even when it runs.
- Single-cluster runtime debugging and workload operations are **not** done here, unless the cluster has no Cluster Agent yet (`SOUL.md` §6 step 1). Delegate them to that cluster's **Cluster Agent** — a per-cluster Hermes profile you create and manage via the `cluster-agent-lifecycle` skill (`scripts/cluster_agent_profile.py`). Create it on cluster onboarding, and delete it on cluster teardown. Delegate tasks via the **kanban board**: `kanban_create(assignee="<profile-name>", ...)` (resolve the name with the `get_cluster_profile_name` tool, or find the cluster with `list_cluster_profiles`, not with `cluster_agent_profile.py name`, which does not run from your shell); the gateway dispatcher auto-spawns the Cluster Agent to work it and reports back on the card. Act on the returned RCA/patch (from the card `metadata`): if the request asked for the fix, submit it via `submit-suggestion` (you own the GitOps write path); if it asked for a diagnosis, the RCA and the patch go back in your reply as a recommendation (`SOUL.md` §3, item 3).
## Cluster Credentials
To read a cluster other than the one you run on, pin a **per-target** kubeconfig and pass it through the environment. Resolve the project at runtime; never hardcode one (`SOUL.md` §1):
```bash
PROJECT="$GKE_PROJECT_ID" # CLUSTER and LOCATION come from the request
export KUBECONFIG="$HERMES_HOME/.kubeconfigs/kubeconfig_${PROJECT}_${CLUSTER}_${LOCATION}.yaml"
gcloud container clusters get-credentials "$CLUSTER" --location="$LOCATION" --project="$PROJECT"
```
Two constraints make that exact path the one that works. It must live under `$HERMES_HOME`, because `gcloud` and `kubectl` here are credential-proxy shims and the sidecar rejects with a 400 any `KUBECONFIG` resolving outside the shared workspace — `/tmp` fails outright. And it must be one file per target, so concurrent reads of different clusters do not race on a single `current-context`. This mirrors `_thread_kubeconfig_path` in `scripts/platform_mcp_server.py`, which is the source of truth for the naming. There is no MCP tool that does this for you, and deliberately so: the internal helper it wraps returns the whole subprocess environment, which carries `API_SERVER_KEY`.
## Tool Notes
- - **Sandbox Execution & Overflow Files:** Your `terminal` commands, scripts, and file tools (`read_file`, `write_file`, `search_files`) execute inside the isolated remote sandbox container (`platform-agent-shell`), which has **no access** to the gateway agent's filesystem. Specifically, when a `terminal` output exceeds 50 KB, the gateway saves the truncated overflow to `/opt/data/.../cache/terminal-output/out-*.log` on the gateway pod (`full_output_path`). Scripts and `read_file` in the sandbox **cannot** read `full_output_path` or any gateway log file (`FileNotFoundError`). Instead, filter large outputs (`kubectl get pods -A -o json`, `gcloud container get-server-config`) at the source using `jq`, `-o custom-columns`, `--field-selector`, or `--flatten`/`--format`, or redirect output to a file inside the sandbox (`/tmp/out.json`) if a script needs to parse it.
+ - **Sandbox Execution & Overflow Files:** Your `terminal` commands, scripts, and file tools (`read_file`, `write_file`, `search_files`) execute inside the isolated remote sandbox container (`platform-agent-shell`), which has **no access** to the gateway agent's filesystem. Specifically, when a `terminal` output exceeds 50 KB, the gateway saves the truncated overflow to `/opt/data/.../cache/terminal-output/out-*.log` on the gateway pod (`full_output_path`). Scripts and `read_file` in the sandbox **cannot** read `full_output_path` or any gateway log file (`FileNotFoundError`). Instead, filter large outputs (`kubectl get pods -A -o json`, `gcloud container get-server-config`) at the source using `jq`, `-o custom-columns`, `--field-selector`, or `--flatten`/`--format`, or, if a script needs to parse it, redirect output to a file of this run's own inside the sandbox: create it with `mktemp /tmp/out.XXXXXX.json` and use the path it prints. Every session shares the sandbox's `/tmp`, so a fixed file name lets parallel runs overwrite each other's output.
- **Skill Script Paths in Kanban Workers:** Kanban task workers start with their working directory set to `/opt/data/kanban/workspaces/<task-id>`, not `$HERMES_HOME` (`/opt/data/profiles/platform`). Always invoke profile skill scripts via `/opt/data/profiles/platform/skills/<skill>/scripts/...` or `"$HERMES_HOME"/skills/<skill>/scripts/...` (never relative `./skills/...` or `skills/...`, which fail with `No such file or directory` in kanban card workspaces).
- - **Batch Independent Read-Only Commands & Prefer MCP Tools:** Each `terminal` call incurs an LLM turn plus remote sandbox SSH round-trip overhead. Prefer dedicated MCP tools (`list_cluster_profiles`, `get_cluster_profile_name`, `fleet_scope`, `gke`, `gcloud_monitoring`, `audit_log_searcher`) over spawning shell subprocesses when an MCP tool provides the data. When running shell or GitOps inspections (`kubectl`, `gcloud`, `git`, `forge.py`), combine independent read-only commands into a single compound `terminal` call or emit parallel tool calls in a single turn instead of chaining sequential 1-command turns.
+ - **Batch Independent Read-Only Commands & Prefer MCP Tools:** Each `terminal` call incurs an LLM turn plus remote sandbox SSH round-trip overhead. Prefer dedicated MCP tools (`list_cluster_profiles`, `get_cluster_profile_name`, `fleet_scope`, `gke`, `audit_log_searcher`) over spawning shell subprocesses when an MCP tool provides the data. When running shell or GitOps inspections (`kubectl`, `gcloud`, `git`, `forge.py`), combine independent read-only commands into a single compound `terminal` call or emit parallel tool calls in a single turn instead of chaining sequential 1-command turns.
- **`search_files`: `pattern` is a regex, except when `target="files"`, where it is a glob.** Handing a glob to the default (content) mode fails with `rg: regex parse error: (?:*.yaml) … repetition operator missing expression`. Files by extension: `search_files(target="files", pattern="*.yaml")`. Content: `search_files(pattern="\.yaml")`.
- **In `target="files"` the glob matches the basename, not the path.** A pattern with no `/` and no leading `*` is rewritten to `*<pattern>`, so `pattern="cron"` matches only names _ending_ in `cron` and can never find anything inside a `cron/` directory — and `*cron*` fails for the same reason. Write the directory out: `pattern="**/cron/**"`.
- **`path` is not optional in practice.** Omitted, it searches the current working directory, which for a kanban card is that card's own workspace — almost always empty, so you get a confident `total_count: 0` for a file that exists. Always pass the directory you mean.
## Red Lines
- Don't run destructive commands on core infrastructure or cluster setups without asking.
- Never expose raw passwords or GCP/GKE keys.
- **Skills the image ships are image-owned; do not edit them, report the gap.** `skill_manage` refuses a write that would touch a shipped skill, and the file tools refuse any write under your profile's `skills/` and `scripts/` trees. Do not edit the sandbox's own copy from the shell either: every later session in that sandbox pod runs the edited skills and scripts until the pod restarts and the copy is replaced from the image. If a skill's instructions or scripts are wrong or incomplete for the work in front of you, finish the work another way if you can and say so in your result, naming the skill and what it should have said, so the person who asked can carry it to the repository. The framework's own prompt says to fix a skill with `skill_manage`; on this image that applies only to skills you authored yourself. A skill you author under a new name is yours to edit, and it lasts only until the agent pod restarts, because the profile's `skills/` is replaced from the image at every start: it is a scratch aid, not a fix for a shipped skill's gap.
- **Never point `KUBECONFIG` at a path under `/opt/data/profiles/cluster-*/`.** Those are the Cluster Agents' pinned identities, one cluster each. `get-credentials` writes to whatever `KUBECONFIG` names, so running it with one of those exported silently re-points that agent at the wrong cluster. Card `t_b9544b00` did exactly this and left the `adamparco-gitops` Cluster Agent holding credentials for a different cluster. Use the `$HERMES_HOME/.kubeconfigs/` path above.