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

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-permissions with a real permission policy: .claude/settings.json in each workspace (allowlist Bash/Read/Edit/WebFetch, deny git push to 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 / forbid in each country.yaml, printed by --check and marked inert everywhere. Note while writing them: the orchestrator never pushes anything — the agent does — so a post-run git diff --name-only check 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 for country.yaml/tasks.yaml themselves: they sit in a live bind mount the runs can write, and config-drift:* in health.js currently 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-secret skill/convention to copy).

Orchestrator robustness

  • 🔥 PR dedupe: before a pr-mode task opens a new PR, check for an existing open countryos/<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_ms says 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} in health.json and 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.js already tracks it, and the scheduler would have to read it).
  • 🧊 Task priority: priority: in TASK_KEYS, ordered wait list. Deferred until the fleet is big enough for ordering to matter — the comparator in runqueue.js is 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-review now lists every open countryos/* PR but still only acts on countryos/global/* — a cms-workspace run gets only its own workspace via addDirs, so it cannot write countries/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.json mtime + Datadog monitor. Until then a host cron or healthchecks.io ping would close the gap.
  • 💡 Runner fallback drill: implement the ANTHROPIC_API_KEY runner 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.js output 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. always and on-failure shipped (field reference countries/TASKS.md); the missing one is "speak only when the run actually did something". tn:decisions-escalate is 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-failure covers this today only by being silent about both. config.js already 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 in index.js, it is who decides whether there were results, and neither existing signal can: - The exit code cannot. runners/script.js maps code === 0 to ok, so "nothing to do" and "did three things" are the same 0. Splitting it (say exit 3 = nothing) means every tools/bin/* script has to adopt the convention, and any script that forgets it silently becomes always. This half shipped 2026-09-07 — one shared table (tools/lib/exit-codes.mjs, contract in countries/TASKS.md), 3 read by runners/script.js as outcome: skipped, and the forget-it case is the safe one: a tool still on 0 behaves exactly as before. It was not a design choice so much as a bug being paid off — _global:sentry-verify posted a red every morning it had nothing to resolve, because sentry resolve had documented 3 as "nothing to resolve" since it was written and nothing on the reading end knew. decisions announce and aging now speak it too (aging had 3 inverted). What is left is the agent half: - The prose cannot. Deciding "0 were needed" from resultText means 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 as gate: (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-results is 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 into res.results, stripped from resultText before 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: script tasks get it by echo. Do decisions first, 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 Artifact tool does not exist in headless claude -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-monthly slash command that reads reports/monthly/*.html and updates each artifact via the stored url — 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-purge skill) if pages look stale. The Sentry half of this now exists (see "Sentry loop closure" below) and the piece to reuse is deploy: in repos.yaml plus sentry'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. develop is not what runs.
  • 🧊 new-content task (pr mode): pick one search opportunity (from business-review / GSC), write a new article via the cms /article skill 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-freshness immediately 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 monthly content-freshness than 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.yaml declared base: develop, but production runs main, 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 declare deploy: {branch, signal}: sentry-release where events carry the deployed commit (telecom, renovation), none where 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 own SENTRY_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: worklist gained FIX-FAILED, the mirror of UNFIXED. Nothing had ever checked whether a fix worked — only whether a human had acted on a REPORT. Still open, and it is one line: cms is the only repo on inferred evidence, and all it needs is SENTRY_RELEASE: "$SEMAPHORE_GIT_SHA" in .kubernetes/deploy/base/app-config-map.yml, next to the APP_VERSION already there. No config/sentry.php required — 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 read SENTRY_RELEASE straight from the environment. That path is already proven in production, since cms's SENTRY_DSN reaches the SDK the same way and cms reports events today. Telecom and renovation get there via config/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 of signal: 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.mjs is 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.userAgent is 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.mjs and 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.mjs shelling out to a binary absent from the container (ENOENT caught and recorded as pdf_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 to pdf-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's stealth flag.

  • 🧊 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/; only countries/, tools/, data/ are live bind mounts.
  • Only ONE daemon at a time (approval lock protects apply, but schedules would double-fire). docker compose up -d after any docker compose down.
  • Delegation pages live at /{gouvernorat}/{delegation}, NOT under /annuaire/ (see tn journal 2026-07-02).