tasks.yaml — field reference
/app/countries/TASKS.md
tasks.yaml — field reference
One manifest per workspace (countries/<cc>/tasks.yaml), a YAML list of tasks.
What a task may declare is capped by its country's guardrails
(countries/<cc>/country.yaml, field reference COUNTRY.md):
autonomy must be in that country's autonomy_allowed, and the budget must fit
under its ceilings — both checked at boot, both fatal.
orchestrator/src/config.js validates every entry at boot: unknown fields, bad
enums, invalid crons, missing SKILL.md, duplicate ids and an undeclared
provider are all fatal — the orchestrator refuses to start and prints each
problem. Keep this doc and config.js (TASK_KEYS, RUNNERS, AUTONOMY,
GATE_KEYS, PROVIDER_KEYS, REPO_KEYS) in sync.
Workspace directories
countries/ holds two kinds of workspace, distinguished by the directory name
(config.js rejects anything else — an unaddressable workspace would otherwise
schedule fine and never be triggerable):
| Directory | What it is |
|---|---|
<cc> — two letters (tn, de) |
A country. Its tasks run that country's business. |
_<name> (_global) |
Not a country: cross-country work. _global owns repo-wide maintenance of the shared application repos (Sentry triage, PR review follow-up) — the tasks that used to sit in fr because the cms was "France's" repo. |
The leading underscore sorts non-countries above the real ones and keeps the two concepts from being conflated again. It is only a directory convention:
- Addressing —
_global:sentry-triageandglobal:sentry-triageboth work, everywhere a task is addressed (--run, GitHub issue, chat,countryos-trigger). - Git branches — the underscore is dropped:
countryos/global/<date>-<topic>, because that name shows up in PRs that humans review. - Ledger / locks / logs — always the directory name (
_global), so history stays greppable per workspace.
Repos
countries/ holds the workspaces; orchestrator/src/repos.yaml
holds the application repos a task may work in. Each entry declares the local
checkout its clone is seeded from, the remote that clone pushes to, the base
branch to cut work from and open PRs against, and the Sentry project that reports
on it.
Every name there is three things at once: a bind mount in docker-compose.yml, a
clone under data/workspaces/<name> made at container start by
src/bootstrap-repos.js, and a value workspace: / repos: will accept.
Naming an undeclared repo is a boot error — the daemon stops with the file
named, rather than scheduling a task that would run in a directory that does not
exist. --check prints the whole table, including whether each clone is actually
there.
An entry may also declare deploy: {branch, signal} — the branch that actually
ships, which is usually not base. base is where pull requests land; for cms
and telecom that is develop, while production runs main. The distinction is not
academic: telecom PR #455 merged to develop on 2026-08-27 four hours after the build
that was still serving traffic had been cut, so "merged" and "deployed" disagreed for
days. signal says how strongly Sentry can prove a commit is live — sentry-release
when the app tags each event with the deployed commit, none when it does not and
deployment can only be inferred from branch ancestry plus a quiet window. Omit the
block entirely and sentry verify returns UNVERIFIABLE rather than guessing, so
nothing in that repo is ever auto-resolved.
Adding a repo is three edits: the entry in repos.yaml, the read-only bind mount
in docker-compose.yml at the src path it declares, and a docker compose up -d
so the clone gets made. Nothing else knows the list.
Runs receive the resolved registry as COUNTRYOS_REPOS (JSON), so a tool in
tools/bin/ can look a repo up by name without parsing YAML — tools/ has no
YAML dependency by design.
| Field | Required | Default | Values |
|---|---|---|---|
id |
yes | — | [a-z0-9-], unique per workspace. Addressed as cc:task-id everywhere (triggers, --run, ledger, locks). |
skill |
agent tasks | — | Directory name under countries/<cc>/skills/; must contain SKILL.md. |
runner |
no | claude-code |
claude-code (agent) · script (deterministic shell, no LLM, costUsd: 0). |
command |
script tasks | — | Shell command, run via sh -c in the workspace. Container paths (/app/...). |
description |
no | — | One line for the dispatcher catalog. Falls back to the skill's description: frontmatter, then to the command. |
schedule |
no | — | Five/six-field cron, TZ from .env. Omit entirely for on-demand tasks — an empty string is an error. |
model |
no | sonnet |
sonnet · opus · haiku · any claude --model value. Cosmetic for runner: script (convention: "-"). |
provider |
no | the country's, else anthropic |
Who serves the run: a name declared in orchestrator/src/providers.yaml. The alias in model is mapped to that provider's real model id by its env recipe, so sonnet on deepseek means deepseek-v4-flash. See COUNTRY.md. |
autonomy |
no | autonomous |
See below. |
workspace |
no | country |
Where the run starts: country (= the task's own workspace dir, countries/<cc>/) or the name of a repo declared in orchestrator/src/repos.yaml. The workspace dir is always readable via --add-dir, whichever is chosen. |
repos |
no | [] |
Other repo clones this run needs, by name from repos.yaml. They arrive via --add-dir, and the prompt lists each one's path, base branch and Sentry project. A task sweeping several repos uses workspace: country plus repos: [...]. |
gate |
no | — | A probe that decides whether the agent runs at all. See below. |
triggerable |
no | true |
false hides the task from the dispatcher, GitHub/chat triggers and task chaining — schedule and --run still work. |
notify |
no | always |
Which run outcomes reach Google Chat: always · on-failure. See below. |
budget |
no | {} |
max_turns (agent only), max_minutes (timeout, default 45). Positive numbers. Must fit under the country's turns_max / minutes_max — see COUNTRY.md. |
gate — running the agent only when there is something to do
gate:
command: sentry worklist --repos cms,telecom,renovation
# max_minutes: 5 (default)
A scheduled agent task pays for a full run whether or not anything arrived since
the last one. A gate puts a cheap deterministic probe in front of it:
| Gate exit | What happens |
|---|---|
0 |
The agent runs, and the gate's stdout is handed to it as the run's worklist. |
3 |
Nothing to do. No agent is spawned. A ledger row is written with outcome: skipped, cost 0 and gate_exit, and nothing is said in Chat. |
| anything else | The probe itself is broken: the run fails loudly and alerts. |
These are the shared exit codes below (3 = nothing to do), not a convention
private to gates.
The command runs through sh -c in the task's own cwd, with the same
environment the agent would get — the same PATH into tools/bin/, the same
provider recipe, COUNTRYOS_COUNTRY_DIR and COUNTRYOS_REPOS. It runs inside
the task's lock, so two invocations never both probe. gate is for agent tasks
only: a runner: script task that can answer "is there anything to do?" can
simply return early itself.
The verdict is declared, not inferred. This is the same argument as
notify: on-results below: a plain exit 0 cannot distinguish "nothing to do"
from "did three things", and reading it out of the probe's prose means a regex
against a sentence the probe is free to reword. So the probe says so with an exit
code, and a probe that has not been taught the convention exits 0 and the task
behaves exactly as it did before — the safe direction to fail in.
Give the agent the gate's output, do not make it re-derive the list. The stdout of a passing gate is injected into the prompt as the authoritative worklist, so a gated run is cheaper than an ungated one even when it does run: the discovery step is already done. It also removes a real failure mode — a run that rebuilds its own list can disagree with the check that admitted it, and then "there was nothing to do" and "it did the wrong thing" look identical from outside. Output is capped at 24k characters, and what does not fit says so in the prompt rather than just stopping.
A gate is not a schedule. Something still has to decide when to ask the question; the gate only decides whether to act on the answer. Keep the cron.
Exit codes — the one contract every command speaks
tools/lib/exit-codes.mjs (mirrored in orchestrator/src/exit-codes.js) is the
whole table. It applies to a gate: probe, a runner: script command:, and
every tools/bin/* CLI:
| Code | Name | Meaning | What the orchestrator does |
|---|---|---|---|
0 |
OK |
It did something. From a probe: there is work. | run recorded ok; notify decides whether the 🟢 is said |
1 |
ERROR |
Usage, parse, network or write error. | run fails loudly, 🔴 in Chat |
2 |
MISCONFIGURED |
A precondition is missing — no token, a repo that is not in the registry, a trigger chain past its depth. No invocation could have worked. | run fails loudly, 🔴 in Chat |
3 |
NOTHING_TO_DO |
Nothing needed doing. Not a failure. | run recorded outcome: skipped, costs nothing, says nothing in Chat |
4 |
NEEDS_HUMAN |
It ran, and the answer needs a person: a rate the published page contradicts, a claim nothing can verify. | run fails loudly — silence would decide it by default |
Three rules, and the second is the one that bites:
1. 3 is reserved, and it never means the opposite. A command that has
nothing to do says so with 3; a probe that found work says 0. decisions aging had these inverted until 2026-09-07 (3 meant "something is overdue"),
which put a trap under the one use its help advertises — a gate: on it would
have skipped the run on exactly the mornings an entry had gone unanswered.
2. Only the last command in a command: chain can declare a skip. The
command runs through sh -c, so a && b stops at a non-zero a — including a
3. _global:price-watch is price-watch check && decisions announce, which is
why price-watch check deliberately returns 0 for "every table agrees" and
never 3: a skip there would swallow the announce. If you want the chain's
first half to be able to say "nothing to do", it belongs in a gate:, not in
front of an &&.
3. Not knowing the convention is safe; guessing at it is not. A tool that
exits 0 when it had nothing to do behaves exactly as it always did — the run
reads as a success and notify decides whether that is said out loud. So
adopting 3 is an improvement a tool opts into, one at a time. What is not safe
is a different meaning for 3, 2 or 4: those are read by the runner, and a
private meaning for one of them turns into a red every morning (or, worse, a
green that hides work). Bare 1 for anything that simply failed is always fine.
What a skip is worth recording. A 3 writes a real ledger row —
outcome: skipped, task_exit: 3, reason — so "how many mornings had nothing
to say" is answerable, and guardrails.js counts it as no run at all rather than
as an error against the task's streak.
notify — which outcomes reach the channel
A run's outcome is always written to the ledger, the console and data/logs/.
notify only decides what is said out loud in Google Chat.
| Value | On success | On failure, crash, or a guardrail refusal |
|---|---|---|
always (default) |
🟢 cc:task (12 turns, 6m32s, ~$0.83 API-equiv) + the report as a thread reply |
speaks |
on-failure |
silent | speaks — unchanged |
on-failure never suppresses a failure, a crash, an alerts.js alert, or the
reply a human gets to a trigger they sent. It removes exactly one message: the
green one.
Why it exists. tn:decisions-escalate runs every morning and its entire job
is to post to Chat when it has something to post. The run notification on top of
that is a second daily message whose content is "the task that would have told you
did not have anything to tell you" — and a channel of those is a channel people
stop reading, which is the failure decisions.md was built to fix, reintroduced
one layer up. Silence from a task like this is the signal that nothing is wrong.
Reach for it when a task already owns its own output channel (it posts to Chat, opens an issue, opens a PR) and the run notification only duplicates it. Do not reach for it to quiet a task that is merely frequent: if nobody would notice that task going quiet for a month, the schedule is what needs changing.
A third value, on-results — speak only when the run actually did something,
so decisions-escalate announcing 0 entries stays silent but 3 entries does not
— is designed and not built; config.js rejects it with that message rather than
as a typo. See ROADMAP.md. A runner: script task gets the same effect today by
exiting 3 on a quiet run (see the exit-code table above): the run is recorded
skipped and nothing is said. What is still missing is the agent-task half, where
the run has to happen in order to find out.
autonomy
| Value | What the task may do | Human gate |
|---|---|---|
autonomous |
Read, research, write inside its own workspace. No outward action. | none — reports only |
pr |
Push countryos/<cc>/* branches (underscore dropped: _global → countryos/global/*), open draft PRs to develop, reply to review comments. Never merges, never marks ready. |
the PR review |
approval |
Two phases: propose writes proposals/<task>/<run>/summary.md → GitHub issue on APPROVALS_REPO → /approve → apply runs with APPROVAL_TOKEN. |
the issue reply |
approval needs an agent to write the proposal, so it is not available to
runner: script. Use it for business/plan decisions; anything that lands as code
should be pr instead — the PR diff is a better review surface than a summary.
Adding a task
- Write
countries/<cc>/skills/<name>/SKILL.mdwithname:+description:frontmatter (the description is what the dispatcher matches free text against). - Add the manifest entry. Start with
autonomy: autonomousand noschedule. - Dry-run it:
docker compose run --rm orchestrator node src/index.js --run cc:task-id. - If it has a
gate, run the gate command by hand first and check both answers: it must exit 3 when there is nothing to do, not just when it errors. A gate that can never exit 3 is a gate that never saves anything. For arunner: scripttask, check the same thing on the command itself —echo $?on a run that had nothing to do. A0there is not wrong, it is just a task that will report a green every time it had nothing to say. - Only then add the
scheduleand raise the autonomy.
Checking a manifest edit
docker compose run --rm orchestrator node src/index.js --check
Runs the same loadTasks() the daemon runs and prints the resolved task table
(runner / autonomy / workspace / provider / schedule, defaults applied), plus the
provider table and whether each prepaid provider's key is actually present — a
green result means the daemon will boot. Do this before restarting after a manifest edit: the
daemon refuses to start on an invalid manifest, so a typo takes down every
country's schedules, not just the task you touched.
decisions.md — the register for work that cannot ship as a PR
autonomy: pr works because a draft PR is the review surface: it has a diff, a
reviewer, a thread and a merge button. Tasks that produce judgement rather than
code have none of that, and until 2026-08-18 they had nowhere to put an ask.
The failure was concrete. tn:business-review is autonomy: autonomous, so its
memo lands in countries/<cc>/reports/ — gitignored, therefore visible only on
the machine that ran the task — and its ask became one Google Chat line. July and
August 2026 both requested authorisation for a content task. Neither was answered,
and nothing was written for two months, because neither was addressed to anybody.
Exactly one approval record has ever been created in this system's history.
countries/<cc>/decisions.md is the fix, and it is deliberately boring:
- It sits outside the gitignored directories, so it is versioned, pushable, reviewable and diffable like any other file in the repo.
- Any task may open an entry; no task may ever close one. Only a human writes
résoluorrejeté, with the reason. - Two kinds.
décision— an arbitration the agent may not make alone.signalement— a real defect outside the finding task's scope, which needs routing to whoever owns it rather than a paragraph in a report nobody reads. business-reviewreads it first and re-raises every open entry, so the count and the age of the oldest go into the executive summary. An unanswered ask gets louder each month instead of being silently rewritten.tools/bin/decisionsparses it (list,aging,escalate). Past a threshold,escalaterefreshes one standing GitHub issue per country onAPPROVALS_REPO— never a new issue per run, which would turn one ignored decision into a pile of duplicates.
When to use which. Something that lands as code → autonomy: pr, always; the
diff is a better review surface than any summary. A two-phase propose/apply where
the agent then acts → autonomy: approval. A question, a trade-off, or a defect
someone else must own → decisions.md.
Routing: the file is state, the channel is the interface
A first version of this escalated everything to a GitHub issue. That was wrong,
and worth recording why: the people who answer a décision are not
developers. Expecting them to open GitHub, navigate to a markdown file, read it,
work out which entries are theirs, edit it and raise a pull request is expecting
something that will simply not happen. Building a correct mechanism on a surface
its audience never visits is the same as not building it.
So escalate routes by who has to answer:
| Type | Goes to | Answered by |
|---|---|---|
décision |
Google Chat, one message per entry | replying in the thread — @countryos TN-001 oui, in any wording |
signalement |
one standing GitHub issue | a developer who can commit the fix |
The Chat path reuses machinery that already exists: triggers/chat.js polls the
spaces, dispatch.js maps free text to a task, and <cc>:decisions-answer
transcribes the reply into the register and confirms in-thread. The answer is read
by a model, not parsed by a regex — a rigid syntax buys determinism at the price
of people not answering at all, which is the failure being fixed.
decisions-answer only ever transcribes. A conditional answer ("yes, but not
before September") stays open with the condition quoted verbatim; recording it
as an approval would be worse than recording nothing.
Cadence. An entry is announced the day it is opened, then reminded every
--quiet-days (7) for as long as it stays open. --older-than (30) changes the
wording to overdue; it does not gate reminders. A first version did gate them
on it, which meant an ignored question sat silent for a month after its only
message — the original failure at a slower tempo. Age should change the tone, never
the fact of asking.