Agent Guidelines¶
β οΈ Before doing anything: read this file AND scan the task board (
docs/eop-tasks/tasks/, published at tasks.theestablishers.com). In EVERY response, include a short attestation that confirms all three: (1) you've read these guidelines, (2) you've checked the board by scope, not UID, so you're not duplicating an existing task, and (3) the task you're working under is named and kept current as you go β and state who you are:<owner-assigned name> Β· <session URL / local session UUID>. Example:β Read AGENTS.md Β· <name> Β· <session id> Β· checked the board (no dup) Β· tracking T-082. For pure Q&A that opens no task, say so:β¦ Β· no task (informational). Applies to every agent, local or cloud β and it's self-triggered: you do this because you read this rule, not because the owner asks.Self-name in the task log: when writing into the task board / decision logs and you need to refer to yourself, sign with the agent name the owner assigned you β never "I", "the agent", a model name, a name you invented, or another agent's name.
A name only helps if you can be sure it's yours. Persist or auto-recall your name only where it uniquely identifies you. Agents that share an environment β e.g. multiple local Claude Code sessions in the same repo path, which share session memory and the same working tree β must not store their name in that shared space, because another agent would read it and adopt it as its own. If you're such a local / shared-environment agent, ask the owner what your name is at the start of the session. If you have no name yet, or can't be certain which is yours, ask before signing anything. The owner's roster of uniquely-identifiable agents is
docs/agents-registry.md.Self-identify via the session-URL ledger before asking the owner. Many agents carry a stable session URL in their system context β
https://claude.ai/code/session_<ID>. Cloud Claude Code definitely does; local Claude Code may or may not β verify in your own environment. If yours does, at session start: read your own session URL, searchdocs/agents-registry.md's "Session ledger" subsection for a row that matches it, and if there's a match adopt the name listed against it without needing to prompt the owner. No match β fall back to the prior rule: ask the owner. No session URL at all β ask the owner; the ledger can't help. Once the owner names you, add your own append-only row to the ledger so a future agent (yourself on a long-running local session, or just an auditor reading the file) can resolve the identity. Never invent a name from an unmatched URL, and never edit / delete another agent's row β the ledger is append-only.β οΈ Commit your work when it's done β every time, before ending your turn. Code edit, doc update, task-board entry, README pointer bump, anything β if you've completed the work,
git addβ commit β push on the designated branch before you reply with the wrap-up. Don't leave a dirty working tree for "the next message": the harness may compress, the session may sleep, the change may be quietly lost, and other agents reading "done" on the task board will see status without the work behind it. The stop-hook will catch a missed commit, but catch it yourself first β and create a draft PR after the push if one doesn't exist yet (per the session prompt's GitHub Integration block). If something genuinely needs to wait for owner confirmation, say so explicitly in your reply and leave the working tree clean β stash, revert, or don't write it yet.
π¦ Operational signals β current state (mutable; flip only on the owner's say-so)¶
Live operational signals, separate from the permanent rules below. Each has a light, a date, and the owner's word. Read them every session. Don't change one unless the owner says so β and when you do, re-date it and quote the new instruction.
π· CRITICAL GUARDRAIL: DEPLOYMENTS ARE ON-DEMAND AND ON-COMMAND ONLY (owner policy, 2026-07-05)¶
To completely eliminate accidental, speculative, or unauthorized production deployments:
1. No Speculative Deploy Choices in Prompts: Agents must never offer or hardcode "deploy" options inside custom multiple-choice selectors, dropdowns, or ask_user panels. If a design option is presented, separate it entirely from deployment.
2. Explicit Standalone Command Only: A deployment can only be triggered if the owner explicitly issues a standalone instruction in the current turn containing a direct command (such as "deploy please" or "deploy now").
3. No Multi-Turn or Blanket Consent: A single deployment command is strictly one-off. Consent from a previous turn or a general select option is never a green light to deploy subsequent commits. If a task is marked "Without deploying...", perform the task, commit, push, and STOP β wait for a separate, explicit deploy instruction.
π¦ Branch policy β READ THE TWO LIGHTS BELOW BEFORE YOU MERGE OR PUSH¶
π’ DEPLOYS GO TO VERCEL NOW β NOT THE NAS (as of 2026-06-29)¶
The app moved back to Vercel (off the self-hosted NAS). That's why
mainnow carries a double light (double = Vercel Β· single = NAS β see the COUNT rule below). If you still assume "main β NAS," that's stale: the deploy target is Vercel. The NAS pipeline is a manual fallback only.βΆ CURRENT SETTING (2026-06-29):
mainπ‘π‘ (double = VERCEL, manual) Β·nightlyπ΄ (paused)¶βΆ DO THIS NOW: commit/merge DIRECTLY INTO
mainONLY β it deploys to Vercel, and deploys are¶manual: a push does NOT trigger one (auto-deploy was turned off at the Vercel end on 2026-06-29). Commit, push, and move on β don't wait for or watch a build; when you actually want production updated, deploy it yourself:
curl -X POST "$VERCEL_DEPLOY_HOOK". Do NOT push tonightly.π MERGES GO THROUGH GITHUB, NEVER LOCALLY (owner instruction, 2026-07-01)¶
Land a branch on
mainby merging its pull request on GitHub β the PR Merge button, or themerge_pull_requestAPI. Do NOTgit checkout main && git merge <branch>+git push origin mainfrom a local / agent clone. Push your work to aclaude/**branch, open a PR, and merge that PR on GitHub so every change tomainis a merged-PR record. (This governs the mechanism; the light table below still governs which branch and whether a deploy fires.) Source (verbatim, owner 2026-07-01): "no, merge to git, never locally. Mark that on AGENTS.MD please"π§± TYPE-CHECK / BUILD BEFORE YOU MERGE (owner-endorsed, 2026-07-02)¶
Before merging any code PR to
main, runNODE_OPTIONS=--max-old-space-size=8192 npx tsc --noEmit(unfiltered) and confirm it's clean β a bare/filteredtsc --noEmitis NOT enough (it has missed implicit-anyhandler params and a miscased icon import that the real type-check caught). Since auto-deploy is OFF and deploys are manual, amainthat doesn't type-check silently blocks every agent's production deploy until someone notices. Don't merge on a filtered/partial type-check. (A fullnext buildruns that same type-check phase and is the truest gate β but it also runs env-dependent static generation, which fails in a credential-less sandbox at pages like/v/[id][Bucket name not specified/ no Firebase creds] for reasons unrelated to your diff. So: heavy unfilteredtscis the reliable local gate; if you runnext build, judge the "Checking validity of types" phase, not a later static-gen env error.) Run it with--incremental falseAND on a freshnpm ciagainst the current lockfile β two 2026-07-16 deploys broke on errors the local gate missed: a stale.tsbuildinfoskipped rechecking a changed file (TS2448, T-167 P2 drawer), and anode_modulespredating two weeks of lockfile changes made local TypeScript infer differently from Vercel's clean-room install (implicit-any, T-167 P3 panel). A gate on stale deps or a warm cache is not a gate. (Origin: the T-024 Airwallex drawer merged un-type-checking at44add12and had to be hot-fixed at11f96e6beforemaincould deploy again.)*
The rule β every time you are about to merge or push: read the two lights on the CURRENT SETTING line above, find the matching row in the table, and do exactly what it says. The two lights are independent. Each branch is one of three states: π’ open + deploy (push here β and the push triggers a deployment), π‘ open, NOβdeploy (push here exactly as π’, but the push triggers NO deployment β commit and move on; do not wait for, monitor, or expect a deploy; deploys are done **manually), or π΄ paused (do NOT push here). Don't assume; the setting changes, so re-read it each session.
Light COUNT = deploy TARGET (added 2026-06-29). Now that there are two deploy targets, a branch's
light-group also encodes where it deploys: a double light (π’π’ / π‘π‘) = Vercel, a single
light (π’ / π‘) = the NAS (the original convention). The colour still means the state (π’
auto-deploy on push Β· π‘ commit, no auto-deploy / manual Β· π΄ paused). π΄ is always shown single β a paused branch deploys nowhere, so it carries no target (and we never signal deploying to both places). Combined examples: π’ = NAS,
auto-deploy; π‘ = NAS, manual; π’π’ = Vercel, auto-deploy; π‘π‘ = Vercel, manual. β οΈ Read the
count per NAMED branch β main π’π’ means main β Vercel, NOT "main π’ + nightly π’"; to keep that
unambiguous, always write the target ("Vercel"/"NAS") in words next to the light-group. (main deploys
to Vercel as of 2026-06-29 β hence its double light; the NAS is now only a manual fallback.)
main |
nightly |
What to do when you merge / push |
|---|---|---|
| π‘ | π΄ | main open but NOβDEPLOY β β¬
TODAY'S SETTING. Commit/merge directly into main ONLY (same push rule as π’π΄) β but the push triggers no deployment. Don't wait for or watch a build; deploys are manual. Do not push to nightly. |
| π’ | π’ | Lockstep. Merge your branch into nightly first, then bring main up to nightly (fast-forward main β nightly) so both end at the same commit. Never leave one ahead of the other. |
| π’ | π΄ | nightly paused. Merge your branch directly into main ONLY. Do not push to nightly. (Unlike π‘, a π’ push does trigger a deploy.) |
| π΄ | π’ | main paused. Merge your branch into nightly ONLY. Do not push to main. |
| π΄ | π΄ | HALT. Both paused β ask the owner before pushing anything. |
- Source (current π‘π΄ setting): owner instruction, 2026-06-29 (verbatim):
"Since now I'm on the Pro Tier of Vercel, I don't want deployment to be triggered by commits anymore. Can you (if AGENTS.MD didn't state clearly,) give the green light on main a yellow light, and add (if not stated clearly) that it means, commit but don't trigger deployment. If it's not something that any agent can do, then can you keep main on yellow, so that the agent won't monitor for deployments, and make sure that auto-deploy per commit is off"
Made π‘ true, not just documented: (1) nas-image.yml's push-trigger was removed (now
workflow_dispatch-only) β a push to main no longer auto-builds/deploys to the NAS; (2) Vercel
auto-deploy is off. Note the real lever: vercel.json's git.deploymentEnabled.main = false does
not catch β what governs git-push deploys is the Vercel project setting
gitProviderOptions.createDeployments. The 2026-06-29 GitHubβVercel reconnect left it enabled, so
every push auto-deployed (3 verified); it's now set to disabled via the Vercel API, so a push to
main (or any branch) no longer auto-deploys β only the deploy hook / API / dashboard button do.
No branch commits were moved.
- Source (target-light convention, 2026-06-29 follow-up): owner instruction (verbatim):
"Also, if you agree that this is necessary, please give each branch a double light (π’π’) for deploying to Vercel, and a single light (π’ as it is now) for deploying to NAS please (and state that on the AGENTS.MD)"
Agreed β two deploy targets now, so the target should be explicit. Implemented as the COUNT = target
rule above (double = Vercel Β· single = NAS; colour still = state), with the target spelled out in words
next to each light so the count can't be misread as the two-branch line.
- Source (Vercel-clarity emphasis + main β double light, 2026-06-29): owner instruction (verbatim):
"some agents are still not aware that we have move things back to Vercel. Please indicate on AGENTS.MD in regards, if the double light and single light instruction isn't clear enough"
Acted on it: Vercel is live and deploying main (verified β 3 production deploys on 2026-06-29), so the
single-NAS light was stale. Flipped main to a double π‘π‘ = Vercel light and added the π’ banner at
the top of this section so the target change can't be missed. Colour stays π‘ = the owner's manual-deploy
policy β now actually enforced: the owner chose to disable auto-deploy, so I set Vercel's
gitProviderOptions.createDeployments = disabled (the vercel.json toggle never caught). A push to
main no longer deploys; production updates go through the deploy hook.
- Prior setting (superseded): π’π΄, 2026-06-23 (owner: "Main green, nightly red. But no need to reverse
and commits of the branches") β main open with auto-deploy ON via the NAS pipeline. Before that: π’π’
LOCKSTEP, 2026-06-22. Flipped to π‘π΄ on 2026-06-29 per the instruction quoted above.
- To change a light: only the owner flips it. When they do, update the CURRENT SETTING line + the
"TODAY'S SETTING" marker in the table, re-date, and quote the new instruction here.
(NAS preview p-eop dropped 2026-06-22 β see T-082; nightly is kept on standby but no longer
serves the task-board build β see the "Task board β orphan branch taskboard" section below.)
π Task board β orphan branch taskboard¶
βΆ The task board at tasks.theestablishers.com publishes from the orphan branch
taskboardβ not frommainand not fromnightly.¶
taskboard has no shared history with main / nightly / claude/** β it's a separate branch graph
that contains only the files the board build needs (docs/eop-tasks/, mkdocs.yml,
requirements-docs.txt, scripts/build-docs-site.py, the deploy workflow, a README.md). The matching
docs-deploy.yml is deliberately absent from web-app branches, so pushes to main / nightly /
claude/** can never trigger a board build, and the board can never accidentally pick up web-app files.
Edit on your work branch first; mirror to taskboard second β every time you change a task or ticket.
The main-side copy of docs/eop-tasks/ is the canonical record other agents read (the board only
shows what's been mirrored). Editing only on taskboard makes the change invisible to anyone reading
from main.
Recipe β use the helper. scripts/sync-taskboard.sh (on main) does the mirror via a sibling
worktree:
# After committing your task/ticket edits on your work branch:
scripts/sync-taskboard.sh # auto commit message
scripts/sync-taskboard.sh "T-101 opened" # custom suffix on the commit message
DRY_RUN=1 scripts/sync-taskboard.sh # plan only, no push
What it does: ensures docs/eop-tasks/ is clean on your branch β adds (or refreshes) a worktree at
../eop-taskboard checked out to taskboard β hard-resets it to origin/taskboard β rsyncs your
docs/eop-tasks/ over (with --delete, so removed task files are removed on the board too) β
commits + pushes. The Cloudflare Pages build runs immediately on push.
β οΈ --delete cuts both ways β the mirror becomes exactly YOUR branch's docs/eop-tasks/. Any task
another agent published to the board from a branch that is NOT merged into yours yet gets silently
DELETED from the site by your sync. Before syncing, make sure your branch's docs/eop-tasks/ is a
superset of the live board (when in doubt: git fetch origin taskboard and diff docs/eop-tasks/
against it; restore anything the diff shows as board-only before you push). Bitten 2026-08-10: a
main-based sync dropped tasks published from an unmerged branch (T-201/T-202); the T-193 Helper caught
and restored them β recorded here so the next agent doesn't repeat it.
Why orphan (not just a separate branch): complete isolation. A future cleanup that retires
nightly doesn't touch the board; a future task-board format change doesn't entangle with web-app
history. The branch graph stays understandable: each branch is its own little world.
Cloudflare side (one-time setup, owner action): in the eop-task-surface Pages project settings,
set the production branch to taskboard so pushes deploy to the live URL. Other branches (if any
are ever pushed) deploy to preview aliases.
Deploy mechanic (reference, not a signal)¶
β οΈ Auto-deploy per commit is OFF as of 2026-06-29 (main is π‘). A push to main triggers no
deployment β nas-image.yml's push-trigger is removed (workflow_dispatch-only) and vercel.json sets
git.deploymentEnabled.main = false. Deploys are manual now: the owner is on Vercel Pro and triggers
production deploys deliberately (Vercel dashboard "Deploy"; or a manual nas-image.yml dispatch as the NAS
fallback). So commit + push, but don't expect, wait for, or watch a deploy.
Exception β Firestore rules DO auto-deploy on merge. The "nothing deploys on a commit" rule above is
about the app. Firestore security rules are a separate pipeline: a push to main that changes any
firestore.*.rules file (or firebase.json / .firebaserc) auto-triggers the deploy-firestore-rules.yml
workflow, which runs firebase deploy --only firestore:rules (rules only β never indexes/functions/data).
So a rules change lands in production on merge β you don't manually deploy rules separately, and you
should expect this one to fire. (Vercel deploys only the Next.js app, not the rules; this workflow keeps the
repo's firestore.*.rules from drifting out of sync with what's actually enforced in Firestore. Verify a run
under GitHub β Actions β "Deploy Firestore rules".)
(Historical, pre-2026-06-29 β auto-deploy: pushing to the green target branch auto-triggered nas-image.yml
β GHCR β the NAS auto-pulled + recreated eop-app. T-091 holds that mechanism + the eop-app-prev
rollback runbook, retained for the manual NAS path. T-084 = the today-manual deploy path.)
Source: Accounting [Infrastructure Development] Β· https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM β 2026-06-22
How to deploy (manual β nothing deploys on a commit anymore)¶
A push to a π‘ branch deploys nothing. Deploy only on the owner's explicit order ("deploy main to
Vercel" / "deploy now"). Prerequisite: the GitHubβVercel connection must be live (Vercel β eop β
Settings β Git); if a deploy 404s with "repository/branch not found," it's severed and only the owner can
reconnect it (a GitHub-app OAuth flow β no API token can).
β οΈ IMPORTANT DEPLOY POLICY (one-off on-demand only): A yellow light (π‘) indicates that deployments are manual and on-command/on-demand. Any user instruction to deploy the web app is strictly one-off. It applies ONLY to that specific command and does not grant a blanket permission or "green light" to trigger deployments for subsequent commits or future tasks. Every individual deployment must be explicitly requested by the owner.
- β Vercel (double-light target) β any of:
- Deploy hook (one call, no token):
curl -X POST "$VERCEL_DEPLOY_HOOK"(URL in.env.local, also in GCP Secret Manager asVERCEL_DEPLOY_HOOKβ how cloud agents fetch it, since the sandbox has no.env.local; the hook is created once in the dashboard β Settings β Git β Deploy Hooks β branchmainβ it is NOT API-creatable). DeploysmainHEAD, so merge first, then fire the hook. - API:
POST https://api.vercel.com/v13/deployments?teamId=team_ne7hiLb7J8wyHgGulNGIxGIz&forceNew=1body{"name":"eop","project":"prj_6Y57ZalE8BdtcYU57lpuoXDQe953","target":"production","gitSource":{"type":"github","repoId":914151172,"ref":"main"}}, authBearer $VERCEL_API_TOKEN(in.env.local/ GCP Secret Manager).vercel.json'sdeploymentEnabled.main:falseblocks only push/auto deploys β a manual trigger still runs. - Dashboard: the project's Deploy button.
- β NAS (single-light target β manual fallback) β GitHub β Actions β "Build NAS app image" β
Run workflow (
workflow_dispatch); builds + pushes to GHCR, the NAS auto-pulls. - β Firestore security rules (separate from the app β auto-deploys on merge, see the "Exception" note above) β three ways:
- Automatic (default, preferred): merge a change to any
firestore.*.rulesfile (orfirebase.json/.firebaserc) intomainβdeploy-firestore-rules.ymldeploys it. Nothing else to do. - Manual re-run (no code change): GitHub β Actions β "Deploy Firestore rules" β Run workflow
(
workflow_dispatch). - CLI:
firebase deploy --only firestore:rules --project aote-pmsβ readsfirebase.json's per-database map (tebs-melβfirestore.tebs-mel.rules,tebs-erlββ¦,aote-systemββ¦); unchanged files are a no-op, so deploying "all rules" only actually updates the file you edited.
Verify the live build afterward via the corner watermark + /deployments (T-108/T-109).
Working agreements¶
"The owner" = the human directing the current session β inferred from context, never the agent itself, and not a hardcoded name (different sessions may have different owners).
- Plain language first; technical names in parens. When explaining a finding or proposing a fix, lead with what's happening and what would change in everyday words β short sentences, before/after framing. Cite file / function / field / task names inline so the owner can navigate, but don't let those names carry the explanation. Don't assume the owner remembers a past task by UID alone β summarise the prior decision in one plain sentence before naming it (e.g. "a previous change stopped writing the GL code on income transactions (T-075)", not "per T-075's GL_REVENUE_RESTRICTED invariant"). If the owner asks you to re-explain, that's the signal the earlier version was too technical; the next version must be more plain, not just shorter.
- Phase wrap-up reports are written for the owner, not for the diff. When you report that a phase of work is finished, the owner is reading as an end-user without the technical background to know what a function name means or does β so a function name is not an explanation. The rule:
- Lead with what the web app can now do that it couldn't before, described as what a person
using it sees and does ("clicking a receipt notification now opens that receipt's details",
not "
ExpenseRecordsTabnow consumes theopenparam"). - A function / component / endpoint name may appear only after the behaviour it enables has been said in plain words β as a pointer for navigation, never as the substance.
- Include the Firestore documents touched, each with its structure before β after in the whole-document format the structure-change gate already requires β and when nothing structural changed, say so explicitly ("reads only" / "writes existing fields in their existing shape"), because silence reads as an oversight.
- If the owner has to ask "but what does that actually do?", the report failed this rule β rewrite it plainer, don't just answer the question and move on. Source: owner instruction, 2026-07-28 (verbatim): "remain agents with their reporting style after they think they've finished a phase of work, to tell them not just mention function names without elaborating what it allows the web app to do as I don't understand function names and I don't know what they do as the end-user and a person without the technical knowledge of understanding so. (Along with Firestore structural explanation as previously requested)"
- No duplicate tasks β match by scope, not UID. Before opening a new task, read the existing tasks
(
docs/eop-tasks/tasks/, published at tasks.theestablishers.com) and check whether any one's scope already covers your objective β a duplicate can hide behind a fresh UID, so compare the work, not the number. If a covering task exists, update/extend that task; never open a parallel task for the same thing. If you spot two existing tasks that already overlap, flag it and fold them. - Keep the task board current. Existing task β update its
T-NNN.md(frontmatterstatus+ a dated progress note); after committing on your work branch, runscripts/sync-taskboard.shto mirror the change to the orphantaskboardbranch β that's what rebuilds the Cloudflare board (see the "Task board β orphan branchtaskboard" section above). β οΈ Flip a task'sstatusin the SAME commit as the ship β "shipped onmain" andstatus: doingmust never coexist. When a deliverable merges, move itsT-NNN.mdstatustodone(with the close-out entry below) in that same commit β the code landing and the board saying so are one act, not two. Can't finish? Leave a dated note on what's left, not a silent staledoing. (The per-response attestation only reminds you about the task you're holding; a task you shipped and walked away from is caught by nothing but this rule.) Genuinely new work β createT-NNN.mdwith the next free UID (docs/eop-tasks/README.md) + frontmatter, and bump the README. - UID collisions across parallel branches β renumber on merge, per PR. The README "next-free"
pointer can't coordinate UIDs across branches that are open at the same time: two agents both read the
same next-free and both allocate it, so the second to merge collides (the
T-NNN.md/I-NNN.mdfile conflicts). Allocating is not reserving. The rule: whoever merges second renumbers their task/ticket to the real next-free at merge time (rebase againstnightlyfirst to see it). To renumber, move the whole identity in one commit βgit mvthe file + update its frontmatteruid/tid, every[[T-NNN]]/related:/task:cross-link, any in-code[T-NNN]markers, and the README pointer β and add a dated renumber entry to the file's decision log explaining the collision. Commit messages and prior dated entries stay as-is (immutable history); only forward references move. Pick a number free past all known in-flight PRs, not justnightly, so you don't immediately re-collide. - Capture the why, not just the what. A task must carry the context a newcomer lacks β written
whenever it becomes known (at the start, mid-work, or at
done): why the task exists (the problem or owner concern behind it), and why it's built / routed the way it is when that isn't the obvious choice β name the reason: an owner concern, a web-app design constraint, or a physical / technical limitation that blocks the routing a reasonable person would expect. The aim is that nobody later asks "why didn't they just do X?" and undoes a deliberate decision. - Record decisions IN the file β attestation, approval, and QUOTED evidence. Whenever you create or
update a task on the board, or make any non-trivial structural choice (Firestore/data shape, schema,
wire format, "why it's built this way"), add a dated entry to that task file (or an ADR in
docs/decisions/) containing: (a) a one-line attestation that you've read these guidelines, (b) what changed, (c) who proposed, (d) who approved (the owner), (e) the rationale, (f) the decisive conversation excerpts quoted verbatim β a conversation link isn't available, so quote the owner's exact words (attributed + dated); don't just paraphrase, and (g) aSource:line βSource: <agent name> Β· <session URL>β directly under the attestation, so cross-agent edits remain attributable independent of git authorship (every Claude agent commits under the same generic git author, so SHAs alone don't distinguish which agent wrote which entry). Per-response chat attestations intentionally omit the name + session URL β that lighter form is for chat only; task-board entries always carry the fullSource:line. No structural change lands without this on record. - Every decision gets a line in the decision index β
docs/eop-tasks/decisions.md. The task file holds the reasoning; the index holds a one-line pointer so a decision can be found later without knowing which task it lives in. Add the row in the same commit as the decision. When a decision is superseded, edit its row β a stale row is worse than no row.
What counts as a decision: a choice that constrains future work β something a later agent would have to undo or work around rather than simply extend. In practice, if any of these is true, index it: - it closed off a real alternative that was on the table (not "the only way it could work"); - it sets a rule or convention others must follow; - it touches data shape, money, or a document that leaves the company; - you would be annoyed to find it silently reversed by someone who didn't know why.
What is not: implementation detail with one sensible answer, naming, reaching for a library already used elsewhere, or a bug fix that restores intended behaviour. Those belong in the commit message.
Write the row so it carries its own context. Date Β· the decision as a single clause, in plain
language, saying what was chosen β and where it isn't obvious, what it was chosen over Β·
[[T-NNN]]. "Use gl for receipt links" is a bad row; "document-link information moves under gl,
flat document-root fields are avoided" is a good one. If it won't fit in a clause, the decision is
probably two decisions.
Never a transcript. The index is pointers; conversation logs are not published to the board β they can't be updated when a decision changes, and they preserve dead ends beside conclusions so a reader can't tell which is current. Source: owner instruction, 2026-07-25 β decision index adopted over storing the working conversation. - β οΈ NEVER change a Firestore document's STRUCTURE without asking the owner first. This is a stop and ask rule, not a documentation habit: you present the change and wait for the owner's approval before writing the code. Data shapes outlive the task that introduced them, get read by pipelines nobody remembers, and are painful to unpick once documents exist in the wild β so the owner decides, not the agent.
What counts as a structure change: adding a field Β· removing one Β· renaming one Β· changing its type or nesting Β· changing whether something is stored vs derived at read time Β· introducing a new collection or document path. Reading existing fields does not require approval.
What to put in front of the owner β for every affected collection:
- the path (payees/{abbreviation}/wopc/{docId}) and which database (aote-system, tebs-erl,
β¦), since the same collection name can exist in more than one;
- the CURRENT structure of the WHOLE DOCUMENT, as it exists in production today β not an
excerpt of the part you intend to touch. Show every field and nested map, with types. This is the
point of the rule: the owner is checking whether an existing field already does the job, or
whether a new field is genuinely necessary, and neither question can be answered from a fragment.
An agent proposing a new field has usually stopped looking once it decided to add one;
- the structure AFTER, with each field marked + added, β removed, ~ changed,
unmarked when untouched β and show any new field in place, beneath its immediate parent, so its
nesting and its siblings are visible rather than described;
- the existing fields you considered and rejected, and why each doesn't fit. If you considered
none, you have not done the work;
- one plain sentence on why, and what breaks if it isn't done.
Source: owner instruction, 2026-07-25 (verbatim): "you can't just list out the affected field. Whenever the work gets to the modification of a Firestore docs, the agent must list out the entire structure of a doc⦠I need to see the current structure of the entire entry and the fields that you're going to add (including its immediate parent field) to see whether you're overlooking information that could be utilize to do the job that we're integrating or a new field or the editing of an existed field is truly necessary."
Ask this as its own question. Don't bury it inside a longer plan where a general "go ahead" gets read as schema approval β a broad approval is not approval for a schema change. If you discover the need mid-task, stop and ask rather than deciding it is small enough to slip in.
Group related fields into maps β do not lay a document out flat. Source: owner instruction,
2026-07-27 (verbatim): "I've been trying to avoid Firestore doc to lay fields flat". When you
propose a new field, propose it under a parent that names what kind of fact it is β
access.role, not a bare role; trail.sentAt, not a bare sentAt. A flat document forces every
reader to know which of thirty sibling fields belong together; a grouped one states it. This is why
the approval gate above insists a new field is shown beneath its immediate parent.
Concretely, when proposing a shape:
- put a fact under the map for its kind β what was granted, what the person is, what happened
and when β rather than beside unrelated facts;
- a map with one field today is fine if the group is real and will grow; a map invented to hold one
permanently-lonely field is not;
- don't reuse a word at two levels with two meanings (a status map that also contains a
status field reads as a bug in every review that follows);
- existing flat documents are not a licence to add more flat fields β group the new ones and say
plainly that the old ones stayed flat, rather than silently matching the worse pattern.
Once approved, record that same before/after in the task file so the shape can be reviewed later without
re-reading the diff β and say explicitly when work touches Firestore but adds no fields ("creates
documents in the existing shape, no schema change"), because silence there reads as an oversight.
Source: owner instruction, 2026-07-25 (verbatim): "write on AGENTS.MD a reminder for all other agents
as a general rule that agents must consult with me regarding modifications of the related Firestore docs
of their structures with the listing out of the current structure and the structure after modification."
- Cross-agent edits on shared task files: sign your work; never silently delete another agent's
prose. Task files get read and updated by multiple agents over time. Two rules keep the trail
honest, independent of git history:
(a) Sign your additions. When you write into a paragraph that belongs to another agent's entry
(e.g. inserting context, correcting a fact, appending a follow-up note), drop a Source (edit):
<agent name> Β· <session URL> line under your insertion so the next reader can see who added what.
Wholly new dated entries don't need this β the entry's own Source: line already covers them.
(b) Strike-through, don't delete. If text needs removing, wrap it in ~~ β¦ ~~ and append an
italicised edit-out note carrying your Source: (*β superseded by <pointer>. Edit-out signed:
<agent name> Β· <session URL>*). The struck text + reason stay readable; the history persists in
the file itself rather than only in git, so an agent reading the task can see what was previously
thought without scrolling commits.
- Close the loop on done β final verdict + blast-radius handoff. Never flip a task to done
silently. Append a closing entry with: (a) the outcome / verdict β what actually landed vs. the
plan, and how it was verified; (b) every commit SHA that touched the task β an append-only running
list (see the sub-bullet), not just the one that "finished" it; the diff lives in git, so don't
re-narrate it; and (c) a blast-radius note for other agents β name the areas the change touches
so an agent working nearby can tell whether it affects their task. The board is how agents stay aware
of each other's changes; a silent done hides a blast radius that may break someone else's work.
- done means MERGED, not DEPLOYED (owner policy, 2026-07-01). A task is eligible for done once
its work is committed / merged to main β a production deploy is not a prerequisite (deploys
are manual and decoupled from merge; see the branch-policy / deploy signals above). Don't park a
finished task in doing just because it hasn't shipped to prod yet: flip it to done at merge, noting
the deploy is still pending if it is (a data-only change is live the moment it's written; code awaits a
deploy β the verdict just says which).
- Record EVERY related SHA, not a single "finishing" commit (owner policy, 2026-07-01). A commit that
claims to fix a task often doesn't β we've had to reopen and rework tasks many times β so a lone
"finishing" hash is misleading. Instead keep an append-only list of every commit SHA that has to do
with the task across its whole life: add each new one (work, fix, follow-up, re-open rework, and the
doc/close commit) as it lands, and never delete earlier ones. Newest last. When a done task is
reopened, keep the list and append the rework SHAs rather than trusting the old "done" hash as the final
word.
- Name the task UID in every commit message (owner policy, 2026-07-01). Every commit that does work
for a task must include that task's T-NNN UID in its commit message β preferably in the
subject line (e.g. feat(records): unify letterhead (T-124) or T-124: β¦). If one commit spans
several tasks, name all the relevant UIDs (e.g. β¦ (T-124) + β¦ (T-125)). This is the rule that
makes the every-related-SHA list above reliably derivable (grep the UID across git log) and lets
reviewers trace commit β task both ways; work commits that never named their task are exactly why the
historical backfill could only be best-effort. (Same for tickets: cite I-NNN when a commit addresses
one.)
Cloud-agent operations (cloud Claude Code only β local agents skip)¶
These rules apply specifically to cloud Claude Code agents (Anthropic-managed sandbox, fresh clone each run, only outbound HTTPS:443 allowed through Anthropic's TLS-MITM Egress Gateway). Local Claude Code running on the owner's machine has a different network model β these rules don't apply there.
- NAS connection β Cloudflare Tunnel, always. The sandbox's egress policy rules out direct SSH,
WireGuard, OpenVPN, and Tailscale (its noise-protocol handshake gets rejected by the Egress
Gateway β see
docs/eop-tasks/runbooks/claude-nas-channel.md). The only path that survives is the outbound HTTPS Cloudflare Tunnel terminating atnas.theestablishers.com. The admin Cloudflare API token for managing / re-issuing tunnel credentials lives in the sandbox env (CLOUDFLARE_API_TOKEN+CLOUDFLARE_TOKEN_NAME). Default to the existing tunnel; only touch the token to fix tunnel issues, never as a routine credential. Local agents may use Tailscale instead β it works from a real machine; it doesn't work from this sandbox. - External credentials β check the sandbox env FIRST, then GCP Secret Manager, then ask. Cloud
Claude Code sessions get task-specific credentials injected straight into the sandbox env β
Cloudflare API tokens, NAS / DSM creds for the Claude β NAS channel, anything bootstrap-provisioned
for the session β and those env vars are always the right first place to look because they're
what the session bootstrap chose to expose. Don't waste a Secret Manager round trip on something
that's already in
env. The discovery order, when the owner asks you to connect to something external: (1)envβ list keys (env -0 | tr '\0' '\n' | grep -E "^[A-Z_][A-Z0-9_]*=" | awk -F= '{print $1}' | sort -u) and scan for credentials that match the target (substring match on the service name, common suffixes like_TOKEN/_KEY/_SECRET/_PASSWORD). NAS / Cloudflare-Tunnel creds live here; never in Secret Manager. (2) GCP Secret Manager, when env didn't have it. The sandbox env carries a Google service- account credential triple (GOOGLE_PROJECT_ID+GOOGLE_CLIENT_EMAIL+GOOGLE_PRIVATE_KEY) with at minimumsecretmanager.secretAccessoron the project (the SA inventory lives indocs/playbooks/sa-audit-context.md). List the project's secrets (gcloud secrets list --project="$GOOGLE_PROJECT_ID"or the@google-cloud/secret-managerSDK), match by name, try the candidates. App-runtime credentials (third-party API tokens, NextAuth secret, etc.) typically live here. (3) Ask the owner β only after both env and Secret Manager exhausted, with a concrete message: which env keys you matched on, which secrets you tried, why each failed. Never echo secret values into chat, commit messages, or any artifact pushed to the repo. - Owner's Google account β claude.ai CONNECTORS, not API keys (documented 2026-07-22, owner
request). The owner's personal Gmail / Google Calendar / Google Drive are reachable through
claude.ai connectors (OAuth apps the owner installs on their claude.ai org), which surface to
a session as MCP tools β there is NO Gmail/Calendar API key in env or Secret Manager, so don't
hunt for one (claim history: briefly FALSE for Gmail on 2026-07-21β22 β a scoped DWD credential
existed for the billing pipeline β then the owner rejected standing mailbox access and it was
fully decommissioned the next day in favour of an owner-side Apps Script PUSH; see the "Billing
emails" bullet below. The claim is TRUE again, now deliberately so. Note signed: Service Account
(Local) Β· local session ca159e39-42a3-4569-b1cd-6e006f6639c3). How to use them:
(a) Discover with the
ListConnectorstool (filter e.g.["gmail","google"]) β it reports each connector's install/auth state ANDenabledInChat. A connector is usable only when both connected and enabled for the current chat; enablement is per-chat, so a connector another session used may still be off in yours. (b) You cannot complete OAuth from a session. If a connector is uninstalled, unauthenticated, or chat-disabled, tell the owner exactly which state it's in and ask them to fix it in their claude.ai connector settings β never ask for tokens/codes in chat. (c) State when documented: Google Drive connected + chat-enabled; Google Calendar connected, chat-disabled; Gmail installed, auth state unknown, chat-disabled. Re-check per session β don't trust this snapshot. (d) Reading the owner's alert emails usually doesn't need Gmail anyway: every alert the app emails (vianotifySystemOwnerβ Resend) is first written to thenotificationscollection in Firestore (aote-systemDB) β query it with the env service-account (newest-first oncreatedAt) to see exactly what the owner was emailed, plus its read/cleared state. - Billing emails β owner-side Apps Script PUSH; the app holds NO Gmail credential, by explicit owner decision (2026-07-22, verbatim: "I don't feel very safe for read-only access to my mailbox" β documented by Service Account (Local); supersedes the short-lived 2026-07-21 DWD pull architecture, which was fully decommissioned the next day).
- Architecture: a Google Apps Script in the OWNER'S OWN account (canonical copy:
docs/apps-script/billing-email-push.gs; install/ops notes indocs/apps-script/README.md) scans four narrow billing-email families (queries declared at the top of the script) and PUSHES each match toPOST /api/workspace/billing/email-ingest, authed withWORKSPACE_INGEST_SECRET. Server-side parse/persist lives inlib/gcpBilling/emailBillingSync.server.ts(invoice PDFs β Drive/File-Archive + per-line-item ledger charges; payment emails β ledger payments; declines + GCP receipts β event collections; auto-payments are INFERRED daily β invoice + no decline within 2 days β flaggedinferred: true, bank-feed cross-checked). - Freshness: every push + a daily heartbeat stamps
systemHealth/billing-email-sync; the scrape-heartbeat watchdog alerts if the script's trigger dies (36h). Remedy = check the script's Executions log + trigger +INGEST_SECRETin script.google.com (owner surface). - DO NOT re-create mailbox credentials. No Gmail API keys, no DWD grants, no service-account
mailbox impersonation β the owner explicitly rejected that class of access. The
gmail-invoice-fetcher@SA still exists but is a keyless shell (its user key + Secret Manager copy + Vercel env were all deleted 2026-07-22); its now-inert DWD grant (client ID115562755728083115762) is pending the owner's removal in admin.google.com. If a future task seems to need mailbox reads, extend the Apps Script's declared queries instead β and get the owner's explicit approval for any new family. - Updating that Apps Script's CODE β scoped SA
apps-script-manager@aote-pms(T-177), not copy-paste. The owner authorized scoped, attributable agent access to their Google account (2026-07-23: "not against agents having access to my account when the access is properly scoped"). A dedicated SA with zero IAM roles and a single DWD scope (https://www.googleapis.com/auth/script.projectsβ Apps Script code only) can pushdocs/apps-script/billing-email-push.gsvia the Apps Script APIprojects.updateContent. Key is in Secret ManagerAPPS_SCRIPT_MANAGER_KEY(client ID116845537588563115906). Hard boundary: this scope canNOT read mail/Drive or RUN the script β no Gmail scope was requested by design, so runningrunOnce/resetStateand the script's mailbox authorization stay the owner's click. Edit the repo.gsβ agent pushes β owner runs. Do NOT add scopes to this SA or reuse it for anything but Apps Script code; spin a new scoped SA for a new capability (the T-148 model). See T-177 for the pending owner setup (API toggle + DWD grant + scriptId).
Tickets β issues (I-NNN, I = Issue)¶
A lightweight ticket queue, separate from T-NNN tasks, for technical issues spotted in the app /
real transaction data (e.g. "the display name renders wrong for certain transactions"). Tickets live in
docs/eop-tasks/tickets/I-NNN.md, with their own next-free UID in tickets/README.md.
- A ticket is the symptom. Capture what's wrong, the
source(the tx / place it was found), repro notes, andstatus(open Β· investigating Β· escalated Β· fixed Β· closed Β· wontfix). Keep them light β observations, not a project plan. - Escalate structural / infra issues to a
T-NNNtask. If the root cause is structural β data shape, schema, routing, a cross-cutting fix β open aT-NNNtask for the deliberate work on top of the ticket and cross-link both ways (ticket frontmattertask: T-NNN; the task body links[[I-NNN]]). The ticket stays the symptom record; theT-NNNtask carries the full rigor (the why, decisions, and the close-the-loop verdict). A one-off data fix can stay ticket-only.
UI & display conventions¶
All field labels must appear on their own line followed by a colon, with the corresponding value rendered on the next line. Field titles use the Newsreader font in Extra Light weight, return strings use Newsreader in Medium weight, and window titles use Cantata One.
All date displays in the web app should use MMM DD, YYYY format unless otherwise specified.
Section headingsβincluding page-dividing titles such as "Personal Information", "Contact Information", "Payment Information", and "Billing Information"βand table headings use the Cantata One font. Table row content uses the Newsreader font at weight 500.
Any empty strings or missing data fields retrieved from Firestore should never cause the web app to become unresponsive. Empty string, null, or undefined values must render as N/A, and retrieval failures as Error. When a numeric or date value is unavailable simply display a dash (-).
Date fields must be validated before calling .toLocaleDateString() or similar methods. Invalid or empty values should be ignored, and the UI should show a placeholder rather than throwing errors or becoming stuck.
Coaching Sessions Page Fonts¶
The coaching sessions dashboard (pages/dashboard/businesses/coaching.tsx) and its dialog tabs follow these typography rules:
- Floating window headers in the student dialog use Cantata One.
- Field labels across
OverviewTab,SessionsTab,BillingTab,PersonalTab, andSessionDetailrender in Newsreader at weightβ―200. - Corresponding field values in those tabs render in Newsreader at weightβ―500.
Debug Notes¶
The student dialog spinner persisted because Vercel served an outdated bundle that lacked the latest loading-flag resets. Redeploying and hard-refreshing the browser resolved the issue. Version logs (=== StudentDialog loaded version 1.1 ===) remain temporarily to confirm deployments.
Later we discovered the dialog could still hang when non-active tabs were not mounted. Conditional rendering prevented PersonalTab, SessionsTab, and BillingTab from firing their data-fetch effects, so the parent never cleared its loading flags. Always render all tabs and toggle visibility with CSS so their callbacks run and the spinner disappears.
Another hang arose when the initial spinner replaced the entire tab layout. With the tabs unrendered, their effects never ran and the loading flags stayed true. The dialog now overlays the spinner while keeping all tabs mounted so those callbacks always clear the flags.
Continuous reloads later surfaced when OverviewTab passed inline callbacks to the child tabs. Each render created new onPersonal, onBilling, and onSummary functions, triggering the childrenβs useEffect hooks repeatedly and re-fetching data in a loop. Memoizing these handlers with useCallback stabilised their references and stopped the dialog from constantly refreshing.
The most recent reload loop traced to defining the error boundary inside OverviewTab. Because the boundary class was re-created on every render, React unmounted and remounted the entire dialog tree, resetting all loading flags and re-triggering data fetches. Moving the boundary to the module scope keeps its identity stable and prevents the dialog from restarting after each render.
Floating windows later became immovable when a onMouseDown handler on the header stopped drag events from reaching react-rnd. Removing that handler restored independent window movement.
- All date displays in the web app use MMM DD, YYYY format.
- Section and table headings use Cantata One font; table row content uses Newsreader font at weightβ―500.
- All modals must provide a Back/Close navigation consistent with the Session Detail modal.
- Tab and sub-tab navigation lives in the dialog sidebar. The Billing parent tab shows the summary; its only child sub-tabs are Retainers and Payment History. Sub-tabs must not appear in top bars or popovers.
- Retainer status colors: green for active, red for expiring/expired, lightBlue for upcoming, and lightGreen when an expired retainer has a future one scheduled.
- Floating window titles use Nunito.
- Selected Payment Detail shows curated labels: Payment Amount, Payment Made On (date only), For session.
- Retainer end date is the day before the same day next month (end at 23:59:59).