ROADMAP — deferred and imagined
/app/ROADMAP.md
Country OS — Roadmap / Next steps
Parking lot for everything deferred or imagined during the v1 build (2026-07-02/03). Not a commitment — pick what's useful when adding workflows. Rough priority per section: 🔥 soon · 🧊 later · 💡 idea.
Safety & auth
- 🔥 Replace
--dangerously-skip-permissionswith a real permission policy:.claude/settings.jsonin each workspace (allowlist Bash/Read/Edit/WebFetch, denygit pushto non-countryos/*branches, deny destructive commands). The container is the only sandbox right now. Its input already exists and is validated:guardrails.scope/web.allow/forbidin eachcountry.yaml, printed by--checkand marked inert everywhere. Note while writing them: the orchestrator never pushes anything — the agent does — so a post-rungit diff --name-onlycheck can only detect an out-of-scope write after the branch exists. Containment has to be tool-level deny rules. Also add a deny rule forcountry.yaml/tasks.yamlthemselves: they sit in a live bind mount the runs can write, andconfig-drift:*inhealth.jscurrently only detects that (hash at boot vs. on disk each tick). - 🔥 Machine identity: commits/PRs are currently authored with Fred's SSH key and token. Create a machine user (or repo deploy key with write) so Country OS actions are attributable and revocable. Also fixes "PR author reviews own PR".
- 🧊 Secrets for K8s: .env works on one machine; K8s needs sealed-secrets or
external-secrets (cms repo already has a
seal-secretskill/convention to copy).
Orchestrator robustness
- 🔥 PR dedupe: before a
pr-mode task opens a new PR, check for an existing opencountryos/<cc>/*PR from the same task — comment/update instead of stacking duplicates (monthly freshness will hit this fast). - 🔥 Clone hygiene, the rest: refresh before each run rather than each container
start, prune merged
countryos/*branches, delete stale local branches. - 🔥 Retry policy: one automatic retry on transient failures (rate limit, network, 5xx from claude) with backoff; distinguish from task-logic failures.
- 🔥 Subscription rate-limit awareness: detect Claude usage-limit errors in the runner, reschedule the run for after the reset instead of failing it.
- 🧊 Raise the cap above 1, once
queued_mssays waiting costs something. The evidence is already readable:/ledger→ "Waiting for a slot" gives how many runs queued, for how long in total, and which tasks wait worst. What is still missing is the live view —{active, queued, cap}inhealth.jsonand in--check, which is "what is happening now" rather than "what it has cost". Needs an answer to what the binding constraint really is: the 6 GB container (one number is enough) or the shared Claude usage window (quota.jsalready tracks it, and the scheduler would have to read it). - 🧊 Task priority:
priority:inTASK_KEYS, ordered wait list. Deferred until the fleet is big enough for ordering to matter — the comparator inrunqueue.jsis the hook, and the script/gate bypass above already covers the case that bites at 22 tasks. A triggered run should outrank a scheduled one whatever its declared tier: a human is waiting on the other end. - 🧊 pr-review beyond
_global:_global:pr-reviewnow lists every opencountryos/*PR but still only acts oncountryos/global/*— a cms-workspace run gets only its own workspace via addDirs, so it cannot writecountries/tn/memory/review-feedback.md, and applying a lesson to one country while filing it in another's memory is worse than not acting. It reports other countries' unaddressed threads instead. To close it: either copy the skill per country, or let_-prefixed workspaces addDir every country dir and route memory writes by the PR's branch prefix. - 🧊 notify.js → Chat app: once chat triggers are live, migrate the one-way webhook to the app credentials so run notifications can thread per run.
- 🧊 Trigger hardening: requester allowlist (GH org members only), and keep in mind trigger text is prompt-injectable — the autonomy rails (draft PRs, approval tokens) are the actual containment, keep them strict.
- 🧊 Retention/rotation: prune
data/logs/, old proposals, ledger archival. - 🔥 External dead-man's switch: everything above lives inside the process — a
dead container/host is still invisible. On K8s: exec liveness probe on
data/health.jsonmtime + Datadog monitor. Until then a host cron or healthchecks.io ping would close the gap. - 💡 Runner fallback drill: implement the
ANTHROPIC_API_KEYrunner variant and actually test the swap once, so the "subscription disappears" plan is proven, not theoretical. Optionally a second-vendor runner (Codex CLI, etc.).
Observability
- 🔥 Weekly cost digest:
scripts/report.jsoutput posted to Google Chat every Monday (runs, errors, API-equiv cost per country/task, week-over-week). - 🧊 OpenTelemetry export from Claude Code (
CLAUDE_CODE_ENABLE_TELEMETRY) to Datadog — the company already uses DD for the CMS; gives token/cost dashboards without custom work. - 🔥
notify: on-results— the third notification policy.alwaysandon-failureshipped (field referencecountries/TASKS.md); the missing one is "speak only when the run actually did something".tn:decisions-escalateis the case that asked for it: 0 open decisions is a run with no results and should stay silent, 3 announced entries is a run with results and should say so.on-failurecovers this today only by being silent about both.config.jsalready rejects the value by name (NOTIFY_PLANNED) so a manifest that tries it gets "not implemented yet", not "unknown value". The hard part is not the branch inindex.js, it is who decides whether there were results, and neither existing signal can: - The exit code cannot.runners/script.jsmapscode === 0took, so "nothing to do" and "did three things" are the same 0. Splitting it (sayexit 3= nothing) means everytools/bin/*script has to adopt the convention, and any script that forgets it silently becomesalways. This half shipped 2026-09-07 — one shared table (tools/lib/exit-codes.mjs, contract incountries/TASKS.md),3read byrunners/script.jsasoutcome: skipped, and the forget-it case is the safe one: a tool still on0behaves exactly as before. It was not a design choice so much as a bug being paid off —_global:sentry-verifyposted a red every morning it had nothing to resolve, becausesentry resolvehad documented3as "nothing to resolve" since it was written and nothing on the reading end knew.decisions announceandagingnow speak it too (aginghad3inverted). What is left is the agent half: - The prose cannot. Deciding "0 were needed" fromresultTextmeans a regex against a sentence a skill is free to rewrite, or a model call to classify a notification — both worse than the message they suppress. Half of this shipped asgate:(2026-08-26, countries/TASKS.md). A gate answers the same question one step earlier — before the run, so a task with nothing to do costs nothing at all instead of costing a run that then has to decide whether to mention it. It settled the "who decides" problem the same way: declared by an exit code, never inferred.on-resultsis still the right answer for the other case, a run that had to happen in order to find out. So it has to be declared by the run, not inferred from it. The cheapest shape that works for both runners: a trailing marker on stdout —COUNTRYOS_RESULTS: 0/COUNTRYOS_RESULTS: 3 announced— parsed off the tail in the executor intores.results, stripped fromresultTextbefore it is notified, and recorded in the ledger row (which makes "how many mornings had nothing to say" answerable, currently it is not). Absent marker = unknown = speak, so a task that has not been taught the convention degrades to today's behaviour instead of going quiet. Agent tasks get the same marker via a line in the skill;runner: scripttasks get it byecho. Dodecisionsfirst, since it is the task that wants it. - 🧊 Anomaly alerts: run cost > 3× task average, or error streak ≥ 2 → notify.
- 🧊 Claude Artifact as a second render target for the monthly report
(2026-08-11): artifacts were the original plan — a hosted claude.ai page whose
link can be pasted into Chat — but the
Artifacttool does not exist in headlessclaude -p, verified twice with an unrestricted tool set and zero permission denials (ToolSearch select:Artifact→ no match). It is interactive-session-only, so the runner can never publish or update one. Hence the monthly report renders to a Google Doc instead. If a prettier page is still wanted: an interactive/publish-monthlyslash command that readsreports/monthly/*.htmland updates each artifact via the storedurl— manual, ~10s/month. Note artifacts also need the URL passed back explicitly (same file path is not enough across sessions) and a one-time manual share per country, and they die with the subscription — which is why they are not the primary target.
Workflows (Tunisia first, then generic)
- 🔥 Post-merge verification task: after a Country OS PR merges + deploys,
re-check the touched production URLs (meta lengths, JSON-LD present, 200s) and
journal the before/after — closes the loop on whether fixes actually shipped.
Consider Cloudflare cache purge (cms
/cache-purgeskill) if pages look stale. The Sentry half of this now exists (see "Sentry loop closure" below) and the piece to reuse isdeploy:inrepos.yamlplussentry's fix-ref → merge-commit → "is it in a serving build" lookup. The lesson it paid for: asking GitHub whether a PR merged answers the wrong question.developis not what runs. - 🧊 new-content task (
prmode): pick one search opportunity (from business-review / GSC), write a new article via the cms/articleskill conventions, ship as draft PR. Start with 1 page/month. - 🧊 STEG tariff watch: cheap scheduled script (not agent) that hashes the
official STEG tariff pages; on change, trigger
content-freshnessimmediately instead of waiting for the monthly run. Pattern: scripts detect, agents judge — which now has a first-class shape,gate:(countries/TASKS.md). Probably better as a gate on a monthlycontent-freshnessthan as a trigger: a gate that hashes the pages turns "run monthly and usually find nothing" into "run when the tariff moved", with no second task and no chain-depth accounting. - 🧊 Outage awareness: the site already proxies live outages
(
/api/coupures, Contents API). A watcher could detect major outage events → timely content/social opportunity (big traffic moments for this site). - ✅ Sentry loop closure (2026-09-01):
sentry verify/sentry resolve, plus_global:sentry-verify(runner: script, no LLM in the write path). Built without waiting for the generic post-merge item above — it needs none of the URL checking, only "did this land, and did the errors stop". Merged is not deployed, and that turned out to be the whole problem.repos.yamldeclaredbase: develop, but production runsmain, and telecom PR #455 merged four hours after the build still serving traffic was cut — resolving on merge would have closed four bugs that were running nowhere. So repos now declaredeploy: {branch, signal}:sentry-releasewhere events carry the deployed commit (telecom, renovation),nonewhere they do not (cms), and no block at all means UNVERIFIABLE rather than a guess. Fixes are named in a declared column 7 of the triage memory, never regexed out of column 6's prose. The gate: a human already merged the PR, and deployment is machine-checked, so there is no second approval inbox. The write sits behind its ownSENTRY_WRITE_TOKEN, so nothing that merely reads Sentry can change an issue's status. It also closes a bigger gap than the one it was written for:worklistgainedFIX-FAILED, the mirror ofUNFIXED. Nothing had ever checked whether a fix worked — only whether a human had acted on a REPORT. Still open, and it is one line:cmsis the only repo on inferred evidence, and all it needs isSENTRY_RELEASE: "$SEMAPHORE_GIT_SHA"in.kubernetes/deploy/base/app-config-map.yml, next to theAPP_VERSIONalready there. Noconfig/sentry.phprequired — cms does not have one, which is exactly why this works: with no published config the SDK falls back to sentry-php's own defaults, and those readSENTRY_RELEASEstraight from the environment. That path is already proven in production, since cms'sSENTRY_DSNreaches the SDK the same way and cms reports events today. Telecom and renovation get there viaconfig/sentry.php's'release' => env('APP_VERSION')instead; either route ends at the same place. Doing it upgrades the fleet's largest triage source (120 rows) to exact evidence and is then a one-word flip ofsignal:here. Cross-repo fixes (a comparator-core commit surfacing in telecom-comparator) stay UNVERIFIABLE: a composer dependency's deploy state is not a question git ancestry can answer. - 💡 Competitor/SERP monitoring: monthly snapshot of who ranks on the head terms (tarifs STEG, coupure courant, etc.) feeding business-review.
- 💡 Self-extension loop: agent proposes new skills/tools for its own country as PRs to this repo (needs this repo on GitHub first — see below).
Scaling & platform
🔥 Shared
fetch_browser— one document-retrieval tool for every campaign.campaigns/netzbetreiber/fetch_browser.mjsis the only place that knows how to get a document a plain GET cannot: browserless over REST (BROWSERLESS_URL), an ordinary desktop UA as a launch flag (not a header —navigator.userAgentis what draws the 403), tracking cookies refused before the CMP asks, a settle window for interstitials, and fetching the document from inside the page so it reuses the session's own clearance cookie. Nothing else can use it: it imports./reply_links.mjsand reads netzbetreiber's own queue and log. Lift the transport into a shared tool (tools/fetch_browser/), leaving each campaign only its queue shape. Why now: wasserversorger had no browser path at all and had grown two pdftotext-based substitutes instead —resolve_singleton.mjsshelling out to a binary absent from the container (ENOENT caught and recorded aspdf_no_text_layer, so a missing tool looked exactly like a scanned PDF) and a hand-rolled JS extractor,pdfextract.mjs. Both dealt with on 2026-09-01; the campaign now saves PDFs topdf-inbox/for an agent to render and read, and carries its own copy of the browserless call. That copy is the thing to delete once this exists. Two campaigns solving document retrieval separately is how the drift started, and country #2 would be the third. Must keep the rule 3 guarantees explicit in the shared tool: never solve a captcha, pass a login or paywall, fill a honeypot, submit a form, or enable browserless'sstealthflag.🧊 Second country playbook: document the checklist (workspace folder, CLAUDE.md goals, tasks.yaml, site facts exploration) and time how long country #2 takes — that number is the pitch to HQ.
🧊 K8s migration: per-task
Jobs instead of in-process spawns (the executor contract already isolates this), CronJobs for schedules, PVC or object storage for ledger/journals, cms clone as an init-container or shared volume.💡 Per-country model policy: let countries declare default model tiers by task class in tasks.yaml, tuned from ledger data (e.g. audits → haiku once the skill is stable).
Known quirks (don't trip on these)
docker compose run <cmd>overrides CMD but the entrypoint still runs — first start clones the cms repo (~1-2 min).- Orchestrator code is baked into the image: rebuild after editing
orchestrator/; onlycountries/,tools/,data/are live bind mounts. - Only ONE daemon at a time (approval lock protects apply, but schedules would
double-fire).
docker compose up -dafter anydocker compose down. - Delegation pages live at
/{gouvernorat}/{delegation}, NOT under/annuaire/(see tn journal 2026-07-02).