0% 5h quota used · resets in 2h live · 08/09/2026 12:11 CEST

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-triage and global:sentry-triage both 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: _globalcountryos/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/approveapply 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

  1. Write countries/<cc>/skills/<name>/SKILL.md with name: + description: frontmatter (the description is what the dispatcher matches free text against).
  2. Add the manifest entry. Start with autonomy: autonomous and no schedule.
  3. Dry-run it: docker compose run --rm orchestrator node src/index.js --run cc:task-id.
  4. 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 a runner: script task, check the same thing on the command itself — echo $? on a run that had nothing to do. A 0 there is not wrong, it is just a task that will report a green every time it had nothing to say.
  5. Only then add the schedule and 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ésolu or rejeté, 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-review reads 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/decisions parses it (list, aging, escalate). Past a threshold, escalate refreshes one standing GitHub issue per country on APPROVALS_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.