16 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 60cc8894a2 feat(atlas): UX — overview rail + gate glyphs + pill reconcile (#3,#4,#5)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
Closes the UX sprint's authored layer.
#3 Horizontal-scroll blindness → an always-visible "PIPELINE" overview rail:
9 colour-coded chips (00 Notice → 08 Learn) showing the whole shape at a glance,
each click-to-scroll to its stage. First-timers now see the arc + that more exists.
#4 Real gate glyph — 🔒 on the two checkpoint transitions (human @04, CI @06),
desktop spine + mobile rows.
#5 Reconcile legend with Plain: decorative node pills hidden in Plain, so colour
is reserved for live status (green/amber/coral) — exactly what the legend keys.
(#6 attached chips/grammar: the question-vs-statement label mix is meaningful —
decisions vs milestones — kept; SVG chip background skipped as low-value.)

New Stage.short (guarded by JSON validity via existing tests). build/vet/lint(0)/
test green; desktop Plain screenshot-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:46:08 +02:00
mathiasandClaude Opus 4.8 863c4c964b feat(atlas): UX — plain node titles + mobile transition rows (re-review #1,#2)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
Lap-2 review's top two findings:
#1 Node titles were still jargon (the lap-1 disease one level down). Every node
now has a plain_t ("var-go Oath" → "Definition of done", "Admission controller"
→ "Tamper-proof seal", …); Plain view leads with it and demotes the technical
name to a dim in-card subtitle — mirrors the stage-head pattern. plain_t on every
authored node is now guard-tested.
#2 Transition labels lived only in the SVG spine (display:none <820px) with a
hover-only rationale — invisible on phones. Added stacked-layout transition rows
(HTML, always-visible, full sentence, no hover) shown on mobile; desktop header
band scoped to ≥821px so mobile isn't stretched.

build/vet/lint(0)/test green; desktop Plain screenshot-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:14:53 +02:00
mathiasandClaude Opus 4.8 ac086b65b9 fix(atlas): UX polish — stage-06 plain nodes + shorter arrow labels (#7)
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 06 no longer renders bare in Plain: Build is now tolerant of a jobless/
absent workflow (keeps an authored plain placeholder instead of erroring), and
generated CI job nodes carry a plain hint. Shortened the two overlong transition
labels ("Order written, sealed, agent-ready" → "Sealed & agent-ready",
"Agents produced a change" → "Change proposed") so they stop clipping.

Build tolerance test-first. build/vet/lint(0)/test green; screenshot-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:46:42 +02:00
mathiasandClaude Opus 4.8 d992ca4a01 feat(atlas): UX polish — legend + feedback-bus honesty + Plain declutter (#6, #8)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
#6: always-visible legend keying the live-status colours (passed/running/failed),
the governance-gate colour, and the dashed feedback loop.
#8: the 08→TELOS loop is labelled "partly manual today" (honest, per PROJECT.md);
Plain view hides the jargon-dense substrate ribbon + technical footer, so a
non-technical viewer isn't shown koala/iguana/ns/assessor-loop proper nouns —
they remain in Technical view.

Frontend/CSS only; verified by screenshot. build/test/lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:40:46 +02:00
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
mathiasandClaude Opus 4.8 3d1f76997b feat(atlas): Phase C — live CD/deploy state on stage 07
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 13s
CD / Detect unsubstituted template (push) Successful in 0s
CD / Deploy via GitOps (push) Has been skipped
Stage 07 now shows the pod's own live deploy state — deployed image tag +
N/M replicas ready, green only when the rollout is fully ready — read from the
k8s Deployment in-cluster (new namespaced deployments-read Role). DeployState/
DeployNode test-first; prepended to the authored CD narrative, 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:39:01 +02:00
mathiasandClaude Opus 4.8 3ff922a4d5 feat(atlas): per-job CI status + build-injected version in the UI
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
Phase C: the CI stage now shows the latest run's per-JOB status (Lint/Test,
Build, Deploy, …), each coloured by outcome, from the Gitea /actions/tasks
per-job entries — replacing the static cd.yml job-id list when live.
LatestRunJobs/RunNodes + RunSummary.State aggregate, all test-first.

Version: injected at build via -ldflags from `git describe --tags` (CI checkout
now fetch-depth:0 + --build-arg), served in /api/atlas.json, shown in the header.
No more hand-maintained version label drifting from the git tag.

Verified: build/vet/lint(0)/test green; version ldflag served correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:21:52 +02:00
mathiasandClaude Opus 4.8 02a23a02dd feat(atlas): Phase C increment 1 — live CI runs on the pipeline
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 0s
The CI stage now shows the pipeline's latest real execution, read live from the
in-cluster Gitea Actions API (public read, no token, gitea-http.gitea.svc):
"▶ run #N · <state>" coloured by outcome, prepended to the generated job list.
LatestRun + RunNode built test-first; cached in the same 30s liveOverlay; falls
back cleanly when Gitea is unreachable.

assessor-loop ledger / session_log deferred: no live CAD data exists for this repo
yet (brain confirms). Gitea run history is the real available trace.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:03:03 +02:00
mathiasandClaude Opus 4.8 633ba153f2 feat(atlas): Phase B tail — live namespaces + trim inline fallback
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
NamespaceSummary (test-first) renders the substrate ns line from live cluster
namespaces (system-filtered, sorted, capped). Handler now overlays both node
specs and the ns line via a single cached liveOverlay(). Removed the inline
SUBSTRATE/STAGES/NS data — /api/atlas.json is the single source; the page shows
an error banner on fetch failure instead of stale data.

GPU model naming intentionally NOT done: koala's node has no GPU product label
(only nvidia.com/gpu count), so "1× GPU" is the API's truth — naming the RTX 5070
would need GPU-feature-discovery, out of scope.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:56:40 +02:00
mathiasandClaude Opus 4.8 39fd9b9adc feat(atlas): hybrid substrate — live specs for cluster nodes, authored rest
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
MergeSubstrate overlays live node specs onto the authored machine list by name:
cluster nodes (koala) get fresh live specs, non-cluster machines (iguana/flamingo/
piguard) stay authored, new live nodes are appended. Test-first. Restores the full
homelab machine list while keeping cluster nodes truthful.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:47:53 +02:00
mathiasandClaude Opus 4.8 74dffc1ff3 feat(atlas): Phase B — substrate from the live cluster
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 0s
The substrate machines are now rendered from the live k3s nodes (arch, cpu,
memory, GPU, k3s version), read in-cluster via the mounted ServiceAccount with a
stdlib HTTP client (no client-go) and a 30s cache; falls back to the authored
substrate whenever the cluster is unreachable. Placement per the homelab decision
"in-cluster only for in-workload ops" — a running web app rendering live data fits.

HostsFromNodes parser built test-first. RBAC (read-only node-reader SA) shipped in
infra k3s/apps/cad-atlas/.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:43:20 +02:00
mathiasandClaude Opus 4.8 a9c72d6ca8 feat(atlas): Phase B — data-driven /api/atlas.json, CI stage generated from cd.yml
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
The atlas no longer hand-maintains its content. Authored data lives in one place
(internal/atlas/atlas.json); the Go layer overlays sourced facts and serves the
result at /api/atlas.json; the frontend fetches + renders (inline arrays kept only
as an offline fallback). First generated source: the CI/CD stage's nodes are parsed
from the repo's own .gitea/workflows/cd.yml — so the viz shows the pipeline that
actually runs (guard/check/build/deploy), dropping the aspirational var-go/oath-gate
node that isn't wired yet. That's the point: it can't drift from the real pipeline.

New internal/atlas package (JobsFromWorkflow, Build) built test-first. Adds
gopkg.in/yaml.v3 (justified: parsing the workflow YAML; stdlib has no YAML).

Verified: go build/vet/lint(0)/test green; /api/atlas.json → 9 stages, CI = real
jobs; frontend renders from the fetch (screenshot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:34:06 +02:00
mathiasandClaude Opus 4.8 c3fda9b26b docs: wire INFRA_DEPLOY_KEY — CI now auto-deploys (#2)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 0s
Deploy job's INFRA_DEPLOY_KEY (write deploy-key cad-atlas-ci on infra) is set;
future main pushes auto-bump the infra image tag → Flux rolls. Also records the
live Authentik-gated endpoint. This commit is itself the first auto-deploy test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 23:57:01 +02:00
24 changed files with 1975 additions and 82 deletions
+5
View File
@@ -62,18 +62,23 @@ jobs:
image-tag: ${{ steps.meta.outputs.sha-tag }} image-tag: ${{ steps.meta.outputs.sha-tag }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # full history + tags so `git describe` sees the SemVer tag
- name: Derive image tags - name: Derive image tags
id: meta id: meta
run: | run: |
SHA=$(git rev-parse --short HEAD) SHA=$(git rev-parse --short HEAD)
VERSION=$(git describe --tags --always --dirty)
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT" echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Build and push to local registry - name: Build and push to local registry
run: | run: |
REGISTRY="localhost:5000" REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}" REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
buildah build \ buildah build \
--build-arg VERSION="${{ steps.meta.outputs.version }}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \ --label "org.opencontainers.image.revision=${{ github.sha }}" \
-t ${REF} \ -t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \ -t ${REGISTRY}/${{ env.IMAGE }}:latest \
+4 -1
View File
@@ -5,7 +5,10 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
COPY go.mod ./ COPY go.mod ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN templ generate && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/cad-atlas ARG VERSION=dev
RUN templ generate && CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X git.d-ma.be/mathias/cad-atlas/internal/version.Value=${VERSION}" \
-o /out/app ./cmd/cad-atlas
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app COPY --from=build /out/app /app
+4 -3
View File
@@ -28,9 +28,10 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
## Deployment ## Deployment
Live on koala k3s: namespace `cad-atlas`, 1 replica, served via ClusterIP `cad-atlas:80 → :8080` Live at **https://atlas.d-ma.be** (Authentik forward-auth — authed users only) on koala k3s:
(manifests in `mathias/infra` `k3s/apps/cad-atlas/`, reconciled by Flux). First deploy was namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathias/infra`
GitOps-direct; the CI `deploy` job auto-deploys future pushes once `INFRA_DEPLOY_KEY` is set (#2). `k3s/apps/cad-atlas/`, reconciled by Flux). CI `deploy` job **auto-deploys** on every main push —
`INFRA_DEPLOY_KEY` (write deploy-key `cad-atlas-ci` on infra) is wired (#2 done).
## The honesty rule ## The honesty rule
+236
View File
@@ -0,0 +1,236 @@
# CAD Atlas — Fresh-Eyes UX Review, Lap 2
Reviewer role: fresh-eyes UX (not implementer). Second pass, judging the shipped
progressive-disclosure sprint against the goal set in `UX-REVIEW.md`. This document
critiques; it does not change code.
Sources reviewed:
- `docs/UX-REVIEW.md` (the spec that was implemented — lap 1)
- `internal/atlas/atlas.json` (authored data: `plain_title`, `plain`, `trans_label`,
`trans` per stage; `plain` per node)
- `internal/web/static/cad-atlas.html` (the renderer: Plain⇄Technical toggle, spine
transition labels, legend, substrate/footer hidden in Plain)
- `/tmp/ux5/home.png` (deployed Plain view, stages 0004 in frame)
---
## 1. Verdict
**The sprint largely achieved the *phase* half of the success criterion and made a real
dent in the *transition* half — but it does not fully clear the bar. Grade for a naive
viewer: B / B+.**
Success criterion was: a viewer with no briefing can explain **every phase** AND **what
advances work across every transition.**
- **Phases: pass.** This is the big win. The headline hierarchy works — big plain_title
("Notice what's happening", "Why we're here", "Agents do the work"), a dim technical
subtitle, and a jargon-free "what happens" sentence. A cold stakeholder can now narrate
every column. This is a genuine, measurable improvement over lap 1, where every column
was a mechanism name. Full marks here.
- **Transitions: partial.** The short spine labels ("Does it matter to us?", "Worth a
session?", "Decision reached", "Sealed & agent-ready") convey the *gist* of each hop,
which is a step-change from the blank arrows of lap 1. But three things hold it back
from "can explain what advances work":
1. The actual "what must be true to advance" sentence (`trans`) is **hover-only** (an
SVG `<title>` tooltip). That is undiscoverable — nothing signals it's hoverable — and
dead on touch devices. So the naive viewer gets the one-line theme, not the gate
logic.
2. The labels are **terse and grammatically mixed**: some are questions ("Does it matter
to us?"), some are achieved-states ("Decision reached", "It's live"). A first-timer
builds two different mental models — "is this the question asked here, or the answer
reached?" — from one row.
3. **They vanish on narrow screens.** All transition labels live inside `svg.spine`,
which is `display:none` under 820px, and the stacked mobile layout renders no HTML
fallback. The single biggest comprehension win of the sprint is absent on a phone.
- **The viewport undermines both.** The atlas is a 9-column horizontal scroll and a laptop
shows ~stages 0003. That means **stage 04, the one human gate — the thing the header
literally advertises ("one human gate") — is off-screen on load**, along with execution,
CI, deploy, and the loop. A first-timer cannot see the shape of the gated flow, cannot
see that a human checkpoint exists, and gets no affordance that five more stages are to
the right. The atlas's whole payoff — "see the auditable flow from signal to pod at a
glance" — is not deliverable in the default viewport.
Net: the copy layer is excellent and the toggle is the right architecture. The remaining
gap is structural (what's visible, and where the transition rationale lives), plus one
stubborn copy residue (node titles). A naive viewer *can* explain the phases unprompted;
they can explain the transitions only at a slogan level, and only for the half of the
pipeline they happen to scroll to.
---
## 2. What works (the genuine wins)
- **Plain default + persisted toggle.** Right call, right default. Nothing was thrown
away; Technical is the old atlas verbatim. Architecture matches the spec.
- **Stage headline hierarchy.** plain_title dominant, technical title demoted to a dim mono
subtitle, plain sentence underneath. Clean, scannable, correct visual weight.
- **Transitions exist at all.** Even terse, the labelled arrows turn a row of boxes into a
narrated flow. This was the #1 lap-1 miss and it shipped in the default layer.
- **Density dropped in Plain.** Tags, risk chips, host ribbon and footer are all suppressed
in Plain (`body.plain`), so the reading surface is airy and calm — a real contrast to the
lap-1 wall of pills.
- **Stage 06 no longer reads as empty.** An authored "Automated checks" node with plain copy
now backstops the live CI generation, so the CI gate never looks like a no-op on a cold
load. Directly fixes lap-1 punch-item 7.
- **Feedback bus is honestly hedged.** Dashed violet + "partly manual today" in both the loop
label and the legend. Matches the dogfooding-honesty discipline.
- **The pulse gives direction.** The travelling dot is a low-cost "this flows left-to-right,
then loops back" cue — helpful for orientation.
---
## 3. What's still weak (shipped Plain view)
**a. Node titles are still pure mechanism-jargon — the exact lap-1 disease, one level down.**
The node *body* is now plain, but the node *title* — the first, boldest thing the eye lands
on in a card — is untouched: "Applied AI Radar", "Intention substrate", "Admission
controller", "var-go Oath", "Session-Dispatch bridge", "Executor + reviewer loop",
"dma-cli · routing + scope", "assessor-loop ledger". The plain body can't fully rescue a
card whose title already framed it as a mechanism. The stage head got the plain-title/tech-
subtitle treatment; the node did **not** — an inconsistency that leaves each column half-
translated. See §4.
**b. The transition rationale is hidden and fragile.** As in §1: `trans` is hover-only
(undiscoverable, touch-dead) and the whole label layer disappears under 820px. The "what
must be true to advance" — the governance content the atlas exists to show — is the least
robustly delivered part of the whole thing.
**c. Horizontal scroll with no affordance = half the pipeline is invisible.** Nothing at the
right edge signals "more stages this way" — no fade, no scrollbar cue, no "→ 0408", no
overview. A first-timer may reasonably believe the pipeline is five stages that end at "Write
the work order". The marquee human gate and the entire execute→ship→loop arc are off-frame by
default. This is the single largest comprehension barrier remaining.
**d. Transition-label attachment is weaker than specced.** Lap-1 §5 asked for **label chips
sitting on the spine**; what shipped is floating 10px mono text ~9px above the arrow with no
background. On the dark grid it reads, but it floats — the viewer has to mentally bind the
text to the arrow beneath it. A chip (or a short leader) would make the label read as *of*
the arrow, not near it.
**e. Gate treatment is cryptic.** Lap-1 §5 asked for a lock/shield glyph + stronger colour on
the two real gates (04 human, 06 CI). What shipped is a "▸ " prefix + gold + bold on those two
labels. A naive viewer will not read "▸" as "governance checkpoint"; the only real cue is
"one of these labels is gold", which leans entirely on the legend. The two most important hops
in the whole atlas deserve a glyph that says *stop / check*, not an arrowhead character.
**f. The legend doesn't match the colours actually on screen.** The KEY decodes green/amber/
coral as CI run-states plus gold=gate + dashed=feedback. But in Plain the cards show **violet,
blue, and coral pills** and **coloured/dashed borders** (council=violet, bridge=dashed-blue,
oath=dashed-gold, executor=coral) that the KEY never explains — while the green/amber/coral CI
states the KEY *does* explain are barely present in Plain. So the viewer sees a violet dot on
"Intention substrate" and a dashed-gold box on "var-go Oath" with no way to decode them, and a
legend describing states they can't see. Colour is carrying two unrelated meanings (authored
semantics vs. live CI status) under one key. Either key every colour/border shown in Plain, or
strip the decorative pills/borders in Plain so colour means only what the legend says.
**g. The technical subtitle is a wash — mild noise, mild help.** Under the plain_title sits
`s.title`: sometimes near-plain ("Signals", "Strategic session"), sometimes pure jargon
("TELOS", "Spec → Gitea issue", "PR → CI", "Execute · agentsquad"). For a naive viewer roughly
half of these subtitles are undecodable filler directly under the headline; for a new engineer
they're a useful canonical-name bridge without a mode-switch. Because it's dim and small the
noise cost is low, so **keep it** — but note the *inconsistency*: the stage trusts the reader
with a dim technical name under a plain headline, yet the node doesn't extend that same courtesy
(§4). Apply the pattern uniformly.
**h. Unexplained accent colours on headlines.** plain_title is violet for TELOS/Loop and amber
for the gate. Meaningful to the author, unkeyed for the viewer — a minor echo of problem (f).
**i. The feedback bus reads as a mystery line in-viewport.** The dashed violet return leg drops
straight down out of the TELOS column, but its label ("What did we learn? · feedback bus")
sits at the very bottom of a 9-column-wide canvas — off-screen for anyone who hasn't scrolled
down and right. In the default view you see an unexplained dashed vertical line and no origin
(stage 08 is off-frame right). The honesty hedge is good; the *legibility* of the loop in the
first screen is poor.
---
## 4. Node-title question — recommendation: **add plain node titles; keep the technical name as a dim subtitle inside the card.**
Do **not** keep titles as-is, and do **not** simply swap in plain titles and delete the
technical ones. Mirror the pattern the stage head already uses, one level down:
```
[pill] Marks its own homework? No. ← plain_t (bold, primary)
Executor + reviewer loop ← t (dim mono subtitle)
One agent does the work; a second, ← plain (body, already shipped)
independent agent reviews it …
```
Reasoning for the mixed audience:
- **The title is the frame.** The eye reads title → body. A jargon title ("Admission
controller", "var-go Oath") sets a mechanism frame that a plain body then fights against.
This is precisely the lap-1 diagnosis ("every node names a *mechanism* rather than the
*thing that happens to the work*") — it was fixed for stages and bodies but left standing in
node titles. The job is half-done until titles get the same treatment.
- **A naive viewer needs the plain title.** "Marks its own homework? No.", "Signed so tampering
shows", "The one human yes/no", "A machine-checkable definition of done" — these are
explainable at a glance; "dma-cli · routing + scope" is not.
- **A new engineer still needs the canonical name.** "var-go Oath", "Ed25519 admission
controller", "assessor-loop ledger" are the searchable terms that connect the picture to the
code and the brain. Deleting them would help the stakeholder and hurt the engineer — the
wrong trade for a "everyone" audience.
- **Consistency is its own win.** Right now stage heads say "plain big / technical small" and
nodes say "technical only". Two rules for the same card type is friction. One rule, applied
at both levels, makes the whole atlas feel like one designed system and makes the toggle's
mental model ("plain names up front, mechanisms one layer in") coherent.
Concretely: add an optional `plain_t` per node in `atlas.json`; in Plain render `plain_t` as
the title and `t` as a `tech-sub`-style dim line (reuse the existing class); in Technical keep
today's behaviour (`t` as title). Nodes without a `plain_t` fall back to `t`, so it's an
incremental authoring task, not a big-bang rewrite.
---
## 5. Prioritized next punch list (top 6 by comprehension impact)
1. **Add plain node titles (`plain_t`), technical name demoted to a dim in-card subtitle.**
Highest impact: the title is the first thing read and it's still the lap-1 jargon disease.
Finishes the progressive-disclosure job the stages already got. (§3a, §4)
2. **Solve the horizontal-scroll blindness.** A first-timer must be able to tell the pipeline
is nine stages and reach the human gate and the loop. Ship at least a right-edge fade +
"→ stages 0408" hint; ideally a "fit to width / overview" zoom toggle so the whole gated
shape (and the one human gate the header promises) is visible at a glance. (§3c)
3. **Render transition labels in the stacked/narrow layout, and surface the `trans` sentence
without a hover.** The labels currently die under 820px (they live only in the SVG) and the
gate rationale is hover-only/touch-dead. Emit `trans_label` as an HTML element between
stacked stages, and make the full `trans` reachable by click/tap (expandable), not just
desktop hover. This is the "explain every transition" half of the success criterion. (§1, §3b)
4. **Give the two governance gates (04 human, 06 CI) a real glyph and make the feedback loop
legible in-viewport.** Replace the "▸" prefix with a lock/shield on the gold gate labels so
the checkpoints read as checkpoints; and attach a visible "What did we learn?" chip to the
top of the feedback return leg near TELOS so the dashed line isn't a mystery in the first
screen. (§3e, §3i)
5. **Reconcile the legend with the colours on screen in Plain.** Either key every pill/border
meaning the Plain view shows (violet/blue/coral pills; council/bridge/oath borders) or drop
the decorative pills/borders in Plain so colour means only the CI states the KEY describes.
Today the legend and the canvas disagree. (§3f, §3h)
6. **Turn floating transition text into attached chips and normalise the grammar.** Give each
label a small chip background so it reads as *on* the arrow, and pick one voice — all
"what-must-be-true" states ("Mattered to us", "Decision reached", "Human said go", "All
checks green", "It's live") reads more consistently than mixing questions and states. (§3d)
---
### Scorecard vs. lap-1 punch list
| Lap-1 item | Status |
|---|---|
| 1. Label every arrow | **Shipped** (desktop only; hover-only rationale; dies on mobile) |
| 2. plain_what per stage + demoted title | **Shipped** — clean |
| 3. Plain⇄Technical toggle, default Plain, persisted | **Shipped** — correct |
| 4. Plain node primary text, `d` on demand | **Half** — bodies plain, **titles still jargon** (§4) |
| 5. Distinguish the two gates (lock/shield) | **Weak** — "▸"+gold, no glyph |
| 6. Legend keying colours + gate types | **Partial** — legend exists but doesn't match Plain colours |
| 7. Fix stage-06 empty column | **Shipped** — authored fallback node |
| 8. Honest feedback bus + suppress proper nouns in Plain | **Half** — bus honest; proper nouns still leak via node titles + tech-subs |
</content>
</invoke>
+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.
+12
View File
@@ -0,0 +1,12 @@
// Package cadatlas embeds module-root build artifacts that live outside any
// subpackage directory. go:embed cannot reach parent directories, so the CI
// workflow — which the atlas parses to generate its CI/CD stage from the real
// pipeline — is embedded here at the module root.
package cadatlas
import _ "embed"
// CDWorkflow is the repo's own Gitea Actions workflow, embedded at build time.
//
//go:embed .gitea/workflows/cd.yml
var CDWorkflow []byte
+2
View File
@@ -3,3 +3,5 @@ module git.d-ma.be/mathias/cad-atlas
go 1.26 go 1.26
require github.com/a-h/templ v0.3.1020 require github.com/a-h/templ v0.3.1020
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -2,3 +2,7 @@ github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+57
View File
@@ -0,0 +1,57 @@
{
"substrate": [
{"n":"koala","k":"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"},
{"n":"iguana","k":"M2 Ultra · Ollama / mlx"},
{"n":"flamingo","k":"daily driver · ~/dev"},
{"n":"piguard","k":"NGINX reverse-proxy · ntfy"}
],
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
"stages": [
{"no":"STAGE 00","short":"Notice","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",
"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":"Applied AI Radar","plain_t":"Automated news scan","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":"Manual capture","plain_t":"Saved by hand","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_t":"Not built yet","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","short":"Why","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/",
"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_t":"The goal board","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","short":"Think","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",
"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":"Design · ADRs · specs","plain_t":"Decide the approach","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":"🏛️ LLM Council","plain_t":"AI review panel","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_t":"Research review panel","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","short":"Write order","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",
"trans_label":"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":"Contract enforced","plain_t":"The work-order rules","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":"Admission controller","plain_t":"Tamper-proof seal","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_t":"Definition of done","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","short":"Human go","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",
"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":"Human triggers execution","plain_t":"The go button","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_t":"Hand-off to agents","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","short":"Build","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",
"trans_label":"Change proposed","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":"Task API","plain_t":"Start a job","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":"Executor + reviewer loop","plain_t":"Build + independent review","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":"dma-cli · routing + scope","plain_t":"Router & guardrails","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_t":"Audit log","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","short":"Check","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",
"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":"Automated checks","plain_t":"Quality checks","plain":"Tests, linters, a security scan, plus a check that the change actually meets the work order — all must pass to continue.","d":"go test · vet · lint · govulncheck + var-go/oath gate.","tags":["green = proceed"]}
]},
{"no":"STAGE 07","short":"Ship","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_t":"Auto-deploy when 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","short":"Learn","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)",
"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_t":"Score & feed back","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"]}
]}
]
}
+88
View File
@@ -0,0 +1,88 @@
package atlas_test
import (
"reflect"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestBuild_OverlaysCIStageNodesFromWorkflow(t *testing.T) {
atlasJSON := []byte(`{
"substrate": [{"n":"koala","k":"gpu"}],
"stages": [
{"no":"STAGE 06","title":"PR → CI","generate":"ci-jobs","nodes":[]},
{"no":"STAGE 01","title":"TELOS","nodes":[{"t":"Intention substrate"}]}
]}`)
workflow := []byte("jobs:\n check:\n a: 1\n build:\n b: 2\n deploy:\n c: 3\n")
a, err := atlas.Build(atlasJSON, workflow)
if err != nil {
t.Fatalf("Build: %v", err)
}
// CI stage nodes generated from the workflow's jobs, in order.
var titles []string
for _, n := range a.Stages[0].Nodes {
titles = append(titles, n.Title)
}
if want := []string{"check", "build", "deploy"}; !reflect.DeepEqual(titles, want) {
t.Fatalf("CI stage nodes = %v, want %v", titles, want)
}
// Non-generated stage is untouched.
if a.Stages[1].Nodes[0].Title != "Intention substrate" {
t.Fatalf("authored stage was altered: %+v", a.Stages[1])
}
// Substrate preserved.
if len(a.Substrate) != 1 || a.Substrate[0].Name != "koala" {
t.Fatalf("substrate not preserved: %+v", a.Substrate)
}
}
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)
}
if n.PlainTitle == "" {
t.Fatalf("stage %s node %q missing plain_t", s.No, n.Title)
}
}
}
}
func TestBuild_KeepsCIPlaceholderWhenNoJobs(t *testing.T) {
atlasJSON := []byte(`{"substrate":[],"stages":[
{"no":"STAGE 06","title":"PR → CI","generate":"ci-jobs","nodes":[{"t":"Automated checks","plain":"checks run here"}]}
]}`)
// A workflow with no jobs block must NOT error and must keep the placeholder.
a, err := atlas.Build(atlasJSON, []byte("name: cd\n"))
if err != nil {
t.Fatalf("Build should tolerate a no-jobs workflow, got: %v", err)
}
if len(a.Stages[0].Nodes) != 1 || a.Stages[0].Nodes[0].Title != "Automated checks" {
t.Fatalf("placeholder not kept: %+v", a.Stages[0].Nodes)
}
}
func TestBuild_ErrorsOnBadAtlasJSON(t *testing.T) {
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")
}
}
+272
View File
@@ -0,0 +1,272 @@
package atlas
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// HostsFromNodes parses a Kubernetes `/api/v1/nodes` list response into
// substrate Host entries, so the atlas machines reflect the live cluster.
func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
var list struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
Status struct {
Capacity map[string]string `json:"capacity"`
NodeInfo struct {
Architecture string `json:"architecture"`
KubeletVersion string `json:"kubeletVersion"`
} `json:"nodeInfo"`
} `json:"status"`
} `json:"items"`
}
if err := json.Unmarshal(nodesJSON, &list); err != nil {
return nil, fmt.Errorf("parse nodes: %w", err)
}
hosts := make([]Host, 0, len(list.Items))
for _, it := range list.Items {
var parts []string
if a := it.Status.NodeInfo.Architecture; a != "" {
parts = append(parts, a)
}
if c := it.Status.Capacity["cpu"]; c != "" {
parts = append(parts, c+" cpu")
}
if gi := memGi(it.Status.Capacity["memory"]); gi != "" {
parts = append(parts, gi+"Gi")
}
if g := it.Status.Capacity["nvidia.com/gpu"]; g != "" && g != "0" {
parts = append(parts, g+"× GPU")
}
if v := k3sVersion(it.Status.NodeInfo.KubeletVersion); v != "" {
parts = append(parts, v)
}
hosts = append(hosts, Host{Name: it.Metadata.Name, Spec: strings.Join(parts, " · ")})
}
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.
type Deploy struct {
Image string
Ready int
Desired int
}
// Tag is the image tag (substring after the last ":").
func (d Deploy) Tag() string {
if i := strings.LastIndex(d.Image, ":"); i >= 0 {
return d.Image[i+1:]
}
return d.Image
}
// DeployState parses a Kubernetes Deployment object into its live state.
func DeployState(deployJSON []byte) (Deploy, error) {
var dep struct {
Spec struct {
Replicas int `json:"replicas"`
Template struct {
Spec struct {
Containers []struct {
Image string `json:"image"`
} `json:"containers"`
} `json:"spec"`
} `json:"template"`
} `json:"spec"`
Status struct {
ReadyReplicas int `json:"readyReplicas"`
} `json:"status"`
}
if err := json.Unmarshal(deployJSON, &dep); err != nil {
return Deploy{}, fmt.Errorf("parse deployment: %w", err)
}
d := Deploy{Ready: dep.Status.ReadyReplicas, Desired: dep.Spec.Replicas}
if len(dep.Spec.Template.Spec.Containers) > 0 {
d.Image = dep.Spec.Template.Spec.Containers[0].Image
}
return d, nil
}
// DeployNode renders the live deploy state as a stage node (green when the
// rollout is fully ready, amber otherwise).
func DeployNode(d Deploy) Node {
pill := "var(--amber)"
if d.Desired > 0 && d.Ready == d.Desired {
pill = "var(--green)"
}
img := d.Image
if i := strings.LastIndex(img, "/"); i >= 0 {
img = img[i+1:] // drop registry host
}
return Node{
Title: fmt.Sprintf("◆ deployed · %s · %d/%d ready", img, d.Ready, d.Desired),
Pill: pill,
Tags: []string{"live · k8s"},
}
}
// NamespaceSummary parses a Kubernetes `/api/v1/namespaces` list into a compact
// "ns: a · b · c" line for the substrate, dropping system namespaces, sorting,
// and capping the count (with "+N more" when it overflows).
func NamespaceSummary(nsJSON []byte) (string, error) {
var list struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
} `json:"items"`
}
if err := json.Unmarshal(nsJSON, &list); err != nil {
return "", fmt.Errorf("parse namespaces: %w", err)
}
var names []string
for _, it := range list.Items {
n := it.Metadata.Name
if strings.HasPrefix(n, "kube-") || n == "default" || n == "flux-system" {
continue
}
names = append(names, n)
}
sort.Strings(names)
const maxShown = 12
more := 0
if len(names) > maxShown {
more = len(names) - maxShown
names = names[:maxShown]
}
if len(names) == 0 {
return "ns: (none)", nil
}
s := "ns: " + strings.Join(names, " · ")
if more > 0 {
s += " · +" + strconv.Itoa(more) + " more"
}
return s, nil
}
// MergeSubstrate overlays live node specs onto the authored substrate: an
// authored host is replaced by the live node of the same name (fresh specs),
// authored-only machines (non-cluster: iguana/flamingo/piguard) are kept, and
// live-only nodes (new to the cluster) are appended.
func MergeSubstrate(authored, live []Host) []Host {
liveByName := make(map[string]Host, len(live))
for _, h := range live {
liveByName[h.Name] = h
}
seen := make(map[string]bool, len(authored))
out := make([]Host, 0, len(authored)+len(live))
for _, a := range authored {
if l, ok := liveByName[a.Name]; ok {
out = append(out, l)
} else {
out = append(out, a)
}
seen[a.Name] = true
}
for _, l := range live {
if !seen[l.Name] {
out = append(out, l)
}
}
return out
}
// memGi converts a Kubernetes memory quantity in Ki (e.g. "67108864Ki") to a
// rounded Gi string. Returns "" if unparseable.
func memGi(ki string) string {
n, err := strconv.ParseFloat(strings.TrimSuffix(ki, "Ki"), 64)
if err != nil {
return ""
}
return strconv.Itoa(int(math.Round(n / 1048576)))
}
// k3sVersion trims a kubeletVersion's build metadata, labelling k3s builds.
// "v1.31.4+k3s1" → "k3s v1.31.4"; "v1.31.4" → "v1.31.4".
func k3sVersion(kubelet string) string {
if kubelet == "" {
return ""
}
if i := strings.Index(kubelet, "+"); i >= 0 {
ver, suffix := kubelet[:i], kubelet[i+1:]
if strings.Contains(suffix, "k3s") {
return "k3s " + ver
}
return ver
}
return kubelet
}
+165
View File
@@ -0,0 +1,165 @@
package atlas_test
import (
"reflect"
"strings"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestHostsFromNodes_DerivesSpecFromNodeStatus(t *testing.T) {
nodes := []byte(`{"items":[
{"metadata":{"name":"koala"},
"status":{"capacity":{"cpu":"16","memory":"67108864Ki","nvidia.com/gpu":"1"},
"nodeInfo":{"architecture":"amd64","kubeletVersion":"v1.31.4+k3s1"}}},
{"metadata":{"name":"worker2"},
"status":{"capacity":{"cpu":"8","memory":"33554432Ki"},
"nodeInfo":{"architecture":"arm64","kubeletVersion":"v1.30.0+k3s1"}}}
]}`)
hosts, err := atlas.HostsFromNodes(nodes)
if err != nil {
t.Fatalf("HostsFromNodes: %v", err)
}
want := []atlas.Host{
{Name: "koala", Spec: "amd64 · 16 cpu · 64Gi · 1× GPU · k3s v1.31.4"},
{Name: "worker2", Spec: "arm64 · 8 cpu · 32Gi · k3s v1.30.0"},
}
if !reflect.DeepEqual(hosts, want) {
t.Fatalf("hosts = %+v\nwant %+v", hosts, want)
}
}
func TestMergeSubstrate_LiveOverridesAuthoredKeepsRestAppendsNew(t *testing.T) {
authored := []atlas.Host{
{Name: "koala", Spec: "RTX 5070 · authored"},
{Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"},
{Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"},
}
live := []atlas.Host{
{Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"},
{Name: "worker9", Spec: "arm64 · 4 cpu"},
}
got := atlas.MergeSubstrate(authored, live)
want := []atlas.Host{
{Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"}, // live wins
{Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"}, // authored kept
{Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"}, // authored kept
{Name: "worker9", Spec: "arm64 · 4 cpu"}, // live-only appended
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("merge = %+v\nwant %+v", got, want)
}
}
func TestMergeSubstrate_NoLiveReturnsAuthored(t *testing.T) {
authored := []atlas.Host{{Name: "koala", Spec: "authored"}}
got := atlas.MergeSubstrate(authored, nil)
if !reflect.DeepEqual(got, authored) {
t.Fatalf("merge with no live = %+v, want %+v", got, authored)
}
}
func TestNamespaceSummary_FiltersSystemSortsAndJoins(t *testing.T) {
ns := []byte(`{"items":[
{"metadata":{"name":"gitea"}},
{"metadata":{"name":"kube-system"}},
{"metadata":{"name":"ai-stack"}},
{"metadata":{"name":"default"}},
{"metadata":{"name":"flux-system"}},
{"metadata":{"name":"brain"}}
]}`)
got, err := atlas.NamespaceSummary(ns)
if err != nil {
t.Fatalf("NamespaceSummary: %v", err)
}
if want := "ns: ai-stack · brain · gitea"; got != want {
t.Fatalf("summary = %q, want %q", got, want)
}
}
func TestNamespaceSummary_CapsWithMore(t *testing.T) {
var items []string
for i := 0; i < 15; i++ {
items = append(items, `{"metadata":{"name":"app`+string(rune('a'+i))+`"}}`)
}
ns := []byte(`{"items":[` + strings.Join(items, ",") + `]}`)
got, err := atlas.NamespaceSummary(ns)
if err != nil {
t.Fatalf("NamespaceSummary: %v", err)
}
if !strings.HasSuffix(got, "· +3 more") {
t.Fatalf("expected cap suffix, got %q", got)
}
}
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) {
dep := []byte(`{"spec":{"replicas":2,"template":{"spec":{"containers":[
{"name":"cad-atlas","image":"localhost:5000/cad-atlas:3ff922a"}]}}},
"status":{"readyReplicas":1,"replicas":2}}`)
d, err := atlas.DeployState(dep)
if err != nil {
t.Fatalf("DeployState: %v", err)
}
if d.Image != "localhost:5000/cad-atlas:3ff922a" || d.Ready != 1 || d.Desired != 2 {
t.Fatalf("deploy = %+v", d)
}
if d.Tag() != "3ff922a" {
t.Fatalf("tag = %q, want 3ff922a", d.Tag())
}
}
func TestDeployNode_GreenOnlyWhenFullyReady(t *testing.T) {
ready := atlas.DeployNode(atlas.Deploy{Image: "localhost:5000/cad-atlas:abc", Ready: 1, Desired: 1})
if ready.Pill != "var(--green)" {
t.Fatalf("ready pill = %q, want green", ready.Pill)
}
if ready.Title != "◆ deployed · cad-atlas:abc · 1/1 ready" {
t.Fatalf("title = %q", ready.Title)
}
rolling := atlas.DeployNode(atlas.Deploy{Image: "x/cad-atlas:def", Ready: 0, Desired: 1})
if rolling.Pill != "var(--amber)" {
t.Fatalf("rolling pill = %q, want amber", rolling.Pill)
}
}
func TestHostsFromNodes_ErrorsOnBadJSON(t *testing.T) {
if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil {
t.Fatal("expected error on bad JSON, got nil")
}
}
+15
View File
@@ -0,0 +1,15 @@
package atlas
import _ "embed"
// DataJSON is the authored atlas content (substrate + stage narrative).
// Sourced/generated facts are overlaid onto it at Build time.
//
//go:embed atlas.json
var DataJSON []byte
// Default builds the atlas from the embedded authored data, overlaying the
// given CI workflow (for the stage marked generate:ci-jobs).
func Default(workflow []byte) (Atlas, error) {
return Build(DataJSON, workflow)
}
+79
View File
@@ -0,0 +1,79 @@
package atlas
import (
"encoding/json"
"fmt"
)
// Host is a substrate machine/entry (koala, iguana, …).
type Host struct {
Name string `json:"n"`
Spec string `json:"k"`
}
// 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 {
Title string `json:"t"`
PlainTitle string `json:"plain_t,omitempty"`
Plain string `json:"plain,omitempty"`
Desc string `json:"d,omitempty"`
Pill string `json:"pill,omitempty"`
Cls string `json:"cls,omitempty"`
Tags []string `json:"tags,omitempty"`
Risk bool `json:"risk,omitempty"`
Gate bool `json:"gate,omitempty"`
}
// Stage is one column of the pipeline. PlainTitle/Plain are the plain-language
// 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 {
No string `json:"no"`
Short string `json:"short,omitempty"`
Title string `json:"title"`
PlainTitle string `json:"plain_title,omitempty"`
Plain string `json:"plain,omitempty"`
Path string `json:"path,omitempty"`
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.
type Atlas struct {
Version string `json:"version,omitempty"`
Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"`
Timeline []RunDot `json:"timeline,omitempty"`
Stages []Stage `json:"stages"`
}
// Build unmarshals the authored atlas JSON and overlays generated facts from
// real sources, so sourced parts can't drift. Currently: any stage marked
// `"generate":"ci-jobs"` gets its Nodes replaced by the workflow's job list.
func Build(atlasJSON, workflow []byte) (Atlas, error) {
var a Atlas
if err := json.Unmarshal(atlasJSON, &a); err != nil {
return Atlas{}, fmt.Errorf("parse atlas data: %w", err)
}
for i := range a.Stages {
if a.Stages[i].Generate != "ci-jobs" {
continue
}
jobs, err := JobsFromWorkflow(workflow)
if err != nil || len(jobs) == 0 {
continue // no CI job list available — keep the authored plain placeholder
}
nodes := make([]Node, 0, len(jobs))
for _, j := range jobs {
nodes = append(nodes, Node{Title: j, Plain: "An automated check that must pass."})
}
a.Stages[i].Nodes = nodes
}
return a, nil
}
+153
View File
@@ -0,0 +1,153 @@
package atlas
import (
"encoding/json"
"fmt"
)
// Job is one job within a workflow run (a Gitea Actions "task").
type Job struct {
Name string
Status string
Conclusion string
}
// State is the effective outcome: conclusion if set, else status.
func (j Job) State() string {
if j.Conclusion != "" {
return j.Conclusion
}
return j.Status
}
// RunSummary is the newest workflow run and its per-job outcomes.
type RunSummary struct {
Number int
SHA string
Title string
Jobs []Job
}
// State aggregates the jobs: failure if any failed, running if any not yet
// succeeded, else success.
func (s RunSummary) State() string {
return aggregateState(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() {
case "failure", "cancelled", "error":
return "failure"
case "success", "skipped": // completed OK — skipped (e.g. deploy on a tag push) doesn't block
default:
pending = true // running / in_progress / waiting / queued / unknown
}
}
if pending {
return "running"
}
return "success"
}
// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries,
// newest first) and returns the newest run with its jobs in pipeline order.
func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
var resp struct {
Tasks []struct {
RunNumber int `json:"run_number"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
SHA string `json:"head_sha"`
Title string `json:"display_title"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
return RunSummary{}, fmt.Errorf("parse tasks: %w", err)
}
if len(resp.Tasks) == 0 {
return RunSummary{}, fmt.Errorf("no workflow tasks")
}
latest := resp.Tasks[0]
s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title}
for _, t := range resp.Tasks {
if t.RunNumber == latest.RunNumber {
s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion})
}
}
// Gitea lists newest (last-finished) first; reverse to pipeline order.
for i, j := 0, len(s.Jobs)-1; i < j; i, j = i+1, j-1 {
s.Jobs[i], s.Jobs[j] = s.Jobs[j], s.Jobs[i]
}
return s, nil
}
// RunNodes renders a run as stage nodes: a summary node followed by one node
// per job, each coloured by outcome.
func RunNodes(s RunSummary) []Node {
sha := s.SHA
if len(sha) > 7 {
sha = sha[:7]
}
nodes := []Node{{
Title: fmt.Sprintf("▶ run #%d · %s", s.Number, s.State()),
Desc: s.Title,
Pill: statePill(s.State()),
Tags: []string{"live · Gitea Actions", sha},
}}
for _, j := range s.Jobs {
nodes = append(nodes, Node{Title: j.Name, Pill: statePill(j.State())})
}
return nodes
}
func statePill(state string) string {
switch state {
case "success":
return "var(--green)"
case "failure", "cancelled", "error":
return "var(--coral)"
}
return "var(--amber)"
}
+106
View File
@@ -0,0 +1,106 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestLatestRunJobs_GroupsNewestRunReversedToPipelineOrder(t *testing.T) {
// Gitea returns tasks newest-first (deploy finished last → appears first).
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy via GitOps","status":"success","head_sha":"633ba153f26abc","display_title":"feat: x"},
{"run_number":28,"name":"Build & Import","status":"success"},
{"run_number":28,"name":"Lint / Test / Vet","status":"success"},
{"run_number":27,"name":"Deploy via GitOps","status":"failure"}
]}`)
s, err := atlas.LatestRunJobs(tasks)
if err != nil {
t.Fatalf("LatestRunJobs: %v", err)
}
if s.Number != 28 || s.SHA != "633ba153f26abc" || s.Title != "feat: x" {
t.Fatalf("summary = %+v", s)
}
// only run 28's jobs, reversed to pipeline order (Lint → Build → Deploy)
got := []string{}
for _, j := range s.Jobs {
got = append(got, j.Name)
}
want := []string{"Lint / Test / Vet", "Build & Import", "Deploy via GitOps"}
if len(got) != 3 || got[0] != want[0] || got[2] != want[2] {
t.Fatalf("jobs = %v, want %v", got, want)
}
if s.State() != "success" {
t.Fatalf("state = %q, want success", s.State())
}
}
func TestRunSummary_StateFailsIfAnyJobFailed(t *testing.T) {
s := atlas.RunSummary{Jobs: []atlas.Job{
{Status: "success"}, {Status: "completed", Conclusion: "failure"},
}}
if s.State() != "failure" {
t.Fatalf("state = %q, want failure", s.State())
}
}
func TestRunNodes_SummaryThenPerJobColoured(t *testing.T) {
s := atlas.RunSummary{Number: 28, SHA: "633ba153f26", Title: "feat: x", Jobs: []atlas.Job{
{Name: "Lint / Test / Vet", Status: "success"},
{Name: "Deploy via GitOps", Status: "failure"},
}}
nodes := atlas.RunNodes(s)
if len(nodes) != 3 {
t.Fatalf("want 3 nodes (summary + 2 jobs), got %d", len(nodes))
}
if nodes[0].Title != "▶ run #28 · failure" || nodes[0].Pill != "var(--coral)" {
t.Fatalf("summary node = %+v", nodes[0])
}
if nodes[1].Title != "Lint / Test / Vet" || nodes[1].Pill != "var(--green)" {
t.Fatalf("job node 1 = %+v", nodes[1])
}
if nodes[2].Pill != "var(--coral)" {
t.Fatalf("failed job pill = %q", nodes[2].Pill)
}
}
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) {
if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil {
t.Fatal("expected error on empty, got nil")
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package atlas builds the CAD Atlas data model, deriving parts from real
// sources (the repo's own CI workflow, infra manifests, the live cluster)
// so the visualization can't drift from reality.
package atlas
import (
"fmt"
"gopkg.in/yaml.v3"
)
// JobsFromWorkflow extracts the job names, in document order, from a Gitea
// Actions / GitHub Actions workflow YAML. Used to generate the CI/CD stage of
// the atlas from the pipeline that actually runs, rather than hand-authoring it.
func JobsFromWorkflow(workflow []byte) ([]string, error) {
var doc yaml.Node
if err := yaml.Unmarshal(workflow, &doc); err != nil {
return nil, fmt.Errorf("parse workflow: %w", err)
}
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
return nil, fmt.Errorf("workflow: expected a top-level mapping")
}
jobs := mappingValue(doc.Content[0], "jobs")
if jobs == nil {
return nil, fmt.Errorf("workflow: no jobs block")
}
if jobs.Kind != yaml.MappingNode {
return nil, fmt.Errorf("workflow: jobs is not a mapping")
}
// A mapping node stores keys and values as alternating Content entries;
// keys are the even indices, in document order.
names := make([]string, 0, len(jobs.Content)/2)
for i := 0; i+1 < len(jobs.Content); i += 2 {
names = append(names, jobs.Content[i].Value)
}
return names, nil
}
// mappingValue returns the value node for key in a YAML mapping node, or nil.
func mappingValue(m *yaml.Node, key string) *yaml.Node {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package atlas_test
import (
"reflect"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestJobsFromWorkflow_ReturnsJobNamesInDocumentOrder(t *testing.T) {
workflow := []byte(`
name: CD
"on":
push:
branches: [main]
jobs:
guard:
runs-on: self-hosted
check:
needs: guard
runs-on: self-hosted
build:
needs: [guard, check]
runs-on: self-hosted
deploy:
needs: [guard, build]
runs-on: self-hosted
`)
jobs, err := atlas.JobsFromWorkflow(workflow)
if err != nil {
t.Fatalf("JobsFromWorkflow: %v", err)
}
want := []string{"guard", "check", "build", "deploy"}
if !reflect.DeepEqual(jobs, want) {
t.Fatalf("jobs = %v, want %v", jobs, want)
}
}
func TestJobsFromWorkflow_ErrorsOnMalformedYAML(t *testing.T) {
if _, err := atlas.JobsFromWorkflow([]byte("jobs: [this is: not valid")); err == nil {
t.Fatal("expected error on malformed YAML, got nil")
}
}
+80
View File
@@ -0,0 +1,80 @@
// Package cluster reads live k3s state from inside a pod via the Kubernetes
// API, using the mounted ServiceAccount credentials. No client-go: the queries
// are read-only and few, so stdlib net/http keeps the dependency surface small.
package cluster
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // well-known in-cluster path, not a secret literal
caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
)
// Nodes returns the raw /api/v1/nodes JSON from the in-cluster API server.
func Nodes() ([]byte, error) { return get("/api/v1/nodes") }
// Namespaces returns the raw /api/v1/namespaces JSON from the in-cluster API server.
func Namespaces() ([]byte, error) { return get("/api/v1/namespaces") }
// Deployment returns the raw JSON for the cad-atlas Deployment (its own live state).
func Deployment() ([]byte, error) {
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) {
host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
if host == "" || port == "" {
return nil, fmt.Errorf("not in-cluster: KUBERNETES_SERVICE_HOST unset")
}
token, err := os.ReadFile(tokenPath)
if err != nil {
return nil, fmt.Errorf("read sa token: %w", err)
}
ca, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read ca: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("invalid cluster CA cert")
}
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
req, err := http.NewRequest(http.MethodGet, "https://"+host+":"+port+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+string(token))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("k8s API GET %s: %s", path, resp.Status)
}
return body, nil
}
+39
View File
@@ -0,0 +1,39 @@
// Package gitea reads the repo's own Gitea Actions run history from the
// in-cluster Gitea service (public read — no token), so the atlas can show the
// pipeline's live executions.
package gitea
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
// base is the in-cluster Gitea service by default; override with GITEA_BASE.
func base() string {
if b := os.Getenv("GITEA_BASE"); b != "" {
return b
}
return "http://gitea-http.gitea.svc.cluster.local:3000"
}
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
func Runs() ([]byte, error) {
url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50"
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea runs: %s", resp.Status)
}
return body, nil
}
+6
View File
@@ -0,0 +1,6 @@
// Package version holds the build version, injected at build time via
// -ldflags "-X .../internal/version.Value=$(git describe --tags --always)".
package version
// Value is the build version. Defaults to "dev" for local/un-injected builds.
var Value = "dev"
+114 -8
View File
@@ -1,20 +1,28 @@
package web package web
import ( import (
"context"
_ "embed" _ "embed"
"encoding/json"
"net/http" "net/http"
"sync"
"time"
cadatlas "git.d-ma.be/mathias/cad-atlas"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
"git.d-ma.be/mathias/cad-atlas/internal/cluster"
"git.d-ma.be/mathias/cad-atlas/internal/gitea"
"git.d-ma.be/mathias/cad-atlas/internal/version"
) )
// atlasHTML is the Phase-A static hero visualization. Phase C replaces this // atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load
// self-contained file with a Templ view hydrated from live CAD trace data // and renders from that data (no inline arrays), so the content is sourced.
// (assessor-loop ledger, session_log, Gitea run API, Flux events).
// //
//go:embed static/cad-atlas.html //go:embed static/cad-atlas.html
var atlasHTML []byte var atlasHTML []byte
// NewHandler serves the CAD Atlas. Root ("/") returns the static atlas; // NewHandler serves the CAD Atlas: the shell at "/", and the sourced data at
// /api/hello is a leftover template probe kept until Phase C wires real endpoints. // "/api/atlas.json" (authored data + CI stage generated from the real cd.yml +
// substrate overlaid from the live cluster when reachable).
func NewHandler() http.Handler { func NewHandler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
@@ -25,8 +33,106 @@ func NewHandler() http.Handler {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(atlasHTML) _, _ = w.Write(atlasHTML)
}) })
mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/atlas.json", func(w http.ResponseWriter, r *http.Request) {
_ = Hello("world").Render(context.Background(), w) a, err := atlas.Default(cadatlas.CDWorkflow)
if err != nil {
http.Error(w, "atlas build failed", http.StatusInternalServerError)
return
}
a.Version = version.Value
ld := liveOverlay()
if len(ld.hosts) > 0 {
a.Substrate = atlas.MergeSubstrate(a.Substrate, ld.hosts)
}
if ld.ns != "" {
a.NS = "Tailscale mesh · " + ld.ns
}
a.Timeline = ld.timeline
if ld.run != nil {
nodes := atlas.RunNodes(*ld.run)
for i := range a.Stages {
if a.Stages[i].Generate == "ci-jobs" {
a.Stages[i].Nodes = nodes
}
}
}
if ld.deploy != nil || ld.flux != nil {
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 {
if a.Stages[i].Generate == "deploy-state" {
a.Stages[i].Nodes = append(live, a.Stages[i].Nodes...)
}
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(a)
}) })
return mux return mux
} }
// liveOverlay holds cluster-sourced substrate facts (node specs + namespace line).
type liveOverlayData struct {
hosts []atlas.Host
ns string
run *atlas.RunSummary
deploy *atlas.Deploy
flux *atlas.Flux
timeline []atlas.RunDot
}
// live cache: query the cluster at most once per TTL; fall back to the authored
// substrate/ns (zero values) whenever the cluster is unreachable.
var (
liveMu sync.Mutex
liveCache liveOverlayData
liveAt time.Time
)
const liveTTL = 30 * time.Second
func liveOverlay() liveOverlayData {
liveMu.Lock()
defer liveMu.Unlock()
if !liveAt.IsZero() && time.Since(liveAt) < liveTTL {
return liveCache
}
liveAt = time.Now()
var d liveOverlayData
if raw, err := cluster.Nodes(); err == nil {
if hosts, err := atlas.HostsFromNodes(raw); err == nil {
d.hosts = hosts
}
}
if raw, err := cluster.Namespaces(); err == nil {
if s, err := atlas.NamespaceSummary(raw); err == nil {
d.ns = s
}
}
if raw, err := gitea.Runs(); err == nil {
if r, err := atlas.LatestRunJobs(raw); err == nil {
d.run = &r
}
if dots, err := atlas.RecentRuns(raw, 12); err == nil {
d.timeline = dots
}
}
if raw, err := cluster.Deployment(); err == nil {
if dep, err := atlas.DeployState(raw); err == nil {
d.deploy = &dep
}
}
if raw, err := cluster.FluxKustomization(); err == nil {
if f, err := atlas.FluxStatus(raw); err == nil {
d.flux = &f
}
}
liveCache = d
return d
}
+40
View File
@@ -1,10 +1,13 @@
package web package web
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
) )
func TestRootServesAtlas(t *testing.T) { func TestRootServesAtlas(t *testing.T) {
@@ -27,6 +30,43 @@ func TestRootServesAtlas(t *testing.T) {
} }
} }
func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) {
srv := httptest.NewServer(NewHandler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/atlas.json")
if err != nil {
t.Fatalf("GET /api/atlas.json: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var a atlas.Atlas
if err := json.NewDecoder(resp.Body).Decode(&a); err != nil {
t.Fatalf("decode atlas: %v", err)
}
if len(a.Stages) == 0 {
t.Fatal("atlas has no stages")
}
// The generate:ci-jobs stage must be populated from the real cd.yml jobs.
got := map[string]bool{}
for _, s := range a.Stages {
if s.No == "STAGE 06" {
for _, n := range s.Nodes {
got[n.Title] = true
}
}
}
for _, want := range []string{"guard", "check", "build", "deploy"} {
if !got[want] {
t.Fatalf("CI stage missing generated job %q (got %v)", want, got)
}
}
}
func TestUnknownPath404(t *testing.T) { func TestUnknownPath404(t *testing.T) {
srv := httptest.NewServer(NewHandler()) srv := httptest.NewServer(NewHandler())
defer srv.Close() defer srv.Close()
+184 -68
View File
@@ -52,6 +52,54 @@
.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}
@media(min-width:821px){
body.plain .stagehead{min-height:188px}
.stage .node:first-of-type{margin-top:24px}
}
.trans-row{display:none}
@media(max-width:820px){
.trans-row{display:block;margin:0 0 10px 6px;padding:7px 12px;border-left:3px solid var(--mono);
background:var(--panel);border-radius:0 8px 8px 0;font-size:12px;color:var(--dim);line-height:1.45}
.trans-row .tr-lbl{display:block;color:var(--mono);font-weight:600;margin-bottom:2px}
.trans-row-gate{border-left-color:var(--gold)}
.trans-row-gate .tr-lbl{color:var(--gold)}
}
/* Plain view hides the jargon-dense infra ribbon + technical footer */
body.plain .substrate{display:none}
body.plain footer{display:none}
.legend{display:flex;gap:16px;flex-wrap:wrap;align-items:center;
padding:7px 22px;border-bottom:1px solid var(--line);background:var(--bg);font-size:11px;color:var(--dim)}
.legend .lbl{letter-spacing:1.5px;margin-right:2px}
.legend .lg{display:inline-flex;align-items:center;gap:6px}
.legend .lg i{width:11px;height:11px;border-radius:3px;display:inline-block;border:1px solid rgba(0,0,0,.35)}
.legend .dash{width:18px;border-top:2px dashed var(--violet);display:inline-block}
/* overview rail — see the whole 9-stage shape + jump to any stage */
.rail{display:flex;gap:4px;flex-wrap:wrap;align-items:center;
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
.rail .lbl{color:var(--dim);letter-spacing:1.5px;margin-right:4px;font-size:11px}
.railchip{font:inherit;font-size:11px;cursor:pointer;color:var(--ink);
background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:4px 9px}
.railchip:hover{border-color:var(--blue)}
.railchip b{color:var(--dim);font-weight:600;margin-right:3px}
.railchip.gate{border-color:rgba(245,185,66,.5)} .railchip.telos{border-color:rgba(155,140,255,.5)}
.railchip.exec{border-color:rgba(255,122,92,.5)} .railchip.cd{border-color:rgba(74,208,122,.5)}
.railarr{color:var(--dim);font-size:10px}
/* Plain view: drop decorative node pills so colour is reserved for live status (keyed in the legend) */
body.plain .node .pill{display:none}
.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}
@@ -114,14 +162,25 @@
<body> <body>
<header> <header>
<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>v0.3 static snapshot (→ live in Phase C)</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="legend mono">
<span class="lbl">KEY</span>
<span class="lg"><i style="background:var(--green)"></i>passed / ready</span>
<span class="lg"><i style="background:var(--amber)"></i>running / waiting</span>
<span class="lg"><i style="background:var(--coral)"></i>failed / blocked</span>
<span class="lg"><i style="background:var(--gold)"></i>governance gate — must pass to advance</span>
<span class="lg"><span class="dash"></span> feedback loop · partly manual</span>
</div>
<div class="rail mono" id="rail"></div>
<div class="scroll"> <div class="scroll">
<div class="track" id="track"> <div class="track" id="track">
@@ -137,6 +196,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>
@@ -145,93 +205,102 @@
<footer class="mono"> <footer class="mono">
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship. CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal). Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
Data: static inventory from <code>brain</code> (2026-07-19). Phase C swaps these arrays for live reads of Data served from <code>/api/atlas.json</code> (authored <code>atlas.json</code> + CI stage from the live <code>cd.yml</code> + substrate from the live cluster nodes).
<code>assessor-loop</code> ledger · <code>session_log</code> · Gitea run API · Flux events. Phase C: <code>Gitea run API</code> live (latest run on the CI stage). Pending: <code>assessor-loop</code> ledger · <code>session_log</code> · per-job status · Flux events.
</footer> </footer>
<script> <script>
const SUBSTRATE=[ // Single source of truth: /api/atlas.json (Go app — authored atlas.json + CI
{n:"koala", k:"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"}, // stage from the live cd.yml + substrate from the live cluster). These start
{n:"iguana", k:"M2 Ultra · Ollama / mlx"}, // empty and are filled by init()'s fetch; on failure the page shows an error
{n:"flamingo",k:"daily driver · ~/dev"}, // banner rather than stale inline data.
{n:"piguard",k:"NGINX reverse-proxy · ntfy"}, let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[];
]; let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
const NS="Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council";
const STAGES=[
{no:"STAGE 00",cls:"",title:"Signals",path:"→ mathias/signals",nodes:[
{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+"]},
{t:"Manual capture",d:"claude.ai strategic drop · brain capture tool.",tags:["ad-hoc"]},
{t:"Aspirational surfaces",pill:"var(--dim)",d:"Telegram / voice / URL → inbox. NOT built.",tags:["gap"]},
]},
{no:"STAGE 01",cls:"telos",title:"TELOS",path:"wiki/telos/",nodes:[
{t:"Intention substrate",pill:"var(--violet)",d:"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.",tags:["brain_query wing=telos"]},
]},
{no:"STAGE 02",cls:"",title:"Strategic session",path:"claude.ai frontier + brain MCP",nodes:[
{t:"Design · ADRs · specs",d:"Human + frontier model. ISC acceptance criteria written here.",tags:["Define / converge"]},
{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:"Autoresearch Council",cls:"council",pill:"var(--violet)",d:"Sibling pipe — ratifies research before the gate.",tags:["proposed: → standalone svc"]},
]},
{no:"STAGE 03",cls:"",title:"Spec → Gitea issue",path:"agent-ready contract",nodes:[
{t:"Contract enforced",d:"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.",tags:["LOW / MED / HIGH"]},
{t:"Admission controller",d:"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.",tags:["chain of custody"]},
{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"]},
]},
{no:"STAGE 04",cls:"gate",title:"Human dispatch gate",path:"the only checkpoint",nodes:[
{t:"Human triggers execution",cls:"gateway",pill:"var(--amber)",d:"Ratify proposed-plan + risk tier, then dispatch.",gate:true},
{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"]},
]},
{no:"STAGE 05",cls:"exec",title:"Execute · agentsquad",path:"koala · cmd/agentsquad-serve",nodes:[
{t:"Task API",pill:"var(--coral)",d:"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).",tags:["single agentsquad.yaml"]},
{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:"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:"assessor-loop ledger",d:"Attestation ledger (audit trail) + brain session_log on completion.",tags:["audit package"]},
]},
{no:"STAGE 06",cls:"",title:"PR → CI",path:"Gitea Actions",nodes:[
{t:"PR + label",d:"Gitea PR · agent-done / agent-blocked label.",tags:[]},
{t:"Mechanical gate",d:"go test · vet · lint · govulncheck. ISC verified mechanically.",tags:["green = proceed"]},
{t:"⚖️ var-go/oath gate",cls:"oath",pill:"var(--gold)",d:"cmd/vargo-gate runs in CI → posts commit status context=var-go/oath. Authoritative FLOOR: failed Oath blocks regardless of reviewer approval (#55 anti-rubber-stamp).",tags:["branch-protection req","enforces @03 Oath"]},
]},
{no:"STAGE 07",cls:"cd",title:"CD → pod",path:"Flux GitOps → k3s",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 08",cls:"telos",title:"Loop back",path:"→ TELOS (feedback bus)",nodes:[
{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"]},
]},
];
const sub=document.getElementById('substrate');
SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
const track=document.getElementById('track'); const track=document.getElementById('track');
const stageEls=[]; let stageEls=[];
STAGES.forEach(s=>{ function renderAtlas(){
const st=document.createElement('div');st.className='stage '+s.cls; const sub=document.getElementById('substrate');
let h=`<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`; sub.querySelectorAll('.host').forEach(el=>el.remove());
s.nodes.forEach(n=>{ SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
stageEls=[];
track.querySelectorAll('.stage,.trans-row').forEach(el=>el.remove());
const plain = MODE==='plain';
STAGES.forEach((s,idx)=>{
const st=document.createElement('div');st.className='stage '+(s.cls||'');
const head = plain
? `<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||'');
const ntitle = (plain && n.plain_t) ? n.plain_t : n.t;
const nsub = (plain && n.plain_t && n.plain_t!==n.t) ? `<div class="tech-sub">${n.t}</div>` : '';
let inner=`<div class="t">${pill}${ntitle}</div>${nsub}`+(body?`<div class="d">${body}</div>`:'');
if(!plain){
if(n.tags&&n.tags.length)inner+=n.tags.map(t=>`<span class="tag mono">${t}</span>`).join(''); 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.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>`;
}); });
st.innerHTML=h;track.appendChild(st);stageEls.push(st); st.innerHTML=h;track.appendChild(st);stageEls.push(st);
}); // stacked-layout transition row (shown on mobile where the SVG spine is hidden)
if(s.trans_label){
const tr=document.createElement('div');
tr.className='trans-row'+((idx===4||idx===6)?' trans-row-gate':'');
tr.innerHTML=`<span class="tr-lbl">${(idx===4||idx===6)?'🔒 ':''}${s.trans_label}</span>${s.trans?' '+s.trans:''}`;
track.appendChild(tr);
}
});
renderRail();
}
function renderRail(){
const rail=document.getElementById('rail');
rail.innerHTML='<span class="lbl">PIPELINE</span>';
STAGES.forEach((s,i)=>{
const c=document.createElement('button');
c.className='railchip'+(s.cls?' '+s.cls:'');
c.innerHTML=`<b>${(s.no||'').replace('STAGE ','')}</b>${s.short||s.plain_title||s.title}`;
c.onclick=()=>{ if(stageEls[i]) stageEls[i].scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'}); };
rail.appendChild(c);
if(i<STAGES.length-1){const a=document.createElement('span');a.className='railarr';a.textContent='→';rail.appendChild(a);}
});
}
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);
@@ -241,7 +310,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 · partly manual today';
// 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){
@@ -264,9 +348,41 @@ 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);});
window.addEventListener('load',replay); async function init(){
build(); try{
const r=await fetch('/api/atlas.json');
if(!r.ok) throw new Error('atlas.json '+r.status);
const data=await r.json();
if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate;
if(typeof data.ns==='string') NS=data.ns;
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;
}catch(e){
console.error('atlas: failed to load /api/atlas.json —',e);
document.getElementById('track').insertAdjacentHTML('beforeend',
'<div class="stage"><div class="no mono">ERROR</div>'+
'<h2 style="color:var(--coral)">Data unavailable</h2>'+
'<div class="path mono">/api/atlas.json failed to load</div></div>');
return;
}
applyMode();
renderAtlas();
renderTimeline();
replay();
}
window.addEventListener('load',init);
</script> </script>
</body> </body>
</html> </html>