4 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 5048450b79 feat(atlas): UX sprint — progressive disclosure (Plain default + transitions)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
Makes the atlas self-explanatory. Every stage/node gains a plain-language layer
(plain_title + plain "what happens" + jargon-free node text); the previous
technical copy demotes to a subtitle + on-demand detail. A Plain⇄Technical
toggle (default Plain, persisted) flips the whole atlas. Biggest win: every
transition arrow is now LABELLED with "what must be true to advance" (the gated-
flow story that was invisible), gate hops (human @04, CI @06) styled distinctly.
Spine repositioned into a uniform header band so labels never collide with copy.

Copy grounded in a fresh-eyes UX review (docs/UX-REVIEW.md, reviewer≠implementer).
Data model: plain_title/plain/trans_label/trans on Stage, plain on Node — guarded
by a test (every stage has plain_title + a transition). Live overlays unchanged.

Verified: build/vet/lint(0)/test green; Plain render screenshot-checked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 08:22:21 +02:00
mathiasandClaude Opus 4.8 1f7c9ab1eb feat(atlas): Phase C — live Flux reconcile status on stage 07
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
Stage 07 now also shows the Flux `apps` Kustomization state — reconciled/failed
+ last-applied revision (main@shortsha) — read in-cluster (new read-only Role in
flux-system). Sits alongside the live deploy node. FluxStatus/FluxNode test-first,
fallback-safe.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:56:42 +02:00
mathiasandClaude Opus 4.8 3e28dcf69f fix(atlas): treat skipped jobs as OK in run aggregate
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
Tag-push runs skip the deploy job (deploy is main-only), so runs with a skipped
job were mislabelled "running" in the timeline. Skipped now counts as completed-
OK; only genuinely in-progress states aggregate to running. Test-first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:48:30 +02:00
mathiasandClaude Opus 4.8 995e428eba feat(atlas): Phase C — recent-runs timeline
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
A strip of the last 12 runs (aggregate pass/fail/running per run) below the
substrate ribbon, coloured, newest-first, run # on hover. Parsed from the same
Gitea /actions/tasks fetch (RecentRuns, test-first; State refactored to share
the aggregate). No new RBAC. Hidden when no live data.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:45:59 +02:00
12 changed files with 585 additions and 64 deletions
+215
View File
@@ -0,0 +1,215 @@
# CAD Atlas — Fresh-Eyes UX Review
Reviewer role: fresh-eyes UX (not implementer). This document critiques the copy and
information architecture of the "From Signal to Pod" atlas and specifies the
progressive-disclosure layer for the coming sprint. It does not change code.
Sources reviewed:
- `internal/atlas/atlas.json` (authored stages + nodes)
- `internal/web/handler.go` (which stages get live data overlaid)
- `.context/PROJECT.md` (ground-truth meaning of each stage/gate)
---
## 1. Diagnosis — why the current atlas is hard for a naive viewer
The atlas is written by the person who built the pipeline, for the person who built the
pipeline. Almost every node names a **mechanism** (`Ed25519 admission controller`,
`var-go Oath`, `dma-cli`, `assessor-loop ledger`, `agentsquad`, `ISC`, `TELOS`) rather
than the **thing that happens to a piece of work**. A compliance officer or a new
engineer cannot answer the two questions they actually have: *"what is happening to the
work at this step?"* and *"why does it move to the next step?"*
The second question is completely unanswered. The atlas renders nine stages connected by
arrows, but the arrows carry **zero copy**. There is no statement of what has to be true
for work to advance — which is exactly where the interesting governance lives (a human
sign-off at 04, a green CI gate at 06, an integrity check at 03). The pipeline's whole
selling point is "auditable, gated flow," yet the gates between stages are invisible.
A viewer sees a row of jargon boxes and an implied left-to-right drift, with no sense of
what earns each hop.
Two smaller aggravators: (a) the reel is dense — 20+ nodes, colour-coded pills whose
meaning is never keyed, and Swedish-homelab proper nouns (koala, iguana, flamingo) that a
stakeholder can't decode; (b) Stage 06 is authored-empty (it's generated live from CI),
so on a cold/offline load that column can read as "nothing happens here," which is the
opposite of the truth — CI is a governance gate.
The fix is not to dumb it down. It is to make **plain-language the default layer** — a
one-line "what happens + why" per stage, and a labelled "what must be true to advance"
per arrow — and demote today's precise, correct technical copy to an **on-demand layer**.
---
## 2. Per-stage plain-language map (0008)
| stage | plain_title | plain_what (one jargon-free sentence) | keep_technical (on-demand) |
|---|---|---|---|
| **00 Signals** | Notice what's happening | New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand. | "Signals → mathias/signals"; Applied AI Radar (Tier-1 daily / Tier-2 weekly), verified-primary bar; brain capture; aspirational inbox surfaces (not built). |
| **01 TELOS** | Why we're here | The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals. | "TELOS — intention substrate", `wiki/telos/`, `brain_query wing=telos`. |
| **02 Strategic session** | Think it through | A human and AI models work out *what* to do and *why*, debating hard calls and writing down the decision and what "done" will mean. | "Strategic session" — claude.ai frontier + brain MCP; ADRs/specs; ISC acceptance criteria; LLM Council (fan-out → anonymous cross-review → chairman synth); Autoresearch Council. |
| **03 Spec → Gitea issue** | Write the work order | The decision is turned into a precise, self-contained work order that an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal. | "Spec → Gitea issue" — binary ISC, risk tier LOW/MED/HIGH, reg-risk assessment, no open human deps; Ed25519 admission controller (#36); var-go Oath (single fenced block, fail-closed). |
| **04 Human dispatch gate** | Human says go | A person reviews the work order and its risk and decides whether to release it — this is the one and only checkpoint where work does not move on its own. | "Human dispatch gate — the only checkpoint"; ratify plan + risk tier; Session-Dispatch bridge (claude.ai MCP → `workflow_run_trigger``cad-dispatch.yml` → agentsquad); dispatch-allow eligibility. |
| **05 Execute · agentsquad** | Agents do the work | AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail. | "Execute · agentsquad" on koala; Task API (`POST /tasks`); executor+reviewer loop (ADK Go + LiteLLM, reviewer on distinct tier); dma-cli routing + 3-layer scope guardrail; assessor-loop attestation ledger + brain session_log. |
| **06 PR → CI** | Automatic quality checks | The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue. | "PR → CI" — Gitea Actions `cd.yml`; go test/vet/lint/govulncheck; **var-go/oath gate** (correctness floor over the reviewer, anti-rubber-stamp #55). *Nodes generated live from the latest CI run.* |
| **07 CD → pod** | Ship it | Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version. | "CD → pod" — Flux GitOps → k3s on koala; "push ≠ deploy: bump tag in mathias/infra"; ntfy on deploy. *Deploy + Flux state overlaid live.* |
| **08 Loop back** | Did it work? | The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped. | "Loop back → TELOS (feedback bus)"; session_log + attestation → brain; outcome scored vs originating goal; arc partly manual (improvement target). |
---
## 3. Per-node plain restatements
The existing `d` text stays as the **technical detail layer**. Each `plain` line below is
the jargon-free default. Kept accurate to PROJECT.md.
**Stage 00 — Signals**
- *Applied AI Radar* → **"An automated scan reads AI news every day (and deeper every week) and keeps only claims backed by a real paper, benchmark, code, or named lab."**
- *Manual capture* → **"Anything interesting spotted by hand gets saved into the same inbox."**
- *Aspirational surfaces* → **"Planned-but-not-built: sending ideas in by Telegram, voice, or a URL."** (mark clearly as a gap / not yet real.)
**Stage 01 — TELOS**
- *Intention substrate* → **"The master list of mission, goals, problems, and current status — the yardstick everything downstream is measured against."**
**Stage 02 — Strategic session**
- *Design · ADRs · specs* → **"A human and a top-tier AI model figure out the approach and write down the decision plus what a finished result must prove."**
- *LLM Council* → **"For hard calls, several AI models answer independently, anonymously critique each other, and a 'chair' model synthesises one verdict — reduces any single model's bias."**
- *Autoresearch Council* → **"A parallel version of the same review that vets research findings before they're allowed through."**
**Stage 03 — Spec → Gitea issue**
- *Contract enforced* → **"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready."**
- *Admission controller* → **"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it."**
- *var-go Oath* → **"A machine-checkable 'definition of done' is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec."**
**Stage 04 — Human dispatch gate**
- *Human triggers execution* → **"A person confirms the plan and its risk level, then releases the work — nothing runs until they do."**
- *Session-Dispatch bridge* → **"The approval flips a switch that hands the signed work order over to the agents to start execution."**
**Stage 05 — Execute · agentsquad**
- *Task API* → **"A request kicks off a job and hands back an id you can poll for progress."**
- *Executor + reviewer loop* → **"One agent does the work; a second, independent agent on a different model reviews it — so nothing marks its own homework."**
- *dma-cli · routing + scope* → **"A router sends each agent to the right AI backend and enforces what it is and isn't allowed to touch, with a confirmation gate as a guardrail."**
- *assessor-loop ledger* → **"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards."**
**Stage 06 — PR → CI** *(nodes generated live from the latest CI run — no authored nodes)*
- Live jobs render here; the plain framing for the column is: **"Automated tests and safety checks run on the proposed change, including a check that it truly satisfies the work order — only a clean pass moves on."**
**Stage 07 — CD → pod**
- *Deploy on green* → **"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it)."** *(live deploy + Flux status also shown.)*
**Stage 08 — Loop back**
- *Close the loop* → **"The outcome is scored against the goal that started it and written back to the mission board, so future planning learns from what actually shipped."**
---
## 4. Transitions — the key deliverable
For each arrow: *what moves the work forward, and what must be true for it to advance.*
These should be rendered **on the arrows themselves** (see §5). Today they are blank.
- **00 → 01 — "Does it matter to us?"**
A raw signal only advances if it connects to something we actually care about. Most
captured signals stop here; the few that touch the mission get pulled up against a goal.
- **01 → 02 — "Worth a session?"**
A goal or problem on the board becomes the seed for a design session when it's decided
it's worth working on now. The goal is the input the session must trace back to.
- **02 → 03 — "Decision reached."**
Once the debate converges on a decision (and what "done" will mean), it advances only
when that thinking is written down as a concrete, testable specification — not while
the answer is still open.
- **03 → 04 — "Order written, sealed, agent-ready."**
Work advances to the gate only when the spec is a complete contract: a pass/fail test, a
risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid
cryptographic signature. A malformed or unsigned order fails closed and does not reach
the gate.
- **04 → 05 — "A human said go."**
This is the hard stop. Nothing crosses automatically. A person must review the plan and
risk and explicitly release it, and the repo must be on the allow-list, before any agent
starts. This is the single human checkpoint in the whole pipeline.
- **05 → 06 — "Agents produced a change."**
Work advances when the agents finish and open a proposed change (a PR) with its audit
log attached. Until there's a concrete change to test, nothing moves.
- **06 → 07 — "All checks green."**
The change advances only if every automated check passes — tests, linters, security
scan, **and** the Oath check proving it meets the original work order. Any red gate stops
it here; a passing reviewer is not enough to override a failed Oath.
- **07 → 08 — "It's live."**
Once the new version is actually running on the server, the deployed outcome becomes the
input to scoring. Advancing means "shipped and observable," not just "merged."
- **08 → TELOS (feedback bus, dashed) — "What did we learn?"**
The scored outcome flows back into the mission board so goals, problems, and priorities
update. This is the loop that makes the pipeline a cycle rather than a line. Note per
PROJECT.md this arc is **partly manual today** and is an explicit improvement target —
the dashed styling should read as "aspirational / not fully automated," not just decorative.
---
## 5. Progressive-disclosure recommendations
**Default (Plain) layer — what everyone sees on load:**
- Each stage column shows: the **plain_title** as the headline, the technical title as a
smaller subtitle, and the one-line **plain_what** directly under it.
- Each node shows its **plain** one-liner as the primary text. The current `d` string is
hidden by default.
- Each arrow shows a short **transition label** (the bolded phrase from §4, e.g. "A human
said go", "All checks green") — this is the single biggest comprehension win and must
ship in the default layer, not behind a toggle.
**On hover / expand (per node):**
- Reveal the technical `d` text, the `tags`, and the pill's meaning.
- On the arrow, hovering expands the short label into the full "what must be true to
advance" sentence from §4.
**Plain ⇄ Technical toggle (global):**
- A single top-level switch, defaulting to **Plain**. Persist the choice (localStorage).
- Plain: plain_title headline, plain_what, plain node lines, short arrow labels. Proper
nouns (koala/iguana/agentsquad/TELOS) suppressed or shown only as a footnote.
- Technical: today's exact copy — titles, `d` strings, tags, substrate host specs — i.e.
the atlas as it exists now. Nothing is lost; the current view becomes "Technical."
- The toggle should crossfade in place, not reflow the whole layout, so a viewer can flip
back and forth and map plain↔technical on the same node.
**Visual cues for the currently-bare transitions:**
- Give every arrow a **label chip** sitting on the spine. Gate arrows (04→05 human, 06→07
CI) get a distinct treatment — a lock/shield glyph and a stronger colour — because those
are the governance moments the whole atlas exists to show.
- Make the **08 → TELOS feedback bus** visibly different (dashed + "partly manual" tag) so
its aspirational status is honest, matching PROJECT.md's dogfooding-honesty discipline.
- Add a small, always-visible **legend** keying the pill colours and the three gate types
(integrity / eligibility / correctness), since colour is currently unexplained.
- For **Stage 06** (authored-empty, generated live): when no live CI data is present, show
the plain_what placeholder ("Automated tests and safety checks run…") rather than an
empty column, so it never reads as "nothing happens here."
**Three-gate overlay (stretch, high value for the compliance audience):**
- A "show governance gates" toggle that highlights the three orthogonal gates on top of
the pipeline: integrity (03, Ed25519), eligibility (04/05, dispatch-allow), correctness
(06, Oath). This directly serves the "audit chain is the viz data" thesis for a
compliance/exec viewer.
---
## 6. Prioritized punch list (top 8 by comprehension impact)
1. **Label every arrow with a plain "what must be true to advance" phrase** (§4). Biggest
miss, biggest win — turns a row of boxes into a story of gated flow. Default layer.
2. **Add a plain_what one-liner per stage** as the default column copy (§2), with the
technical title demoted to subtitle.
3. **Ship the Plain ⇄ Technical global toggle, defaulting to Plain**, persisting choice;
current copy becomes the Technical view (nothing thrown away).
4. **Rewrite node primary text to the plain lines** (§3); move existing `d` to hover/expand.
5. **Visually distinguish the two real gates (04 human, 06 CI)** with lock/shield glyphs and
stronger colour so the checkpoints read as checkpoints.
6. **Add a legend** keying pill colours and the three gate types — colour currently carries
meaning nobody can decode.
7. **Fix the Stage-06 empty-column problem**: show a plain placeholder when live CI data is
absent, so the CI gate never looks like a no-op.
8. **Make the 08→TELOS feedback bus honestly aspirational** (dashed + "partly manual" tag),
and suppress homelab proper nouns (koala/iguana/flamingo/agentsquad/TELOS) in Plain mode,
surfacing them only in Technical or a footnote.
+36 -27
View File
@@ -7,40 +7,49 @@
], ],
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council", "ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
"stages": [ "stages": [
{"no":"STAGE 00","title":"Signals","path":"→ mathias/signals","nodes":[ {"no":"STAGE 00","title":"Signals","plain_title":"Notice what's happening","plain":"New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand.","path":"→ mathias/signals",
{"t":"Applied AI Radar","d":"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).","tags":["cron · daily/weekly","→ signals #126+"]}, "trans_label":"Does it matter to us?","trans":"A raw signal only advances if it connects to something we actually care about. Most captured signals stop here; the few that touch the mission get pulled up against a goal.","nodes":[
{"t":"Manual capture","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]}, {"t":"Applied AI Radar","plain":"An automated scan reads AI news every day (and deeper every week) and keeps only claims backed by a real paper, benchmark, code, or named lab.","d":"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).","tags":["cron · daily/weekly","→ signals #126+"]},
{"t":"Aspirational surfaces","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]} {"t":"Manual capture","plain":"Anything interesting spotted by hand gets saved into the same inbox.","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]},
{"t":"Aspirational surfaces","plain":"Planned but not built yet: sending ideas in by Telegram, voice, or a URL.","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]}
]}, ]},
{"no":"STAGE 01","cls":"telos","title":"TELOS","path":"wiki/telos/","nodes":[ {"no":"STAGE 01","cls":"telos","title":"TELOS","plain_title":"Why we're here","plain":"The mission, goals, and problems we're actually trying to solve live here — every piece of work downstream has to trace back to one of these goals.","path":"wiki/telos/",
{"t":"Intention substrate","pill":"var(--violet)","d":"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.","tags":["brain_query wing=telos"]} "trans_label":"Worth a session?","trans":"A goal or problem on the board becomes the seed for a design session when it's decided worth working on now. The goal is the input the session must trace back to.","nodes":[
{"t":"Intention substrate","plain":"The master list of mission, goals, problems, and current status — the yardstick everything downstream is measured against.","pill":"var(--violet)","d":"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.","tags":["brain_query wing=telos"]}
]}, ]},
{"no":"STAGE 02","title":"Strategic session","path":"claude.ai frontier + brain MCP","nodes":[ {"no":"STAGE 02","title":"Strategic session","plain_title":"Think it through","plain":"A human and AI models work out what to do and why, debating hard calls and writing down the decision and what \"done\" will mean.","path":"claude.ai frontier + brain MCP",
{"t":"Design · ADRs · specs","d":"Human + frontier model. ISC acceptance criteria written here.","tags":["Define / converge"]}, "trans_label":"Decision reached","trans":"It advances only when the thinking converges on a decision and is written down as a concrete, testable specification — not while the answer is still open.","nodes":[
{"t":"🏛️ LLM Council","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]}, {"t":"Design · ADRs · specs","plain":"A human and a top-tier AI model figure out the approach and write down the decision plus what a finished result must prove.","d":"Human + frontier model. ISC acceptance criteria written here.","tags":["Define / converge"]},
{"t":"Autoresearch Council","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]} {"t":"🏛️ LLM Council","plain":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]},
{"t":"Autoresearch Council","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]}
]}, ]},
{"no":"STAGE 03","title":"Spec → Gitea issue","path":"agent-ready contract","nodes":[ {"no":"STAGE 03","title":"Spec → Gitea issue","plain_title":"Write the work order","plain":"The decision is turned into a precise, self-contained work order an AI agent can execute unsupervised — with a pass/fail definition of done, a risk rating, and a tamper-proof seal.","path":"agent-ready contract",
{"t":"Contract enforced","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]}, "trans_label":"Order written, sealed, agent-ready","trans":"Advances to the gate only when the spec is a complete contract: a pass/fail test, a risk tier, a regulatory note, no open human dependencies, one embedded Oath, and a valid cryptographic signature. A malformed or unsigned order fails closed and never reaches the gate.","nodes":[
{"t":"Admission controller","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]}, {"t":"Contract enforced","plain":"The work order must have a clear pass/fail test, a risk rating, a regulatory-risk note, and no unfinished human dependencies before it counts as agent-ready.","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]},
{"t":"⚖️ var-go Oath","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]} {"t":"Admission controller","plain":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
{"t":"⚖️ var-go Oath","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
]}, ]},
{"no":"STAGE 04","cls":"gate","title":"Human dispatch gate","path":"the only checkpoint","nodes":[ {"no":"STAGE 04","cls":"gate","title":"Human dispatch gate","plain_title":"Human says go","plain":"A person reviews the work order and its risk and decides whether to release it — the one and only checkpoint where work does not move on its own.","path":"the only checkpoint",
{"t":"Human triggers execution","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true}, "trans_label":"A human said go","trans":"The hard stop. Nothing crosses automatically — a person must review the plan and risk and explicitly release it, and the repo must be on the allow-list, before any agent starts. This is the single human checkpoint in the whole pipeline.","nodes":[
{"t":"Session-Dispatch bridge","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]} {"t":"Human triggers execution","plain":"A person confirms the plan and its risk level, then releases the work — nothing runs until they do.","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true},
{"t":"Session-Dispatch bridge","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
]}, ]},
{"no":"STAGE 05","cls":"exec","title":"Execute · agentsquad","path":"koala · cmd/agentsquad-serve","nodes":[ {"no":"STAGE 05","cls":"exec","title":"Execute · agentsquad","plain_title":"Agents do the work","plain":"AI agents actually build the thing — one writes, a second independent one reviews it to avoid marking its own homework — and every step is logged for the audit trail.","path":"koala · cmd/agentsquad-serve",
{"t":"Task API","pill":"var(--coral)","d":"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).","tags":["single agentsquad.yaml"]}, "trans_label":"Agents produced a change","trans":"Advances when the agents finish and open a proposed change (a PR) with its audit log attached. Until there's a concrete change to test, nothing moves.","nodes":[
{"t":"Executor + reviewer loop","cls":"win","pill":"var(--coral)","d":"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.","risk":true}, {"t":"Task API","plain":"A request kicks off a job and hands back an id you can poll for progress.","pill":"var(--coral)","d":"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).","tags":["single agentsquad.yaml"]},
{"t":"dma-cli · routing + scope","cls":"bridge","pill":"var(--blue)","d":"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.","tags":["backend routing","scope guardrail"]}, {"t":"Executor + reviewer loop","plain":"One agent does the work; a second, independent agent on a different model reviews it — so nothing marks its own homework.","cls":"win","pill":"var(--coral)","d":"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.","risk":true},
{"t":"assessor-loop ledger","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]} {"t":"dma-cli · routing + scope","plain":"A router sends each agent to the right AI backend and enforces what it is and isn't allowed to touch, with a confirmation gate as a guardrail.","cls":"bridge","pill":"var(--blue)","d":"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.","tags":["backend routing","scope guardrail"]},
{"t":"assessor-loop ledger","plain":"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards.","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]}
]}, ]},
{"no":"STAGE 06","title":"PR → CI","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs","nodes":[]}, {"no":"STAGE 06","title":"PR → CI","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
{"no":"STAGE 07","cls":"cd","title":"CD → pod","path":"Flux GitOps → k3s","generate":"deploy-state","nodes":[ "trans_label":"All checks green","trans":"Advances only if every automated check passes — tests, linters, security scan, and the Oath check proving it meets the original work order. Any red gate stops it here; a passing reviewer is not enough to override a failed Oath.","nodes":[]},
{"t":"Deploy on green","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]} {"no":"STAGE 07","cls":"cd","title":"CD → pod","plain_title":"Ship it","plain":"Once everything is green, the change is deployed automatically to the live server — with the rule that merging code alone doesn't ship it; the release has to be pointed at the new version.","path":"Flux GitOps → k3s","generate":"deploy-state",
"trans_label":"It's live","trans":"Once the new version is actually running on the server, the deployed outcome becomes the input to scoring. Advancing means shipped and observable, not just merged.","nodes":[
{"t":"Deploy on green","plain":"When all checks pass, the release system rolls the new version onto the live server automatically — but only once the release is pointed at that version (merging code alone doesn't ship it).","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]}
]}, ]},
{"no":"STAGE 08","cls":"telos","title":"Loop back","path":"→ TELOS (feedback bus)","nodes":[ {"no":"STAGE 08","cls":"telos","title":"Loop back","plain_title":"Did it work?","plain":"The result is scored against the goal that started it and fed back into the mission board, so the next round of planning learns from what shipped.","path":"→ TELOS (feedback bus)",
{"t":"Close the loop","pill":"var(--violet)","d":"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)","tags":["continuous"]} "trans_label":"What did we learn?","trans":"The scored outcome flows back into the mission board so goals, problems, and priorities update — the loop that makes the pipeline a cycle rather than a line. Partly manual today; an explicit improvement target.","nodes":[
{"t":"Close the loop","plain":"The outcome is scored against the goal that started it and written back to the mission board, so future planning learns from what actually shipped.","pill":"var(--violet)","d":"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)","tags":["continuous"]}
]} ]}
] ]
} }
+23
View File
@@ -41,6 +41,29 @@ func TestBuild_OverlaysCIStageNodesFromWorkflow(t *testing.T) {
} }
} }
func TestDefault_HasPlainLayerAndTransitions(t *testing.T) {
a, err := atlas.Build(atlas.DataJSON, []byte("jobs:\n guard:\n a: 1\n"))
if err != nil {
t.Fatalf("Build embedded atlas: %v", err)
}
for _, s := range a.Stages {
if s.PlainTitle == "" {
t.Fatalf("stage %s missing plain_title", s.No)
}
if s.TransLabel == "" {
t.Fatalf("stage %s missing trans_label", s.No)
}
if s.Generate == "ci-jobs" {
continue // nodes are generated live, no authored plain
}
for _, n := range s.Nodes {
if n.Plain == "" {
t.Fatalf("stage %s node %q missing plain", s.No, n.Title)
}
}
}
}
func TestBuild_ErrorsOnBadAtlasJSON(t *testing.T) { func TestBuild_ErrorsOnBadAtlasJSON(t *testing.T) {
if _, err := atlas.Build([]byte("{not json"), []byte("jobs:\n x:\n a: 1\n")); err == nil { if _, err := atlas.Build([]byte("{not json"), []byte("jobs:\n x:\n a: 1\n")); err == nil {
t.Fatal("expected error on bad atlas JSON, got nil") t.Fatal("expected error on bad atlas JSON, got nil")
+64
View File
@@ -53,6 +53,70 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
return hosts, nil return hosts, nil
} }
// Flux is the reconciliation state of a Flux Kustomization.
type Flux struct {
Ready bool
Reason string
Revision string
}
// FluxStatus parses a Flux Kustomization object into its reconcile state.
func FluxStatus(kustJSON []byte) (Flux, error) {
var k struct {
Status struct {
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
Reason string `json:"reason"`
} `json:"conditions"`
LastAppliedRevision string `json:"lastAppliedRevision"`
} `json:"status"`
}
if err := json.Unmarshal(kustJSON, &k); err != nil {
return Flux{}, fmt.Errorf("parse kustomization: %w", err)
}
f := Flux{Revision: shortRev(k.Status.LastAppliedRevision)}
for _, c := range k.Status.Conditions {
if c.Type == "Ready" {
f.Ready = c.Status == "True"
f.Reason = c.Reason
}
}
return f, nil
}
// FluxNode renders the Flux reconcile state as a stage node.
func FluxNode(f Flux) Node {
if f.Ready {
return Node{
Title: "⟳ Flux · reconciled · " + f.Revision,
Pill: "var(--green)",
Tags: []string{"live · k8s"},
}
}
return Node{
Title: "⟳ Flux · " + f.Reason,
Pill: "var(--coral)",
Tags: []string{"live · k8s"},
}
}
// shortRev turns a Flux revision "main@sha1:<full>" into "main@<short>".
func shortRev(rev string) string {
at := strings.Index(rev, "@")
if at < 0 {
return rev
}
branch, sha := rev[:at], rev[at+1:]
if c := strings.LastIndex(sha, ":"); c >= 0 {
sha = sha[c+1:]
}
if len(sha) > 7 {
sha = sha[:7]
}
return branch + "@" + sha
}
// Deploy is the live state of a Kubernetes Deployment. // Deploy is the live state of a Kubernetes Deployment.
type Deploy struct { type Deploy struct {
Image string Image string
+28
View File
@@ -98,6 +98,34 @@ func TestNamespaceSummary_CapsWithMore(t *testing.T) {
} }
} }
func TestFluxStatus_ParsesReadyReasonAndShortRevision(t *testing.T) {
k := []byte(`{"status":{
"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"Applied revision: main@sha1:7a51d1d13944"}],
"lastAppliedRevision":"main@sha1:7a51d1d13944c481b04fc434d9a2aca2a25632e4"}}`)
f, err := atlas.FluxStatus(k)
if err != nil {
t.Fatalf("FluxStatus: %v", err)
}
if !f.Ready || f.Reason != "ReconciliationSucceeded" || f.Revision != "main@7a51d1d" {
t.Fatalf("flux = %+v", f)
}
}
func TestFluxNode_GreenWhenReadyCoralWhenNot(t *testing.T) {
ready := atlas.FluxNode(atlas.Flux{Ready: true, Reason: "ReconciliationSucceeded", Revision: "main@7a51d1d"})
if ready.Pill != "var(--green)" {
t.Fatalf("ready pill = %q", ready.Pill)
}
if ready.Title != "⟳ Flux · reconciled · main@7a51d1d" {
t.Fatalf("title = %q", ready.Title)
}
notReady := atlas.FluxNode(atlas.Flux{Ready: false, Reason: "BuildFailed"})
if notReady.Pill != "var(--coral)" {
t.Fatalf("not-ready pill = %q", notReady.Pill)
}
}
func TestDeployState_ParsesImageAndReplicas(t *testing.T) { func TestDeployState_ParsesImageAndReplicas(t *testing.T) {
dep := []byte(`{"spec":{"replicas":2,"template":{"spec":{"containers":[ dep := []byte(`{"spec":{"replicas":2,"template":{"spec":{"containers":[
{"name":"cad-atlas","image":"localhost:5000/cad-atlas:3ff922a"}]}}}, {"name":"cad-atlas","image":"localhost:5000/cad-atlas:3ff922a"}]}}},
+23 -13
View File
@@ -11,9 +11,11 @@ type Host struct {
Spec string `json:"k"` Spec string `json:"k"`
} }
// Node is a card within a stage. // Node is a card within a stage. Plain is the jargon-free default text; Desc is
// the technical detail shown on demand.
type Node struct { type Node struct {
Title string `json:"t"` Title string `json:"t"`
Plain string `json:"plain,omitempty"`
Desc string `json:"d,omitempty"` Desc string `json:"d,omitempty"`
Pill string `json:"pill,omitempty"` Pill string `json:"pill,omitempty"`
Cls string `json:"cls,omitempty"` Cls string `json:"cls,omitempty"`
@@ -22,23 +24,31 @@ type Node struct {
Gate bool `json:"gate,omitempty"` Gate bool `json:"gate,omitempty"`
} }
// Stage is one column of the pipeline. When Generate is set, its Nodes are // Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
// derived from a source at Build time rather than taken from the authored data. // default layer; Title/Path/Nodes[].Desc are the technical layer. TransLabel/
// Trans annotate the outgoing transition (the arrow to the next stage): what
// moves work forward and what must be true to advance. When Generate is set,
// Nodes are derived from a live source at Build time.
type Stage struct { type Stage struct {
No string `json:"no"` No string `json:"no"`
Title string `json:"title"` Title string `json:"title"`
Path string `json:"path,omitempty"` PlainTitle string `json:"plain_title,omitempty"`
Cls string `json:"cls,omitempty"` Plain string `json:"plain,omitempty"`
Generate string `json:"generate,omitempty"` Path string `json:"path,omitempty"`
Nodes []Node `json:"nodes"` Cls string `json:"cls,omitempty"`
Generate string `json:"generate,omitempty"`
TransLabel string `json:"trans_label,omitempty"`
Trans string `json:"trans,omitempty"`
Nodes []Node `json:"nodes"`
} }
// Atlas is the full data model the frontend renders. // Atlas is the full data model the frontend renders.
type Atlas struct { type Atlas struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
Substrate []Host `json:"substrate"` Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"` NS string `json:"ns,omitempty"`
Stages []Stage `json:"stages"` Timeline []RunDot `json:"timeline,omitempty"`
Stages []Stage `json:"stages"`
} }
// Build unmarshals the authored atlas JSON and overlays generated facts from // Build unmarshals the authored atlas JSON and overlays generated facts from
+49 -7
View File
@@ -31,20 +31,62 @@ type RunSummary struct {
// State aggregates the jobs: failure if any failed, running if any not yet // State aggregates the jobs: failure if any failed, running if any not yet
// succeeded, else success. // succeeded, else success.
func (s RunSummary) State() string { func (s RunSummary) State() string {
allSucceeded := true return aggregateState(s.Jobs)
for _, j := range s.Jobs { }
// RunDot is one run's aggregate outcome for the recent-runs timeline.
type RunDot struct {
Number int `json:"number"`
State string `json:"state"`
}
// RecentRuns parses a Gitea `/actions/tasks` response (per-job, newest first)
// into up to n most-recent runs with their aggregate outcome, newest first.
func RecentRuns(tasksJSON []byte, n int) ([]RunDot, error) {
var resp struct {
Tasks []struct {
RunNumber int `json:"run_number"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
return nil, fmt.Errorf("parse tasks: %w", err)
}
var order []int
jobsByRun := map[int][]Job{}
for _, t := range resp.Tasks {
if _, seen := jobsByRun[t.RunNumber]; !seen {
order = append(order, t.RunNumber)
}
jobsByRun[t.RunNumber] = append(jobsByRun[t.RunNumber], Job{Status: t.Status, Conclusion: t.Conclusion})
}
dots := make([]RunDot, 0, n)
for _, rn := range order {
if len(dots) >= n {
break
}
dots = append(dots, RunDot{Number: rn, State: aggregateState(jobsByRun[rn])})
}
return dots, nil
}
// aggregateState folds per-job outcomes into a run outcome.
func aggregateState(jobs []Job) string {
pending := false
for _, j := range jobs {
switch j.State() { switch j.State() {
case "failure", "cancelled", "error": case "failure", "cancelled", "error":
return "failure" return "failure"
case "success": case "success", "skipped": // completed OK — skipped (e.g. deploy on a tag push) doesn't block
default: default:
allSucceeded = false pending = true // running / in_progress / waiting / queued / unknown
} }
} }
if allSucceeded { if pending {
return "success" return "running"
} }
return "running" return "success"
} }
// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries, // LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries,
+34
View File
@@ -65,6 +65,40 @@ func TestRunNodes_SummaryThenPerJobColoured(t *testing.T) {
} }
} }
func TestRecentRuns_GroupsRunsNewestFirstWithAggregateState(t *testing.T) {
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy","status":"success"},
{"run_number":28,"name":"Build","status":"success"},
{"run_number":27,"name":"Deploy","status":"completed","conclusion":"failure"},
{"run_number":26,"name":"Build","status":"running"},
{"run_number":25,"name":"Deploy","status":"success"}
]}`)
dots, err := atlas.RecentRuns(tasks, 3)
if err != nil {
t.Fatalf("RecentRuns: %v", err)
}
if len(dots) != 3 {
t.Fatalf("want 3 dots (capped), got %d: %+v", len(dots), dots)
}
want := []atlas.RunDot{{28, "success"}, {27, "failure"}, {26, "running"}}
for i := range want {
if dots[i] != want[i] {
t.Fatalf("dot[%d] = %+v, want %+v", i, dots[i], want[i])
}
}
}
func TestRunState_SkippedJobsCountAsOK(t *testing.T) {
// A tag-push run skips the deploy job; the run still succeeded.
s := atlas.RunSummary{Jobs: []atlas.Job{
{Status: "skipped"}, {Status: "success"}, {Status: "success"},
}}
if s.State() != "success" {
t.Fatalf("skipped+success run state = %q, want success", s.State())
}
}
func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) { func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) {
if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil { if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil {
t.Fatal("expected error on empty, got nil") t.Fatal("expected error on empty, got nil")
+6
View File
@@ -29,6 +29,12 @@ func Deployment() ([]byte, error) {
return get("/apis/apps/v1/namespaces/cad-atlas/deployments/cad-atlas") return get("/apis/apps/v1/namespaces/cad-atlas/deployments/cad-atlas")
} }
// FluxKustomization returns the raw JSON for the Flux `apps` Kustomization that
// reconciles this repo's manifests.
func FluxKustomization() ([]byte, error) {
return get("/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/flux-system/kustomizations/apps")
}
func get(path string) ([]byte, error) { func get(path string) ([]byte, error) {
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
if host == "" || port == "" { if host == "" || port == "" {
+1 -1
View File
@@ -21,7 +21,7 @@ func base() string {
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first). // Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
func Runs() ([]byte, error) { func Runs() ([]byte, error) {
url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=20" url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50"
client := &http.Client{Timeout: 5 * time.Second} client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client
if err != nil { if err != nil {
+24 -7
View File
@@ -47,6 +47,7 @@ func NewHandler() http.Handler {
if ld.ns != "" { if ld.ns != "" {
a.NS = "Tailscale mesh · " + ld.ns a.NS = "Tailscale mesh · " + ld.ns
} }
a.Timeline = ld.timeline
if ld.run != nil { if ld.run != nil {
nodes := atlas.RunNodes(*ld.run) nodes := atlas.RunNodes(*ld.run)
for i := range a.Stages { for i := range a.Stages {
@@ -55,11 +56,17 @@ func NewHandler() http.Handler {
} }
} }
} }
if ld.deploy != nil { if ld.deploy != nil || ld.flux != nil {
node := atlas.DeployNode(*ld.deploy) var live []atlas.Node
if ld.deploy != nil {
live = append(live, atlas.DeployNode(*ld.deploy))
}
if ld.flux != nil {
live = append(live, atlas.FluxNode(*ld.flux))
}
for i := range a.Stages { for i := range a.Stages {
if a.Stages[i].Generate == "deploy-state" { if a.Stages[i].Generate == "deploy-state" {
a.Stages[i].Nodes = append([]atlas.Node{node}, a.Stages[i].Nodes...) a.Stages[i].Nodes = append(live, a.Stages[i].Nodes...)
} }
} }
} }
@@ -71,10 +78,12 @@ func NewHandler() http.Handler {
// liveOverlay holds cluster-sourced substrate facts (node specs + namespace line). // liveOverlay holds cluster-sourced substrate facts (node specs + namespace line).
type liveOverlayData struct { type liveOverlayData struct {
hosts []atlas.Host hosts []atlas.Host
ns string ns string
run *atlas.RunSummary run *atlas.RunSummary
deploy *atlas.Deploy deploy *atlas.Deploy
flux *atlas.Flux
timeline []atlas.RunDot
} }
// live cache: query the cluster at most once per TTL; fall back to the authored // live cache: query the cluster at most once per TTL; fall back to the authored
@@ -110,12 +119,20 @@ func liveOverlay() liveOverlayData {
if r, err := atlas.LatestRunJobs(raw); err == nil { if r, err := atlas.LatestRunJobs(raw); err == nil {
d.run = &r d.run = &r
} }
if dots, err := atlas.RecentRuns(raw, 12); err == nil {
d.timeline = dots
}
} }
if raw, err := cluster.Deployment(); err == nil { if raw, err := cluster.Deployment(); err == nil {
if dep, err := atlas.DeployState(raw); err == nil { if dep, err := atlas.DeployState(raw); err == nil {
d.deploy = &dep d.deploy = &dep
} }
} }
if raw, err := cluster.FluxKustomization(); err == nil {
if f, err := atlas.FluxStatus(raw); err == nil {
d.flux = &f
}
}
liveCache = d liveCache = d
return d return d
} }
+82 -9
View File
@@ -52,6 +52,22 @@
.host .k{color:var(--dim);font-size:11px} .host .k{color:var(--dim);font-size:11px}
.host.mesh{border-style:dashed;color:var(--dim)} .host.mesh{border-style:dashed;color:var(--dim)}
.tl{display:none;gap:5px;align-items:center;flex-wrap:wrap;
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
.tl .lbl{color:var(--dim);font-size:11px;letter-spacing:1.5px;margin-right:4px}
.rundot{width:22px;height:16px;border-radius:4px;border:1px solid rgba(0,0,0,.35);
display:inline-flex;align-items:center;justify-content:center;
font-size:9px;color:#08121f;font-weight:600}
/* progressive disclosure */
.tech-sub{color:var(--dim);font-size:11px;font-family:ui-monospace,SFMono-Regular,monospace;margin:1px 0 5px}
.plain-what{color:var(--ink);font-size:12.5px;line-height:1.45;margin-bottom:2px;opacity:.92}
.translabel{fill:var(--mono);font-size:10px;font-family:ui-monospace,SFMono-Regular,monospace}
.translabel-gate{fill:var(--gold);font-weight:700}
.stagehead{min-height:64px}
body.plain .stagehead{min-height:188px}
.stage .node:first-of-type{margin-top:24px}
.scroll{overflow-x:auto;padding:24px 22px 20px} .scroll{overflow-x:auto;padding:24px 22px 20px}
.track{position:relative;display:flex;align-items:flex-start;min-width:max-content} .track{position:relative;display:flex;align-items:flex-start;min-width:max-content}
svg.spine{position:absolute;left:0;top:0;z-index:0;pointer-events:none;overflow:visible} svg.spine{position:absolute;left:0;top:0;z-index:0;pointer-events:none;overflow:visible}
@@ -116,12 +132,14 @@
<h1><b>CAD</b> Atlas · From Signal to Pod</h1> <h1><b>CAD</b> Atlas · From Signal to Pod</h1>
<span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span> <span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span>
<div class="controls"> <div class="controls">
<button id="mode" class="on">View · <span id="modeState">Plain</span></button>
<button id="replay"><span class="dot"></span> Replay</button> <button id="replay"><span class="dot"></span> Replay</button>
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button> <button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
</div> </div>
</header> </header>
<div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div> <div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div>
<div class="tl mono" id="timeline"></div>
<div class="scroll"> <div class="scroll">
<div class="track" id="track"> <div class="track" id="track">
@@ -137,6 +155,7 @@
stroke-dasharray="5 5" opacity=".7"></path> stroke-dasharray="5 5" opacity=".7"></path>
<text id="loopLbl" fill="#9b8cff" font-size="11" <text id="loopLbl" fill="#9b8cff" font-size="11"
font-family="ui-monospace,monospace" opacity=".85"></text> font-family="ui-monospace,monospace" opacity=".85"></text>
<g id="translabels"></g>
</svg> </svg>
<div class="pulse" id="pulse"></div> <div class="pulse" id="pulse"></div>
</div> </div>
@@ -154,7 +173,8 @@
// stage from the live cd.yml + substrate from the live cluster). These start // stage from the live cd.yml + substrate from the live cluster). These start
// empty and are filled by init()'s fetch; on failure the page shows an error // empty and are filled by init()'s fetch; on failure the page shows an error
// banner rather than stale inline data. // banner rather than stale inline data.
let SUBSTRATE=[], NS="", STAGES=[]; let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[];
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
const track=document.getElementById('track'); const track=document.getElementById('track');
let stageEls=[]; let stageEls=[];
@@ -166,14 +186,22 @@ function renderAtlas(){
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh); const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
stageEls=[]; stageEls=[];
track.querySelectorAll('.stage').forEach(el=>el.remove()); track.querySelectorAll('.stage').forEach(el=>el.remove());
const plain = MODE==='plain';
STAGES.forEach(s=>{ STAGES.forEach(s=>{
const st=document.createElement('div');st.className='stage '+s.cls; const st=document.createElement('div');st.className='stage '+(s.cls||'');
let h=`<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`; const head = plain
s.nodes.forEach(n=>{ ? `<div class="no mono">${s.no}</div><h2>${s.plain_title||s.title}</h2>`+
`<div class="tech-sub">${s.title}</div>`+(s.plain?`<div class="plain-what">${s.plain}</div>`:'')
: `<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
let h = `<div class="stagehead">${head}</div>`;
(s.nodes||[]).forEach(n=>{
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:''; const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
let inner=`<div class="t">${pill}${n.t}</div><div class="d">${n.d}</div>`; const body = plain ? (n.plain||n.d||'') : (n.d||'');
if(n.tags&&n.tags.length)inner+=n.tags.map(t=>`<span class="tag mono">${t}</span>`).join(''); let inner=`<div class="t">${pill}${n.t}</div>`+(body?`<div class="d">${body}</div>`:'');
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`; if(!plain){
if(n.tags&&n.tags.length)inner+=n.tags.map(t=>`<span class="tag mono">${t}</span>`).join('');
if(n.risk)inner+=`<div class="risk mono"><span class="lo">LOW · auto</span><span class="md">MED · ntfy gate</span><span class="hi">HIGH · blocked</span></div>`;
}
if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`; if(n.gate)inner+=`<div class="gatebtns mono"><div class="g ok">✓ approve</div><div class="g no">✕ reject</div></div>`;
h+=`<div class="node ${n.cls||''}">${inner}</div>`; h+=`<div class="node ${n.cls||''}">${inner}</div>`;
}); });
@@ -181,17 +209,34 @@ function renderAtlas(){
}); });
} }
function renderTimeline(){
const tl=document.getElementById('timeline');
if(!TIMELINE.length){tl.style.display='none';return;}
tl.innerHTML='<span class="lbl">RECENT RUNS</span>';
TIMELINE.forEach(d=>{
const c=d.state==='success'?'var(--green)':(d.state==='failure'?'var(--coral)':'var(--amber)');
const el=document.createElement('span');
el.className='rundot';el.style.background=c;
el.title='run #'+d.number+' · '+d.state;el.textContent=d.number;
tl.appendChild(el);
});
tl.style.display='flex';
}
/* ---- geometry ---- */ /* ---- geometry ---- */
const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'), const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'),
loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'), loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'),
pulse=document.getElementById('pulse'); pulse=document.getElementById('pulse');
const RAILY=70; let RAILY=70;
let cs=[],loopY=0,spineLen=0,loopLen=0,slow=false,raf=null,t0=null,mobile=false; let cs=[],loopY=0,spineLen=0,loopLen=0,slow=false,raf=null,t0=null,mobile=false;
function build(){ function build(){
mobile=window.matchMedia('(max-width:820px)').matches; mobile=window.matchMedia('(max-width:820px)').matches;
if(mobile)return; if(mobile)return;
cs=stageEls.map(s=>s.offsetLeft+s.offsetWidth/2); cs=stageEls.map(s=>s.offsetLeft+s.offsetWidth/2);
// spine sits in the band between the (uniform) stage headers and the first node
const firstTops=stageEls.map(s=>{const n=s.querySelector('.node');return n?s.offsetTop+n.offsetTop:120;});
RAILY=Math.max(66, Math.min(...firstTops)-16);
const maxBottom=Math.max(...stageEls.map(s=>s.offsetTop+s.offsetHeight)); const maxBottom=Math.max(...stageEls.map(s=>s.offsetTop+s.offsetHeight));
loopY=maxBottom+40; loopY=maxBottom+40;
spine.setAttribute('width',track.scrollWidth); spine.setAttribute('width',track.scrollWidth);
@@ -201,7 +246,22 @@ function build(){
const lastX=cs[cs.length-1], telosX=cs[1]; const lastX=cs[cs.length-1], telosX=cs[1];
loopPath.setAttribute('d',`M ${lastX} ${RAILY} L ${lastX} ${loopY} L ${telosX} ${loopY} L ${telosX} ${RAILY}`); loopPath.setAttribute('d',`M ${lastX} ${RAILY} L ${lastX} ${loopY} L ${telosX} ${loopY} L ${telosX} ${RAILY}`);
loopLbl.setAttribute('x',(telosX+lastX)/2-90);loopLbl.setAttribute('y',loopY-8); loopLbl.setAttribute('x',(telosX+lastX)/2-90);loopLbl.setAttribute('y',loopY-8);
loopLbl.textContent='feedback bus · outcome → goal'; loopLbl.textContent=(STAGES[8]&&STAGES[8].trans_label?STAGES[8].trans_label+' · ':'')+'feedback bus';
// transition labels on the spine — the "what must be true to advance" story.
// Always visible (default layer). Gate hops (04→05 human, 06→07 CI) in gold.
const tg=document.getElementById('translabels'); tg.innerHTML='';
for(let i=0;i<cs.length-1;i++){
const s=STAGES[i]; if(!s||!s.trans_label) continue;
const gate = (i===4||i===6);
const t=document.createElementNS('http://www.w3.org/2000/svg','text');
t.setAttribute('x',(cs[i]+cs[i+1])/2);t.setAttribute('y',RAILY-9);
t.setAttribute('text-anchor','middle');
t.setAttribute('class',gate?'translabel translabel-gate':'translabel');
t.textContent=(gate?'▸ ':'')+s.trans_label;
const ttl=document.createElementNS('http://www.w3.org/2000/svg','title');
ttl.textContent=s.trans||''; t.appendChild(ttl);
tg.appendChild(t);
}
spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength(); spineLen=spinePath.getTotalLength();loopLen=loopPath.getTotalLength();
} }
function run(ts){ function run(ts){
@@ -224,6 +284,16 @@ function replay(){cancelAnimationFrame(raf);t0=null;build();if(!mobile)raf=reque
document.getElementById('replay').onclick=replay; document.getElementById('replay').onclick=replay;
document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow); document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow);
document.getElementById('slowState').textContent=slow?'on':'off';replay();}; document.getElementById('slowState').textContent=slow?'on':'off';replay();};
function applyMode(){
document.getElementById('modeState').textContent = MODE==='plain'?'Plain':'Technical';
document.getElementById('mode').classList.toggle('on', MODE==='plain');
document.body.classList.toggle('plain', MODE==='plain');
}
document.getElementById('mode').onclick=()=>{
MODE = MODE==='plain' ? 'technical' : 'plain';
localStorage.setItem('atlas-mode',MODE);
applyMode(); renderAtlas(); replay();
};
window.addEventListener('resize',()=>{clearTimeout(window._r);window._r=setTimeout(replay,150);}); window.addEventListener('resize',()=>{clearTimeout(window._r);window._r=setTimeout(replay,150);});
async function init(){ async function init(){
try{ try{
@@ -233,6 +303,7 @@ async function init(){
if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate; if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate;
if(typeof data.ns==='string') NS=data.ns; if(typeof data.ns==='string') NS=data.ns;
if(Array.isArray(data.stages)) STAGES=data.stages; if(Array.isArray(data.stages)) STAGES=data.stages;
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
if(data.version) document.getElementById('ver').textContent=data.version; if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){ }catch(e){
console.error('atlas: failed to load /api/atlas.json —',e); console.error('atlas: failed to load /api/atlas.json —',e);
@@ -242,7 +313,9 @@ async function init(){
'<div class="path mono">/api/atlas.json failed to load</div></div>'); '<div class="path mono">/api/atlas.json failed to load</div></div>');
return; return;
} }
applyMode();
renderAtlas(); renderAtlas();
renderTimeline();
replay(); replay();
} }
window.addEventListener('load',init); window.addEventListener('load',init);