9 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
18 changed files with 901 additions and 70 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>
+28 -28
View File
@@ -7,51 +7,51 @@
],
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
"stages": [
{"no":"STAGE 00","title":"Signals","plain_title":"Notice what's happening","plain":"New ideas and developments worth reacting to are collected — mostly an automated daily/weekly scan of AI news, plus things saved by hand.","path":"→ mathias/signals",
{"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":"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":"Anything interesting spotted by hand gets saved into the same inbox.","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]},
{"t":"Aspirational surfaces","plain":"Planned but not built yet: sending ideas in by Telegram, voice, or a URL.","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]}
{"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","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/",
{"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":"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"]}
{"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","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",
{"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":"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":"For hard calls, several AI models answer independently, anonymously critique each other, and a \"chair\" model synthesises one verdict — reducing any single model's bias.","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]},
{"t":"Autoresearch Council","plain":"A parallel version of the same review that vets research findings before they're allowed through.","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]}
{"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","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",
{"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":"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":"The work order is cryptographically signed when created, so any later tampering is detectable and the eventual change can be checked against it.","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
{"t":"⚖️ var-go Oath","plain":"A machine-checkable \"definition of done\" is embedded in the work order — exactly one, or the order is rejected — later used to prove the result actually meets the spec.","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
{"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","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",
{"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":"A person confirms the plan and its risk level, then releases the work — nothing runs until they do.","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true},
{"t":"Session-Dispatch bridge","plain":"The approval flips a switch that hands the signed work order over to the agents to start execution.","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
{"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","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",
{"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":"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":"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":"A router sends each agent to the right AI backend and enforces what it is and isn't allowed to touch, with a confirmation gate as a guardrail.","cls":"bridge","pill":"var(--blue)","d":"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.","tags":["backend routing","scope guardrail"]},
{"t":"assessor-loop ledger","plain":"Every step is recorded in a tamper-evident log so the whole run can be audited afterwards.","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]}
{"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","plain_title":"Automatic quality checks","plain":"The proposed change is run through automated tests and safety checks — including a check that it actually satisfies the work order's definition of done — and only a clean pass lets it continue.","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs",
{"no":"STAGE 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":"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"]}
{"t":"Automated checks","plain_t":"Quality checks","plain":"Tests, linters, a security scan, plus a check that the change actually meets the work order — all must pass to continue.","d":"go test · vet · lint · govulncheck + var-go/oath gate.","tags":["green = proceed"]}
]},
{"no":"STAGE 07","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",
{"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":"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"]}
{"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","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)",
{"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":"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"]}
{"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"]}
]}
]
}
+3
View File
@@ -60,6 +60,9 @@ func TestDefault_HasPlainLayerAndTransitions(t *testing.T) {
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)
}
}
}
}
+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)
}
}
+18 -13
View File
@@ -14,14 +14,16 @@ type Host struct {
// 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"`
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"`
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. PlainTitle/Plain are the plain-language
@@ -31,6 +33,7 @@ type Node struct {
// Nodes are derived from a live source at Build time.
type Stage struct {
No string `json:"no"`
Short string `json:"short,omitempty"`
Title string `json:"title"`
PlainTitle string `json:"plain_title,omitempty"`
Plain string `json:"plain,omitempty"`
@@ -44,11 +47,13 @@ type Stage struct {
// 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
+37 -2
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
@@ -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":
+50
View File
@@ -2,6 +2,7 @@ package atlas_test
import (
"testing"
"time"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
@@ -104,3 +105,52 @@ func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) {
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
}
+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
}
+14
View File
@@ -55,6 +55,14 @@ func NewHandler() http.Handler {
a.Stages[i].Nodes = nodes
}
}
a.CIDurationS, a.CDDurationS = atlas.StageSeconds(*ld.run)
}
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
@@ -84,6 +92,7 @@ type liveOverlayData struct {
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
@@ -123,6 +132,11 @@ 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
+97 -13
View File
@@ -65,8 +65,18 @@
.translabel{fill:var(--mono);font-size:10px;font-family:ui-monospace,SFMono-Regular,monospace}
.translabel-gate{fill:var(--gold);font-weight:700}
.stagehead{min-height:64px}
body.plain .stagehead{min-height:188px}
.stage .node:first-of-type{margin-top:24px}
@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}
@@ -76,6 +86,19 @@
.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}
@@ -88,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}
@@ -157,6 +180,7 @@
<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">
@@ -190,7 +214,7 @@
// 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');
@@ -202,9 +226,9 @@ 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());
track.querySelectorAll('.stage,.trans-row').forEach(el=>el.remove());
const plain = MODE==='plain';
STAGES.forEach(s=>{
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>`+
@@ -214,15 +238,40 @@ function renderAtlas(){
(s.nodes||[]).forEach(n=>{
const pill=n.pill?`<span class="pill" style="background:${n.pill}"></span>`:'';
const body = plain ? (n.plain||n.d||'') : (n.d||'');
let inner=`<div class="t">${pill}${n.t}</div>`+(body?`<div class="d">${body}</div>`:'');
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);}
});
}
@@ -274,21 +323,54 @@ function build(){
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;
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)';
@@ -321,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);
+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=