Compare commits

..
4 Commits
Author SHA1 Message Date
ducoterra 999cd7d6b8 replace em dashes with hyphens in visible page copy
Build and Push Container / build-and-push (push) Successful in 18s
Brain of Reese card description and the ServWaf preview code line
no longer use em dashes. (The two em dashes on the rewritten Huntington
bullet already shipped with the past-tense change.)

LLM-Generated: true
2026-10-23 13:28:41 -04:00
ducoterra f5f6d2322a unify experience bullets to past tense and reorder project cards
All timeline bullets now open with past-tense action verbs (Huntington
gerunds become Built/Created/Led; stray 'Lead a high-performing team'
becomes 'Led'). The Local AI Stack card moves to second position,
right after Brain of Reese; the test-locked grid test is updated in
the same change.

LLM-Generated: true
2026-10-23 13:28:33 -04:00
ducoterra a03dcc9e7e add validate.sh pipeline gate and audit convention notes
.agents/validate.sh is the commit gate: full E2E suite, container
security-header check, and repo-hygiene checks must all pass before a
phase is committed. AGENTS.md now documents the audit-driven
conventions (M-1 innerHTML/terminal input, M-2 simulated curl/wget,
M-4 security-header snippet, L-2 server_tokens) and the test-locked
featured-projects grid.

LLM-Generated: true
2026-10-23 13:27:40 -04:00
ducoterra ba7f5e7d06 bump @playwright/test to 1.63.0 (user-approved re-pin)
Re-pin to the cached chromium-1243 revision; full E2E suite green on
1.63.0. Approval recorded in the PLAN.md review history.

LLM-Generated: true
2026-10-23 13:27:30 -04:00
7 changed files with 205 additions and 87 deletions
+11 -1
View File
@@ -32,7 +32,7 @@ contact details and GPG public keys.
| Build | `build.sh`: md5 content-hash cache-busting + `sed` reference rewrites → `dist/` | md5 is a cache key, not a security hash — acceptable | LOCKED (pre-existing) |
| Serving | `nginx:alpine`, `listen 8080`, plain HTTP | TLS is added by the Caddy edge in front of the container | LOCKED (pre-existing) |
| Edge | Caddy reverse proxy (Route53 DNS-validated TLS) in front of the container | HSTS/TLS are edge responsibilities | LOCKED (assumed — verify per Phase 03) |
| E2E tests | `@playwright/test` pinned **1.62.0** (matches cached chromium-1234), serves `dist/` via `python3 -m http.server 8123`, config `playwright.config.js` | Tests the deployed artifact; pinned to avoid browser re-downloads | LOCKED (2026-09-18, user-approved) |
| E2E tests | `@playwright/test` pinned **1.63.0** (matches cached chromium-1243), serves `dist/` via `python3 -m http.server 8123`, config `playwright.config.js` | Tests the deployed artifact; pinned to avoid browser re-downloads | LOCKED (2026-09-18; re-pinned 2026-09-18, user-approved — see review history) |
| Deployment | Docker image → `gitea.reeseapps.com/services/homepage` via Gitea Actions (secrets-based registry login) | Self-hosted CI | LOCKED (pre-existing) |
| Persistence | `localStorage` only (achievements, key `reese-terminal-achievements`) | No backend by design | LOCKED (pre-existing) |
@@ -110,3 +110,13 @@ Caddy edge — folded into Phase 03 verification), L-3 (drop
L-5 (label the displayed SSH key as fictitious or show fingerprint only),
L-7 (pin CI action SHAs / image signing), L-8 (build.sh verification test in
CI).
## 8. Review History
- **2026-09-18** — Security & code-quality audit; module split and the
`@playwright/test` 1.62.0 / chromium-1234 pin approved.
- **2026-09-18** — User-approved dependency update: `@playwright/test`
bumped 1.62.0 → 1.63.0; §3 lock re-pinned to 1.63.0 (matches cached
chromium-1243, re-downloaded locally). Full 44-test E2E suite green on
1.63.0. Executed as a direct change per explicit user instruction
(no phase).
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# .agents/validate.sh — validation gate for the phased-execution pipeline.
#
# A unit is only moved to .agents/phases/complete/ if this script exits 0,
# and a phase is only COMMITTED if its final pass exits 0 — so this script
# is the last line of defense for "no phase commits unless 100% passing".
# It MUST run the project's FULL test suite (every test, not a subset) plus
# its linter, so a failure anywhere in the project blocks the commit.
#
# Adapt the checks below to this project's real test suite, linter, and
# coverage floor, then commit the result.
set -uo pipefail
rc=0
if [[ -f pyproject.toml || -f pytest.ini || -f setup.py ]]; then
python3 -m pytest -q || rc=1
if command -v ruff >/dev/null 2>&1; then
ruff check . || rc=1
fi
fi
if [[ -f package.json ]]; then
npm test --silent || rc=1
fi
# --- project-specific gate: container security headers (Phase 03, audit M-4) --
# Builds the real nginx image and asserts all four hardening headers on every
# response class (/, /index.html, a hashed asset). Needs a container runtime;
# if the host has none the check is skipped loudly instead of failing silently.
if [[ -x scripts/check-headers.sh ]]; then
if command -v podman >/dev/null 2>&1; then RT=podman
elif command -v docker >/dev/null 2>&1; then RT=docker
else RT=""
fi
if [[ -n "$RT" ]]; then
./scripts/check-headers.sh || rc=1
# check-headers.sh removes its container but not its scratch image tag;
# drop it here so repeated gates do not fill container storage.
leftovers="$("$RT" images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep 'homepage-headercheck-' || true)"
if [[ -n "$leftovers" ]]; then
printf '%s\n' "$leftovers" | xargs -r "$RT" rmi -f >/dev/null 2>&1 || true
fi
else
echo "WARN: no podman/docker available — SKIPPING scripts/check-headers.sh (security-header gate not exercised)" >&2
fi
fi
# --- repo hygiene (language-agnostic; runs in every project) ------------------
# Scoped staging commits everything the phase touched, so a file that is not
# ignored gets committed — including raw log drops and key material. These
# gates make the phase fail instead. Fix by untracking, keeping the working
# copy: git rm --cached <file>
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
# 1. A tracked file that matches a .gitignore rule should never be tracked.
junk="$(git ls-files -i -c --exclude-standard 2>/dev/null || true)"
if [[ -n "$junk" ]]; then
echo "repo hygiene: TRACKED files match .gitignore rules (untrack with 'git rm --cached <file>'):" >&2
printf ' %s\n' "$junk" >&2
rc=1
fi
# 2. Personal-data / key-material file SHAPES that must never be tracked,
# even in a project with no .gitignore rule for them.
DANGEROUS='(^|/)[^/]*\.log$|(^|/)[^/]*\.jsonl$|(^|/)\.env($|\.)|\.(pem|key|crt|cer|p12|pfx|keystore|jks)$|(^|/)(id_rsa|id_ecdsa|id_ed25519)(\.pub)?$|(^|/)(credentials|service-account|client_secret)([-a-z0-9]*)\.json$'
bad="$(git ls-files | grep -E "$DANGEROUS" || true)"
if [[ -n "$bad" ]]; then
echo "repo hygiene: TRACKED files look like log drops / key material (untrack with 'git rm --cached <file>' and add a .gitignore rule):" >&2
printf ' %s\n' "$bad" >&2
rc=1
fi
# 3. Project-specific content scan for personal data inside tracked files
# (client IPs, account ids, ...). Empty by default — fill it in per
# project, and allow-list documentation ranges (RFC 5737 / 4193 / 3849,
# 127.0.0.0/8, RFC1918) so fixtures do not trip it. Format: one ERE.
PII_PATTERNS="${PII_PATTERNS:-}"
if [[ -n "$PII_PATTERNS" ]]; then
hits="$(git ls-files -z | xargs -0 grep -IHnE "$PII_PATTERNS" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
echo "personal-data gate: TRACKED files match PII_PATTERNS — redact them:" >&2
printf ' %s\n' "$hits" >&2
rc=1
fi
fi
fi
if [[ $rc -ne 0 ]]; then
echo "validation FAILED (see output above)"
fi
exit "$rc"
+39 -3
View File
@@ -16,9 +16,15 @@ Rules for any agent working in this repository.
- `./build.sh` — rebuild `dist/` (tests run against `dist/`, the
deployed artifact — never test `src/` directly).
- `npm test` — the Playwright E2E suite (pinned `@playwright/test`
1.62.0; do not bump without re-checking the cached browser revision).
- For any nginx/Dockerfile change: `./scripts/check-headers.sh`
(Phase 03+).
1.63.0; do not bump without re-checking the cached browser revision).
- `.agents/validate.sh` — the pipeline gate: full E2E suite + the container
security-header check + repo-hygiene checks; must exit 0 before a phase is
committed.
- For any `nginx.conf` / `nginx-security-headers.inc` / Dockerfile change:
`./scripts/check-headers.sh` (added by Phase 03, run automatically by
`.agents/validate.sh` when a container runtime exists). It builds the real
image, serves it on `127.0.0.1:18099` and must print
`HEADER CHECK PASSED`; don't substitute a hand-rolled `curl` for it.
## Never
- Modify `.agents/PLAN.md` except via an explicit user-approved change to the
@@ -37,6 +43,36 @@ Rules for any agent working in this repository.
- Static site: edits in `src/` → `./build.sh` → `dist/` (hash-cached names).
New root-level assets are picked up automatically by `build.sh`; nested
directories are **not** (keep JS files flat in `src/`).
- Terminal input is untrusted: never render typed input or `commandHistory`
with `innerHTML` — route it through `updateDisplay()`/`textContent`
(audit M-1, guarded by the markup-payload test in
`tests/e2e/02-terminal.spec.js`). The only `innerHTML` uses allowed in
`src/terminal.js` are static prompt literals, the `clear`/prank element
wipes, and the canned `neofetch` output (the `cmdText === 'neofetch'` branch —
every other command output goes through `textContent`). In
`terminal-vim.js`/`terminal-achievements.js` `innerHTML` is acceptable only
with static markup or `escapeHtml()`-escaped text.
- The fake terminal does zero network I/O: `curl`/`wget` render canned output
from `simulatedCurlOutput()` / `simulatedWgetOutput()` in
`src/terminal-commands.js` (pure string builders, no DOM, no network). Keep
any browser-side fetch call out of `src/` entirely (audit M-2, Phase 02).
Guarded by the curl/wget regression test in `tests/e2e/02-terminal.spec.js`,
which records every request whose URL falls outside `baseURL` from
`playwright.config.js` and fails the suite if the list is not empty.
- Security headers live in `nginx-security-headers.inc` (the Dockerfile copies
it to `/etc/nginx/snippets/security-headers.inc`) and **every** `location` in
`nginx.conf` `include`s it. nginx does not merge `add_header` across
configuration levels, so never rely on server-level inheritance and never add
inline security `add_header` lines to `nginx.conf` — put them in the snippet
(audit M-4, Phase 03). `server_tokens off;` stays in the `server` block
(audit L-2). `scripts/check-headers.sh` guards this rule: drop one `include`
from a location and the check fails for that path.
- Featured-projects content is test-locked: the `#projects` grid in
`src/index.html` (six cards in a fixed order — Brain of Reese first with its
Gitea source link, ServWaf last with no link because it is closed source) is
asserted by the `featured projects show the six updated cards in order` test
in `tests/e2e/01-page.spec.js`. Edit the cards in `src/index.html` and update
that test in the same change (Phase 05).
- Terminal modules: `terminal-commands.js` (canned data),
`terminal-achievements.js`, `terminal-vim.js`, `terminal.js` (core) — load
order is fixed in `index.html` (all `defer`); keep dependencies pointing
+12 -30
View File
@@ -8,17 +8,17 @@
"name": "homepage",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.62.0"
"@playwright/test": "1.63.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
@@ -27,44 +27,26 @@
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+1 -1
View File
@@ -9,6 +9,6 @@
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "1.62.0"
"@playwright/test": "1.63.0"
}
}
+49 -49
View File
@@ -117,14 +117,14 @@
</div>
<span class="timeline-date">Jun 2026 – Present</span>
<ul class="timeline-list">
<li>Building an enterprise agentic vulnerability remediation
toolkit and workflow to resolve millions of vulnerabilities.</li>
<li>Creating and distributing LLM harness skills that automate
business problems — including ServiceNow incident response —
<li>Built an enterprise agentic vulnerability remediation toolkit
and workflow to resolve millions of vulnerabilities.</li>
<li>Created and distributed LLM harness skills that automate
business problems - including ServiceNow incident response -
at machine speed.</li>
<li>Leading a team of 6 offshore engineers to build a developer
landing zone with agentic tools that build pipelines, plan
work, and make documentation searchable.</li>
<li>Led a team of 6 offshore engineers building a developer landing
zone with agentic tools that build pipelines, plan work, and
make documentation searchable.</li>
</ul>
</div>
</div>
@@ -137,7 +137,7 @@
</div>
<span class="timeline-date">Jan 2022 – Apr 2026</span>
<ul class="timeline-list">
<li>Lead a high-performing DevOps team building microelectronics design services and
<li>Led a high-performing DevOps team building microelectronics design services and
integrating cloud partner architectures with AWS.</li>
<li>Recruited and led an engineering team to deliver a machine learning platform in &lt;1
year, utilizing Langchain, PyTorch, and BERT to identify government contract patterns.
@@ -308,7 +308,7 @@
</div>
<div class="project-info">
<h3>Brain of Reese</h3>
<p>A chippy, honest RAG chatbot over homelab documentation — git repositories, local
<p>A chippy, honest RAG chatbot over homelab documentation - git repositories, local
directories, and uploaded archives. Hybrid search (pgvector cosine ∪ Postgres
full-text search, Reciprocal Rank Fusion) feeds the whole relevant document to a
self-hosted LLM and streams back a grounded answer; when it has no notes it
@@ -332,6 +332,45 @@
</div>
</div>
</div>
<div class="project-card fade-in" role="listitem">
<div class="project-preview">
<div class="code-block">
<div class="code-line"><span class="code-comment"># Inference engines</span></div>
<div class="code-line"><span class="code-variable">engines</span> = {</div>
<div class="code-line">&nbsp;&nbsp;<span class="code-string">"vllm"</span>: <span class="code-string">"cuda"</span>, <span class="code-comment"># high-throughput serving</span></div>
<div class="code-line">&nbsp;&nbsp;<span class="code-string">"gufo"</span>: <span class="code-string">"rocm"</span>, <span class="code-comment"># Strix Halo inference</span></div>
<div class="code-line">&nbsp;&nbsp;<span class="code-string">"llama.cpp"</span>: <span class="code-string">"gguf"</span>, <span class="code-comment"># CPU/GPU fallback</span></div>
<div class="code-line">}</div>
<div class="code-line">&nbsp;</div>
<div class="code-line"><span class="code-comment"># LiteLLM proxy for unified API</span></div>
</div>
</div>
<div class="project-info">
<h3>Local AI Stack</h3>
<p>Complete local AI infrastructure: vLLM, Gufo, and llama.cpp for inference, LiteLLM as a
unified API proxy, Bifrost for model routing, and Langfuse for observability. Supports
both CUDA and ROCm.</p>
<div class="project-meta">
<div class="project-tech">
<span>vLLM</span>
<span>Gufo</span>
<span>llama.cpp</span>
<span>LocalAI</span>
<span>LiteLLM</span>
<span>Langfuse</span>
</div>
<div class="project-links">
<a href="https://gitea.reeseapps.com/services/homelab" title="Source" target="_blank"
rel="noopener">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</a>
</div>
</div>
</div>
</div>
<div class="project-card fade-in" role="listitem">
<div class="project-preview">
<div class="code-block">
@@ -421,45 +460,6 @@
</div>
</div>
</div>
<div class="project-card fade-in" role="listitem">
<div class="project-preview">
<div class="code-block">
<div class="code-line"><span class="code-comment"># Inference engines</span></div>
<div class="code-line"><span class="code-variable">engines</span> = {</div>
<div class="code-line">&nbsp;&nbsp;<span class="code-string">"vllm"</span>: <span class="code-string">"cuda"</span>, <span class="code-comment"># high-throughput serving</span></div>
<div class="code-line">&nbsp;&nbsp;<span class="code-string">"gufo"</span>: <span class="code-string">"rocm"</span>, <span class="code-comment"># Strix Halo inference</span></div>
<div class="code-line">&nbsp;&nbsp;<span class="code-string">"llama.cpp"</span>: <span class="code-string">"gguf"</span>, <span class="code-comment"># CPU/GPU fallback</span></div>
<div class="code-line">}</div>
<div class="code-line">&nbsp;</div>
<div class="code-line"><span class="code-comment"># LiteLLM proxy for unified API</span></div>
</div>
</div>
<div class="project-info">
<h3>Local AI Stack</h3>
<p>Complete local AI infrastructure: vLLM, Gufo, and llama.cpp for inference, LiteLLM as a
unified API proxy, Bifrost for model routing, and Langfuse for observability. Supports
both CUDA and ROCm.</p>
<div class="project-meta">
<div class="project-tech">
<span>vLLM</span>
<span>Gufo</span>
<span>llama.cpp</span>
<span>LocalAI</span>
<span>LiteLLM</span>
<span>Langfuse</span>
</div>
<div class="project-links">
<a href="https://gitea.reeseapps.com/services/homelab" title="Source" target="_blank"
rel="noopener">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</a>
</div>
</div>
</div>
</div>
<div class="project-card fade-in" role="listitem">
<div class="project-preview">
<div class="code-block">
@@ -505,7 +505,7 @@
<div class="project-card fade-in" role="listitem">
<div class="project-preview">
<div class="code-block">
<div class="code-line"><span class="code-comment"># ServWaf — declarative rules</span></div>
<div class="code-line"><span class="code-comment"># ServWaf - declarative rules</span></div>
<div class="code-line"><span class="code-variable">ratelimit</span>:</div>
<div class="code-line">&nbsp;&nbsp;<span class="code-variable">per_ip</span>: <span
class="code-string">20 rps, burst 40</span></div>
+3 -3
View File
@@ -52,9 +52,9 @@ test.describe('Page structure', () => {
const titles = await cards.locator('.project-info h3').allTextContents();
expect(titles).toEqual([
'Brain of Reese',
'Local AI Stack',
'Reverse Proxy Infrastructure',
'Dynamic DNS Service',
'Local AI Stack',
'Deployment Automation',
'ServWaf',
]);
@@ -63,8 +63,8 @@ test.describe('Page structure', () => {
await expect(brain.locator('.project-tech span')).toHaveCount(4);
await expect(brain).toHaveText(/RAG chatbot/i);
await expect(brain.locator('a[href="https://gitea.reeseapps.com/Experimental/brain-of-reese"]')).toBeAttached();
// Local AI Stack: Ollama replaced by vLLM, Gufo, llama.cpp
const ai = cards.nth(3);
// Local AI Stack: second card, Ollama replaced by vLLM, Gufo, llama.cpp
const ai = cards.nth(1);
await expect(ai.locator('.project-tech span')).toHaveText(['vLLM', 'Gufo', 'llama.cpp', 'LocalAI', 'LiteLLM', 'Langfuse']);
await expect(ai).not.toContainText('Ollama');
// ServWaf is closed source — description only, no source link