14 Commits
Author SHA1 Message Date
mathias 6967d12d1d feat(atlas): weight replay pacing on CI/CD stages by real job durations (#5)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
TDD: Job.Seconds() + StageSeconds() derive real CI/CD dwell time from the
latest run's per-job created_at/updated_at (last job in pipeline order =
deploy/stage 07, everything before it = CI/stage 06).

Frontend: weightedSpineDist() redistributes the pixel-time-budget the
06/07 segments already had, splitting it by real CI:CD duration ratio
instead of raw pixel width. Falls back to the exact prior constant-speed
sweep when no run data is available (weights default to segment pixel
length) or when a job is skipped (0 duration) — no behavior change for
stages 00-05/08, which still have no live timing source (same gap as #5's
ledger item).

Verified: real duration values flow through /api/atlas.json (ci_duration_s:
18 observed against a real run), full page screenshot confirms no visual
regression.
2026-07-20 23:22:53 +02:00
mathias b2761a4747 feat(gitea): surface mathias's own open Gitea issues on stage 03 (#4)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Has been skipped
TDD: IssueNodes parses /repos/issues/search into stage nodes (title, repo
tag, clickable html_url). gitea.MyIssues() reads GITEA_TOKEN and skips
gracefully when unset, mirroring the existing liveOverlay fallback pattern.

Single-operator homelab, not per-visitor OAuth: a static read-only PAT
gates on "cleared Authentik forward-auth", not per-user token exchange —
see #4 discussion. GITEA_TOKEN provisioning in infra (ExternalSecret) is
a separate follow-up; without it the overlay is inert (no crash, just no
live nodes), so this ships safely ahead of that wiring.

Nodes with a url now render as clickable <a class="node"> instead of
<div class="node">.
2026-07-20 23:04:44 +02:00
mathias 68ae01cb75 docs(project): drop stale branch-protection caveat, closed by #8
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
2026-07-20 20:53:18 +00:00
mathias 2f5fca8513 docs(oath): mark S3 enforced now that branch protection requires var-go/oath (closes #8)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
2026-07-20 20:51:35 +00:00
mathiasandClaude Sonnet 5 805b76d7c3 feat(oath): gate cad-atlas's own real candidate, not swedsl's toy stub (#8)
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
oathcandidate/ is a separate Go module (mirrors swedsl's own
oath/testdata/selfcandidate pattern, keeping var-go's transitive deps
out of the deployed atlas binary) whose Build() parses the committed
.gitea/workflows/cd.yml and checks the "oath" job exists and invokes
cmd/vargo-gate. TDD: passes against the real file, fails closed on a
fixture missing the job.

Rewires the oath CI job to go-run vargo-gate from its real module path
(git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0, unblocked
by swedsl#35/#38) against VARGO_CANDIDATE_DIR=oathcandidate, instead of
checking out swedsl and gating its hardcoded toy fixture. Private-module
auth via a short-lived GIT_ASKPASS script (token never in argv, never
written to git config, matches act_runner's env:-block-with-secrets
gotcha).

Discovered along the way: var-go's parser needs single-line,
period-separated oath sentences with no Given/When/Then/And keyword
stripping — this repo's older oaths (incl. #1) used an unverified
multi-line keyword-prefixed style. #8's oath uses the proven format.

Still not required by branch protection pending a real-PR confirmation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:33:17 +02:00
mathiasandClaude Sonnet 5 346037c5c8 docs(oath): correct stale Executor framing after swedsl#27 closed
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 4s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
swedsl#27 (var-go strategic-fit ADR) closed 2026-07-18 and killed the
Executor/Reviewer path entirely — var-go is gate-only by design, each
consuming repo supplies its own candidate. The real blocker for
enforcement here is swedsl/oath's non-importable module path
(swedsl#35), not a nonexistent Executor. Corrects PROJECT.md,
INCEPTION-OATH.md, and the cd.yml comment to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 12:16:53 +02:00
mathiasandClaude Sonnet 5 f7a0281ca2 feat(ci): wire var-go/oath gate into CI (#1)
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / var-go/oath (push) Has been skipped
CD / Build & Import (push) Successful in 13s
CD / Deploy via GitOps (push) Has been skipped
Adds an oath job to cd.yml: on pull_request, checks out swedsl (the
vargo-gate source — its oath submodule isn't go-installable, module
path isn't a real import path) and runs cmd/vargo-gate against this
repo's linked issue, posting a var-go/oath commit status.

Deliberately NOT required by branch protection: vargo-gate's candidate
is still a hardcoded toy self-test registry (swedsl's own #9 fixture),
not a real PR-diff checker, so it fails closed against any real oath
until swedsl ships an Executor (swedsl#27). Requiring it now would
permanently block every cad-atlas PR. Disclosed in the CI config
comment, PROJECT.md, and docs/INCEPTION-OATH.md (honest-stub
discipline).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 12:10:25 +02:00
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
22 changed files with 1382 additions and 93 deletions
+10 -3
View File
@@ -54,9 +54,16 @@ assessor-loop ledger) → `06 PR → CI` (go test/vet/lint/govulncheck + **var-g
This repo is built *through* the workflow it depicts. It is `dispatch-allow`-enabled, and its
own build increments are governed by a **var-go Oath** embedded in their spec issues (see the
Stage-03 tracking issue). Bootstrapping honesty (per swedsl honest-stub discipline): the Oath is
**defined** but `cmd/vargo-gate` is **not yet wired** into this repo's CI — until it is, the Oath
is advisory here. Wiring it is a first tracked task; disclosed in code, this doc, and CI config.
Stage-03 tracking issue). `cmd/vargo-gate` is wired into `.gitea/workflows/cd.yml`'s `oath` job —
on every pull_request it fetches the linked issue's oath and gates cad-atlas's **own real
candidate** (`oathcandidate/`, #8): it parses the committed `.gitea/workflows/cd.yml` and checks
the `oath` job actually exists and invokes `cmd/vargo-gate`, then posts the `var-go/oath` commit
status. This is a real check (TDD'd: passes on the real file, fails closed on a fixture missing
the job), not swedsl's toy self-test stub — swedsl#35 (import path) and swedsl#38 (real-candidate
subprocess gating) unblocked it. **Required by branch protection on `main`** (#8, closed
2026-07-20), confirmed holding on a real PR. Direct pushes remain allowlisted for `mathias` per
this repo's TBD convention. Disclosed in the CI config comment, this doc, and
`docs/INCEPTION-OATH.md`.
## Brain references (source of truth — `brain_get <path>`)
+68
View File
@@ -53,6 +53,74 @@ jobs:
- name: Run checks
run: task check
- name: oathcandidate module — vet + test (private dep, short-lived askpass)
working-directory: oathcandidate
run: |
set -euo pipefail
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
ASKPASS=$(mktemp)
{ echo '#!/bin/sh'
echo 'case "$1" in'
echo ' *Username*) echo oauth2 ;;'
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
echo 'esac'
} > "$ASKPASS"
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
go vet ./...
go test ./...
rm -f "$ASKPASS"
oath:
name: var-go/oath
needs: guard
# cad-atlas's own real candidate (#8): oathcandidate/ parses the committed
# .gitea/workflows/cd.yml and gates it against cad-atlas#8's oath — replacing the
# earlier wiring-only proof (#1) that always gated swedsl's toy self-test fixture
# and always failed closed. cmd/vargo-gate (swedsl#35/#37/#38) now go-installs
# cleanly from its real module path and runs the candidate module in a sandboxed
# subprocess (SubprocessGate, ADR-0003) — a green status here means "the committed
# CI config satisfies its oath", not merely "the wiring ran". Still NOT required by
# branch protection (#8) until proven green on a real PR.
if: needs.guard.outputs.is_template != 'true' && github.event_name == 'pull_request'
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: oathcandidate/go.mod
cache: false
- name: Run vargo-gate (fetch linked oath -> sandboxed-gate the real candidate -> post status)
env:
VARGO_GITEA_BASEURL: ${{ github.server_url }}
VARGO_GITEA_OWNER: ${{ github.repository_owner }}
VARGO_GITEA_REPO: cad-atlas
# Oath issue resolution (swedsl#38): a "Closes #NN" reference in the PR body
# picks the linked oath issue; VARGO_GITEA_ISSUE is the fallback (PR's own
# number, correct only for a PR filed directly against its oath issue).
VARGO_PR_BODY: ${{ github.event.pull_request.body }}
VARGO_GITEA_ISSUE: ${{ github.event.pull_request.number }}
VARGO_GITEA_SHA: ${{ github.event.pull_request.head.sha }}
VARGO_CANDIDATE_DIR: oathcandidate
# Sandbox is ON by default (untrusted PR code runs in a fresh user+net
# namespace, swedsl#37); no need to set VARGO_SANDBOX here.
run: |
set -euo pipefail
export DMABE_GITEA_API_TOKEN='${{ secrets.DMABE_GITEA_API_TOKEN }}'
ASKPASS=$(mktemp)
{ echo '#!/bin/sh'
echo 'case "$1" in'
echo ' *Username*) echo oauth2 ;;'
echo ' *) echo "$DMABE_GITEA_API_TOKEN" ;;'
echo 'esac'
} > "$ASKPASS"
chmod 700 "$ASKPASS"
export GIT_ASKPASS="$ASKPASS" GIT_TERMINAL_PROMPT=0 GOPRIVATE=git.d-ma.be
go run git.d-ma.be/mathias/swedsl/oath/cmd/vargo-gate@oath/v0.28.0
rm -f "$ASKPASS"
build:
name: Build & Import
needs: [guard, check]
+13 -7
View File
@@ -3,8 +3,10 @@
The acceptance contract for standing up cad-atlas. The sprint is finalized only when this
Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath.md`.
> **Status of enforcement:** this Oath is currently **advisory** (human-verified). Machine
> enforcement via `var-go/oath` is deferred — see the honesty rule below and issue #1.
> **Status of enforcement:** `var-go/oath` gates a real candidate (`oathcandidate/`, #8 — parses
> the committed CI workflow, TDD'd pass/fail-closed) and is now **required by branch protection**
> on `main` (verified green on a real PR). Direct pushes remain allowlisted for `mathias` per this
> repo's TBD convention.
## General clauses (any inception sprint)
@@ -24,7 +26,7 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
|---|--------|--------|----------|
| S1 | Atlas served at `/`, renders all 9 stages signal→pod | ✅ | `internal/web/handler.go` + `static/cad-atlas.html` |
| S2 | Oath covered in the viz (stages 03 + 06) | ✅ | var-go Oath nodes in the atlas |
| S3 | `var-go/oath` enforces cad-atlas's own PRs | **deferred → #1** | var-go v1 candidate is a toy self-test; module not cross-repo consumable. See honesty rule. |
| S3 | `var-go/oath` enforces cad-atlas's own PRs | ✅ | `oathcandidate/` gates the real `.gitea/workflows/cd.yml` (TDD green: passes real file, fails closed on a fixture missing the job) via swedsl's sandboxed `SubprocessGate` (swedsl#35/#38). Branch protection on `main` now requires `var-go/oath`, confirmed holding on a real PR (#8). |
## Deployment
@@ -36,10 +38,14 @@ namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathia
## The honesty rule
A clause blocked by an external dependency is **descoped and tracked, never marked satisfied**
a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3's real
enforcement depends on a var-go Executor (swedsl#27) + a published `oath` module; it is tracked as
a fast-follow on **#1**, not claimed here. The `DMABE_GITEA_API_TOKEN` Actions secret is
pre-provisioned so #1 can land without a secret-write.
a self-lying Oath is a rubber stamp, the exact failure the Oath exists to prevent. S3 is now fully
enforced: real candidate wired and branch-protection-required (#8), confirmed on a real PR. The
`DMABE_GITEA_API_TOKEN` Actions secret is pre-provisioned so #1 and #8 both landed without a
secret-write.
Also surfaced by #8: this file's own "Oath (advisory form)" below predates the discovery that
var-go's parser requires single-line, period-separated sentences with no `Given`/`Then`/`And`
keyword stripping — it has never been machine-gated and would need reformatting first if it ever is.
## The Oath (advisory form)
+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.
+38 -27
View File
@@ -7,40 +7,51 @@
],
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
"stages": [
{"no":"STAGE 00","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 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","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 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","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 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","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 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","generate":"gitea-issues",
"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","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 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","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 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","title":"PR → CI","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs","nodes":[]},
{"no":"STAGE 07","cls":"cd","title":"CD → pod","path":"Flux GitOps → k3s","generate":"deploy-state","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 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 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"]}
{"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"]}
]}
]
}
+40
View File
@@ -41,6 +41,46 @@ func TestBuild_OverlaysCIStageNodesFromWorkflow(t *testing.T) {
}
}
func TestDefault_HasPlainLayerAndTransitions(t *testing.T) {
a, err := atlas.Build(atlas.DataJSON, []byte("jobs:\n guard:\n a: 1\n"))
if err != nil {
t.Fatalf("Build embedded atlas: %v", err)
}
for _, s := range a.Stages {
if s.PlainTitle == "" {
t.Fatalf("stage %s missing plain_title", s.No)
}
if s.TransLabel == "" {
t.Fatalf("stage %s missing trans_label", s.No)
}
if s.Generate == "ci-jobs" {
continue // nodes are generated live, no authored plain
}
for _, n := range s.Nodes {
if n.Plain == "" {
t.Fatalf("stage %s node %q missing plain", s.No, n.Title)
}
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")
+64
View File
@@ -53,6 +53,70 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
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
+28
View File
@@ -98,6 +98,34 @@ func TestNamespaceSummary_CapsWithMore(t *testing.T) {
}
}
func TestFluxStatus_ParsesReadyReasonAndShortRevision(t *testing.T) {
k := []byte(`{"status":{
"conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"Applied revision: main@sha1:7a51d1d13944"}],
"lastAppliedRevision":"main@sha1:7a51d1d13944c481b04fc434d9a2aca2a25632e4"}}`)
f, err := atlas.FluxStatus(k)
if err != nil {
t.Fatalf("FluxStatus: %v", err)
}
if !f.Ready || f.Reason != "ReconciliationSucceeded" || f.Revision != "main@7a51d1d" {
t.Fatalf("flux = %+v", f)
}
}
func TestFluxNode_GreenWhenReadyCoralWhenNot(t *testing.T) {
ready := atlas.FluxNode(atlas.Flux{Ready: true, Reason: "ReconciliationSucceeded", Revision: "main@7a51d1d"})
if ready.Pill != "var(--green)" {
t.Fatalf("ready pill = %q", ready.Pill)
}
if ready.Title != "⟳ Flux · reconciled · main@7a51d1d" {
t.Fatalf("title = %q", ready.Title)
}
notReady := atlas.FluxNode(atlas.Flux{Ready: false, Reason: "BuildFailed"})
if notReady.Pill != "var(--coral)" {
t.Fatalf("not-ready pill = %q", notReady.Pill)
}
}
func TestDeployState_ParsesImageAndReplicas(t *testing.T) {
dep := []byte(`{"spec":{"replicas":2,"template":{"spec":{"containers":[
{"name":"cad-atlas","image":"localhost:5000/cad-atlas:3ff922a"}]}}},
+32
View File
@@ -0,0 +1,32 @@
package atlas
import (
"encoding/json"
"fmt"
)
// IssueNodes parses a Gitea `/repos/issues/search` response (the
// authenticated user's own open issues, newest first) into one node per
// issue, linking out to the issue.
func IssueNodes(searchJSON []byte) ([]Node, error) {
var issues []struct {
Number int `json:"number"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
if err := json.Unmarshal(searchJSON, &issues); err != nil {
return nil, fmt.Errorf("parse issues: %w", err)
}
nodes := make([]Node, 0, len(issues))
for _, i := range issues {
nodes = append(nodes, Node{
Title: fmt.Sprintf("#%d %s", i.Number, i.Title),
Tags: []string{"live · Gitea", i.Repository.FullName},
URL: i.HTMLURL,
})
}
return nodes, nil
}
+41
View File
@@ -0,0 +1,41 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestIssueNodes_OneNodePerIssueWithRepoTagAndURL(t *testing.T) {
search := []byte(`[
{"number":212,"title":"segment-embedder: add smoke test","html_url":"https://git.d-ma.be/mathias/infra/issues/212","repository":{"full_name":"mathias/infra"}},
{"number":8,"title":"Write cad-atlas's own vargo-gate candidate","html_url":"https://git.d-ma.be/mathias/cad-atlas/issues/8","repository":{"full_name":"mathias/cad-atlas"}}
]`)
nodes, err := atlas.IssueNodes(search)
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if len(nodes) != 2 {
t.Fatalf("want 2 nodes, got %d", len(nodes))
}
if nodes[0].Title != "#212 segment-embedder: add smoke test" {
t.Fatalf("title = %q", nodes[0].Title)
}
if nodes[0].URL != "https://git.d-ma.be/mathias/infra/issues/212" {
t.Fatalf("url = %q", nodes[0].URL)
}
if len(nodes[0].Tags) != 2 || nodes[0].Tags[0] != "live · Gitea" || nodes[0].Tags[1] != "mathias/infra" {
t.Fatalf("tags = %v", nodes[0].Tags)
}
}
func TestIssueNodes_EmptyListReturnsEmptyNotNil(t *testing.T) {
nodes, err := atlas.IssueNodes([]byte(`[]`))
if err != nil {
t.Fatalf("IssueNodes: %v", err)
}
if nodes == nil || len(nodes) != 0 {
t.Fatalf("nodes = %+v, want empty non-nil slice", nodes)
}
}
+38 -24
View File
@@ -11,35 +11,49 @@ type Host struct {
Spec string `json:"k"`
}
// Node is a card within a stage.
// Node is a card within a stage. Plain is the jargon-free default text; Desc is
// the technical detail shown on demand.
type Node struct {
Title string `json:"t"`
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"`
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"`
URL string `json:"url,omitempty"`
}
// Stage is one column of the pipeline. When Generate is set, its Nodes are
// derived from a source at Build time rather than taken from the authored data.
// 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"`
Title string `json:"title"`
Path string `json:"path,omitempty"`
Cls string `json:"cls,omitempty"`
Generate string `json:"generate,omitempty"`
Nodes []Node `json:"nodes"`
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"`
Version string `json:"version,omitempty"`
Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"`
Timeline []RunDot `json:"timeline,omitempty"`
CIDurationS float64 `json:"ci_duration_s,omitempty"`
CDDurationS float64 `json:"cd_duration_s,omitempty"`
Stages []Stage `json:"stages"`
}
// Build unmarshals the authored atlas JSON and overlays generated facts from
@@ -55,12 +69,12 @@ func Build(atlasJSON, workflow []byte) (Atlas, error) {
continue
}
jobs, err := JobsFromWorkflow(workflow)
if err != nil {
return Atlas{}, fmt.Errorf("stage %s: %w", a.Stages[i].No, err)
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})
nodes = append(nodes, Node{Title: j, Plain: "An automated check that must pass."})
}
a.Stages[i].Nodes = nodes
}
+43 -8
View File
@@ -3,13 +3,18 @@ package atlas
import (
"encoding/json"
"fmt"
"time"
)
// Job is one job within a workflow run (a Gitea Actions "task").
// Job is one job within a workflow run (a Gitea Actions "task"). Started/
// Finished are best-effort (zero value if the job hasn't completed or the
// timestamp failed to parse).
type Job struct {
Name string
Status string
Conclusion string
Started time.Time
Finished time.Time
}
// State is the effective outcome: conclusion if set, else status.
@@ -20,6 +25,14 @@ func (j Job) State() string {
return j.Status
}
// Seconds is how long the job ran, or 0 if either timestamp is missing/invalid.
func (j Job) Seconds() float64 {
if j.Started.IsZero() || j.Finished.Before(j.Started) {
return 0
}
return j.Finished.Sub(j.Started).Seconds()
}
// RunSummary is the newest workflow run and its per-job outcomes.
type RunSummary struct {
Number int
@@ -73,20 +86,20 @@ func RecentRuns(tasksJSON []byte, n int) ([]RunDot, error) {
// aggregateState folds per-job outcomes into a run outcome.
func aggregateState(jobs []Job) string {
allSucceeded := true
pending := false
for _, j := range jobs {
switch j.State() {
case "failure", "cancelled", "error":
return "failure"
case "success":
case "success", "skipped": // completed OK — skipped (e.g. deploy on a tag push) doesn't block
default:
allSucceeded = false
pending = true // running / in_progress / waiting / queued / unknown
}
}
if allSucceeded {
return "success"
if pending {
return "running"
}
return "running"
return "success"
}
// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries,
@@ -100,6 +113,8 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
Conclusion string `json:"conclusion"`
SHA string `json:"head_sha"`
Title string `json:"display_title"`
Created string `json:"created_at"`
Updated string `json:"updated_at"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
@@ -113,7 +128,12 @@ func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
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})
started, _ := time.Parse(time.RFC3339, t.Created)
finished, _ := time.Parse(time.RFC3339, t.Updated)
s.Jobs = append(s.Jobs, Job{
Name: t.Name, Status: t.Status, Conclusion: t.Conclusion,
Started: started, Finished: finished,
})
}
}
// Gitea lists newest (last-finished) first; reverse to pipeline order.
@@ -142,6 +162,21 @@ func RunNodes(s RunSummary) []Node {
return nodes
}
// StageSeconds splits a run's real job durations across the two live-timed
// stages: the last job in pipeline order is the deploy (stage 07), everything
// before it is CI (stage 06). Returns 0, 0 if there are no jobs.
func StageSeconds(s RunSummary) (ci, cd float64) {
if len(s.Jobs) == 0 {
return 0, 0
}
last := len(s.Jobs) - 1
for _, j := range s.Jobs[:last] {
ci += j.Seconds()
}
cd = s.Jobs[last].Seconds()
return ci, cd
}
func statePill(state string) string {
switch state {
case "success":
+60
View File
@@ -2,6 +2,7 @@ package atlas_test
import (
"testing"
"time"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
@@ -89,8 +90,67 @@ func TestRecentRuns_GroupsRunsNewestFirstWithAggregateState(t *testing.T) {
}
}
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")
}
}
func TestLatestRunJobs_ParsesPerJobTimestampsIntoSeconds(t *testing.T) {
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy via GitOps","status":"success","created_at":"2026-07-20T21:05:44Z","updated_at":"2026-07-20T21:05:50Z"},
{"run_number":28,"name":"Lint / Test / Vet","status":"success","created_at":"2026-07-20T21:05:39Z","updated_at":"2026-07-20T21:05:44Z"}
]}`)
s, err := atlas.LatestRunJobs(tasks)
if err != nil {
t.Fatalf("LatestRunJobs: %v", err)
}
// pipeline order: Lint first, Deploy last
if got := s.Jobs[0].Seconds(); got != 5 {
t.Fatalf("Lint job seconds = %v, want 5", got)
}
if got := s.Jobs[1].Seconds(); got != 6 {
t.Fatalf("Deploy job seconds = %v, want 6", got)
}
}
func TestStageSeconds_LastJobIsDeploySumOfRestIsCI(t *testing.T) {
s := atlas.RunSummary{Jobs: []atlas.Job{
{Name: "Lint", Started: mustParse("2026-07-20T21:00:00Z"), Finished: mustParse("2026-07-20T21:00:10Z")}, // 10s
{Name: "Build", Started: mustParse("2026-07-20T21:00:10Z"), Finished: mustParse("2026-07-20T21:00:25Z")}, // 15s
{Name: "Deploy", Started: mustParse("2026-07-20T21:00:25Z"), Finished: mustParse("2026-07-20T21:00:33Z")}, // 8s
}}
ci, cd := atlas.StageSeconds(s)
if ci != 25 {
t.Fatalf("ci = %v, want 25", ci)
}
if cd != 8 {
t.Fatalf("cd = %v, want 8", cd)
}
}
func TestStageSeconds_NoJobsReturnsZero(t *testing.T) {
ci, cd := atlas.StageSeconds(atlas.RunSummary{})
if ci != 0 || cd != 0 {
t.Fatalf("ci=%v cd=%v, want 0,0", ci, cd)
}
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
+6
View File
@@ -29,6 +29,12 @@ 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 == "" {
+33 -4
View File
@@ -4,6 +4,7 @@
package gitea
import (
"context"
"fmt"
"io"
"net/http"
@@ -21,9 +22,37 @@ func base() string {
// 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
return get(base()+"/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50", "")
}
// MyIssues returns the raw /repos/issues/search JSON for the token owner's
// own open issues across every repo they can see. Requires GITEA_TOKEN — a
// read-only PAT for the mathias account (this is a single-operator homelab,
// not per-visitor OAuth: anyone who clears Authentik forward-auth sees
// Mathias's own data). Returns an error if GITEA_TOKEN is unset, so callers
// can skip the overlay gracefully.
func MyIssues() ([]byte, error) {
token := os.Getenv("GITEA_TOKEN")
if token == "" {
return nil, fmt.Errorf("GITEA_TOKEN not set")
}
url := base() + "/api/v1/repos/issues/search?state=open&created=true&type=issues&limit=8"
return get(url, token)
}
// get performs a short-lived GET, optionally with a bearer token, and returns
// the response body.
func get(url, token string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
@@ -33,7 +62,7 @@ func Runs() ([]byte, error) {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea runs: %s", resp.Status)
return nil, fmt.Errorf("gitea: %s", resp.Status)
}
return body, nil
}
+29 -3
View File
@@ -55,12 +55,26 @@ func NewHandler() http.Handler {
a.Stages[i].Nodes = nodes
}
}
a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run)
}
if ld.deploy != nil {
node := atlas.DeployNode(*ld.deploy)
if ld.issues != nil {
for i := range a.Stages {
if a.Stages[i].Generate == "gitea-issues" {
a.Stages[i].Nodes = append(ld.issues, a.Stages[i].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([]atlas.Node{node}, a.Stages[i].Nodes...)
a.Stages[i].Nodes = append(live, a.Stages[i].Nodes...)
}
}
}
@@ -76,7 +90,9 @@ type liveOverlayData struct {
ns string
run *atlas.RunSummary
deploy *atlas.Deploy
flux *atlas.Flux
timeline []atlas.RunDot
issues []atlas.Node
}
// live cache: query the cluster at most once per TTL; fall back to the authored
@@ -116,11 +132,21 @@ func liveOverlay() liveOverlayData {
d.timeline = dots
}
}
if raw, err := gitea.MyIssues(); err == nil {
if nodes, err := atlas.IssueNodes(raw); err == nil {
d.issues = nodes
}
}
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
}
+167 -17
View File
@@ -59,6 +59,47 @@
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}
.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}
@@ -70,8 +111,8 @@
.stage .no{color:var(--dim);font-size:11px;letter-spacing:2px}
.stage h2{font-size:17px;margin:6px 0 2px}
.stage .path{color:var(--mono);font-size:11.5px;margin-bottom:6px;min-height:16px}
.node{border:1px solid var(--line);border-radius:11px;background:var(--panel);
padding:12px 13px;margin-top:12px;position:relative;
.node{display:block;border:1px solid var(--line);border-radius:11px;background:var(--panel);
padding:12px 13px;margin-top:12px;position:relative;color:inherit;text-decoration:none;
transition:border-color .25s,box-shadow .25s,transform .25s}
.node .t{font-weight:600;margin-bottom:3px;display:flex;align-items:center;gap:7px}
.node .d{color:var(--dim);font-size:12px}
@@ -123,6 +164,7 @@
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
<span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span>
<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="slowmo">Slow-mo · <span id="slowState">off</span></button>
</div>
@@ -130,6 +172,15 @@
<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="track" id="track">
@@ -145,6 +196,7 @@
stroke-dasharray="5 5" opacity=".7"></path>
<text id="loopLbl" fill="#9b8cff" font-size="11"
font-family="ui-monospace,monospace" opacity=".85"></text>
<g id="translabels"></g>
</svg>
<div class="pulse" id="pulse"></div>
</div>
@@ -162,7 +214,8 @@
// stage from the live cd.yml + substrate from the live cluster). These start
// empty and are filled by init()'s fetch; on failure the page shows an error
// banner rather than stale inline data.
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[];
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[], CI_DUR=0, CD_DUR=0;
let MODE = localStorage.getItem('atlas-mode') || 'plain'; // 'plain' | 'technical'
const track=document.getElementById('track');
let stageEls=[];
@@ -173,19 +226,52 @@ function renderAtlas(){
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').forEach(el=>el.remove());
STAGES.forEach(s=>{
const st=document.createElement('div');st.className='stage '+s.cls;
let h=`<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
s.nodes.forEach(n=>{
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>`:'';
let inner=`<div class="t">${pill}${n.t}</div><div class="d">${n.d}</div>`;
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>`;
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.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>`;
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
const tag = n.url ? 'a' : 'div';
const link = n.url ? ` href="${n.url}" target="_blank" rel="noopener"` : '';
h+=`<${tag} class="node ${n.cls||''}"${link}>${inner}</${tag}>`;
});
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);}
});
}
@@ -207,13 +293,16 @@ function renderTimeline(){
const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'),
loopPath=document.getElementById('loopPath'), loopLbl=document.getElementById('loopLbl'),
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;
function build(){
mobile=window.matchMedia('(max-width:820px)').matches;
if(mobile)return;
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));
loopY=maxBottom+40;
spine.setAttribute('width',track.scrollWidth);
@@ -223,17 +312,65 @@ function build(){
const lastX=cs[cs.length-1], telosX=cs[1];
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.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();
}
// weightedSpineDist maps f (0..1 time-progress across the spine) to an arc-length
// distance. Default weight per stage-to-stage segment is its own pixel length —
// reduces to the old constant-pixel-speed sweep. When a real run's CI/CD durations
// are known, the pixel-time-budget already held by the 06(CI)/07(CD) segments is
// re-split by their real relative duration instead of by raw pixel width — so the
// reel visits stage 06 vs 07 at speeds proportional to how long they actually took.
function weightedSpineDist(f){
const n=cs.length-1;
if(n<=0)return 0;
const segLens=[];for(let i=0;i<n;i++)segLens.push(cs[i+1]-cs[i]);
const weights=segLens.slice();
if(CI_DUR>0&&CD_DUR>0&&n>=7){
const budget=weights[5]+weights[6], tot=CI_DUR+CD_DUR;
weights[5]=budget*CI_DUR/tot; weights[6]=budget*CD_DUR/tot;
}
const totalW=weights.reduce((a,b)=>a+b,0);
const target=f*totalW;
let acc=0;
for(let i=0;i<n;i++){
if(target<=acc+weights[i]||i===n-1){
const local=weights[i]>0?(target-acc)/weights[i]:0;
const segStart=cs[i]-cs[0];
return Math.min(spineLen,Math.max(0,segStart+segLens[i]*Math.min(1,Math.max(0,local))));
}
acc+=weights[i];
}
return spineLen;
}
function run(ts){
if(mobile)return;
if(!t0)t0=ts;
const dur=slow?16000:7000;
const p=Math.min((ts-t0)/dur,1);
const total=spineLen+loopLen, dist=total*p;
let pt,onLoop=dist>spineLen;
pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
const spineFrac=spineLen/(spineLen+loopLen);
let dist,onLoop;
if(p<spineFrac){
dist=weightedSpineDist(p/spineFrac); onLoop=false;
}else{
dist=spineLen+loopLen*((p-spineFrac)/(1-spineFrac)); onLoop=true;
}
let pt=onLoop?loopPath.getPointAtLength(dist-spineLen):spinePath.getPointAtLength(dist);
pulse.style.left=pt.x+'px';pulse.style.top=pt.y+'px';
pulse.style.background=onLoop?'var(--violet)':'var(--amber)';
pulse.style.boxShadow=onLoop?'0 0 15px 4px rgba(155,140,255,.75)':'0 0 15px 4px rgba(245,185,66,.75)';
@@ -246,6 +383,16 @@ function replay(){cancelAnimationFrame(raf);t0=null;build();if(!mobile)raf=reque
document.getElementById('replay').onclick=replay;
document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow);
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);});
async function init(){
try{
@@ -256,6 +403,8 @@ async function init(){
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(typeof data.ci_duration_s==='number') CI_DUR=data.ci_duration_s;
if(typeof data.cd_duration_s==='number') CD_DUR=data.cd_duration_s;
if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){
console.error('atlas: failed to load /api/atlas.json —',e);
@@ -265,6 +414,7 @@ async function init(){
'<div class="path mono">/api/atlas.json failed to load</div></div>');
return;
}
applyMode();
renderAtlas();
renderTimeline();
replay();
+108
View File
@@ -0,0 +1,108 @@
// Package oathcandidate supplies cad-atlas's own real var-go candidate (cad-atlas#8):
// steps that gate its own CI-workflow oath by actually parsing the committed
// .gitea/workflows/cd.yml, not a stub that hardcodes an unrelated toy vocabulary.
// var-go injects and owns the gate across the subprocess boundary (SubprocessGate,
// ADR-0003), so this package supplies only the prose->behaviour binding and never a
// verdict — it cannot self-certify.
package oathcandidate
import (
"os"
"path/filepath"
"strings"
oath "git.d-ma.be/mathias/swedsl/oath"
"gopkg.in/yaml.v3"
)
// workflowState is the candidate's domain: the job names and concatenated step-run
// scripts parsed out of one Gitea Actions workflow file.
type workflowState struct {
jobNames map[string]bool
jobRuns map[string]string // job name -> every step's `run:` script, concatenated
}
type workflowFile struct {
Jobs map[string]struct {
Steps []struct {
Run string `yaml:"run"`
} `yaml:"steps"`
} `yaml:"jobs"`
}
// Build returns cad-atlas's candidate registry. cmd/vargo-gate runs the generated
// harness with cwd = this module's own directory (SubprocessGate's
// cmd.Dir = candidateModuleDir contract) — one level under the cad-atlas repo root
// in cad-atlas's real layout — so a workflow path in the oath text like
// ".gitea/workflows/cd.yml" is read relative to "..".
func Build() *oath.Registry[workflowState] {
reg := oath.NewRegistry[workflowState]()
if err := reg.Stimulus(`the CI workflow file {string} is parsed`,
func(_ workflowState, path string) workflowState {
return parseWorkflow(path)
}); err != nil {
panic(err)
}
if err := reg.Sensor(`it defines a job named {string}`,
func(s workflowState, name string) string {
if s.jobNames[name] {
return name
}
return "<no such job>"
}); err != nil {
panic(err)
}
// Checks what the workflow file can actually attest to: the job's run script
// invokes the gate binary. The "var-go/oath" commit-status context string
// itself lives in vargo-gate's Go code, not the YAML — not something this
// file-level check can see, so it isn't what's asserted here.
if err := reg.Sensor(`the job named {string} invokes {string}`,
func(s workflowState, job, cmd string) (string, string) {
run, ok := s.jobRuns[job]
foundJob := "<no such job>"
if ok {
foundJob = job
}
foundCmd := cmd
if !ok || !strings.Contains(run, cmd) {
foundCmd = "<not invoked>"
}
return foundJob, foundCmd
}); err != nil {
panic(err)
}
return reg
}
// parseWorkflow reads and parses a Gitea Actions workflow file relative to the
// repo root (see Build's doc comment for the cwd contract). A read or parse
// failure returns an empty state — every sensor then observes "not found",
// which fails the gate closed rather than silently skipping the check.
func parseWorkflow(repoRelativePath string) workflowState {
state := workflowState{jobNames: map[string]bool{}, jobRuns: map[string]string{}}
data, err := os.ReadFile(filepath.Join("..", repoRelativePath))
if err != nil {
return state
}
var wf workflowFile
if err := yaml.Unmarshal(data, &wf); err != nil {
return state
}
for name, job := range wf.Jobs {
state.jobNames[name] = true
var runs strings.Builder
for _, step := range job.Steps {
runs.WriteString(step.Run)
runs.WriteString("\n")
}
state.jobRuns[name] = runs.String()
}
return state
}
+79
View File
@@ -0,0 +1,79 @@
package oathcandidate
import (
"os"
"path/filepath"
"testing"
oath "git.d-ma.be/mathias/swedsl/oath"
)
// realOath is cad-atlas#8's actual oath text — the same var block committed to
// that issue. Gating it against the REAL checked-out .gitea/workflows/cd.yml
// proves the candidate reads real CI config, not a fixture standing in for it.
//
// Format note (discovered writing this test): var-go's parser requires a
// SINGLE-LINE paragraph — sentences are split by "." within that line, not by
// newline — and does NOT strip Given/When/Then/And keywords before matching a
// step. cad-atlas's older oaths (e.g. issue #1) use a multi-line, keyword-prefixed
// style that was never actually exercised against this parser (every prior gate
// run errored before reaching real sentence matching). Plain declarative
// sentences, period-separated, one line — see swedsl's own gate_test.go fixtures.
const realOath = "```var\n" +
`the CI workflow file ".gitea/workflows/cd.yml" is parsed. it defines a job named "oath". the job named "oath" invokes "cmd/vargo-gate".` +
"\n```\n"
// TestBuild_GatesRealWorkflow is named before Build existed (TDD): it fails to
// compile until Build() and the workflow-parsing steps exist, and fails to pass
// until they parse the REAL committed cd.yml correctly — this is the file that
// must go from red to green, not a mock.
func TestBuild_GatesRealWorkflow(t *testing.T) {
// go test's cwd is already this package's dir (oathcandidate/), matching
// SubprocessGate's cmd.Dir = candidateModuleDir contract exactly — no chdir
// needed to reproduce it here.
verdict, err := oath.Gate([]byte(realOath), Build())
if err != nil {
t.Fatalf("Gate returned error: %v", err)
}
if !verdict.Pass {
if verdict.Failure != nil {
t.Fatalf("Gate did not pass: failure=%+v", *verdict.Failure)
}
t.Fatalf("Gate did not pass against the real committed cd.yml: %+v", verdict)
}
}
// TestBuild_FailsClosedOnMissingJob proves the candidate is a REAL check, not a
// rubber stamp: gating a workflow file that has no "oath" job must fail.
func TestBuild_FailsClosedOnMissingJob(t *testing.T) {
dir := t.TempDir()
workflowsDir := filepath.Join(dir, ".gitea", "workflows")
if err := os.MkdirAll(workflowsDir, 0o755); err != nil {
t.Fatal(err)
}
noOathJob := "jobs:\n check:\n steps:\n - run: go test ./...\n"
if err := os.WriteFile(filepath.Join(workflowsDir, "cd.yml"), []byte(noOathJob), 0o644); err != nil {
t.Fatal(err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
// SubprocessGate always runs the candidate with cmd.Dir = candidateModuleDir,
// one level under the repo root (cad-atlas's real layout) — reproduce that by
// chdir-ing into a sibling "candidate/" dir under the fixture root.
candDir := filepath.Join(dir, "candidate")
if err := os.MkdirAll(candDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(candDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(cwd) })
verdict, err := oath.Gate([]byte(realOath), Build())
if err == nil && verdict.Pass {
t.Fatalf("expected the gate to fail closed on a workflow with no oath job, got Pass=true")
}
}
+18
View File
@@ -0,0 +1,18 @@
// Package oathcandidate is cad-atlas's committed real candidate: the STEPS that
// gate its own CI-workflow oath (cad-atlas#8). Deliberately a separate module (not
// part of the main cad-atlas module) so var-go's transitive deps (cucumber-expressions,
// goldmark) never link into the deployed atlas binary mirrors swedsl's own
// oath/testdata/selfcandidate pattern.
module oathcandidate
go 1.26.4
require (
git.d-ma.be/mathias/swedsl/oath v0.28.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
)
+16
View File
@@ -0,0 +1,16 @@
git.d-ma.be/mathias/swedsl/oath v0.28.0 h1:q4WXlGtMlDymhmuw9Pdc025OTLs1wl8THsrz/raeMxs=
git.d-ma.be/mathias/swedsl/oath v0.28.0/go.mod h1:kEOX7Wubf3g/HTKzuoHD4fm6zNSl55cSV/qgy+ezMoI=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0 h1:zvZFnbmtQxwHq6ru5gHxpfBloLq9wmjoKbdwOzt/XNA=
github.com/cucumber/cucumber-expressions/go/v18 v18.1.0/go.mod h1:+Qe2kvmilsdGRFJ+zlkjXp84rPEf6O/idcoOsvnIORY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
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=