- app > Harbor App setup gate shows heading 'Almost done' with status refresh-required when harbor doctor --check fails (Docker daemon down or not yet in group), with error text distinguishing the two sub-cases; there is no separate 'Environment issue' heading @spec @setup-gate-docker-down-copy @implemented
- install > webui first boot > compose.webui.yml healthcheck grants a 15m start_period so first-boot model downloads keep the container in 'starting' (not 'unhealthy') and 'up -d --wait' does not fail mid-download @spec @webui-first-boot @implemented
- install > webui first boot > run_up prints a first-start notice about embedding/audio model downloads when webui is being started and services/webui/cache/embedding does not exist @spec @webui-first-boot @implemented
- install > webui first boot > run_open waits (up to 900s) for a container in health status 'starting' to become healthy before opening the browser, warning if it turns unhealthy @spec @webui-first-boot @implemented
- label: services > webui x searxng > config.searxng.json seeds Open WebUI's current web.search.* config keys (enable, engine searxng, searxng_query_url from SEARXNG_QUERY_URL env) instead of the legacy rag.web.search.* prefix that Open WebUI's rename_prefix migration discards when web.search.* rows already exist
  command: python3 -c "import json; c=json.load(open('services/webui/configs/config.searxng.json')); w=c['web']['search']; assert w['enable'] is True and w['engine']=='searxng' and w['searxng_query_url']=='$'+'{SEARXNG_QUERY_URL}' and 'web' not in c.get('rag',{}), c" && echo ok
  tags: [spec, core-integration, implemented]
- label: services > webui x searxng x ollama > config.x.searxng.ollama.json sets rag.ollama.base_url to HARBOR_OLLAMA_INTERNAL_URL (not Open WebUI's host.docker.internal default) and uses the auto-pulled nomic-embed-text:latest embedding model
  command: python3 -c "import json; r=json.load(open('services/webui/configs/config.x.searxng.ollama.json'))['rag']; assert r['ollama']['base_url']=='$'+'{HARBOR_OLLAMA_INTERNAL_URL}' and r['embedding_model']=='nomic-embed-text:latest', r" && echo ok
  tags: [spec, core-integration, implemented]
- label: vllm default CUDA image is vllm/vllm-openai pinned to v0.26.0 (entrypoint 'vllm serve'; the '--model' flag command Harbor passes remains valid)
  command: grep -q 'HARBOR_VLLM_VERSION="v0.26.0"' profiles/default.env
  tags: [vllm, rocm-followup, implemented]
- label: vllm ROCm overlay services/compose.x.vllm.rocm.yml mounts /dev/kfd+/dev/dri and rebuilds on a configurable ROCm base image via HARBOR_VLLM_ROCM_IMAGE/HARBOR_VLLM_ROCM_VERSION build args (default rocm/vllm)
  command: grep -q 'HARBOR_VLLM_ROCM_IMAGE' services/compose.x.vllm.rocm.yml profiles/default.env && grep -q /dev/kfd services/compose.x.vllm.rocm.yml
  tags: [vllm, rocm, rocm-followup, implemented]
- label: vllm ROCm overlay keeps an image-agnostic entrypoint override (python3 -m vllm.entrypoints.openai.api_server) so non-vllm-openai ROCm bases still start the OpenAI server
  command: grep -q 'vllm.entrypoints.openai.api_server' services/compose.x.vllm.rocm.yml
  tags: [vllm, rocm, rocm-followup, implemented]
- sim > compose.sim.yml passes INTERNAL_API_SECRET (HARBOR_SIM_INTERNAL_API_SECRET) to the sim-realtime container; realtime's env schema requires it and crash-loops without it @sim @notable-sweep @spec @implemented
- label: deerflow > build contexts are configurable via HARBOR_DEERFLOW_GIT_REF, pinned to the upstream v2.0.0 tag; gateway builds from backend/Dockerfile and frontend from frontend/Dockerfile (target prod) at the repo root context
  command: grep -q "deer-flow.git#v2.0.0" profiles/default.env && grep -q "dockerfile: backend/Dockerfile" services/compose.deerflow.yml && grep -q "dockerfile: frontend/Dockerfile" services/compose.deerflow.yml
  tags: [deerflow, notable-sweep, spec, implemented]
- cli > get_service_port prefers the configured <service>.host_port when it is among the container's published ports, so multi-port services (browseruse UI vs VNC) resolve harbor url to the main UI port @cli @notable-sweep @spec @implemented
- label: photoprism > HARBOR_PHOTOPRISM_STORAGE defaults to ./services/photoprism/storage (was ./photoprism/storage, which created a stray root-owned photoprism/ dir at the repo root)
  command: grep -q 'HARBOR_PHOTOPRISM_STORAGE="./services/photoprism/storage"' profiles/default.env
  tags: [photoprism, notable-sweep, spec, implemented]
- label: services > mcpo x mcp servers > uvx-launched stdio servers (mcp-server-time, mcp-server-fetch) pass --with 'mcp<2' because they import McpError, removed in mcp 2.0 - same pin as mcpo itself
  command: test $(grep -l 'mcp<2' services/mcpo/configs/mcpo.mcp-server-time.json services/mcpo/configs/mcpo.mcp-server-fetch.json | wc -l) -eq 2
  tags: [spec, implemented]
- label: services > omnichain > services/omnichain/data is host-owned via a tracked .gitkeep and ignored via an anchored /services/omnichain/data/ gitignore entry (old unanchored omnichain/data/ never matched)
  command: git check-ignore -q services/omnichain/data/sample && grep -q '^/services/omnichain/data/\*' .gitignore && [ -f services/omnichain/data/.gitkeep ] && [ "$(stat -c %u services/omnichain/data)" = "$(id -u)" ]
  tags: [svc-testing, implemented]
- label: nexa > the nexa entrypoint setpriv-drops to the host user (TARGET_UID/TARGET_GID from compose) and chowns the /root/.cache/nexa.ai bind mount so model downloads in HARBOR_NEXA_CACHE stay host-owned
  command: grep -q setpriv services/nexa/entrypoint.sh && grep -q TARGET_UID services/compose.nexa.yml
  tags: [services-it, nexa, nexa-ownership, implemented]
- label: s2s > compose.x.s2s.llamacpp.yml sets HARBOR_S2S_MODEL to HARBOR_S2S_LLAMACPP_MODEL (default LiquidAI/LFM2.5-8B-A1B-GGUF:Q8_0 in default.env) so the responses-api warmup against the llamacpp router carries a model name instead of failing 400 'model name is missing'
  command: grep -q 'HARBOR_S2S_MODEL=${HARBOR_S2S_LLAMACPP_MODEL}' services/compose.x.s2s.llamacpp.yml && grep -q 'HARBOR_S2S_LLAMACPP_MODEL=' profiles/default.env
  tags: [services-it, s2s, implemented]
# project

## domain
- a Harbor is a containerized LLM toolkit: Bash CLI, Docker Compose service orchestration, and optional Tauri desktop app
- a Service is a named Harbor handle with a compose file under services/ and metadata in the app catalog
- a Backend is a Service that exposes an OpenAI-compatible inference API for other services and host tools
- a Satellite is a Service that depends on a Backend or external keys and is wired through cross-compose integrations
- a ComposeIntegration is a compose.x.<consumer>.<provider>.yml overlay applied when both handles are in the active set
- a Launch routes harbor launch to either a host coding-tool adapter or a containerized service CLI
- a Host tool is an installed agent CLI on the host PATH that Harbor configures with backend URL, API key, and model
- a Boost is Harbor's LLM proxy that applies modules and workflows to chat completions and compat API surfaces
- a Workflow is a named ordered list of Boost module steps advertised as workflow-prefixed model IDs
- a Module is a Boost Python plugin with an ID_PREFIX that transforms chat via async apply(chat, llm)
- Harbor merges compose.yml, per-service compose files, capability overlays, and cross-compose files into __harbor.yml
- a Profile is a named snapshot of workspace .env configuration under the Harbor home directory
- Satellite uses Backend through ComposeIntegration depends_on, env vars, and mounted config templates

## distribution
- label: the npm package name is @avcodes/harbor
  command: jq -e -r '.name' package.json | grep -q '@avcodes/harbor'
- label: the PyPI package name is llm-harbor
  command: grep -q 'name = "llm-harbor"' pyproject.toml
- label: version is synchronized across package.json, pyproject.toml, and app/package.json
  command: V=$(jq -r .version package.json) && grep -q "version = \"$V\"" pyproject.toml && jq -e --arg v "$V" ".version == \$v" app/package.json >/dev/null
- label: install.sh and requirements.sh handle platform-specific installation and dependency detection
  command: test -f install.sh && test -f requirements.sh
- Harbor is a containerized LLM toolkit distributed as a Docker Compose project with a CLI and Tauri desktop app
- label: the project is licensed under Apache 2.0
  command: head -1 LICENSE | grep -q 'Apache'
- label: install.sh verifies the install with harbor doctor --check so non-critical doctor warnings do not fail setup
  command: rg -q 'doctor --check' install.sh
  tags: [implemented]

## development
- label: local Deno runtime caches under .deno-cache are ignored by Git
  command: git check-ignore -q .deno-cache/latest.txt
  tags: [spec, implemented]
- label: the release pipeline never publishes stale Boost docs: release.sh rebuilds the boost image before 'harbor dev docs', and 'harbor dev docs --check' regenerates docs/5.2.2 and docs/5.2.3 without writing, naming the boost source commit and resolved image id it generated from, and exits non-zero on any drift
  command: b=$(grep -n 'harbor build boost' .scripts/release.sh | head -1 | cut -d: -f1); g=$(grep -n 'harbor dev docs' .scripts/release.sh | head -1 | cut -d: -f1); [ -n "$b" ] && [ -n "$g" ] && [ "$b" -lt "$g" ] || exit 1; out=$(./harbor.sh dev docs --check 2>&1); rc=$?; printf '%s\n' "$out" | grep -q '^docgen image: .*-boost sha256:' && [ $rc -eq 0 ]
  tags: [docgen, implemented]

# cli
- label: harbor.sh is the main CLI entrypoint, a Bash script over 5000 lines
  command: test -x harbor.sh && test $(wc -l < harbor.sh) -ge 5000
- label: CLI internals are rewritten in Deno TypeScript under routines/
  command: test -f routines/deno.json && test $(find routines -name '*.ts' | wc -l) -ge 10
- label: dev scripts live in .scripts/ and must be run via harbor dev, not directly
  command: test -d .scripts && test $(find .scripts -name '*.ts' -o -name '*.sh' | wc -l) -ge 20
- label: harbor config get/set/search/update reads and writes workspace .env; defaults live in profiles/default.env plus per-service services/<name>/default.env
  command: grep -q 'env_manager' harbor.sh && test -f profiles/default.env && test -f services/chandra/default.env
- label: harbor config update merges profiles/default.env and every services/*/default.env into the active .env
  command: grep -q 'merge_default_env_files' harbor.sh && grep -q 'collect_default_env_files' harbor.sh
- label: harbor compose up uses docker compose up -d --wait requiring Compose 2.23.1 or newer
  command: grep -q 'up -d --wait' harbor.sh && grep -q 'desired_compose_minor="23"' harbor.sh
  tags: [implemented]
- label: harbor logs tails container output by default and can hang unattended agents
  command: grep -q -- "logs -n \"\$tail_lines\" -f" harbor.sh
  tags: [implemented]
- label: harbor.sh avoids Bash 4 case-conversion parameter expansion for macOS Bash 3 compatibility
  command: bash -n harbor.sh && ! rg -q '\$\{[^}]+(\^\^|,,)' harbor.sh
  tags: [implemented]
- label: harbor doctor --check exits nonzero only for critical environment issues
  command: rg -q 'check_mode=true' harbor.sh && rg -q 'has_critical' harbor.sh
  tags: [implemented]
- when compose_with_options fails in get_services, harbor ls exits nonzero instead of executing a bare 'config' word @get-services-guard @implemented
- label: harbor doctor SELinux AVC probe reads audit logs instead of blocking on noninteractive stdin
  command: timeout 5 bash -lc 'cd /home/everlier/code/harbor && tail -f /dev/null | harbor doctor >/tmp/harbor-doctor-stdin.out 2>/tmp/harbor-doctor-stdin.err'
  tags: [spec, app-doctor-stdin, implemented]
- bare harbor open resolves ui.main instead of reporting no service specified @spec @implemented
- bare harbor tunnel resolves ui.main instead of reporting no service specified @spec @implemented
- _resolve_ui_service centralizes default UI target resolution for open, url, and tunnel @spec @implemented
- harbor up --open opens the first started service instead of ui.main when no separate open target is given @spec @implemented
- _resolve_ui_service reads ui.main at call time and validates the resolved service exists @spec @implemented
- harbor qr documents and resolves ui.main like harbor url @spec @implemented
- label: harbor up --open targets the first started service after defaults and --no-defaults are resolved
  command: rg -q 'open_target="\$\{display_services\[0\]:-\}"' harbor.sh
  tags: [spec, implemented]
- label: harbor doctor --check reports Docker, Compose, Harbor home, git, curl, and git-repository status before returning, while only critical environment failures make it exit nonzero
  command: out="$(bash ./harbor.sh doctor --check 2>&1)" && rc=$? || rc=$?; [ "$rc" -eq 0 ] && echo "$out" | grep -q "Docker is installed" && echo "$out" | grep -q "Compose" && echo "$out" | grep -q "Harbor home" && echo "$out" | grep -q "git is installed" && echo "$out" | grep -q "curl is installed" && echo "$out" | grep -q "essential checks passed" && grep -q "not a git repository" harbor.sh
  tags: [spec, release-validation, implemented]
- label: host deno invocations set DENO_NO_UPDATE_CHECK=1 to suppress the update-check nag and its dl.deno.land telemetry
  command: test $(grep -c 'DENO_NO_UPDATE_CHECK=1 deno run' harbor.sh) -eq 3
  tags: [spec, deno-no-update-check, implemented]
- label: when compose_with_options fails, run_pull returns 1 instead of executing a bare 'pull' word
  command: grep -q 'compose_cmd=$(compose_with_options) || return 1' harbor.sh && grep -q 'compose_cmd=$(compose_with_options "${flag_args\[@\]}" "${service_args\[@\]}") || return 1' harbor.sh
  tags: [spec, implemented, pull-resolver-guard]
- label: when the compose resolver routine fails or returns empty, compose_with_options emits a 'false' sentinel command so bare $(compose_with_options ...) call sites fail cleanly instead of executing a bare docker subcommand
  command: grep -A6 'The compose file merge routine produced no output' harbor.sh | grep -q 'echo "false"'
  tags: [spec, implemented, pull-resolver-guard]
- label: harbor up with multiple services and -t tails logs for all requested services, not just the first
  command: rg -q "run_logs \"\\\$\\{filtered_args\\[@\\]\\}\"" harbor.sh
  tags: [spec, implemented]
- label: harbor env <service> accepts --rm and --unset as aliases for unset: removal deletes the KEY line from services/<service>/override.env and never writes a literal __RM= entry
  command: bash -c 'echo TESTKEY=1 >> services/webtop/override.env; ./harbor.sh env webtop --rm TESTKEY >/dev/null 2>&1; ! grep -q "__RM\|TESTKEY" services/webtop/override.env'
  tags: [spec, env-fix, implemented]
- label: harbor url falls back to the configured <service>.host_port for host-networked containers with no docker port mappings
  command: ./harbor.sh url homeassistant | grep -q 'http://localhost:8123'
  tags: [implemented]

## launch
- label: harbor launch routes host tools codex, claude, opencode, pi, and others to OpenAI-compatible Harbor backends with optional --backend, --model, --config, --web, and --workflow
  command: rg -q 'launch_host_tool_command' harbor.sh && rg -q 'launch_detect_backend' harbor.sh && rg -q -- '--web' harbor.sh && rg -q -- '--workflow' harbor.sh
  tags: [implemented]
- label: harbor launch --service forces the containerized Harbor service instead of the host tool when names collide
  command: rg -q 'force_service_launch' harbor.sh && rg -q 'harbor launch --service opencode' docs/3.-Harbor-CLI-Reference.md
  tags: [implemented]
- label: harbor launch starts llamacpp as the default backend when no OpenAI-compatible Harbor backend is running
  command: rg -q 'starting llamacpp' harbor.sh
  tags: [implemented]
- label: harbor launch --web starts Boost and SearXNG and configures a boost-prefixed workflow model
  command: rg -q 'launch_prepare_boost_workflow' harbor.sh && rg -q 'searxng' harbor.sh
  tags: [implemented]
- label: harbor launch validates the host tool binary before starting backend or Boost services
  command: rg -q 'started compose before validating the host binary' tests/suites/02-cli.sh
  tags: [implemented]
- label: harbor launch exposes --web and --workflow as Boost routing modifiers and rejects removed groups such as --time and --notes
  command: ./harbor.sh launch --help | rg -q -- '--web' && ./harbor.sh launch --help | rg -q -- '--workflow' && ! ./harbor.sh launch --help | rg -q -- '--time|--notes|--files|--scratch'
  tags: [implemented]
- label: harbor launch --workflow accepts Boost module names but does not advertise built-in workflow presets when none ship
  command: ./harbor.sh launch --help | rg -q 'Boost module' && ./harbor.sh launch --help | rg -q 'No built-in workflow presets ship by default' && ! ./harbor.sh launch --help | rg -q 'workflow presets:'
  tags: [spec, reported-regression, implemented]
- label: harbor launch prefers a reachable backend listed in services.default over other running backends when --backend is not given
  command: grep -q 'launch_default_backends' harbor.sh && grep -A4 'launch_detect_backend()' harbor.sh >/dev/null
  tags: [spec, implemented]
## models
- label: harbor models supports source-aware list, pull, and rm for ollama, llamacpp, dmr, mlx, and omlx
  command: rg -q 'run_models_routine' harbor.sh && rg -q 'dmr' routines/models.ts && rg -q 'mlx' routines/models.ts && rg -q 'omlx' routines/models.ts
  tags: [implemented]

## compose-merge
- label: compose environment-list merging is key-aware: a later cross-file's KEY=VALUE entry replaces an earlier same-KEY entry even when byte-identical, so .a.b tie-breaker files take effect
  command: grep -q 'mergeEnvironmentArrays' routines/utils.ts
  tags: [implemented, bughunt]
- label: compose deepMerge normalizes list-form depends_on to map form when merging, so cross-file depends_on never drops init-sidecar completion conditions
  command: deno eval --unstable-sloppy-imports 'import {deepMerge} from "./routines/utils.ts"; const r: any = deepMerge({depends_on:{i:{condition:"service_completed_successfully"}}} as any, {depends_on:["o"]} as any); if (!r.depends_on?.i) Deno.exit(1);'
  tags: [implemented, bughunt]

## pull
- label: harbor pull with mixed service and model arguments passes only model arguments to the model pull path
  command: rg -q 'model_args\+\=' harbor.sh && rg -q 'Mixed service and model arguments' harbor.sh && rg -q 'run_models_pull "\$\{model_args\[@\]\}"' harbor.sh
  tags: [spec, reported-regression, implemented]

## up
- label: service_compose_exists accepts cross-file-only selectors (tokens appearing as parts of compose.x.* filenames, e.g. mcp-server-time), keeping the documented 'harbor up mcpo mcp-server-time' flow working
  command: bash -c 'source /dev/null; ./harbor.sh cmd mcpo mcp-server-time >/dev/null'
  tags: [services-it, implemented]

## hf
- label: check_hf_cache in harbor.sh uses 'hf cache ls' (scan-cache was removed in hf CLI 1.x)
  command: grep -q 'cache ls --no-truncate' harbor.sh && ! grep -q 'run_hf_docker_cli scan-cache' harbor.sh
  tags: [sweep7, implemented]

## config
- label: Harbor loads per-service defaults from services/<name>/default.env alongside profiles/default.env: a key defined only there (chandra.host_port) resolves through harbor config get on a fresh .env with no copy in profiles/default.env, and several harbor processes repairing an empty .env at once leave it complete and free of duplicate keys because the assembled defaults are renamed into place atomically
  command: d=$(mktemp -d) && mkdir -p "$d/profiles" && cp profiles/default.env "$d/profiles/" && for f in services/*/default.env; do mkdir -p "$d/$(dirname "$f")" && cp "$f" "$d/$f"; done && cp harbor.sh "$d/" && : > "$d/.env" && for c in "ls" "ls -a" "defaults" "config get history.file"; do HARBOR_HOME="$d" "$d/harbor.sh" $c >/dev/null 2>&1 & done; wait; keys=$(grep -c "=" "$d/.env"); dupes=$(grep -v "^[[:space:]]*#" "$d/.env" | grep "=" | sed "s/=.*//" | sort | uniq -d); port=$(HARBOR_HOME="$d" "$d/harbor.sh" config get chandra.host_port 2>/dev/null | tail -1); rm -rf "$d"; test "$keys" -ge 50 && test -z "$dupes" && test "$port" = 35080 && test "$(grep -c HARBOR_CHANDRA profiles/default.env)" -eq 0
  tags: [config, svc-defaults, implemented]

## down
- label: harbor down <service> and harbor restart <service> touch only the named services and their <service>-* companions: down uses compose stop + rm -f (never a project-wide down --remove-orphans) and restart brings the set back with an explicitly named up, so unrelated running services are never stopped or recreated
  command: bash -c 'd=$(mktemp -d); export L=$(mktemp); printf "%s\n" "#!/usr/bin/env bash" "case \"\$*\" in *\"compose version\"*) echo v2.30.0; exit 0;; *\"config --services\"*) printf \"ollama\nboost\n\"; exit 0;; *\"compose ps\"*) echo ollama; exit 0;; esac" "[ \"\$1\" = compose ] && printf \"%s\n\" \"\$*\" >> \"\$L\"" "exit 0" > "$d/docker"; chmod +x "$d/docker"; export HARBOR_LEGACY_CLI=true HARBOR_CAPABILITIES_AUTODETECT=false PATH="$d:$PATH"; ./harbor.sh down boost >/dev/null 2>&1 && ./harbor.sh restart boost >/dev/null 2>&1 && grep -q -- " stop --timeout 10 boost$" "$L" && grep -q -- " rm -f boost$" "$L" && grep -q -- " up -d --wait boost$" "$L" && ! grep -q -- "--remove-orphans" "$L" && ! grep -Eq -- " (stop|rm -f|up -d --wait) .*(ollama|webui|llamacpp)" "$L"'
  tags: [cli, down-scope, implemented]

# services
- label: compose.yml defines only the shared harbor-network without service definitions
  command: grep -q 'harbor-network' compose.yml && ! grep -q '^services:' compose.yml
- label: each Harbor service has a compose file at services/compose.<handle>.yml
  command: test $(find services -maxdepth 1 -name 'compose.*.yml' ! -name 'compose.x.*' | wc -l) -ge 100
- label: cross-service integrations use compose.x.<consumer>.<backend>.yml files selected when both handles are active
  command: test $(find services -maxdepth 1 -name 'compose.x.*.yml' | wc -l) -ge 300
- label: profiles/default.env contains over 800 HARBOR configuration keys
  command: test $(grep -c '^HARBOR_' profiles/default.env) -ge 800
- label: default services on harbor up without handles are webui and llamacpp
  command: grep -q 'HARBOR_SERVICES_DEFAULT="webui;llamacpp"' profiles/default.env
  tags: [implemented]
- label: dmr mlx and omlx are host-managed backends proxied by Caddy containers on the harbor network
  command: test -f services/compose.dmr.yml && test -f services/compose.mlx.yml && test -f services/compose.omlx.yml && test -f services/dmr/Caddyfile
  tags: [implemented]
- label: harbor up dmr mlx or omlx starts the host runner before bringing up the proxy container
  command: rg -q 'run_dmr_command' harbor.sh && rg -q 'run_mlx_command' harbor.sh && rg -q 'run_omlx_command' harbor.sh
  tags: [implemented]
- label: ml-intern is a satellite with cross-compose integrations for ollama llamacpp vllm dmr mlx and omlx
  command: test -f services/compose.ml-intern.yml && test -f services/compose.x.ml-intern.omlx.yml
  tags: [implemented]
- label: mi is a CLI satellite launched via harbor mi and harbor launch with compose.x integrations for backends
  command: test -f services/compose.mi.yml && rg -q 'run_mi_command' harbor.sh
  tags: [implemented]
- label: facts is a CLI satellite run via harbor facts with the caller workspace bind-mounted
  command: test -f services/compose.facts.yml && rg -q 'run_facts_command' harbor.sh
  tags: [implemented]
- label: agent skills and install docs describe defaults as webui plus llamacpp, not ollama
  command: grep -q 'llamacpp` | Port: 33831 | Default service' skills/run-llms/SKILL.md && ! grep -q 'Ollama + Open WebUI' docs/1.0.-Installing-Harbor.md && ! grep -rq 'Port: 33821 | Default service' skills/
  tags: [implemented]

## hermes
- label: hermes ships a non-empty default API key (sk-hermes) so the gateway API server never runs with auth disabled
  command: grep -q 'HARBOR_HERMES_API_KEY="sk-hermes"' profiles/default.env && grep -q 'hermes.api_key' .scripts/migrations/0.5.0.ts
  tags: [implemented, bughunt]
- label: harbor hermes help prints usage and returns without proxying to the container
  command: grep -A2 'Any other Hermes CLI command' harbor.sh | grep -q 'return 0'
  tags: [implemented, bughunt]
- label: hermes docs do not advertise the nonexistent 'harbor hermes server' subcommand; the API server starts automatically with harbor up
  command: ! grep -rq 'harbor hermes server' docs/
  tags: [implemented, bughunt]
- label: hermes llamacpp/ollama cross-files persist the backend into Hermes config.yaml via configure-provider.sh entrypoint (Hermes ignores OPENAI_BASE_URL env alone); model defaults from HARBOR_HERMES_LLAMACPP_MODEL / HARBOR_HERMES_OLLAMA_MODEL
  command: grep -q HERMES_PROVIDER_BASE_URL services/compose.x.hermes.llamacpp.yml && grep -q HERMES_PROVIDER_BASE_URL services/compose.x.hermes.ollama.yml && grep -q configure-provider.sh services/compose.hermes.yml && grep -q HARBOR_HERMES_LLAMACPP_MODEL profiles/default.env
  tags: [hermes-provider, implemented]
- label: gitignore: the repo-root hermes workspace dir (HARBOR_HERMES_WORKSPACE=./hermes default) is ignored so hermes runtime data never shows as untracked
  command: git check-ignore hermes/
  tags: [gitignore-anchor, implemented]
- label: hermes workspace defaults under services/hermes: HARBOR_HERMES_WORKSPACE=./services/hermes/data in profiles/default.env, so a fresh 'harbor up hermes' creates no repo-root ./hermes directory
  command: grep -qx 'HARBOR_HERMES_WORKSPACE=./services/hermes/data' profiles/default.env
  tags: [hermes-workspace, spec, implemented]

## searxng
- label: shipped settings.yml passes SearXNG engine about-schema validation: every engine about section uses require_api_key (no requires_api_key typo)
  command: ! grep -q 'requires_api_key' services/searxng/settings.yml
  tags: [spec, implemented]

## langflow
- label: langflow data bind mount is chowned to the host user by a langflow-init sidecar before langflow starts (compose.langflow.yml depends_on langflow-init service_completed_successfully; services/langflow/workspace-init.sh)
  command: grep -q 'langflow-init' services/compose.langflow.yml && test -f services/langflow/workspace-init.sh
  tags: [spec, implemented]

## librechat
- label: librechat-rag waits for librechat-vector (pgvector) readiness: compose.librechat.yml has a pg_isready healthcheck on librechat-vector and librechat-rag depends_on it with condition service_healthy, preventing first-boot 'connection refused' crash
  command: grep -q pg_isready services/compose.librechat.yml && grep -q 'service_healthy' services/compose.librechat.yml
  tags: [spec, implemented]
- label: the Ollama custom endpoint baseURL in librechat.yml is the API root (.../v1) without a /chat/completions suffix — LibreChat's client appends the path itself and 404s on a full path
  command: grep -q 'INTERNAL_URL}/v1"' services/librechat/librechat.yml && ! grep -q 'INTERNAL_URL}/v1/chat/completions' services/librechat/librechat.yml
  tags: [spec, services-it, implemented]
- label: compose.librechat.yml pins getmeili/meilisearch:v1.35.1 with data mounted at services/librechat/meili_data_v1.35 (version-suffixed dir so bumps start a fresh index)
  command: grep -q 'meilisearch:v1.35.1' services/compose.librechat.yml && grep -q 'meili_data_v1.35:/meili_data' services/compose.librechat.yml
  tags: [implemented]

## fabric
- label: fabric config mounts into the image user's real home (/home/appuser/.config/fabric, image runs as appuser not root) and the entrypoint creates an empty .env if missing, since fabric hard-fails without one even when vendor/model come from environment
  command: grep -q '/home/appuser/.config/fabric' services/compose.fabric.yml && grep -q 'touch /home/appuser/.config/fabric/.env' services/compose.fabric.yml
  tags: [spec, implemented]

## cmdh
- label: cmdh's ollama adapter passes a literal JSON schema as the structured-output format (no zod-to-json-schema dependency), because zod-to-json-schema v3 with zod v4 silently produces an empty schema that Ollama rejects with 'invalid JSON schema in format'
  command: grep -q 'format: CmdhResponseSchema' services/cmdh/ollama.ts && ! grep -q "from 'zod-to-json-schema'" services/cmdh/ollama.ts
  tags: [spec, implemented]
- When LLM_HOST is OpenAI and OPENAI_MODEL_NAME is unset, cmdh's openai adapter (services/cmdh/openai.ts, mounted over the upstream source) resolves the model by listing the backend's /v1/models and using the first entry, so OpenAI-mode cross-files (llamacpp, dmr, mlx, omlx, tgi) work without an explicit model name @spec @implemented

## plandex
- label: services/plandex/Dockerfile installs the plandex CLI from the GitHub raw install script (app/cli/install.sh), not the dead plandex.ai domain
  command: grep -q 'raw.githubusercontent.com/plandex-ai/plandex/main/app/cli/install.sh' services/plandex/Dockerfile
  tags: [spec, implemented]
- label: plandex-db pins postgres:17 — postgres:18+ images refuse the /var/lib/postgresql/data bind-mount layout used by Harbor
  command: grep -q 'image: postgres:17' services/compose.plandex.yml
  tags: [spec, implemented]
- label: plandex-server uses the official plandexai/plandex-server image (the ghcr.io/wipash rolling image crashes: bundled litellm launch fails on missing uvicorn)
  command: grep -q 'plandexai/plandex-server' services/compose.plandex.yml
  tags: [spec, implemented]
- label: compose.plandex.yml does not bind-mount /etc/timezone (absent on Fedora-family hosts; docker creates it as a directory and the mount then fails against the image's file)
  command: ! grep -q -- '- /etc/timezone' services/compose.plandex.yml
  tags: [spec, implemented]
- label: plandex-server port mapping and the CLI's PLANDEX_API_HOST use port 8099 (official image in GOENV=development listens on 8099, not 8080)
  command: grep -q 'plandex-server:8099' services/compose.plandex.yml && grep -q ':8099$' services/compose.plandex.yml
  tags: [spec, implemented]
- label: H4 plandex health probe allows 300s — first boot does fresh postgres init + LiteLLM bootstrap + migrations before the listener opens
  command: grep -qE 'H4 plandex server health.+plandex-server./health.+ 300$' tests/services-integration.sh
  tags: [implemented]
- label: the plandex CLI container idles under 'harbor up plandex' (compose overrides the image entrypoint with sleep infinity) so it never exits on the interactive auth prompt; 'harbor plandex ...' still runs the CLI via compose run --entrypoint plandex
  command: grep -q 'sleep' services/compose.plandex.yml && grep -q -- '--entrypoint \"plandex\"' harbor.sh
  tags: [spec, implemented]

## webtop
- label: services/webtop/Dockerfile does not install neofetch (package removed from current Ubuntu base of the webtop image; it broke the build)
  command: ! grep -q neofetch services/webtop/Dockerfile
  tags: [spec, implemented]
- label: services/webtop/Dockerfile installs node via NodeSource (bundles npm; the legacy npmjs.org/install.sh bootstrap is defunct and distro nodejs/npm conflict) and installs yarn via npm
  command: grep -q 'deb.nodesource.com' services/webtop/Dockerfile && ! grep -q 'RUN curl -L https://npmjs.org/install.sh' services/webtop/Dockerfile
  tags: [spec, implemented]

## litellm
- label: a litellm.llamacpp config fragment plus compose.x.litellm.llamacpp.yml overlay route llamacpp/* requests through LiteLLM to the llama.cpp router via wildcard routing
  command: grep -q 'llamacpp/\*' services/litellm/litellm.llamacpp.yaml && grep -q litellm.llamacpp.yaml services/compose.x.litellm.llamacpp.yml
  tags: [services-runner, implemented]
- label: litellm.optillm.yaml routes via provider wildcard (optillm/* -> openai/* at http://optillm:8000/v1) instead of a pinned static model, so any backend model reaches optillm through LiteLLM as optillm/<model>
  command: grep -q 'model_name: "optillm/\*"' services/litellm/litellm.optillm.yaml && grep -q 'model: "openai/\*"' services/litellm/litellm.optillm.yaml
  tags: [services-it, implemented]

## boost
- label: ChatNode.history() merges consecutive same-role plain messages (no tool calls) so multi-assistant-turn modules like g1 pass llama.cpp's 'cannot have 2+ assistant messages at the end' validation
  command: cd services/boost && python -m pytest tests/test_chat_node.py -q -k merge
  tags: [services-runner, implemented]

## chatui
- label: envify.js bridges Harbor's legacy MODELS config to chat-ui >= 0.10's single-provider scheme by emitting OPENAI_BASE_URL and OPENAI_API_KEY from the first configured openai endpoint, so per-backend configs (chatui.llamacpp.yml etc.) still take effect
  command: grep -q OPENAI_BASE_URL services/chatui/envify.js
  tags: [spec, services-it, implemented]

## traefik
- label: traefik.config default is ./services/traefik/traefik.yml so the compose bind mount resolves to the shipped config (repo-root-relative convention)
  command: grep -q 'HARBOR_TRAEFIK_CONFIG="./services/traefik/traefik.yml"' profiles/default.env
  tags: [services-it, implemented]

## drawio
- label: drawio ollama overlay sets OLLAMA_BASE_URL to the native Ollama API root (${HARBOR_OLLAMA_INTERNAL_URL}/api) because next-ai-draw-io uses ollama-ai-provider (createOllama), which 404s against the OpenAI-compatible /v1 root
  command: grep -q 'OLLAMA_INTERNAL_URL}/api' services/compose.x.drawio.ollama.yml && ! grep -q 'OLLAMA_INTERNAL_URL}/v1' services/compose.x.drawio.ollama.yml
  tags: [services-it, implemented]

## anythingllm
- label: anythingllm-init sidecar chowns the storage bind mount to the image's fixed uid 1000 before anythingllm starts, fixing the first-boot crash-loop (SQLite 'unable to open database file' on a root-owned storage dir)
  command: grep -q 'anythingllm-init' services/compose.anythingllm.yml && test -f services/anythingllm/storage-init.sh
  tags: [services-it, implemented]
- label: anythingllm ollama overlay sets EMBEDDING_MODEL_PREF=nomic-embed-text:latest (part of the ollama default pull) — the ollama embedder aborts every chat with 'No embedding model was set' when the pref is missing
  command: grep -q 'EMBEDDING_MODEL_PREF=nomic-embed-text' services/compose.x.anythingllm.ollama.yml
  tags: [services-it, implemented]

## ldr
- label: compose.x.ldr.searxng.yml sets LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL (LDR's env mapping for the searxng instance_url setting) instead of the stale SEARXNG_INSTANCE/LDR_SEARCH__TOOL names, so LDR web research actually queries SearXNG
  command: grep -q 'LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL' services/compose.x.ldr.searxng.yml && ! grep -q 'SEARXNG_INSTANCE=' services/compose.x.ldr.searxng.yml
  tags: [spec, services-it, implemented]
- label: compose.ldr.yml mounts the ldr workspace data dir at /data (the image's LDR_DATA_DIR) so users and research history persist across container recreation
  command: grep -q 'HARBOR_LDR_WORKSPACE}/data:/data' services/compose.ldr.yml
  tags: [spec, services-it, implemented]

## presenton
- label: compose.x.presenton.ollama.yml points OLLAMA_URL at HARBOR_OLLAMA_INTERNAL_URL (compose-network ollama), not a hardcoded host port unreachable from inside the container
  command: grep -q 'OLLAMA_URL=${HARBOR_OLLAMA_INTERNAL_URL}' services/compose.x.presenton.ollama.yml
  tags: [spec, services-it, implemented]
- label: presenton auth is disabled by default via HARBOR_PRESENTON_DISABLE_AUTH -> DISABLE_AUTH so the API works out of the box (upstream 428s 'Login setup is required' otherwise)
  command: grep -q 'DISABLE_AUTH=${HARBOR_PRESENTON_DISABLE_AUTH}' services/compose.presenton.yml && grep -q 'HARBOR_PRESENTON_DISABLE_AUTH="true"' profiles/default.env
  tags: [spec, services-it, implemented]

## bifrost
- label: bifrost bootstrap-provider.sh verifies provider-key presence via GET /api/providers/<p>/keys (the provider object redacts keys), so re-running the bootstrap against a persisted config.db exits 0 instead of failing with 'record with this name already exists'
  command: grep -q 'providers/$BIFROST_PROVIDER/keys' services/bifrost/bootstrap-provider.sh
  tags: [services-it, implemented]

## optillm
- label: compose.optillm.yml sets OPTILLM_HOST=0.0.0.0 (list-form environment) because upstream optillm now binds 127.0.0.1 by default, which makes the published port unreachable
  command: grep -q 'OPTILLM_HOST=0.0.0.0' services/compose.optillm.yml
  tags: [services-it, implemented]

## metamcp
- label: metamcp start-sse.mjs seeds a default project/profile/API key when the api_keys table is empty, so 'harbor up metamcp' succeeds headlessly on a fresh database instead of crash-looping until the UI is opened
  command: grep -q 'seeding default project/profile/key' services/metamcp/start-sse.mjs
  tags: [services-it, implemented]
- label: metamcp-postgres uses postgres:16-alpine (floating 16.x patch tag, data-compatible within major 16)
  command: grep -q 'postgres:16-alpine' services/compose.metamcp.yml
  tags: [implemented]

## mcp-inspector
- label: compose.mcp-inspector.yml runs the inspector with HOST=0.0.0.0 and ALLOWED_ORIGINS for the remapped host port, with no socat entrypoint — the old socat forwarder self-connect-looped (inspector binds ::1, socat forwarded 127.0.0.1 into its own wildcard listener), leaving both published ports dead
  command: grep -q 'HOST=0.0.0.0' services/compose.mcp-inspector.yml && ! grep -q 'inspector-entrypoint' services/compose.mcp-inspector.yml && [ ! -f services/mcp/inspector-entrypoint.sh ]
  tags: [services-it, implemented]
- label: compose.mcp-inspector.yml sets DANGEROUSLY_BIND_ALL_INTERFACES=true alongside HOST=0.0.0.0 so inspector >=2.0.0 starts in-container
  command: grep -q DANGEROUSLY_BIND_ALL_INTERFACES=true services/compose.mcp-inspector.yml
  tags: [sweep7, implemented]

## s2s
- s2s wraps huggingface/speech-to-speech, the modular VAD-STT-LLM-TTS realtime voice pipeline, as a Harbor backend service @spec
- label: s2s has a compose file at services/compose.s2s.yml defining an s2s service on the harbor network
  command: test -f services/compose.s2s.yml && grep -q 'harbor-network' services/compose.s2s.yml
  tags: [spec]
- label: s2s builds its image from the upstream repo's own Dockerfile via the HARBOR_S2S_GIT_REF build context since no official image is published
  command: grep -q 'HARBOR_S2S_GIT_REF' services/compose.s2s.yml && grep -q 'HARBOR_S2S_GIT_REF' profiles/default.env
  tags: [spec]
- label: s2s runs in realtime mode exposing the OpenAI Realtime-compatible WebSocket endpoint /v1/realtime on container port 8765, published via HARBOR_S2S_HOST_PORT
  command: grep -q 'HARBOR_S2S_HOST_PORT' profiles/default.env && grep -q '8765' services/compose.s2s.yml
  tags: [spec]
- label: s2s LLM backend defaults to responses-api pointed at an OpenAI-compatible endpoint, configurable via HARBOR_S2S_OPENAI_URL, HARBOR_S2S_OPENAI_KEY and HARBOR_S2S_MODEL
  command: grep -q 'HARBOR_S2S_OPENAI_URL' profiles/default.env && grep -q 'HARBOR_S2S_OPENAI_KEY' profiles/default.env && grep -q 'HARBOR_S2S_MODEL' profiles/default.env
  tags: [spec]
- label: s2s STT and TTS components are selectable via HARBOR_S2S_STT and HARBOR_S2S_TTS with upstream defaults (parakeet-tdt STT, qwen3 TTS)
  command: grep -q 'HARBOR_S2S_STT' profiles/default.env && grep -q 'HARBOR_S2S_TTS' profiles/default.env
  tags: [spec]
- label: s2s system prompt is configurable via HARBOR_S2S_SYSTEM_PROMPT passed as --init_chat_prompt with --init_chat_role system
  command: grep -q 'HARBOR_S2S_SYSTEM_PROMPT' profiles/default.env && grep -q 'init_chat_prompt' services/compose.s2s.yml
  tags: [spec]
- label: s2s extra pipeline CLI flags can be appended via HARBOR_S2S_EXTRA_ARGS
  command: grep -q 'HARBOR_S2S_EXTRA_ARGS' profiles/default.env
  tags: [spec]
- label: s2s persists HuggingFace model cache via the shared global cache mount so models are not re-downloaded across restarts
  command: grep -q '.cache' services/compose.s2s.yml
  tags: [spec]
- label: s2s has a compose.x integration with llamacpp routing the LLM stage to the llamacpp OpenAI-compatible API
  command: test -f services/compose.x.s2s.llamacpp.yml
  tags: [spec]
- label: s2s supports NVIDIA GPU via the standard compose.x.s2s.nvidia.yml overlay and runs on CPU without it
  command: test -f services/compose.x.s2s.nvidia.yml
  tags: [spec]
- label: harbor ls includes s2s and harbor up s2s resolves a valid compose configuration
  command: ./harbor.sh ls | grep -q '^s2s$'
  tags: [spec]
- label: s2s has a documentation page in docs/ covering the realtime client (scripts/listen_and_play_realtime.py --host <host> --port <port>), env vars, and backend integration
  command: ls docs/ | grep -qi 'speech-to-speech'
  tags: [spec]
- label: s2s has metadata (name, tags, logo) registered in app/src/serviceMetadata.ts
  command: grep -q "s2s" app/src/serviceMetadata.ts
  tags: [spec]

## morphic
- label: compose.morphic.yml includes a morphic-db PostgreSQL sidecar (postgres:17-alpine, data in services/morphic/postgres) that morphic depends_on with service_healthy, and sets DATABASE_URL/DATABASE_RESTRICTED_URL to it plus DATABASE_SSL_DISABLED, ENABLE_AUTH=false, MORPHIC_CLOUD_DEPLOYMENT=false — required since upstream morphic main runs drizzle migrations at startup
  command: grep -q 'morphic-db' services/compose.morphic.yml && grep -q 'DATABASE_URL=postgresql://morphic:morphic@morphic-db:5432/morphic' services/compose.morphic.yml && grep -q 'service_healthy' services/compose.morphic.yml
  tags: [spec, morphic-postgres, implemented]

## mindsdb x backends
- label: every mindsdb backend cross-file (llamacpp, vllm, dmr, mlx, omlx, ollama) sets LLM_FUNCTION_MODEL alongside LLM_FUNCTION_BASE_URL so the LLM() SQL function works out of the box (MindsDB errors on missing 'model_name' otherwise)
  command: bash -c 'for f in services/compose.x.mindsdb.{llamacpp,vllm,dmr,mlx,omlx,ollama}.yml; do grep -q LLM_FUNCTION_MODEL $f || exit 1; done'
  tags: [spec, mindsdb-llm-model, implemented]

## onyx
- label: onyx-index (Vespa) depends on an onyx-init sidecar that chowns workspace bind-mount subdirs (vespa, api_logs, background_logs, minio — not db) to HARBOR_USER_ID:HARBOR_GROUP_ID, since Vespa runs non-root and fatally fails on a root-owned /opt/vespa/var
  command: bash -c 'grep -q onyx-init services/compose.onyx.yml && grep -q vespa services/onyx/workspace-init.sh && ! grep -qE "^  chown.*workspace\"?$" services/onyx/workspace-init.sh'
  tags: [spec, onyx-startup, implemented]
- label: onyx-api receives USER_AUTH_SECRET from HARBOR_ONYX_USER_AUTH_SECRET (non-empty default in profiles/default.env) because Onyx refuses to start with an empty auth secret
  command: bash -c 'grep -q "USER_AUTH_SECRET: ..HARBOR_ONYX_USER_AUTH_SECRET." services/compose.onyx.yml && grep -qE "^HARBOR_ONYX_USER_AUTH_SECRET=.+" profiles/default.env'
  tags: [spec, onyx-startup, implemented]
- label: onyx api and background workers run with ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false and ONYX_DISABLE_VESPA=false so the Vespa-backed Harbor deployment neither probes a nonexistent OpenSearch sidecar nor rejects the Vespa index
  command: bash -c '[ $(grep -c "ENABLE_OPENSEARCH_INDEXING_FOR_ONYX: .false." services/compose.onyx.yml) -eq 2 ] && [ $(grep -c "ONYX_DISABLE_VESPA: .false." services/compose.onyx.yml) -eq 2 ]'
  tags: [spec, onyx-startup, implemented]

## cognee
- label: cognee and cognee-mcp depend on ollama-init with condition service_healthy (ollama-init is a long-running healthy sidecar, never 'completed')
  command: grep -A2 'ollama-init' services/compose.x.cognee.ollama.yml | grep -q 'service_healthy' && ! grep -q 'service_completed_successfully' services/compose.x.cognee.ollama.yml
  tags: [services-it, cognee, implemented]

## karakeep
- label: karakeep pins Meilisearch v1.41.0 and sets MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true so existing indexes upgrade in place on version bumps
  command: grep -q 'HARBOR_KARAKEEP_MEILI_VERSION="v1.41.0"' profiles/default.env && grep -q 'MEILI_EXPERIMENTAL_DUMPLESS_UPGRADE=true' services/compose.karakeep.yml
  tags: [implemented]

## dify
- label: compose.dify.yml runs Dify 1.16.1 with the 1.x service set: api, worker, worker-beat, web, plugin daemon (own dify_plugin Postgres DB auto-created on first start), agent backend with dify-local-sandbox and dify-agent-ssrf, code sandbox, ssrf proxy, nginx front door, weaviate, certbot, and the dify-openai shim
  command: grep -q "dify-plugin-daemon" services/compose.dify.yml && grep -q "dify-agent:" services/compose.dify.yml && grep -q "MODE: beat" services/compose.dify.yml
  tags: [spec, dify-1x, implemented]
- label: profiles/default.env pins HARBOR_DIFY_VERSION 1.16.1, HARBOR_DIFY_SANDBOX_VERSION 0.2.15, HARBOR_DIFY_PLUGIN_DAEMON_VERSION 0.6.3-local, HARBOR_DIFY_WEAVIATE_VERSION 1.27.0
  command: grep -q "HARBOR_DIFY_VERSION=\"1.16.1\"" profiles/default.env && grep -q "HARBOR_DIFY_PLUGIN_DAEMON_VERSION=\"0.6.3-local\"" profiles/default.env && grep -q "HARBOR_DIFY_SANDBOX_VERSION=\"0.2.15\"" profiles/default.env && grep -q "HARBOR_DIFY_WEAVIATE_VERSION=\"1.27.0\"" profiles/default.env
  tags: [spec, dify-1x, implemented]
- label: vendored nginx/ssrf templates under services/dify are the upstream 1.16.1 docker templates with hostnames rewritten to Harbor dify-* service names
  command: grep -q "http://dify-plugin-daemon:5002" services/dify/nginx/conf.d/default.conf.template && grep -q "dstdomain dify-agent" services/dify/ssrf_proxy/squid-agent.conf.template
  tags: [spec, dify-1x, implemented]
- label: the dify-openai bridge translates OpenAI chat completions to Dify 1.x APIs: Chat via /v1/chat-messages, Workflow via /v1/workflows/run including the blocking-mode single-JSON response shape and OUTPUT_VARIABLE selection from workflow outputs
  command: grep -q 'workflow_run_id' services/dify/openai/app.js && grep -q 'extractWorkflowOutput' services/dify/openai/app.js
  tags: [dify-1x, spec, implemented]
- label: HARBOR_DIFY_INPUT_VARIABLE and HARBOR_DIFY_OUTPUT_VARIABLE are plumbed from profiles/default.env through compose.dify.yml into the dify-openai container
  command: grep -q 'INPUT_VARIABLE=${HARBOR_DIFY_INPUT_VARIABLE}' services/compose.dify.yml && grep -q HARBOR_DIFY_INPUT_VARIABLE profiles/default.env
  tags: [dify-1x, spec, implemented]
- label: dify base configuration (upstream .env.example values with Harbor hostnames) lives in tracked services/dify/dify.env loaded via env_file before services/dify/override.env, so override.env stays an empty user-editable overlay like every other service
  command: bash -c 'grep -q dify.env services/compose.dify.yml && grep -q "^DB_HOST=dify-db" services/dify/dify.env && ! grep -q "^DB_HOST=" services/dify/override.env'
  tags: [spec, env-fix, implemented]
- label: compose.dify.yml defines a dify-websocket collaboration container (dify-api image, gevent websocket workers) gated by HARBOR_DIFY_WEBSOCKET_REPLICAS (default 0, so it does not start); enabling collaboration is replicas=1 plus ENABLE_COLLABORATION_MODE=true and NGINX_SOCKET_IO_UPSTREAM=dify-websocket:5001 via harbor env dify, as documented in docs/2.3.3
  command: docker compose -f compose.yml -f services/compose.dify.yml config --services | grep -q dify-websocket && grep -q 'HARBOR_DIFY_WEBSOCKET_REPLICAS' profiles/default.env && grep -q 'dify-websocket' 'docs/2.3.3-Satellite&colon-Dify.md'
  tags: [dify-websocket, spec, implemented]

## k6
- label: HARBOR_K6_GRAFANA_VERSION in profiles/default.env pins grafana 11.6.1, verified to provision both shipped dashboards and the influxdb datasource
  command: grep -q 'HARBOR_K6_GRAFANA_VERSION="11.6.1"' profiles/default.env
  tags: [spec, svc-testing, implemented]

## ol1
- label: services/ol1/Dockerfile builds without cloning the deleted upstream tcsenpai/ol1 repo, copying Harbor's own app.py instead
  command: ! grep -q 'git clone' services/ol1/Dockerfile && grep -q 'COPY app.py' services/ol1/Dockerfile
  tags: [spec, svc-testing, implemented]

## omnichain
- label: openai.ts /v1/models returns an empty model list (not 500) when data/chains does not exist yet on a fresh install
  command: grep -q 'existsSync(chainsDir)' services/omnichain/openai.ts
  tags: [spec, svc-testing, implemented]
- label: omnichain image installs socat so entrypoint.sh port forwarding does not error with command-not-found
  command: grep -q socat services/omnichain/Dockerfile
  tags: [spec, svc-testing, implemented]

## chatnio
- label: chatnio's app secret is configurable via HARBOR_CHATNIO_SECRET with a >=32-byte default; the entrypoint injects it into the merged /config/config.yaml so upstream's short-secret warning does not fire
  command: grep -q 'HARBOR_CHATNIO_SECRET="harbor-chatnio-secret-change-me-32chars"' profiles/default.env && grep -q 'HARBOR_CHATNIO_SECRET' services/chatnio/start_chatnio.sh
  tags: [spec, implemented]

## khoj x llamacpp
- label: compose.x.khoj.llamacpp.yml sets KHOJ_DEFAULT_CHAT_MODEL from HARBOR_KHOJ_LLAMACPP_MODEL (default LiquidAI/LFM2.5-8B-A1B-GGUF:Q8_0, a solid general default; qwen3-named models also work since the 2.0.0-beta.28 pin); HARBOR_KHOJ_DEFAULT_MODEL stays ollama-only
  command: grep -q 'KHOJ_DEFAULT_CHAT_MODEL=${HARBOR_KHOJ_LLAMACPP_MODEL}' services/compose.x.khoj.llamacpp.yml && grep -q 'HARBOR_KHOJ_LLAMACPP_MODEL="LiquidAI/LFM2.5-8B-A1B-GGUF:Q8_0"' profiles/default.env
  tags: [spec, khoj-llamacpp, implemented]
- label: khoj image is pinned to 2.0.0-beta.28+, which includes the upstream None-content streaming fix (khoj-ai/khoj#1370), so qwen3-named llamacpp models stream without the TypeError crash
  command: grep -q 'HARBOR_KHOJ_VERSION="2.0.0-beta.28"' profiles/default.env
  tags: [spec, khoj-llamacpp, implemented]
  id: m9n

## llamaswap
- label: llamaswap default config points -m at /app/.cache/llama.cpp (the compose mount), not /root/.cache
  command: grep -q 'app/.cache/llama.cpp' services/llamaswap/config.yaml && ! grep -q 'root/.cache' services/llamaswap/config.yaml
  tags: [implemented]
- label: compose.llamaswap.yml additionally binds HARBOR_HF_CACHE read-only at ${HOME}/.cache/huggingface (the host-absolute HF cache path) so GGUF symlinks in the llama.cpp cache that target absolute host HF-cache blob paths resolve inside the container
  command: grep -q ':${HOME}/.cache/huggingface:ro' services/compose.llamaswap.yml
  tags: [spec, cache-symlinks, implemented]

## langfuse
- label: clickhouse in the langfuse compose stack starts without a pinned user so the official entrypoint can chown bind-mounted data dirs on first boot
  command: test "$(grep -c "user: .101:101." services/compose.langfuse.yml)" -eq 0
  tags: [spec, implemented]

## kotaemon
- label: compose.x.kotaemon.llamacpp.yml sets OPENAI_CHAT_MODEL from HARBOR_KOTAEMON_LLAMACPP_MODEL and does not set LOCAL_MODEL
  command: grep -q 'OPENAI_CHAT_MODEL=${HARBOR_KOTAEMON_LLAMACPP_MODEL}' services/compose.x.kotaemon.llamacpp.yml && ! grep -q 'LOCAL_MODEL=' services/compose.x.kotaemon.llamacpp.yml && grep -q HARBOR_KOTAEMON_LLAMACPP_MODEL profiles/default.env
  tags: [spec, implemented]
- label: kotaemon entrypoint wrapper launches the app with the image venv python (/app/.venv/bin/python app.py) after the setpriv drop
  command: grep -q '/app/.venv/bin/python app.py' services/kotaemon/entrypoint.sh
  tags: [spec, implemented]

## localai
- label: compose.localai.yml additionally binds HARBOR_HF_CACHE read-only at ${HOME}/.cache/huggingface so llama.cpp-cache GGUF symlinks targeting absolute host HF-cache blob paths resolve inside the container
  command: grep -q ':${HOME}/.cache/huggingface:ro' services/compose.localai.yml
  tags: [spec, cache-symlinks, implemented]

## lmdeploy
- label: lmdeploy compose uses HARBOR_LMDEPLOY_VERSION for the image tag and HARBOR_LMDEPLOY_MODEL for the served model instead of hardcoded values
  command: bash -c 'grep -q "lmdeploy:\${HARBOR_LMDEPLOY_VERSION}" services/compose.lmdeploy.yml && grep -q "HARBOR_LMDEPLOY_MODEL" services/compose.lmdeploy.yml && grep -q "HARBOR_LMDEPLOY_VERSION=" profiles/default.env && grep -q "HARBOR_LMDEPLOY_MODEL=" profiles/default.env'
  tags: [sweep6, implemented]

## daytona
- label: daytona-dex writes its sqlite db as the host user: a dex-init sidecar pre-chowns the dex workspace dir and the dex container runs as HARBOR_USER_ID (fallback 1001), so first boot does not fail with 'touch /var/dex/dex.db: Permission denied'
  command: bash -c 'grep -q daytona-dex-init services/compose.daytona.yml && grep -qF "user: \"\${HARBOR_USER_ID" services/compose.daytona.yml && test -f services/daytona/dex-init.sh'
  tags: [sweep6, implemented]

## hf
- label: compose.hf.yml mounts the optional custom CA at /harbor/custom-ca.pem, outside /etc/ssl/certs, so the /dev/null placeholder does not break hf_xet TLS client construction
  command: grep -q '/harbor/custom-ca.pem' services/compose.hf.yml && ! grep -q '/etc/ssl/certs/custom-ca.pem' services/compose.hf.yml
  tags: [sweep7, implemented]
- label: hf image entrypoint unsets empty SSL_CERT_FILE/REQUESTS_CA_BUNDLE (compose defines them empty when HARBOR_HF_SSL_CERT_FILE is unset; hf_xet reqwest errors on empty SSL_CERT_FILE)
  command: grep -q 'unset SSL_CERT_FILE' services/hf/entrypoint.sh && grep -q 'entrypoint.sh' services/hf/Dockerfile
  tags: [hf-xet, spec, implemented]
- label: hf entrypoint setpriv-drops to the host user (TARGET_UID/GID from HARBOR_USER_ID/GROUP_ID) so downloads into the shared HF cache stay host-owned; no recursive chown of the cache mount
  command: grep -q 'setpriv --reuid' services/hf/entrypoint.sh && grep -q 'TARGET_UID=${HARBOR_USER_ID}' services/compose.hf.yml
  tags: [hf-xet, spec, implemented]

## perplexideez
- label: compose.x.perplexideez.{ollama,searxng}.yml set env on perplexideez-service (the app), not the perplexideez nginx proxy, and LLM_MODE carries no literal quotes
  command: grep -q 'perplexideez-service:' services/compose.x.perplexideez.ollama.yml && grep -q 'perplexideez-service:' services/compose.x.perplexideez.searxng.yml && grep -q 'LLM_MODE=ollama' services/compose.x.perplexideez.ollama.yml
  tags: [sweep7, implemented]

## bolt
- label: bolt runs the prebuilt bolt.diy image without an in-container rebuild; startup fetches wrangler@4 via npx because the upstream production image prunes it
  command: grep -q 'npx -y wrangler@4' services/compose.bolt.yml && ! grep -q 'pnpm run build' services/compose.bolt.yml
  tags: [sweep5, implemented]

## airweave
- label: airweave backend receives required upstream settings (STATE_SECRET, SVIX_JWT_SECRET, DENSE_EMBEDDER local_minilm, EMBEDDING_DIMENSIONS 384, SPARSE_EMBEDDER fastembed_bm25) and reaches the text2vec service through the /health-rewriting nginx shim
  command: grep -q STATE_SECRET services/compose.airweave.yml && grep -q airweave-embeddings-proxy services/compose.airweave.yml && grep -q HARBOR_AIRWEAVE_EMBEDDING_DIMENSIONS profiles/default.env && test -f services/airweave/embeddings-proxy.conf
  tags: [sweep5, implemented]
- label: airweave backend healthcheck declares start_period 60s so first-boot migrations (~20s+) do not fail the initial harbor up
  command: grep -A6 'http://localhost:8001/health' services/compose.airweave.yml | grep -q 'start_period: 60s' && echo PASS
  tags: [airweave, spec, implemented]

## opencode
- label: opencode data/config defaults live under ./services/opencode/ with the data dir gitignored, so runtime sqlite/logs never dirty the repo
  command: grep -q 'HARBOR_OPENCODE_DATA="./services/opencode/data"' profiles/default.env && grep -q 'HARBOR_OPENCODE_CONFIG="./services/opencode/config"' profiles/default.env && git check-ignore -q services/opencode/data && git ls-files --error-unmatch services/opencode/config/opencode.json
  tags: [sweep8, implemented]
- label: with an active backend, compose.opencode.ts chains the model-discovery script through the /harbor-entrypoint.sh setpriv wrapper (and the harbor-cli-only path does the same), so the opencode process runs as the host user, not root
  command: grep -c "'/harbor-entrypoint.sh'" services/compose.opencode.ts | grep -qx 2
  tags: [opencode-setpriv, spec, implemented]

## lemonade
- label: lemonade workspace default lives under ./services/lemonade/data (gitignored), not a stray ./lemonade dir at repo root
  command: grep -q 'HARBOR_LEMONADE_WORKSPACE="./services/lemonade/data"' profiles/default.env && grep -q 'services/lemonade/data/' .gitignore
  tags: [sweep8, implemented]

## omniparser
- label: omniparser attaches the NVIDIA GPU via a compose.x.omniparser.nvidia.yml capability overlay (CUDA-only image; without it the container never saw a GPU)
  command: docker compose --env-file .env -f compose.yml -f services/compose.omniparser.yml -f services/compose.x.omniparser.nvidia.yml config --quiet
  tags: [sweep8, implemented]

## bench
- label: profiles/default.env points HARBOR_BENCH_TASKS/RESULTS under ./services/bench (the bundled defaultTasks.yml actually exists there), so 'harbor bench run' mounts a file, not a docker-created empty directory
  command: grep -q 'HARBOR_BENCH_TASKS="./services/bench/defaultTasks.yml"' profiles/default.env && grep -q 'HARBOR_BENCH_RESULTS="./services/bench/results"' profiles/default.env && test -f services/bench/defaultTasks.yml
  tags: [sweep8, implemented]
- label: services/bench/results exists in the repo (tracked .gitkeep) so the bench results bind mount stays host-owned instead of being docker-created as root
  command: test -f services/bench/results/.gitkeep && git ls-files --error-unmatch services/bench/results/.gitkeep >/dev/null 2>&1
  tags: [sweep8, spec, implemented]

## nanobot
- label: services/nanobot/entrypoint.sh forces gateway.host=0.0.0.0 in config.json so the health endpoint is reachable through the published container port
  command: grep -q "config\['gateway'\]\['host'\] = '0.0.0.0'" services/nanobot/entrypoint.sh
  tags: [sweep8, implemented]
- label: compose.nanobot.yml mounts HARBOR_NANOBOT_CONFIG_DIR at /home/nanobot/.nanobot — the config dir nanobot resolves from the image's HOME — so Harbor's backend injection and persistence actually reach nanobot
  command: grep -q ':/home/nanobot/.nanobot' services/compose.nanobot.yml && ! grep -q ':/root/.nanobot' services/compose.nanobot.yml
  tags: [sweep8, implemented]

## tts
- label: tts mounts Harbor's tracked services/tts/config customization and keeps downloaded voices in gitignored services/tts/voices instead of a stray repo-root ./tts dir
  command: grep -q 'HARBOR_TTS_CONFIG_FOLDER="./services/tts/config"' profiles/default.env && grep -q 'HARBOR_TTS_VOICES_FOLDER="./services/tts/voices"' profiles/default.env && git check-ignore -q services/tts/voices && git ls-files --error-unmatch services/tts/config/pre_process_map.yaml
  tags: [sweep8, implemented]

## lmeval
- label: lmeval runtime cache/results dirs under services/lmeval are gitignored so eval runs never dirty the repo
  command: git check-ignore -q services/lmeval/cache/anyfile && git check-ignore -q services/lmeval/results/anyfile
  tags: [sweep8, implemented]
- label: services/lmeval cache and results dirs exist in the repo (tracked .gitkeep) so the bind mounts stay host-owned instead of docker-created as root
  command: test -f services/lmeval/cache/.gitkeep && test -f services/lmeval/results/.gitkeep && git ls-files --error-unmatch services/lmeval/cache/.gitkeep services/lmeval/results/.gitkeep >/dev/null 2>&1
  tags: [sweep8, spec, implemented]

## raglite
- label: raglite image installs llama-cpp-python explicitly because the raglite CLI unconditionally probes llama.cpp at startup while the package no longer hard-depends on it
  command: grep -q 'pip install llama-cpp-python' services/raglite/Dockerfile
  tags: [sweep8, implemented]

## mcpforge
- label: compose.mcpforge.yml runs the gateway as HARBOR_USER_ID and gates it on an mcpforge-init sidecar that pre-creates a host-owned sqlite db, so first boot succeeds
  command: grep -q 'mcpforge-init' services/compose.mcpforge.yml && test -f services/mcpforge/data-init.sh
  tags: [sweep9, implemented]

## openfang
- label: compose.openfang.ts entrypoint resolves the config dir from OPENFANG_HOME so the generated config.toml (api_listen + backend wiring) is actually read by the daemon
  command: grep -q 'OPENFANG_HOME' services/compose.openfang.ts
  tags: [sweep9, implemented]

## ros-mcp-server
- label: Dockerfile installs upstream via its pyproject (with headless opencv) and compose keeps stdin open so the stdio MCP server stays running under harbor up
  command: grep -q 'pip install --no-cache-dir \.' services/ros-mcp-server/Dockerfile && grep -q 'stdin_open: true' services/compose.ros-mcp-server.yml
  tags: [sweep9, implemented]

## openclaw
- label: entrypoint.sh update path guards missing gateway.auth/gateway.remote objects and no longer emits the removed controlUi.allowInsecureAuth key
  command: grep -q 'if (!config.gateway.remote)' services/openclaw/entrypoint.sh && ! grep -q 'allowInsecureAuth": true' services/openclaw/entrypoint.sh
  tags: [sweep9, implemented]
- label: entrypoint.sh update path backfills gateway.mode on existing configs so upstream gateway start is not blocked by missing gateway.mode
  command: grep -q 'config.gateway.mode' services/openclaw/entrypoint.sh
  tags: [sweep9-boot, implemented]
- label: entrypoint.sh update path scrubs the removed meta.lastTouchedAt key from older configs so upstream schema validation passes
  command: grep -q 'lastTouchedAt' services/openclaw/entrypoint.sh
  tags: [sweep9-boot, implemented]
- label: entrypoint.sh runs openclaw doctor --fix non-interactively before gateway start on existing configs so legacy device identities and schema drift are migrated automatically
  command: grep -q 'doctor --fix' services/openclaw/entrypoint.sh
  tags: [sweep9-boot, implemented]

## mi
- label: HARBOR_MI_LLAMACPP_MODEL defaults to LiquidAI/LFM2.5-8B-A1B-GGUF:Q8_0, matching the fleet-wide llama.cpp default
  command: grep -q 'HARBOR_MI_LLAMACPP_MODEL="LiquidAI/LFM2.5-8B-A1B-GGUF:Q8_0"' profiles/default.env
  tags: [sweep9, implemented]
- label: services/mi/data exists in the repo (gitkeep) so the mi workspace bind mount is host-owned instead of docker-created root-owned
  command: git ls-files --error-unmatch services/mi/data/.gitkeep
  tags: [sweep9-boot, implemented]

## ownership
- label: flowise persists data at /home/node/.flowise (image runs as node uid 1000, HOME=/home/node); the workspace bind mount targets that path, not /root/.flowise
  command: grep -q '/home/node/.flowise' services/compose.flowise.yml && ! grep -q '/root/.flowise' services/compose.flowise.yml
  tags: [ownership2, spec, implemented]
- label: perplexica-be runs as the host user (HARBOR_USER_ID) with an init sidecar chowning ./services/perplexica/data, so db.sqlite stays host-owned
  command: grep -q 'HARBOR_USER_ID' services/compose.perplexica.yml && grep -q 'perplexica-init' services/compose.perplexica.yml
  tags: [ownership2, spec, implemented]
- label: sillytavern runs as the host user (HARBOR_USER_ID), so files it writes into the workspace bind mounts stay host-owned
  command: grep -q 'HARBOR_USER_ID' services/compose.sillytavern.yml
  tags: [ownership2, spec, implemented]
- label: flowise version is pinned to 3.1.3 (3.1.4/latest crash at startup in the vendored connect-sqlite3 session store)
  command: grep -q 'HARBOR_FLOWISE_VERSION="3.1.3"' profiles/default.env
  tags: [ownership2, spec, implemented]
- label: lightrag, paperless, paperless-gpt, whishper and linkwarden each have an <handle>-init alpine sidecar (services/<handle>/workspace-init.sh) that chowns the workspace bind mount to HARBOR_USER_ID:HARBOR_GROUP_ID before the main container starts (depends_on service_completed_successfully)
  command: for h in lightrag paperless paperless-gpt whishper linkwarden; do grep -q "$h-init:" services/compose.$h.yml && grep -q "service_completed_successfully" services/compose.$h.yml && test -f services/$h/workspace-init.sh && grep -Eq "^\s*chown (-R )?\"\\$\{TARGET_UID[^\"]*:\\$\{TARGET_GID[^\"]*\" \"?/workspace" services/$h/workspace-init.sh || { echo "$h"; exit 1; }; done; ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C lightrag | jq -e --arg u "$(ev HARBOR_USER_ID)" --arg g "$(ev HARBOR_GROUP_ID)" '.services as $s | ($s.lightrag.depends_on["lightrag-init"].condition=="service_completed_successfully") and ($s["lightrag-init"] | (.image|startswith("alpine:")) and .entrypoint==["/bin/sh","/init.sh"] and .environment.TARGET_UID==$u and .environment.TARGET_GID==$g and ([.volumes[]|select(.target=="/init.sh" and (.source|endswith("/services/lightrag/workspace-init.sh")))]|length==1) and ([.volumes[]|select(.target=="/workspace")][0].source == ($s.lightrag.volumes[]|select(.target=="/app/data")|.source)))' >/dev/null
  tags: [lightrag, ownership4, spec, implemented]
- label: lightrag and linkwarden run as the host user (user: HARBOR_USER_ID:HARBOR_GROUP_ID); lightrag mounts its workspace as a single bind mount and linkwarden mounts only the data/ and meili/ targets that linkwarden-init chowns first, because Docker creates missing bind-mount targets root-owned
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C lightrag | jq -e --arg u "$(ev HARBOR_USER_ID):$(ev HARBOR_GROUP_ID)" '.services.lightrag | .user==$u and ([.volumes[]|select(.type=="bind")]|length==1) and (.volumes[0].target=="/app/data")' >/dev/null && grep -q "user: \${HARBOR_USER_ID}:\${HARBOR_GROUP_ID}" services/compose.lightrag.yml && grep -q "user: \${HARBOR_USER_ID}:\${HARBOR_GROUP_ID}" services/compose.linkwarden.yml && grep -q "HARBOR_LIGHTRAG_WORKSPACE}:/app/data" services/compose.lightrag.yml && ! grep -q "rag_storage:/app" services/compose.lightrag.yml && grep -Eq '^\s*- \$\{HARBOR_LINKWARDEN_WORKSPACE\}/data:/data/data' services/compose.linkwarden.yml && grep -Eq '^\s*- \$\{HARBOR_LINKWARDEN_WORKSPACE\}/meili:/meili_data' services/compose.linkwarden.yml && ! grep -Eq '^\s*- \$\{HARBOR_LINKWARDEN_WORKSPACE\}:/' services/compose.linkwarden.yml
  tags: [lightrag, ownership4, spec, implemented]

## llamacpp
- label: compose.llamacpp.yml additionally binds HARBOR_HF_CACHE read-only at ${HOME}/.cache/huggingface so llama.cpp-cache GGUF symlinks targeting absolute host HF-cache blob paths resolve inside the container
  command: grep -q ':${HOME}/.cache/huggingface:ro' services/compose.llamacpp.yml
  tags: [spec, cache-symlinks, implemented]

## ikllamacpp
- label: compose.ikllamacpp.yml additionally binds HARBOR_HF_CACHE read-only at ${HOME}/.cache/huggingface so llama.cpp-cache GGUF symlinks targeting absolute host HF-cache blob paths resolve inside the container
  command: grep -q ':${HOME}/.cache/huggingface:ro' services/compose.ikllamacpp.yml
  tags: [spec, cache-symlinks, implemented]

## ollama
- label: compose.ollama.yml additionally binds HARBOR_HF_CACHE read-only at ${HOME}/.cache/huggingface so llama.cpp-cache GGUF symlinks targeting absolute host HF-cache blob paths resolve inside the container
  command: grep -q ':${HOME}/.cache/huggingface:ro' services/compose.ollama.yml
  tags: [spec, cache-symlinks, implemented]
- label: the ollama-init sidecar is slim: it runs on alpine:3.20 (instead of the multi-GB ollama image), mounts only services/ollama/init_entrypoint.sh with no /root/.ollama volume since pulls go through the ollama server's HTTP API, gets no GPU devices even under the rocm capability (which still gives the ollama server /dev/kfd, /dev/dri and the :rocm image), waits for ollama service_healthy, and flips to healthy on /run/harbor/ollama-init-done (2s interval, 900 retries) so consumers like cognee and cognee-mcp keep depending on it with service_healthy
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C ollama cognee | jq -e '(.services["ollama-init"] | .image=="alpine:3.20" and ([.volumes[]|select(.target=="/root/.ollama")]|length==0) and ([.volumes[]|select(.target=="/init_entrypoint.sh" and (.source|endswith("/services/ollama/init_entrypoint.sh")))]|length==1) and .entrypoint==["/init_entrypoint.sh"] and .depends_on.ollama.condition=="service_healthy" and (.healthcheck | .test==["CMD-SHELL","test -f /run/harbor/ollama-init-done"] and .interval=="2s" and .retries==900)) and .services.cognee.depends_on["ollama-init"].condition=="service_healthy" and .services["cognee-mcp"].depends_on["ollama-init"].condition=="service_healthy"' >/dev/null && C ollama rocm | jq -e '(.services.ollama | (.image|endswith(":rocm")) and ([.devices[].source]|index("/dev/kfd")!=null and index("/dev/dri")!=null)) and (.services["ollama-init"] | .image=="alpine:3.20" and (.devices==null or (.devices|length==0)) and (.deploy.resources.reservations.devices==null))' >/dev/null && ! grep -q 'ollama-init' services/compose.x.ollama.rocm.yml && grep -q '^HARBOR_OLLAMA_DEFAULT_MODELS="nomic-embed-text:latest"$' profiles/default.env
  tags: [ollama-init, implemented]
- label: the ollama-init entrypoint is POSIX sh with no ollama CLI and no apk add: it pulls each comma-separated HARBOR_OLLAMA_DEFAULT_MODELS entry by streaming a POST to /api/pull with busybox wget, rejects model names containing quotes or backslashes, accepts a pull only when the stream ends with status success, warns and continues past a model that fails, and always creates the /run/harbor/ollama-init-done marker before staying alive so one bad model id never blocks dependents
  id: eda
  command: s=services/ollama/init_entrypoint.sh; head -1 $s | grep -qx '#!/bin/sh' && shellcheck -s sh $s && ! grep -Eq 'read -ra|pipefail|\[\[|ollama pull|apk add|local ' $s && grep -q 'wget' $s && grep -q '/api/pull' $s && grep -q '"stream":true' $s && grep -q 'Model names containing quotes or backslashes are not supported' $s && grep -q 'tail -n 1' $s && grep -q '"status":"success"' $s && grep -q 'Pulled model' $s && grep -q 'touch /run/harbor/ollama-init-done' $s && grep -q 'exec tail -f /dev/null' $s && d=$(mktemp -d); printf '#!/bin/sh\necho stub wget failure >&2; exit 1\n' >$d/wget; chmod +x $d/wget; docker run --rm -e PATH=/stub:/usr/local/bin:/usr/bin:/bin -e HARBOR_OLLAMA_DEFAULT_MODELS='probe-bad:latest, probe-second:latest' -v $d:/stub:ro -v $PWD/services/ollama/init_entrypoint.sh:/init_entrypoint.sh:ro alpine:3.20 sh -c '/init_entrypoint.sh >/tmp/out 2>&1 & sleep 3; cat /tmp/out; grep -q WARNING /tmp/out && grep -q probe-bad /tmp/out && grep -q probe-second /tmp/out && test -f /run/harbor/ollama-init-done'; r=$?; rm -rf $d; exit $r
  tags: [ollama-init, implemented]
- label: [runtime: ollama up] after ./harbor.sh up --no-defaults ollama the harbor.ollama-init container reports healthy and Ollama on host port 33821 (HARBOR_OLLAMA_HOST_PORT) lists the default model nomic-embed-text:latest in /api/tags, pulled by the slim sidecar over HTTP
  command: docker inspect -f '{{.Config.Image}} {{.State.Status}} {{.State.Health.Status}}' harbor.ollama-init | grep -q '^alpine:3.20 running healthy$' && curl -sf localhost:33821/api/tags | jq -e '[.models[].name]|index("nomic-embed-text:latest")!=null' >/dev/null
  tags: [ollama-init, runtime, implemented]

## ktransformers
- label: compose.ktransformers.yml additionally binds HARBOR_HF_CACHE read-only at ${HOME}/.cache/huggingface so llama.cpp-cache GGUF symlinks targeting absolute host HF-cache blob paths resolve inside the container
  command: grep -q ':${HOME}/.cache/huggingface:ro' services/compose.ktransformers.yml
  tags: [spec, cache-symlinks, implemented]

## jupyter
- label: compose.jupyter.yml additionally binds HARBOR_HF_CACHE read-only at ${HOME}/.cache/huggingface so llama.cpp-cache GGUF symlinks targeting absolute host HF-cache blob paths resolve inside the container
  command: grep -q ':${HOME}/.cache/huggingface:ro' services/compose.jupyter.yml
  tags: [spec, cache-symlinks, implemented]

## solo
- label: services/solo config and data dirs exist in the repo (tracked .gitkeep) so the workspace bind mounts stay host-owned instead of docker-created as root
  command: test -f services/solo/config/.gitkeep && test -f services/solo/data/.gitkeep && git ls-files --error-unmatch services/solo/config/.gitkeep services/solo/data/.gitkeep >/dev/null 2>&1
  tags: [sweep8, spec, implemented]

## tei
- label: tei is a backend service at services/compose.tei.yml serving HuggingFace text-embeddings-inference on host port 35030 (container port 80)
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C tei | jq -e --arg p "$(ev HARBOR_TEI_HOST_PORT)" '.services.tei | (.image|startswith("ghcr.io/huggingface/text-embeddings-inference:")) and (.ports|length==1) and .ports[0].target==80 and .ports[0].published==$p and (.command|index("--port") as $i|.[$i+1]=="80")' >/dev/null && grep -q "^HARBOR_TEI_HOST_PORT=35030$" profiles/default.env
  tags: [tei, spec, implemented]
- label: tei model is selected via HARBOR_TEI_MODEL (passed as --model-id) with default BAAI/bge-small-en-v1.5
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_MODEL=probe-model C tei | jq -e '.services.tei.command | index("--model-id") as $i | $i!=null and .[$i+1]=="probe-model"' >/dev/null && grep -q '^HARBOR_TEI_MODEL="BAAI/bge-small-en-v1.5"$' profiles/default.env
  tags: [tei, spec, implemented]
- label: tei passes HARBOR_TEI_EXTRA_ARGS (default empty) verbatim to text-embeddings-router and HARBOR_HF_TOKEN into the container as HF_TOKEN for gated models
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_EXTRA_ARGS="--probe-flag probe-val" HARBOR_HF_TOKEN=probe-tok C tei | jq -e '.services.tei | (.command|index("--probe-flag") as $i|$i!=null and .[$i+1]=="probe-val") and .environment.HF_TOKEN=="probe-tok"' >/dev/null && grep -q '^HARBOR_TEI_EXTRA_ARGS=""$' profiles/default.env
  tags: [tei, spec, implemented]
- label: tei mounts the host HuggingFace hub cache (HARBOR_HF_CACHE/hub) at /data so models are downloaded once in the standard cache layout
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_HF_CACHE=/probe-cache C tei | jq -e '.services.tei.volumes | length==1 and .[0].type=="bind" and .[0].source=="/probe-cache/hub" and .[0].target=="/data"' >/dev/null && grep -q 'HARBOR_HF_CACHE:-./services/tei/.hf-cache}/hub:/data' services/compose.tei.yml
  tags: [tei, spec, implemented]
- label: tei defaults to the CPU image tag (HARBOR_TEI_VERSION=cpu-latest); compose.x.tei.nvidia.yml swaps to the CUDA tag (HARBOR_TEI_VERSION_CUDA, default latest) and reserves all nvidia GPUs
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_VERSION=probe-cpu HARBOR_TEI_VERSION_CUDA=probe-cuda C tei | jq -e '.services.tei.image=="ghcr.io/huggingface/text-embeddings-inference:probe-cpu"' >/dev/null && HARBOR_TEI_VERSION=probe-cpu HARBOR_TEI_VERSION_CUDA=probe-cuda C tei nvidia | jq -e '.services.tei | .image=="ghcr.io/huggingface/text-embeddings-inference:probe-cuda" and (.deploy.resources.reservations.devices | length==1 and .[0].driver=="nvidia" and .[0].count==-1 and (.[0].capabilities|index("gpu")!=null))' >/dev/null && grep -q '^HARBOR_TEI_VERSION="cpu-latest"$' profiles/default.env && grep -q '^HARBOR_TEI_VERSION_CUDA="latest"$' profiles/default.env
  tags: [tei, spec, implemented]
- label: tei exposes POST /embed and OpenAI-compatible POST /v1/embeddings returning float vectors
  command: curl -sf localhost:35030/v1/embeddings -H 'Content-Type: application/json' -d '{"input":"hello"}' | grep -q '"embedding"'
  tags: [tei, spec, implemented]
- label: tei is documented at docs/2.2.26-Backend-Text-Embeddings-Inference.md and registered in serviceMetadata.ts as a backend with HST.backend and HST.api tags, the upstream projectUrl and the doc wikiUrl
  command: test -f docs/2.2.26-Backend-Text-Embeddings-Inference.md && grep -A6 '^    tei: {' app/src/serviceMetadata.ts | grep -q "tags: \[HST.backend, HST.api\]" && grep -A6 '^    tei: {' app/src/serviceMetadata.ts | grep -q "projectUrl: 'https://github.com/huggingface/text-embeddings-inference'" && grep -A6 '^    tei: {' app/src/serviceMetadata.ts | grep -q '2.2.26-Backend-Text-Embeddings-Inference`' && grep -A6 '^    tei: {' app/src/serviceMetadata.ts | grep -q "logo: 'https://"
  tags: [tei, spec, implemented]
- label: tei healthcheck probes GET /health on container port 80 every 5s and its start_period is 30m so a first-run model download does not flip the container to unhealthy
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C tei | jq -e '.services.tei.healthcheck | .test[0]=="CMD-SHELL" and (.test|join(" ")|test("/dev/tcp/127\\.0\\.0\\.1/80 ") and test("GET /health HTTP") and test("200 OK")) and .start_period=="30m0s" and .interval=="5s" and .retries>=10' >/dev/null
  tags: [tei, spec, implemented]
- label: tei ships a screenshot of its Swagger UI at docs/harbor-tei.png embedded in its doc
  command: test -f docs/harbor-tei.png && grep -q 'harbor-tei.png' docs/2.2.26-Backend-Text-Embeddings-Inference.md
  tags: [tei, spec, implemented]
- label: compose.x.webui.tei.yml makes webui depend on healthy tei and sets RAG_EMBEDDING_ENGINE=openai, RAG_OPENAI_API_BASE_URL=http://tei:80/v1, RAG_OPENAI_API_KEY and RAG_EMBEDDING_MODEL=HARBOR_TEI_MODEL
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_MODEL=probe-model C tei webui | jq -e '.services.webui | .depends_on.tei.condition=="service_healthy" and (.environment | .RAG_EMBEDDING_ENGINE=="openai" and .RAG_OPENAI_API_BASE_URL=="http://tei:80/v1" and .RAG_EMBEDDING_MODEL=="probe-model" and (.RAG_OPENAI_API_KEY|length>0))' >/dev/null
  tags: [tei, spec, implemented]
- label: compose.x.webui.tei.yml mounts services/webui/configs/config.tei.json at /app/configs/config.tei.json, which webui renders and upserts into its persisted config on every start, so TEI becomes the embedder on existing installs, not only first boot
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C tei webui | jq -e '[.services.webui.volumes[]|select(.type=="bind" and .target=="/app/configs/config.tei.json" and (.source|endswith("/services/webui/configs/config.tei.json")))]|length==1' >/dev/null && grep -Eq '^\s*- \./services/webui/configs/config\.tei\.json:/app/configs/config\.tei\.json' services/compose.x.webui.tei.yml
  tags: [tei, spec, implemented]
- label: services/webui/configs/config.tei.json is valid JSON setting rag.embedding_engine=openai, rag.embedding_model=${HARBOR_TEI_MODEL}, rag.openai.api_base_url=http://tei:80/v1 and a non-empty rag.openai.api_key, and nothing else
  command: jq -e '(keys==["rag"]) and (.rag|keys==["embedding_engine","embedding_model","openai"]) and .rag.embedding_engine=="openai" and .rag.embedding_model=="${HARBOR_TEI_MODEL}" and (.rag.openai|keys==["api_base_url","api_key"]) and .rag.openai.api_base_url=="http://tei:80/v1" and (.rag.openai.api_key|length>0)' services/webui/configs/config.tei.json >/dev/null
  tags: [tei, spec, implemented]
- label: compose.x.librechat.tei.yml makes librechat-rag depend on healthy tei and sets EMBEDDINGS_PROVIDER=openai with RAG_OPENAI_BASEURL=http://tei:80/v1, RAG_OPENAI_API_KEY and EMBEDDINGS_MODEL=HARBOR_TEI_MODEL (the rag-api lite image lacks langchain_huggingface, so the native huggingfacetei provider crashes at import)
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_MODEL=probe-model C tei librechat | jq -e '.services["librechat-rag"] | .depends_on.tei.condition=="service_healthy" and (.environment | .EMBEDDINGS_PROVIDER=="openai" and .RAG_OPENAI_BASEURL=="http://tei:80/v1" and .EMBEDDINGS_MODEL=="probe-model" and (.RAG_OPENAI_API_KEY|length>0))' >/dev/null && ! grep -q 'huggingfacetei' <(grep -v '^\s*#' services/compose.x.librechat.tei.yml)
  tags: [tei, spec, implemented]
- label: compose.x.anythingllm.tei.yml makes anythingllm depend on healthy tei and points its generic-openai embedder at http://tei:80/v1 (EMBEDDING_MODEL_PREF=HARBOR_TEI_MODEL, EMBEDDING_MODEL_MAX_CHUNK_LENGTH=512, GENERIC_OPEN_AI_EMBEDDING_API_KEY); whole-document ingest is proven at runtime by the [runtime: anythingllm+tei up] fact, not by these env vars
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_MODEL=probe-model C tei anythingllm | jq -e '.services.anythingllm | .depends_on.tei.condition=="service_healthy" and (.environment | .EMBEDDING_ENGINE=="generic-openai" and .EMBEDDING_BASE_PATH=="http://tei:80/v1" and .EMBEDDING_MODEL_PREF=="probe-model" and .EMBEDDING_MODEL_MAX_CHUNK_LENGTH=="512" and (.GENERIC_OPEN_AI_EMBEDDING_API_KEY|length>0))' >/dev/null
  tags: [tei, spec, implemented]
- label: compose.x.lightrag.tei.yml makes lightrag depend on healthy tei and sets EMBEDDING_BINDING=openai, EMBEDDING_BINDING_HOST=http://tei:80/v1, EMBEDDING_BINDING_API_KEY, EMBEDDING_MODEL=HARBOR_TEI_MODEL and EMBEDDING_DIM=HARBOR_LIGHTRAG_TEI_EMBEDDING_DIM (default 384)
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_MODEL=probe-model HARBOR_LIGHTRAG_TEI_EMBEDDING_DIM=4321 C tei lightrag | jq -e '.services.lightrag | .depends_on.tei.condition=="service_healthy" and (.environment | .EMBEDDING_BINDING=="openai" and .EMBEDDING_BINDING_HOST=="http://tei:80/v1" and .EMBEDDING_MODEL=="probe-model" and .EMBEDDING_DIM=="4321" and (.EMBEDDING_BINDING_API_KEY|length>0))' >/dev/null && grep -q "^HARBOR_LIGHTRAG_TEI_EMBEDDING_DIM=384$" profiles/default.env
  tags: [tei, spec, implemented]
- label: compose.x.traefik.tei.yml routes Host(tei.HARBOR_TRAEFIK_DOMAIN) to container port 80 over the traefik-public network
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C tei traefik | jq -e --arg d "$(ev HARBOR_TRAEFIK_DOMAIN)" '.services.tei | (.labels | .["traefik.enable"]=="true" and .["traefik.http.routers.tei.rule"]=="Host(`tei."+$d+"`)" and .["traefik.http.services.tei.loadbalancer.server.port"]=="80") and (.networks|has("traefik-public"))' >/dev/null
  tags: [tei, spec, implemented]
- label: tei doc follows the Windmill format in order: **Key Features** list, then #### Starting, #### Configuration, ##### GPU, #### Integration with Harbor, #### Troubleshooting, #### Links
  command: d=docs/2.2.26-Backend-Text-Embeddings-Inference.md; prev=0; for h in "#### Starting" "#### Configuration" "##### Environment Variables" "##### GPU" "#### Integration with Harbor" "#### Troubleshooting" "#### Links"; do n=$(grep -n -m1 "^$h$" $d | cut -d: -f1); [ -n "$n" ] && [ "$n" -gt "$prev" ] || { echo "missing or misordered: $h"; exit 1; }; prev=$n; done; [ "$(grep -A6 "^\*\*Key Features" $d | grep -c "^- ")" -ge 3 ] && grep -q "harbor up tei nvidia" $d && grep -q "^# Start TEI" $d
  tags: [tei, spec, implemented]
- label: tei /info reports the loaded model id matching HARBOR_TEI_MODEL
  command: curl -sf localhost:35030/info | grep -q "\"model_id\":\"$(./harbor.sh config get tei.model)\""
  tags: [tei, spec, implemented]
- label: services/tei/check-webui-embed.sh asserts GET /api/v1/retrieval/embedding reports engine openai at http://tei:80/v1, uploads via POST /api/v1/files/, and only passes when TEI's log gains an openai_embed line
  command: s=services/tei/check-webui-embed.sh; test -x $s && grep -q "/api/v1/retrieval/embedding" $s && grep -q '"openai"' $s && grep -q "http://tei:80/v1" $s && grep -q "/api/v1/files/" $s && grep -q "grep -c openai_embed" $s && grep -Eq '\[ "\$after" -gt "\$before" \].*exit 0' $s && grep -Eq "^echo .*; exit 1$" $s && ! grep -Eq "^\s*exit 0" $s
  tags: [tei, spec, implemented]
- label: services/tei/check-librechat-embed.sh asserts librechat-rag runs with RAG_OPENAI_BASEURL=http://tei:80/v1, uploads through POST /api/files with endpoint=agents and tool_resource=file_search, and only passes when TEI's log gains an openai_embed line
  command: s=services/tei/check-librechat-embed.sh; test -x $s && grep -q "RAG_OPENAI_BASEURL=http://tei:80/v1" $s && grep -q "/api/files" $s && grep -q '"endpoint", "agents"' $s && grep -q '"tool_resource", "file_search"' $s && grep -q "grep -c openai_embed" $s && grep -Eq '\[ "\$after" -gt "\$before" \].*exit 0' $s && grep -Eq "^echo .*; exit 1$" $s && ! grep -Eq "^\s*exit 0" $s
  tags: [tei, spec, implemented]
- label: [runtime: webui+tei up] with harbor up webui tei, GET /api/v1/retrieval/embedding reports engine openai at http://tei:80/v1 and uploading a file to /api/v1/files/ produces an openai_embed line in TEI's log (services/tei/check-webui-embed.sh; SKIPs with exit 1 when the stack is not running)
  command: ./services/tei/check-webui-embed.sh
  tags: [tei, spec, implemented]
- label: [runtime: librechat+tei up] with harbor up librechat tei, librechat-rag stays running with RAG_OPENAI_BASEURL=http://tei:80/v1, and uploading a file to an Agents-endpoint agent as a File Search resource (POST /api/files with endpoint=agents and tool_resource=file_search, the request LibreChat's agent builder sends; plain-chat attachments never embed) is ingested by rag-api and produces an openai_embed line in TEI's log (services/tei/check-librechat-embed.sh; SKIPs with exit 1 when the stack is not running)
  command: ./services/tei/check-librechat-embed.sh
  tags: [tei, spec, implemented]
- label: tei runs text-embeddings-router with --max-client-batch-size HARBOR_TEI_MAX_CLIENT_BATCH_SIZE (default 512, TEI's own default is 32) so a RAG tool that sends every chunk of a document in one /v1/embeddings call (AnythingLLM: 50+ inputs for a 3,000-word file) is not rejected with 422
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TEI_MAX_CLIENT_BATCH_SIZE=4321 C tei | jq -e '.services.tei.command | index("--max-client-batch-size") as $i | $i!=null and .[$i+1]=="4321"' >/dev/null && grep -q "^HARBOR_TEI_MAX_CLIENT_BATCH_SIZE=512$" profiles/default.env
  tags: [tei, spec, implemented]
- label: services/tei/check-anythingllm-embed.sh asserts anythingllm runs with EMBEDDING_BASE_PATH=http://tei:80/v1, creates a throwaway workspace, uploads a 3,000+ word file through POST /api/workspace/<slug>/upload-and-embed, fails on a TEI 'batch size' rejection or an empty workspace document list, only passes when TEI's log gains an openai_embed line, and deletes the workspace and document afterwards
  command: s=services/tei/check-anythingllm-embed.sh; test -x $s && grep -q "EMBEDDING_BASE_PATH=http://tei:80/v1" $s && grep -q "/workspace/new" $s && grep -q "upload-and-embed" $s && grep -q "seq 1 400" $s && grep -q "batch size" $s && grep -q "documents | length >= 1" $s && grep -q "grep -c openai_embed" $s && grep -Eq "^\[ \"\\\$after\" -gt \"\\\$before\" \]" $s && grep -q "DELETE \"\$api/workspace/\$slug\"" $s && grep -q "remove-documents" $s && grep -q "trap cleanup EXIT" $s && ! grep -Eq "^\s*exit 0" $s
  tags: [tei, spec, implemented]
- label: [runtime: anythingllm+tei up] with harbor up anythingllm tei, uploading a 3,000+ word document to a workspace (POST /api/workspace/<slug>/upload-and-embed, the drag-into-chat path) sends all of its 50+ chunks in one /v1/embeddings call that TEI accepts (no 422 batch size line), TEI's log gains an openai_embed line and the workspace lists the document (services/tei/check-anythingllm-embed.sh; SKIPs with exit 1 when the stack is not running)
  command: ./services/tei/check-anythingllm-embed.sh
  tags: [tei, spec, implemented]
- label: tei doc troubleshooting covers the 422 'batch size N > maximum allowed batch size' rejection (cause: RAG tools send whole documents in one call; fix: HARBOR_TEI_MAX_CLIENT_BATCH_SIZE or AnythingLLM's GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS) and states that over-long inputs are auto-truncated by default and only rejected with 422 under --auto-truncate false
  command: d=docs/2.2.26-Backend-Text-Embeddings-Inference.md; grep -q "^##### .batch size N > maximum allowed batch size. (422)$" $d && grep -q "HARBOR_TEI_MAX_CLIENT_BATCH_SIZE" $d && grep -q "GENERIC_OPEN_AI_EMBEDDING_MAX_CONCURRENT_CHUNKS=32" $d && grep -q "check-anythingllm-embed.sh" $d && grep -q "^##### Inputs too long$" $d && grep -q "auto_truncate.:true" $d && grep -q "\-\-auto-truncate false" $d && grep -q "422 inputs must have less than 512 tokens" $d && ! grep -q "413" $d
  tags: [tei, spec, implemented]
- label: tei doc content matches the service shape: the env table lists HARBOR_TEI_HOST_PORT 35030, HARBOR_TEI_MAX_CLIENT_BATCH_SIZE 512 and HARBOR_TEI_EXTRA_ARGS, the Volumes list names the HF hub cache at /data and override.env, the Integration table has one row per cross-file consumer (webui, librechat, anythingllm, lightrag) with end-user walkthroughs for Open WebUI, AnythingLLM and LibreChat, the CUDA tags named exist upstream (turing-latest, 86-latest, 89-latest), and the model-change recipe lists a re-index step per consumer
  command: d=docs/2.2.26-Backend-Text-Embeddings-Inference.md; grep -q "^HARBOR_TEI_HOST_PORT *35030$" $d && grep -q "^HARBOR_TEI_MAX_CLIENT_BATCH_SIZE *512$" $d && grep -q "^HARBOR_TEI_EXTRA_ARGS$" $d && grep -q "^##### Volumes$" $d && grep -q "/hub. (default .*) is mounted at ./data." $d && grep -q "services/tei/override.env" $d && for c in webui librechat anythingllm lightrag; do grep -q "^| .harbor up $c tei. |" $d || { echo "missing row: $c"; exit 1; }; done && for w in "Using it from Open WebUI" "Using it from AnythingLLM" "Using it from LibreChat"; do grep -q "^$w" $d || { echo "missing walkthrough: $w"; exit 1; }; done && grep -q "turing-latest" $d && grep -q "86-latest" $d && grep -q "89-latest" $d && for c in "Open WebUI" LibreChat AnythingLLM LightRAG; do grep -q "^- \*\*$c\*\*: " $d || { echo "missing re-index: $c"; exit 1; }; done
  tags: [tei, spec, implemented]

## lightrag
- label: lightrag service compose file at services/compose.lightrag.yml exposes port 9621 on HARBOR_LIGHTRAG_HOST_PORT and persists /app/data under HARBOR_LIGHTRAG_WORKSPACE
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C lightrag | jq -e --arg p "$(ev HARBOR_LIGHTRAG_HOST_PORT)" '.services.lightrag | (.ports|length==1 and .[0].target==9621 and .[0].published==$p) and ([.volumes[]|select(.target=="/app/data")]|length==1) and (.volumes[]|select(.target=="/app/data")|.source|endswith("/services/lightrag/data"))' >/dev/null
  tags: [lightrag, implemented]
- label: lightrag ollama cross-file runs the LLM through Ollama's OpenAI-compatible /v1 (LLM_BINDING=openai at HARBOR_OLLAMA_INTERNAL_URL/v1, OPENAI_LLM_EXTRA_BODY reasoning_effort=HARBOR_LIGHTRAG_OLLAMA_REASONING_EFFORT default none so Qwen3-style thinking is off), keeps embeddings on the native binding (EMBEDDING_BINDING=ollama), and depends on ollama with condition: service_healthy
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_LIGHTRAG_OLLAMA_REASONING_EFFORT=probe-effort C lightrag ollama | jq -e --arg u "$(ev HARBOR_OLLAMA_INTERNAL_URL)" '.services.lightrag | (.depends_on.ollama.condition=="service_healthy") and (.environment | .LLM_BINDING=="openai" and .LLM_BINDING_HOST==$u+"/v1" and .EMBEDDING_BINDING=="ollama" and .EMBEDDING_BINDING_HOST==$u and ((.OPENAI_LLM_EXTRA_BODY|fromjson)=={"reasoning_effort":"probe-effort"}))' >/dev/null && grep -q '^HARBOR_LIGHTRAG_OLLAMA_REASONING_EFFORT="none"$' profiles/default.env
  tags: [lightrag, spec, implemented]
- label: lightrag llamacpp cross-file uses the openai binding against http://llamacpp:8080/v1 for the LLM, depends on llamacpp with condition: service_healthy, and sends OPENAI_LLM_EXTRA_BODY chat_template_kwargs.enable_thinking=HARBOR_LIGHTRAG_LLAMACPP_THINKING (default false) so Qwen3-family models answer instead of reasoning past the per-call timeout
  command: grep -q 'LLM_BINDING=openai' services/compose.x.lightrag.llamacpp.yml && grep -q 'http://llamacpp:8080/v1' services/compose.x.lightrag.llamacpp.yml && grep -q 'condition: service_healthy' services/compose.x.lightrag.llamacpp.yml && grep -q 'enable_thinking":${HARBOR_LIGHTRAG_LLAMACPP_THINKING}' services/compose.x.lightrag.llamacpp.yml && grep -q 'HARBOR_LIGHTRAG_LLAMACPP_THINKING=false' profiles/default.env
  tags: [lightrag, implemented]
- label: lightrag tei cross-file points EMBEDDING_BINDING=openai at the tei container with EMBEDDING_MODEL=HARBOR_TEI_MODEL
  command: grep -q 'EMBEDDING_BINDING=openai' services/compose.x.lightrag.tei.yml && grep -q 'http://tei:80/v1' services/compose.x.lightrag.tei.yml && grep -q 'HARBOR_TEI_MODEL' services/compose.x.lightrag.tei.yml
  tags: [lightrag, implemented]
- label: all HARBOR_LIGHTRAG_ vars referenced by lightrag compose files are defined in profiles/default.env
  command: for v in $(grep -ohE "HARBOR_LIGHTRAG_[A-Z_]+" services/compose.lightrag.yml services/compose.x.lightrag.*.yml | sort -u); do grep -q "^$v=" profiles/default.env || { echo missing $v; exit 1; }; done
  tags: [lightrag, implemented]
- label: lightrag is registered in serviceMetadata.ts as a satellite with the rag tag and documented in docs/2.3.93-Satellite-LightRAG.md
  command: grep -A2 "^    lightrag: {" app/src/serviceMetadata.ts | grep -q "tags: \[HST.satellite, HST.rag" && grep -A5 "^    lightrag: {" app/src/serviceMetadata.ts | grep -q "2.3.93-Satellite-LightRAG" && test -f docs/2.3.93-Satellite-LightRAG.md && test -f docs/harbor-lightrag.png && grep -q "harbor-lightrag.png" docs/2.3.93-Satellite-LightRAG.md
  tags: [lightrag, implemented]
- label: [runtime: lightrag ollama up] harbor up lightrag ollama with shipped defaults ingests a document via POST /documents/text, GET /graphs returns at least one node afterwards, at least 4 of 5 different POST /query mode=hybrid questions answer with the expected entity in unhedged prose rather than [no-context], keyword lists or extraction JSON, and the check deletes its document afterwards leaving the document list identical to before (services/lightrag/check-graph.sh --stack ollama; refuses to run unless the lightrag container's LLM_BINDING_HOST is ollama). Observed on the shipped defaults: 5/5 consecutive runs passed, 25/25 answers grounded, 0 JSON, knowledge base restored after each; a user document uploaded afterwards answered 5/5 hybrid and 5/5 naive questions
  id: 2pr
  command: ./services/lightrag/check-graph.sh --stack ollama
  tags: [lightrag, implemented]
- label: lightrag llamacpp cross-file serializes LLM calls (MAX_ASYNC=HARBOR_LIGHTRAG_LLAMACPP_MAX_ASYNC default 1, MAX_PARALLEL_INSERT=1) and bounds each call (LLM_TIMEOUT=HARBOR_LIGHTRAG_LLAMACPP_TIMEOUT default 180, OPENAI_LLM_MAX_COMPLETION_TOKENS=HARBOR_LIGHTRAG_LLAMACPP_MAX_TOKENS default 4096) so extraction workers never queue into LightRAG's 480s worker timeout against a single-model llama-server
  command: grep -q 'MAX_ASYNC=${HARBOR_LIGHTRAG_LLAMACPP_MAX_ASYNC}' services/compose.x.lightrag.llamacpp.yml && grep -q 'MAX_PARALLEL_INSERT=1' services/compose.x.lightrag.llamacpp.yml && grep -q 'LLM_TIMEOUT=${HARBOR_LIGHTRAG_LLAMACPP_TIMEOUT}' services/compose.x.lightrag.llamacpp.yml && grep -q 'OPENAI_LLM_MAX_COMPLETION_TOKENS=${HARBOR_LIGHTRAG_LLAMACPP_MAX_TOKENS}' services/compose.x.lightrag.llamacpp.yml && grep -q '^HARBOR_LIGHTRAG_LLAMACPP_MAX_ASYNC=1' profiles/default.env && grep -q '^HARBOR_LIGHTRAG_LLAMACPP_TIMEOUT=180' profiles/default.env && grep -q '^HARBOR_LIGHTRAG_LLAMACPP_MAX_TOKENS=4096' profiles/default.env
  tags: [lightrag, spec, implemented]
- label: [runtime: lightrag llamacpp tei up] harbor up lightrag llamacpp tei with shipped defaults ingests a paragraph via POST /documents/text within 5 minutes and GET /graphs returns at least one node afterwards (services/lightrag/check-graph.sh --stack llamacpp --ingest-only; the script refuses to run unless the lightrag container's LLM_BINDING_HOST is llamacpp). Answers on this path are experimental and not asserted: measured on shipped defaults, 3 of 3 consecutive full check-graph.sh --stack llamacpp runs failed with 2 of 15 hybrid answers grounded and 13 returned as keyword/entity JSON, and 14 of 15 ad-hoc questions across naive/local/global/hybrid/mix modes returned keyword JSON; the doc states these numbers and points users at the ollama path for answers
  id: pj4
  command: ./services/lightrag/check-graph.sh --stack llamacpp --ingest-only
  tags: [lightrag, spec, implemented]
- label: the LightRAG doc names Ollama as the recommended LLM backend and the only one whose runtime check passes, marks both llamacpp rows of the Backends table experimental, and its troubleshooting entry for keyword/extraction JSON answers states what was observed (reproduces on ROCm builds of both llama-server and Ollama when an answer call follows a JSON-mode call on the same runner, not on CPU, not a prompt-cache or sampling artifact) together with the measured pass rates, instead of claiming Ollama is unaffected
  id: myx
  command: grep -q 'Ollama is the recommended LLM backend, and the only one Harbor' docs/2.3.93-Satellite-LightRAG.md && [ "$(grep -c 'harbor up lightrag llamacpp .*(experimental)' docs/2.3.93-Satellite-LightRAG.md)" = 2 ] && grep -q 'follows a JSON-mode call' docs/2.3.93-Satellite-LightRAG.md && ! grep -q 'ollama unaffected\|which is not affected' docs/2.3.93-Satellite-LightRAG.md && grep -q '5 of 5 consecutive' docs/2.3.93-Satellite-LightRAG.md
  tags: [lightrag, spec, implemented]
- label: lightrag WORKING_DIR is keyed by embedder: /app/data/rag_storage/openai by default, overridden to rag_storage/ollama and rag_storage/tei by the respective cross-files, so switching embedders never causes an embedding dim mismatch
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; [ "$(C lightrag | jq -r .services.lightrag.environment.WORKING_DIR)" = /app/data/rag_storage/openai ] && [ "$(C lightrag ollama | jq -r .services.lightrag.environment.WORKING_DIR)" = /app/data/rag_storage/ollama ] && [ "$(C lightrag tei | jq -r .services.lightrag.environment.WORKING_DIR)" = /app/data/rag_storage/tei ] && [ "$(C lightrag llamacpp ollama | jq -r .services.lightrag.environment.WORKING_DIR)" = /app/data/rag_storage/ollama ]
  tags: [lightrag, spec, implemented]
- label: lightrag and paperless-gpt llamacpp cross-files ship a non-empty default model (HARBOR_LIGHTRAG_LLAMACPP_MODEL / HARBOR_PAPERLESS_GPT_LLAMACPP_MODEL) because empty model names 400 against the llama.cpp router
  command: grep -qE "^HARBOR_LIGHTRAG_LLAMACPP_MODEL=\"[^\"]+\"" profiles/default.env && grep -q "HARBOR_PAPERLESS_GPT_LLAMACPP_MODEL=\"LiquidAI" profiles/default.env
  tags: [lightrag, paperless, spec, implemented]
- label: compose.x.lightrag.llamacpp.ollama.yml (3-dot, applied after the 2-dot cross-files) keeps the LLM on llamacpp (openai binding at http://llamacpp:8080/v1, HARBOR_LIGHTRAG_LLAMACPP_MODEL) while the ollama cross-file supplies embeddings, so 'harbor up lightrag llamacpp ollama' generates with llama.cpp and embeds with Ollama
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_LIGHTRAG_LLAMACPP_MODEL=probe-llm C lightrag llamacpp ollama | jq -e --arg u "$(ev HARBOR_OLLAMA_INTERNAL_URL)" '.services.lightrag.environment | .LLM_BINDING=="openai" and .LLM_BINDING_HOST=="http://llamacpp:8080/v1" and .LLM_MODEL=="probe-llm" and .EMBEDDING_BINDING=="ollama" and .EMBEDDING_BINDING_HOST==$u' >/dev/null
  tags: [lightrag, spec, implemented]
- label: compose.x.webui.lightrag.yml registers http://lightrag:9621 as an Open WebUI Ollama connection (services/webui/configs/config.lightrag.json: per-URL api_config with an X-API-Key header and prefix_id lightrag) and whitelists only /api/tags, /api/version and /api/ps on lightrag, so lightrag:latest appears as a model in Open WebUI while /api/chat and /documents stay key-protected
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C lightrag ollama webui | jq -e '(.services.lightrag.environment.WHITELIST_PATHS=="/health,/api/tags,/api/version,/api/ps") and ([.services.webui.volumes[]|select(.target=="/app/configs/config.lightrag.json" and (.source|endswith("/services/webui/configs/config.lightrag.json")))]|length==1) and (.services.webui.depends_on.lightrag.condition=="service_healthy")' >/dev/null && jq -e '.ollama | (.base_urls==["http://lightrag:9621"]) and (.api_configs["http://lightrag:9621"] | .enable==true and .prefix_id=="lightrag" and (.headers["X-API-Key"]|test("HARBOR_LIGHTRAG_API_KEY")))' services/webui/configs/config.lightrag.json >/dev/null && [ "$(C lightrag | jq -r .services.lightrag.environment.WHITELIST_PATHS)" = /health ]
  tags: [lightrag, spec, implemented]
- label: lightrag healthcheck polls /health with a start_period and the container carries harbor.service/harbor.port labels; compose.x.traefik.lightrag.yml routes lightrag.HARBOR_TRAEFIK_DOMAIN to port 9621
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C lightrag traefik | jq -e --arg d "$(ev HARBOR_TRAEFIK_DOMAIN)" '.services.lightrag | (.healthcheck | .test[0]=="CMD" and (.test|join(" ")|test("http://127\\.0\\.0\\.1:9621/health\\W")) and (.start_period|test("^[0-9]+[ms]"))) and (.labels | .["harbor.service"]=="lightrag" and .["harbor.port"]=="9621" and .["traefik.enable"]=="true" and .["traefik.http.routers.lightrag.rule"]=="Host(`lightrag."+$d+"`)" and .["traefik.http.services.lightrag.loadbalancer.server.port"]=="9621") and (.networks|has("traefik-public"))' >/dev/null
  tags: [lightrag, spec, implemented]
- label: lightrag metadata logo is the HKUDS GitHub avatar, and docs/2.3.93-Satellite-LightRAG.md follows the Windmill doc layout (#### Starting/Configuration/Integration with Harbor/Troubleshooting/Links) with a backend selection table and an Open WebUI section
  command: grep -A4 "^    lightrag: {" app/src/serviceMetadata.ts | grep -q "logo: .https://github.com/HKUDS.png" && d=docs/2.3.93-Satellite-LightRAG.md; prev=0; for h in "#### Starting" "#### Configuration" "##### Backends" "#### Integration with Harbor" "##### Open WebUI" "#### Troubleshooting" "#### Links"; do n=$(grep -n -m1 "^$h$" $d | cut -d: -f1); [ -n "$n" ] && [ "$n" -gt "$prev" ] || { echo "missing or misordered: $h"; exit 1; }; prev=$n; done; [ "$(grep -A4 "^\*\*Key Features" $d | grep -c "^- ")" -ge 3 ] && sed -n "/^##### Backends/,/^#####/p" $d | grep -q "^| .harbor up lightrag ollama" && grep -q "harbor up lightrag ollama webui" $d
  tags: [lightrag, spec, implemented]
- label: on harbor up lightrag ollama the lightrag container's entrypoint is wrapped by services/lightrag/ollama-init.sh (mounted at /harbor/ollama-init.sh, no cross-file-only sidecar, so harbor down lightrag works without naming ollama): it pulls HARBOR_LIGHTRAG_OLLAMA_MODEL and HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_MODEL, derives lightrag/<model> with num_ctx=HARBOR_LIGHTRAG_OLLAMA_EXTRACT_NUM_CTX and lightrag/<model>-query with num_ctx=HARBOR_LIGHTRAG_OLLAMA_NUM_CTX via /api/create (Ollama's /v1 ignores per-request num_ctx), then execs the image entrypoint; the healthcheck start_period is raised to 30m to cover the first pull
  id: l0i
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_LIGHTRAG_OLLAMA_MODEL=probe-base HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_MODEL=probe-embed HARBOR_LIGHTRAG_OLLAMA_EXTRACT_NUM_CTX=1111 HARBOR_LIGHTRAG_OLLAMA_NUM_CTX=2222 C lightrag ollama | jq -e --arg u "$(ev HARBOR_OLLAMA_INTERNAL_URL)" '(.services|has("lightrag-ollama-init")|not) and (.services.lightrag | .entrypoint==["/bin/sh","/harbor/ollama-init.sh"] and ([.volumes[]|select(.target=="/harbor/ollama-init.sh" and (.source|endswith("/services/lightrag/ollama-init.sh")))]|length==1) and .healthcheck.start_period=="30m0s" and (.environment | .OLLAMA_HOST==$u and .LIGHTRAG_OLLAMA_BASE_MODEL=="probe-base" and .LIGHTRAG_OLLAMA_EMBEDDING_MODEL=="probe-embed" and .LIGHTRAG_OLLAMA_EXTRACT_MODEL=="lightrag/probe-base" and .LIGHTRAG_OLLAMA_QUERY_MODEL=="lightrag/probe-base-query" and .LIGHTRAG_OLLAMA_EXTRACT_NUM_CTX=="1111" and .LIGHTRAG_OLLAMA_NUM_CTX=="2222"))' >/dev/null && s=services/lightrag/ollama-init.sh && grep -q "^set -eu$" $s && grep -q "^ *post /api/pull .*{base}" $s && grep -q "^ *post /api/pull .*{embedding}" $s && grep -Eq "^ *post /api/create .*num_ctx" $s && grep -Eq "^ *create \"\\$\{extract\}\" \"\\$\{extract_ctx\}\"" $s && grep -Eq "^ *create \"\\$\{query\}\" \"\\$\{query_ctx\}\"" $s && grep -q "^exec /usr/local/bin/docker-entrypoint.sh " $s && grep -q '^HARBOR_LIGHTRAG_OLLAMA_MODEL="qwen3.5:4b"$' profiles/default.env
  tags: [lightrag, spec, implemented]
- label: lightrag ollama cross-file sends extraction/keyword calls to lightrag/<model> (LLM_MODEL) and query answers to lightrag/<model>-query (QUERY_LLM_MODEL), two derived models with different parameters so Ollama runs them on separate runners; the 3-dot llamacpp.ollama cross-file resets QUERY_LLM_MODEL to HARBOR_LIGHTRAG_LLAMACPP_MODEL. Rationale: on ROCm Ollama an answer call that follows a JSON-mode call on the same runner returns that JSON instead of an answer
  id: l0j
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_LIGHTRAG_OLLAMA_MODEL=probe-base C lightrag ollama | jq -e '.services.lightrag.environment | .LLM_MODEL=="lightrag/probe-base" and .QUERY_LLM_MODEL=="lightrag/probe-base-query"' >/dev/null && HARBOR_LIGHTRAG_LLAMACPP_MODEL=probe-llm C lightrag llamacpp ollama | jq -e '.services.lightrag.environment | .LLM_MODEL=="probe-llm" and .QUERY_LLM_MODEL=="probe-llm"' >/dev/null
  tags: [lightrag, spec, implemented]
- label: services/lightrag/check-graph.sh accepts --stack ollama|llamacpp (inspects the lightrag container's LLM_BINDING_HOST and exits non-zero with SKIP when another backend is up), --ingest-only, --corpus (five notes plus a multi-chunk 3,600-word file, questions asked in hybrid and naive mode, failed ingests fail the check), --queries N (default 5) and --keep; asks N different questions per mode, counts an answer only when it contains the expected entity and does not hedge (no record/not real/fictional/cannot find), fails when more than one answer per mode misses, and on exit (pass or fail) deletes its own documents via DELETE /documents/delete_document and fails if the document id list differs from the snapshot taken before ingest, so the check never leaves anything in the user's knowledge base
  id: l0k
  command: c=services/lightrag/check-graph.sh; grep -q -- "--keep" $c && grep -q -- "--corpus" $c && grep -q "modes=(hybrid naive)" $c && grep -q "FAIL: ingest failed" $c && grep -q "delete_document" $c && grep -q "trap cleanup EXIT" $c && grep -q "fictional" $c && grep -q "differs from the pre-check snapshot" $c && grep -q "not grounded" $c && bash -n $c
  tags: [lightrag, spec, implemented]
- label: lightrag runs entity extraction in JSON mode (ENTITY_EXTRACTION_USE_JSON from HARBOR_LIGHTRAG_EXTRACTION_JSON, default true) because the delimiter-based default yields 0 entities / 0 relations with the shipped local models, which left the Knowledge Graph empty and graph modes answering [no-context]
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; [ "$(HARBOR_LIGHTRAG_EXTRACTION_JSON=probe-json C lightrag | jq -r .services.lightrag.environment.ENTITY_EXTRACTION_USE_JSON)" = probe-json ] && grep -q "^HARBOR_LIGHTRAG_EXTRACTION_JSON=true$" profiles/default.env
  tags: [lightrag, spec, implemented]
- label: lightrag ships RERANK_BY_DEFAULT=false (HARBOR_LIGHTRAG_RERANK_BY_DEFAULT) so queries no longer log 'Rerank is enabled but no rerank model is configured' while RERANK_BINDING is null
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; [ "$(HARBOR_LIGHTRAG_RERANK_BY_DEFAULT=probe-rerank C lightrag | jq -r .services.lightrag.environment.RERANK_BY_DEFAULT)" = probe-rerank ] && grep -q "^HARBOR_LIGHTRAG_RERANK_BY_DEFAULT=false$" profiles/default.env
  tags: [lightrag, spec, implemented]
- label: lightrag ollama default model is qwen3.5:4b (qwen2.5:0.5b/1.5b and qwen3:0.6b return garbage for the long graph-mode answer prompts on ROCm Ollama), and the doc names the Open WebUI model id as lightrag.lightrag:latest and describes HARBOR_LIGHTRAG_EXTRACTION_JSON, HARBOR_LIGHTRAG_RERANK_BY_DEFAULT and HARBOR_LIGHTRAG_LLAMACPP_THINKING
  id: tl8
  command: grep -q '^HARBOR_LIGHTRAG_OLLAMA_MODEL="qwen3.5:4b"' profiles/default.env && grep -q 'qwen3.5:4b' docs/2.3.93-Satellite-LightRAG.md && grep -q 'lightrag.lightrag:latest' docs/2.3.93-Satellite-LightRAG.md && grep -q 'HARBOR_LIGHTRAG_LLAMACPP_THINKING' docs/2.3.93-Satellite-LightRAG.md
  tags: [lightrag, spec, implemented]
- label: lightrag ollama cross-file sets EMBEDDING_MODEL and EMBEDDING_DIM from HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_MODEL / HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_DIM (defaults nomic-embed-text:latest / 768) so the ollama storage dir is created with the dim of the model that fills it
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_MODEL=probe-embed HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_DIM=4321 C lightrag ollama | jq -e '.services.lightrag.environment | .EMBEDDING_MODEL=="probe-embed" and .EMBEDDING_DIM=="4321"' >/dev/null && grep -q '^HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_MODEL="nomic-embed-text:latest"$' profiles/default.env && grep -q "^HARBOR_LIGHTRAG_OLLAMA_EMBEDDING_DIM=768$" profiles/default.env
  tags: [lightrag, spec, implemented]
- label: lightrag ollama cross-file serializes LLM calls (MAX_ASYNC=HARBOR_LIGHTRAG_OLLAMA_MAX_ASYNC default 1, MAX_PARALLEL_INSERT=1; LightRAG's default fan-out queued a 3,000-word file's extraction calls into the 360 s worker timeout) and caps the answer prompt (TOP_K/CHUNK_TOP_K/MAX_TOTAL_TOKENS from HARBOR_LIGHTRAG_OLLAMA_TOP_K/CHUNK_TOP_K/MAX_TOTAL_TOKENS, defaults 10/5/8000; LightRAG's 40/20/30000 gave a 4B and a 9B model 0 of 8 correct answers on a mixed corpus)
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_LIGHTRAG_OLLAMA_MAX_ASYNC=7 HARBOR_LIGHTRAG_OLLAMA_TOP_K=71 HARBOR_LIGHTRAG_OLLAMA_CHUNK_TOP_K=72 HARBOR_LIGHTRAG_OLLAMA_MAX_TOTAL_TOKENS=7300 C lightrag ollama | jq -e '.services.lightrag.environment | .MAX_ASYNC=="7" and .MAX_PARALLEL_INSERT=="1" and .TOP_K=="71" and .CHUNK_TOP_K=="72" and .MAX_TOTAL_TOKENS=="7300"' >/dev/null && grep -q "^HARBOR_LIGHTRAG_OLLAMA_MAX_ASYNC=1$" profiles/default.env && grep -q "^HARBOR_LIGHTRAG_OLLAMA_TOP_K=10$" profiles/default.env && grep -q "^HARBOR_LIGHTRAG_OLLAMA_CHUNK_TOP_K=5$" profiles/default.env && grep -q "^HARBOR_LIGHTRAG_OLLAMA_MAX_TOTAL_TOKENS=8000$" profiles/default.env
  tags: [lightrag, spec, implemented]
- label: services/compose.x.lightrag.ollama.rocm.yml (applied on ROCm hosts) sets LIGHTRAG_OLLAMA_QUERY_NUM_GPU from HARBOR_LIGHTRAG_OLLAMA_ROCM_QUERY_NUM_GPU (default 0) where the ollama cross-file sets it from HARBOR_LIGHTRAG_OLLAMA_QUERY_NUM_GPU (default -1), and ollama-init.sh bakes num_gpu into lightrag/<model>-query only when the value is 0 or more, so query answers run on CPU on ROCm Ollama (whose GPU runner answers a 2.5K-token prompt from the wrong document while CPU answers correctly) and stay on the GPU elsewhere
  command: C(){ docker compose --env-file .env "$@" config --format json; }; HARBOR_LIGHTRAG_OLLAMA_QUERY_NUM_GPU=-5 HARBOR_LIGHTRAG_OLLAMA_ROCM_QUERY_NUM_GPU=3 C -f compose.yml -f services/compose.ollama.yml -f services/compose.lightrag.yml -f services/compose.x.lightrag.ollama.yml -f services/compose.x.lightrag.ollama.rocm.yml | jq -e '.services.lightrag.environment.LIGHTRAG_OLLAMA_QUERY_NUM_GPU=="3"' >/dev/null && HARBOR_LIGHTRAG_OLLAMA_QUERY_NUM_GPU=-5 C -f compose.yml -f services/compose.ollama.yml -f services/compose.lightrag.yml -f services/compose.x.lightrag.ollama.yml | jq -e '.services.lightrag.environment.LIGHTRAG_OLLAMA_QUERY_NUM_GPU=="-5"' >/dev/null && grep -q "^HARBOR_LIGHTRAG_OLLAMA_QUERY_NUM_GPU=-1$" profiles/default.env && grep -q "^HARBOR_LIGHTRAG_OLLAMA_ROCM_QUERY_NUM_GPU=0$" profiles/default.env && grep -q '^if \[ "\${query_gpu}" -ge 0 \]; then$' services/lightrag/ollama-init.sh && grep -Eq '^ *create "\$\{query\}" "\$\{query_ctx\}" "\\"num_gpu\\":\$\{query_gpu\}"$' services/lightrag/ollama-init.sh
  tags: [lightrag, spec, implemented]
- label: [runtime: lightrag ollama up] harbor up lightrag ollama with shipped defaults answers a realistic knowledge base: check-graph.sh --stack ollama --corpus ingests five one-line notes plus a 3,600-word file that chunks into four pieces next to whatever the KB already holds, asks five questions spanning the notes and different chunks of the long file in hybrid and in naive mode, passes only when at most one answer per mode misses, and deletes all six documents again leaving the document list as before. Observed on this host (ROCm, qwen3.5:4b, query copy on CPU): 4 of 4 consecutive runs passed: 18 of 20 hybrid answers grounded (5/5, 4/5, 4/5, 5/5) and 16 of 20 naive (4/5 each; the naive miss is always the pilot question, whose chunk naive retrieval ranks below the top 5), knowledge base restored after each run; before the MAX_ASYNC/prompt-budget/CPU-query settings the same corpus scored 0 of 8 hybrid and 0 of 8 naive (takes 11-15 minutes on this host: run with facts check --timeout 1500 --tags lightrag-corpus)
  command: ./services/lightrag/check-graph.sh --stack ollama --corpus
  tags: [lightrag, implemented, lightrag-corpus]
- label: lightrag-init creates HARBOR_LIGHTRAG_WORKSPACE/inputs and /prompts (PROMPT_DIR) so every directory the doc's Volumes section lists exists after a start
  command: grep -q "^mkdir -p /workspace/inputs /workspace/prompts$" services/lightrag/workspace-init.sh && grep -q "data/prompts/" docs/2.3.93-Satellite-LightRAG.md
  tags: [lightrag, spec, implemented]
- label: the LightRAG doc states the measured mixed-corpus results (0 of 8 hybrid and naive before, the --corpus numbers after), explains the MAX_ASYNC, prompt budget and ROCm CPU-query settings under Backends, has troubleshooting entries for wrong-document/'there is no mention' answers and for 'Worker execution timeout after 360s' ingest failures, a Traefik subsection, and no longer tells the user to pull the embedding model themselves
  command: d=docs/2.3.93-Satellite-LightRAG.md; grep -q "0 of 8 hybrid and 0 of 8 naive" $d && grep -q -- "--corpus" $d && grep -q "HARBOR_LIGHTRAG_OLLAMA_MAX_ASYNC=1" $d && grep -q "HARBOR_LIGHTRAG_OLLAMA_ROCM_QUERY_NUM_GPU" $d && grep -q "Worker execution timeout after 360s" $d && grep -q "there is no mention of" $d && grep -q "^##### Traefik" $d && ! grep -q "pull nomic-embed-text. yourself" $d && ! grep -q "before v0.5.6" $d && ! grep -q "CORPUS_" $d
  tags: [lightrag, spec, implemented]
- label: services/lightrag/ollama-init.sh removes every lightrag/* model in Ollama's /api/tags other than the current lightrag/<model> and lightrag/<model>-query pair before deriving them (DELETE /api/delete), so copies from a previous HARBOR_LIGHTRAG_OLLAMA_MODEL or num_gpu setting do not accumulate in every Ollama client's model list; base and embedding models are never removed. Observed: a start with lightrag/qwen3.5:9b and -query left over logged 'removing stale' for both and ollama ls listed only the 4b pair afterwards
  command: grep -q "^prune()" services/lightrag/ollama-init.sh && grep -q "/api/delete" services/lightrag/ollama-init.sh && grep -q "name.startswith(\"lightrag/\") and name not in keep" services/lightrag/ollama-init.sh && grep -qE "^prune$" services/lightrag/ollama-init.sh
  tags: [lightrag, spec, implemented]
- label: the LightRAG doc states that HARBOR_LIGHTRAG_OLLAMA_TOP_K/CHUNK_TOP_K/MAX_TOTAL_TOKENS and RERANK_BY_DEFAULT only govern POST /query and the Ollama-compatible chat route, that the web UI's Retrieval tab sends its own Parameters panel values (frontend defaults 40/20/30000 with rerank on; the server has no setting that changes them) and tells UI users to set 10/5/8000 and untick Enable Rerank, notes that the Documents tab shows All (0) until a reload after entering the API key, and that derived lightrag/* models appear in every Ollama client and are pruned by ollama-init.sh (with a harbor ollama rm recipe)
  command: D=docs/2.3.93-Satellite-LightRAG.md; grep -q "whose defaults are hardcoded in the frontend" $D && grep -q "untick .Enable Rerank" $D && grep -q "All (0)" $D && grep -q "harbor ollama rm lightrag/" $D && grep -q "removes any other .lightrag/\*. model" $D
  tags: [lightrag, spec, implemented]

## linkwarden
- label: compose.linkwarden.yml sets restart: unless-stopped on linkwarden, linkwarden-db and linkwarden-meili (justified in a compose comment: the image runs web+worker under concurrently -k, so a worker crash exits the container) while linkwarden-init keeps no policy; linkwarden publishes port 3000, db is postgres, meili is meilisearch
  command: $(./harbor.sh cmd --no-defaults linkwarden) config --format json | jq -e '(.services.linkwarden.ports|length==1 and .[0].target==3000) and (.services["linkwarden-db"].image|startswith("postgres:")) and (.services["linkwarden-meili"].image|test("meilisearch")) and ([.services.linkwarden,.services["linkwarden-db"],.services["linkwarden-meili"]][]|.restart=="unless-stopped") and (.services["linkwarden-init"].restart==null)' >/dev/null && grep -B6 -m1 "^    restart: unless-stopped" services/compose.linkwarden.yml | grep -q "concurrently -k"
  tags: [linkwarden, spec, implemented]
- label: every HARBOR_LINKWARDEN_* var referenced by linkwarden compose files is defined in profiles/default.env
  command: for v in $(grep -oh 'HARBOR_LINKWARDEN_[A-Z_]*' services/compose.linkwarden.yml services/compose.x.linkwarden.*.yml | sort -u); do grep -q "^$v=" profiles/default.env || { echo missing $v; exit 1; }; done
  tags: [linkwarden, spec, implemented]
- label: linkwarden ollama cross-file sets NEXT_PUBLIC_OLLAMA_ENDPOINT_URL to the internal ollama URL and OLLAMA_MODEL from HARBOR_LINKWARDEN_OLLAMA_MODEL
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; $(./harbor.sh cmd --no-defaults linkwarden ollama) config --format json | jq -e --arg u "$(ev HARBOR_OLLAMA_INTERNAL_URL)" --arg m "$(ev HARBOR_LINKWARDEN_OLLAMA_MODEL)" '.services.linkwarden.environment | .NEXT_PUBLIC_OLLAMA_ENDPOINT_URL==$u and .OLLAMA_MODEL==$m' >/dev/null
  tags: [linkwarden, spec, implemented]
- label: linkwarden llamacpp cross-file points CUSTOM_OPENAI_BASE_URL at http://llamacpp:8080/v1 with OPENAI_MODEL from HARBOR_LINKWARDEN_LLAMACPP_MODEL
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; $(./harbor.sh cmd --no-defaults linkwarden llamacpp) config --format json | jq -e --arg m "$(ev HARBOR_LINKWARDEN_LLAMACPP_MODEL)" '.services.linkwarden.environment | .CUSTOM_OPENAI_BASE_URL=="http://llamacpp:8080/v1" and .OPENAI_MODEL==$m and (.OPENAI_API_KEY|length>0)' >/dev/null
  tags: [linkwarden, spec, implemented]
- label: linkwarden has app metadata, a satellite doc at docs/2.3.97-Satellite-Linkwarden.md with screenshot, and a data .gitignore
  command: grep -q '^    linkwarden: {' app/src/serviceMetadata.ts && test -f docs/2.3.97-Satellite-Linkwarden.md && test -f docs/harbor-linkwarden.png && grep -q harbor-linkwarden.png docs/2.3.97-Satellite-Linkwarden.md && grep -q '^data' services/linkwarden/.gitignore
  tags: [linkwarden, spec, implemented]
- after harbor up linkwarden a user can sign up, save a link and receive AI-generated tags from ollama (verified manually via agent-browser) @linkwarden @spec @implemented
- label: linkwarden depends on linkwarden-db (pg_isready healthcheck) and linkwarden-meili (/health healthcheck) with condition service_healthy so Prisma migrations do not race the database on a cold start
  command: $(./harbor.sh cmd --no-defaults linkwarden) config --format json | jq -e '(.services["linkwarden-db"].healthcheck.test==["CMD-SHELL","pg_isready -U postgres"]) and (.services["linkwarden-meili"].healthcheck.test|index("http://127.0.0.1:7700/health")) and (.services.linkwarden.depends_on | .["linkwarden-db"].condition=="service_healthy" and .["linkwarden-meili"].condition=="service_healthy")' >/dev/null
  tags: [linkwarden, spec, implemented]
- label: linkwarden compose defines a curl healthcheck against /login on port 3000 (not /api/v1/public/users/1, which 404s until the first account exists and kept the container in `starting` on a cold first boot) with a start_period, justified in a compose comment
  command: $(./harbor.sh cmd --no-defaults linkwarden) config --format json | jq -e '.services.linkwarden.healthcheck | .test==["CMD","curl","-sf","http://127.0.0.1:3000/login"] and (.start_period|test("^[0-9]+s$"))' >/dev/null && ! grep -Eq '^\s*test:.*api/v1/public/users/1' services/compose.linkwarden.yml && grep -B4 -m1 '"http://127.0.0.1:3000/login"' services/compose.linkwarden.yml | grep -q "empty database"
  tags: [linkwarden, spec, implemented]
- label: linkwarden-meili runs as HARBOR_USER_ID:HARBOR_GROUP_ID with MEILI_NO_ANALYTICS=true and mounts HARBOR_LINKWARDEN_WORKSPACE/meili at /meili_data, the same directory linkwarden-init mounts at /workspace/meili and chowns (workspace-init.sh chown -R TARGET_UID:TARGET_GID /workspace), so services/linkwarden/meili stays host-owned
  id: 549
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; $(./harbor.sh cmd --no-defaults linkwarden) config --format json | jq -e --arg u "$(ev HARBOR_USER_ID):$(ev HARBOR_GROUP_ID)" '.services["linkwarden-meili"] as $m | $m.user==$u and $m.environment.MEILI_NO_ANALYTICS=="true" and ([$m.volumes[]|select(.target=="/meili_data")]|length==1) and ([$m.volumes[]|select(.target=="/meili_data")][0].source|endswith("/meili")) and (([$m.volumes[]|select(.target=="/meili_data")][0].source) == ([.services["linkwarden-init"].volumes[]|select(.target=="/workspace/meili")][0].source)) and ($m.depends_on["linkwarden-init"].condition=="service_completed_successfully")' >/dev/null && grep -Eq '^\s*chown -R "\$\{TARGET_UID[^"]*:\$\{TARGET_GID[^"]*" /workspace\s*$' services/linkwarden/workspace-init.sh
  tags: [linkwarden, spec, implemented]
- label: linkwarden has vllm and mlx cross-files wiring CUSTOM_OPENAI_BASE_URL to the backend's /v1 endpoint with OPENAI_MODEL from HARBOR_VLLM_MODEL / HARBOR_MLX_MODEL
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; for b in vllm:8000 mlx:8080; do n=${b%%:*}; $(./harbor.sh cmd --no-defaults linkwarden $n) config --format json | jq -e --arg b "http://$b/v1" --arg m "$(ev HARBOR_$(echo $n | tr a-z A-Z)_MODEL)" '.services.linkwarden.environment | .CUSTOM_OPENAI_BASE_URL==$b and .OPENAI_MODEL==$m' >/dev/null || exit 1; done
  tags: [linkwarden, spec, implemented]
- label: the linkwarden doc follows the Windmill format: #### Starting/Configuration/Integration with Harbor/Troubleshooting/Links headings, Key Features list, Volumes and File ownership subsections
  command: d=docs/2.3.97-Satellite-Linkwarden.md; prev=0; for h in '#### Starting' '#### Configuration' '#### Integration with Harbor' '#### Troubleshooting' '#### Links'; do n=$(grep -n -m1 "^$h$" $d | cut -d: -f1); [ -n "$n" ] && [ "$n" -gt "$prev" ] || exit 1; prev=$n; done; [ "$(grep -A4 '^\*\*Key Features' $d | grep -c '^- ')" -ge 3 ] && grep -q '^##### Volumes' $d && grep -q '^##### File ownership' $d && awk '/^#{4,5} /{if(h!=""&&c<1&&!(h~/^#### /&&$0~/^##### /)){exit 1};h=$0;c=0;next} /^\s*$/{next} {c++} END{exit (c<1)}' $d
  tags: [linkwarden, spec, implemented]
- label: linkwarden runs as HARBOR_USER_ID:HARBOR_GROUP_ID and mounts HARBOR_LINKWARDEN_WORKSPACE/data at /data/data, the same directory linkwarden-init mounts at /workspace/data and chowns before linkwarden starts, so archived files under services/linkwarden/data stay host-owned
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; $(./harbor.sh cmd --no-defaults linkwarden) config --format json | jq -e --arg u "$(ev HARBOR_USER_ID):$(ev HARBOR_GROUP_ID)" '.services.linkwarden as $l | $l.user==$u and ([$l.volumes[]|select(.target=="/data/data")]|length==1) and (([$l.volumes[]|select(.target=="/data/data")][0].source) == ([.services["linkwarden-init"].volumes[]|select(.target=="/workspace/data")][0].source)) and ($l.depends_on["linkwarden-init"].condition=="service_completed_successfully")' >/dev/null
  tags: [linkwarden, spec, implemented]
- label: linkwarden exposes ALLOW_PRIVATE_NETWORK_ACCESS, ARCHIVE_TAKE_COUNT and BROWSER_TIMEOUT as HARBOR_LINKWARDEN_* config (defaults true/5/5) and the doc lists all three
  command: for v in ALLOW_PRIVATE_NETWORK_ACCESS ARCHIVE_TAKE_COUNT BROWSER_TIMEOUT; do grep -q "^HARBOR_LINKWARDEN_$v=" profiles/default.env && grep -q "HARBOR_LINKWARDEN_$v" docs/2.3.97-Satellite-Linkwarden.md || { echo $v; exit 1; }; done; ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; $(./harbor.sh cmd --no-defaults linkwarden) config --format json | jq -e --arg a "$(ev HARBOR_LINKWARDEN_ALLOW_PRIVATE_NETWORK_ACCESS)" --arg c "$(ev HARBOR_LINKWARDEN_ARCHIVE_TAKE_COUNT)" --arg t "$(ev HARBOR_LINKWARDEN_BROWSER_TIMEOUT)" '.services.linkwarden.environment | .ALLOW_PRIVATE_NETWORK_ACCESS==$a and .ARCHIVE_TAKE_COUNT==$c and .BROWSER_TIMEOUT==$t' >/dev/null
  tags: [linkwarden, spec, implemented]
- label: the linkwarden doc Troubleshooting has a 'Linkwarden container exited after archiving' entry (concurrently -k chain, EPIPE, restart policy, docker start resumes the backlog), covers the browser-timeout and Monolith buffer log lines, a security note on ALLOW_PRIVATE_NETWORK_ACCESS with the config to disable it, a version-pinning warning, and per-backend (ollama/llamacpp/vllm/mlx) tag verification with the tagging-lags-archiving caveat
  command: d=docs/2.3.97-Satellite-Linkwarden.md; grep -q "^##### Linkwarden container exited after archiving" $d && grep -q "concurrently -k" $d && grep -q "write EPIPE" $d && grep -q "docker start harbor.linkwarden" $d && grep -q "restart: unless-stopped" $d && grep -q "Browser has been open for more than 5 minutes" $d && grep -q "Monolith output exceeded buffer limit" $d && grep -q "^\*\*Security note:\*\*" $d && grep -q "linkwarden.allow_private_network_access false" $d && grep -q "PLAYWRIGHT_BROWSERS_PATH" $d && grep -q "llamacpp:8080/v1/models" $d && grep -q "vllm:8000/v1/models" $d && grep -q "lag" $d
  tags: [linkwarden, spec, implemented]
- after a 21-link bulk add including https://www.ietf.org/rfc/rfc2616.txt the worker crash (browser timeout -> write EPIPE -> exit 1) no longer leaves harbor.linkwarden stopped: Docker restarts it and the remaining links are archived without manual intervention (verified manually in gauntlet round 17) @linkwarden @spec @implemented

## paperless
- label: compose.paperless-gpt.yml resolved alone has no depends_on paperless, so paperless-gpt is valid standalone
  id: vqw
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless-gpt | jq -e '.services["paperless-gpt"] | (.depends_on // {}) | has("paperless") | not' >/dev/null
  tags: [paperless, spec, implemented]
- label: compose.x.paperless-gpt.paperless.yml makes paperless-gpt depend on paperless with condition service_healthy when both handles run
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless-gpt paperless | jq -e '.services["paperless-gpt"].depends_on.paperless.condition == "service_healthy"' >/dev/null
  tags: [paperless, spec, implemented]
- label: compose.paperless.yml resolves paperless to image ghcr.io/paperless-ngx/paperless-ngx:HARBOR_PAPERLESS_VERSION, publishes HARBOR_PAPERLESS_HOST_PORT to container port 8000, and sets no restart policy
  id: 9i3
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_PAPERLESS_VERSION=probe-tag HARBOR_PAPERLESS_HOST_PORT=34999 C paperless | jq -e '.services.paperless | .image == "ghcr.io/paperless-ngx/paperless-ngx:probe-tag" and (.ports | length == 1) and .ports[0].published == "34999" and .ports[0].target == 8000 and (has("restart") | not)' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless uses the paperless-valkey sidecar as its broker (PAPERLESS_REDIS=redis://paperless-valkey:6379) and PAPERLESS_DBENGINE=sqlite, with valkey persisting to <workspace>/valkey
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless | jq -e '.services.paperless.environment.PAPERLESS_REDIS == "redis://paperless-valkey:6379" and .services.paperless.environment.PAPERLESS_DBENGINE == "sqlite" and (.services["paperless-valkey"].image | startswith("valkey/valkey:")) and ([.services["paperless-valkey"].volumes[] | select(.target == "/data" and (.source | endswith("/services/paperless/data/valkey")))] | length == 1)' >/dev/null
  tags: [paperless, spec, implemented]
- label: HARBOR_PAPERLESS_ADMIN_USER / ADMIN_PASSWORD / SECRET_KEY / URL / OCR_LANGUAGE / TIME_ZONE from the environment land verbatim in the paperless container's PAPERLESS_* env (no hardcoded values)
  id: 9cw
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_PAPERLESS_ADMIN_USER=pu HARBOR_PAPERLESS_ADMIN_PASSWORD=pp HARBOR_PAPERLESS_SECRET_KEY=ps HARBOR_PAPERLESS_URL=http://probe:1 HARBOR_PAPERLESS_OCR_LANGUAGE=pl HARBOR_PAPERLESS_TIME_ZONE=pz C paperless | jq -e '.services.paperless.environment | .PAPERLESS_ADMIN_USER == "pu" and .PAPERLESS_ADMIN_PASSWORD == "pp" and .PAPERLESS_SECRET_KEY == "ps" and .PAPERLESS_URL == "http://probe:1" and .PAPERLESS_OCR_LANGUAGE == "pl" and .PAPERLESS_TIME_ZONE == "pz"' >/dev/null
  tags: [paperless, spec, implemented]
- label: profiles/default.env declares every HARBOR_PAPERLESS_* key the compose files reference, with non-empty admin user, admin password, secret key and URL defaults
  command: for k in $(grep -ho 'HARBOR_PAPERLESS_[A-Z_]*' services/compose.paperless.yml services/compose.paperless-gpt.yml services/compose.x.paperless-gpt.*.yml | sort -u); do grep -q "^$k=" profiles/default.env || { echo "missing $k"; exit 1; }; done; for k in ADMIN_USER ADMIN_PASSWORD SECRET_KEY URL; do grep -Eq "^HARBOR_PAPERLESS_$k=\"[^\"]+\"$" profiles/default.env || { echo "empty $k"; exit 1; }; done
  tags: [paperless, spec, implemented]
- label: HARBOR_PAPERLESS_WORKSPACE defaults to ./services/paperless/data, which is gitignored, and PAPERLESS_CONSUMPTION_DIR points at /workspace/consume so <workspace>/consume is the auto-import drop folder
  id: sl1
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; grep -q '^HARBOR_PAPERLESS_WORKSPACE="./services/paperless/data"$' profiles/default.env && grep -q '^data/' services/paperless/.gitignore && C paperless | jq -e '.services.paperless.environment.PAPERLESS_CONSUMPTION_DIR == "/workspace/consume"' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless mounts HARBOR_PAPERLESS_WORKSPACE exactly once, at /workspace, and the only other paperless bind mount is the read-only sync-admin-password.sh hook
  id: xzx
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_PAPERLESS_WORKSPACE=/probe/ws C paperless | jq -e '.services.paperless.volumes | length == 2 and ([.[] | select(.source == "/probe/ws" and .target == "/workspace")] | length == 1) and ([.[] | select((.source | endswith("/services/paperless/sync-admin-password.sh")) and .target == "/harbor/sync-admin-password.sh" and .read_only == true)] | length == 1)' >/dev/null
  tags: [paperless, ownership4, spec, implemented]
- label: paperless redirects PAPERLESS_DATA_DIR, PAPERLESS_MEDIA_ROOT, PAPERLESS_CONSUMPTION_DIR and PAPERLESS_EXPORT_DIR to /workspace/data, /workspace/media, /workspace/consume and /workspace/export in the resolved config
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless | jq -e '.services.paperless.environment | .PAPERLESS_DATA_DIR == "/workspace/data" and .PAPERLESS_MEDIA_ROOT == "/workspace/media" and .PAPERLESS_CONSUMPTION_DIR == "/workspace/consume" and .PAPERLESS_EXPORT_DIR == "/workspace/export"' >/dev/null
  tags: [paperless, ownership4, spec, implemented]
- label: services/paperless/workspace-init.sh, executed under stubbed mkdir/chown, chowns /workspace non-recursively and mkdir+chown -R exactly data, media, consume and export to TARGET_UID:TARGET_GID, never touching valkey/
  command: T=$(mktemp -d); printf '#!/bin/sh\necho "chown $*" >> %s/log\n' "$T" > "$T/chown"; printf '#!/bin/sh\necho "mkdir $*" >> %s/log\n' "$T" > "$T/mkdir"; chmod +x "$T/chown" "$T/mkdir"; sh -n services/paperless/workspace-init.sh && PATH="$T:$PATH" TARGET_UID=4242 TARGET_GID=4343 sh services/paperless/workspace-init.sh && r=0 || r=1; if [ $r = 0 ]; then grep -qx 'chown 4242:4343 /workspace' "$T/log" || r=1; for d in data media consume export; do grep -qx "mkdir -p /workspace/$d" "$T/log" && grep -qx "chown -R 4242:4343 /workspace/$d" "$T/log" || r=1; done; grep -q valkey "$T/log" && r=1; [ "$(grep -c '^chown' "$T/log")" = 5 ] || r=1; fi; rm -rf "$T"; exit $r
  tags: [paperless, ownership4, spec, implemented]
- label: the paperless-init alpine sidecar runs services/paperless/workspace-init.sh with TARGET_UID/TARGET_GID=HARBOR_USER_ID/HARBOR_GROUP_ID on the same workspace, and paperless waits for it with condition service_completed_successfully
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_USER_ID=4242 HARBOR_GROUP_ID=4343 C paperless | jq -e '.services as $s | $s.paperless.depends_on["paperless-init"].condition == "service_completed_successfully" and ($s["paperless-init"] | (.image | startswith("alpine:")) and .entrypoint == ["/bin/sh", "/init.sh"] and .environment.TARGET_UID == "4242" and .environment.TARGET_GID == "4343" and ([.volumes[] | select(.target == "/init.sh" and (.source | endswith("/services/paperless/workspace-init.sh")))] | length == 1) and ([.volumes[] | select(.target == "/workspace")][0].source == ([$s.paperless.volumes[] | select(.target == "/workspace")][0].source)))' >/dev/null
  tags: [paperless, ownership4, spec, implemented]
- label: paperless runs as the host user via USERMAP_UID/USERMAP_GID=HARBOR_USER_ID/HARBOR_GROUP_ID and paperless-gpt via PUID/PGID, so files written at runtime stay host-owned
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_USER_ID=4242 HARBOR_GROUP_ID=4343 C paperless paperless-gpt | jq -e '.services.paperless.environment.USERMAP_UID == "4242" and .services.paperless.environment.USERMAP_GID == "4343" and .services["paperless-gpt"].environment.PUID == "4242" and .services["paperless-gpt"].environment.PGID == "4343"' >/dev/null
  tags: [paperless, ownership4, spec, implemented]
- label: paperless-valkey's healthcheck runs valkey-cli ping and paperless depends on it with condition service_healthy
  id: j4d
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless | jq -e '(.services["paperless-valkey"].healthcheck.test | join(" ") | test("valkey-cli ping")) and .services.paperless.depends_on["paperless-valkey"].condition == "service_healthy"' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless's healthcheck probes http://localhost:8000/api/ and accepts only 200/302/401/403, so dependents can use condition service_healthy
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless | jq -e '.services.paperless.healthcheck | (.test | join(" ") | test("localhost:8000/api/") and test("200\\|302\\|401\\|403")) and .retries >= 30' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless-gpt's healthcheck is a wget against http://localhost:8080/ so dependents can use condition service_healthy
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless-gpt | jq -e '.services["paperless-gpt"].healthcheck.test | join(" ") | test("wget .*http://localhost:8080/")' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless's entrypoint copies /harbor/sync-admin-password.sh into /custom-cont-init.d, chmods it 700 and then exec /init, because the s6 hook refuses non-root-owned bind-mounted scripts
  id: 11t
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C paperless | jq -e '.services.paperless.entrypoint | .[0] == "/bin/sh" and .[1] == "-c" and (.[2] | test("mkdir -p /custom-cont-init.d") and test("cp /harbor/sync-admin-password.sh /custom-cont-init.d/") and test("chmod 700 /custom-cont-init.d/sync-admin-password.sh") and test("exec /init$"))' >/dev/null
  tags: [paperless, spec, implemented]
- label: sync-admin-password.sh's Django snippet, run against a fake User model, calls set_password+save only when the stored password does not match PAPERLESS_ADMIN_PASSWORD, and leaves a matching or missing user untouched
  command: T=$(mktemp -d); mkdir -p "$T/django/contrib/auth"; touch "$T/django/__init__.py" "$T/django/contrib/__init__.py" "$T/django/contrib/auth/__init__.py"; printf 'import os\nclass _U:\n    def __init__(s, pw): s.pw = pw; s.set = None; s.saved = False\n    def check_password(s, p): return s.pw == p\n    def set_password(s, p): s.set = p\n    def save(s): s.saved = True\nclass _Q:\n    def filter(s, username): return s\n    def first(s):\n        m = os.environ["MODE"]\n        return None if m == "missing" else _U("secret" if m == "match" else "old")\nclass User:\n    objects = _Q()\n' > "$T/django/contrib/auth/models.py"; sed -n "/<<'PY'$/,/^PY$/p" services/paperless/sync-admin-password.sh | sed '1d;$d' > "$T/snippet.py"; printf 'from django.contrib.auth.models import User\nimport runpy, sys\ng = runpy.run_path(sys.argv[1])\nu = g["user"]\nm = __import__("os").environ["MODE"]\nif m == "missing": assert u is None\nelif m == "match": assert u.set is None and not u.saved\nelse: assert u.set == "secret" and u.saved\n' > "$T/run.py"; r=0; for m in mismatch match missing; do out=$(cd "$T" && MODE=$m PAPERLESS_ADMIN_USER=admin PAPERLESS_ADMIN_PASSWORD=secret PYTHONPATH="$T" python3 run.py snippet.py) || r=1; done; [ $r = 0 ] && { echo "$out" | grep -q "does not exist yet"; } || r=1; rm -rf "$T"; exit $r
  tags: [paperless, spec, implemented]
- label: sync-admin-password.sh, executed under a stubbed python3, exits 0 without running Django when PAPERLESS_ADMIN_USER/PASSWORD are empty, and otherwise runs manage.py shell -c with the sync snippet from PAPERLESS_SRC_DIR
  command: T=$(mktemp -d); printf '#!/bin/sh\necho "python3 $*" > %s/log\n' "$T" > "$T/python3"; chmod +x "$T/python3"; bash -n services/paperless/sync-admin-password.sh || exit 1; PATH="$T:$PATH" USER_IS_NON_ROOT=1 PAPERLESS_SRC_DIR="$T" PAPERLESS_ADMIN_USER= PAPERLESS_ADMIN_PASSWORD= bash services/paperless/sync-admin-password.sh | grep -q "nothing to sync" && ! test -f "$T/log" && PATH="$T:$PATH" USER_IS_NON_ROOT=1 PAPERLESS_SRC_DIR="$T" PAPERLESS_ADMIN_USER=admin PAPERLESS_ADMIN_PASSWORD=secret bash services/paperless/sync-admin-password.sh >/dev/null && grep -q '^python3 manage.py shell -c ' "$T/log" && grep -q 'set_password' "$T/log" && grep -q 'check_password' "$T/log"; r=$?; rm -rf "$T"; exit $r
  tags: [paperless, spec, implemented]
- label: compose.paperless-gpt.yml resolves paperless-gpt to image icereed/paperless-gpt:HARBOR_PAPERLESS_GPT_VERSION, publishes HARBOR_PAPERLESS_GPT_HOST_PORT to container port 8080, and starts via the read-only mounted services/paperless-gpt/entrypoint.sh
  id: 6zk
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_PAPERLESS_GPT_VERSION=probe-tag HARBOR_PAPERLESS_GPT_HOST_PORT=34998 C paperless-gpt | jq -e '.services["paperless-gpt"] | .image == "icereed/paperless-gpt:probe-tag" and .ports[0].published == "34998" and .ports[0].target == 8080 and .entrypoint == ["/bin/sh", "/harbor/entrypoint.sh"] and ([.volumes[] | select(.target == "/harbor/entrypoint.sh" and .read_only == true and (.source | endswith("/services/paperless-gpt/entrypoint.sh")))] | length == 1)' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless-gpt's container receives PAPERLESS_BASE_URL=http://paperless:8000, PAPERLESS_API_TOKEN=HARBOR_PAPERLESS_GPT_API_TOKEN (empty by default), the Harbor admin credentials and HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS, which the entrypoint needs for the token exchange and tag bootstrap
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; grep -q '^HARBOR_PAPERLESS_GPT_API_TOKEN=""$' profiles/default.env && HARBOR_PAPERLESS_ADMIN_USER=pu HARBOR_PAPERLESS_ADMIN_PASSWORD=pp HARBOR_PAPERLESS_GPT_API_TOKEN=pt HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS="t1 t2" C paperless-gpt | jq -e '.services["paperless-gpt"].environment | .PAPERLESS_BASE_URL == "http://paperless:8000" and .PAPERLESS_API_TOKEN == "pt" and .HARBOR_PAPERLESS_ADMIN_USER == "pu" and .HARBOR_PAPERLESS_ADMIN_PASSWORD == "pp" and .HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS == "t1 t2"' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless-gpt entrypoint, executed against a stubbed wget with PAPERLESS_API_TOKEN unset, POSTs the admin credentials to PAPERLESS_BASE_URL/api/token/ and execs /app/entrypoint.sh with the returned token exported as PAPERLESS_API_TOKEN and HARBOR_PAPERLESS_ADMIN_PASSWORD unset
  command: T=$(mktemp -d); mkdir -p "$T/app"; printf '#!/bin/sh\necho "wget $*" >> %s/log\ncase "$*" in *"/api/token/"*) printf %%s "{\\"token\\": \\"tok123\\"}";; *"/api/tags/?"*) printf %%s "{\\"count\\": 1}";; esac\n' "$T" > "$T/wget"; printf '#!/bin/sh\necho "TOKEN=$PAPERLESS_API_TOKEN PW=${HARBOR_PAPERLESS_ADMIN_PASSWORD-unset} ARGS=$*"\n' > "$T/app/entrypoint.sh"; printf '#!/bin/sh\nexit 0\n' > "$T/sleep"; chmod +x "$T/wget" "$T/sleep" "$T/app/entrypoint.sh"; sh -n services/paperless-gpt/entrypoint.sh || exit 1; sed "s#exec /app/entrypoint.sh#exec $T/app/entrypoint.sh#" services/paperless-gpt/entrypoint.sh > "$T/ep.sh"; out=$(env -u PAPERLESS_API_TOKEN PATH="$T:$PATH" PAPERLESS_BASE_URL=http://probe:8000 HARBOR_PAPERLESS_ADMIN_USER=pu HARBOR_PAPERLESS_ADMIN_PASSWORD=pp HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS=x sh "$T/ep.sh" a1 a2 2>&1); r=$?; [ $r = 0 ] && echo "$out" | grep -q 'TOKEN=tok123 PW=unset ARGS=a1 a2' && grep -q -- '--post-data username=pu&password=pp http://probe:8000/api/token/' "$T/log" || r=1; rm -rf "$T"; exit $r
  tags: [paperless, spec, implemented]
- label: paperless-gpt entrypoint never calls /api/token/ when PAPERLESS_API_TOKEN (HARBOR_PAPERLESS_GPT_API_TOKEN) is already set, and passes that token through to /app/entrypoint.sh
  command: T=$(mktemp -d); mkdir -p "$T/app"; printf '#!/bin/sh\necho "wget $*" >> %s/log\nprintf %%s "{\\"count\\": 1}"\n' "$T" > "$T/wget"; printf '#!/bin/sh\necho "TOKEN=$PAPERLESS_API_TOKEN"\n' > "$T/app/entrypoint.sh"; chmod +x "$T/wget" "$T/app/entrypoint.sh"; sed "s#exec /app/entrypoint.sh#exec $T/app/entrypoint.sh#" services/paperless-gpt/entrypoint.sh > "$T/ep.sh"; out=$(PATH="$T:$PATH" PAPERLESS_API_TOKEN=given PAPERLESS_BASE_URL=http://probe:8000 HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS=x sh "$T/ep.sh" 2>&1); r=$?; [ $r = 0 ] && echo "$out" | grep -q 'TOKEN=given' && ! grep -q '/api/token/' "$T/log" || r=1; rm -rf "$T"; exit $r
  tags: [paperless, spec, implemented]
- label: paperless-gpt entrypoint exits 1 after a single /api/token/ attempt (no retry) when paperless answers HTTP 400, and its stderr names 'config set paperless.admin_password' and HARBOR_PAPERLESS_GPT_API_TOKEN as the fix
  id: thi
  command: T=$(mktemp -d); printf '#!/bin/sh\necho "wget $*" >> %s/log\necho "HTTP/1.1 400 Bad Request" >&2\nexit 8\n' "$T" > "$T/wget"; printf '#!/bin/sh\nexit 0\n' > "$T/sleep"; chmod +x "$T/wget" "$T/sleep"; err=$(PATH="$T:$PATH" PAPERLESS_API_TOKEN= PAPERLESS_BASE_URL=http://probe:8000 HARBOR_PAPERLESS_ADMIN_USER=pu HARBOR_PAPERLESS_ADMIN_PASSWORD=pp sh services/paperless-gpt/entrypoint.sh 2>&1 >/dev/null); r=$?; [ $r = 1 ] && [ "$(grep -c '/api/token/' "$T/log")" = 1 ] && echo "$err" | grep -q 'config set paperless.admin_password' && echo "$err" | grep -q 'HARBOR_PAPERLESS_GPT_API_TOKEN' && r=0 || r=1; rm -rf "$T"; exit $r
  tags: [paperless, spec, implemented]
- label: paperless-gpt entrypoint retries /api/token/ on connection failures (wget error without an HTTP 400) and proceeds once a token is returned
  command: T=$(mktemp -d); mkdir -p "$T/app"; printf '#!/bin/sh\nn=$(cat %s/n 2>/dev/null || echo 0); n=$((n+1)); echo $n > %s/n\ncase "$*" in *"/api/token/"*) if [ $n -le 2 ]; then echo "failed: Connection refused." >&2; exit 4; fi; printf %%s "{\\"token\\": \\"tok\\"}";; *) printf %%s "{\\"count\\": 1}";; esac\n' "$T" "$T" > "$T/wget"; printf '#!/bin/sh\nexit 0\n' > "$T/sleep"; printf '#!/bin/sh\necho "TOKEN=$PAPERLESS_API_TOKEN"\n' > "$T/app/entrypoint.sh"; chmod +x "$T/wget" "$T/sleep" "$T/app/entrypoint.sh"; sed "s#exec /app/entrypoint.sh#exec $T/app/entrypoint.sh#" services/paperless-gpt/entrypoint.sh > "$T/ep.sh"; out=$(PATH="$T:$PATH" PAPERLESS_API_TOKEN= PAPERLESS_BASE_URL=http://probe:8000 HARBOR_PAPERLESS_ADMIN_USER=pu HARBOR_PAPERLESS_ADMIN_PASSWORD=pp HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS= sh "$T/ep.sh" 2>&1); r=$?; [ $r = 0 ] && echo "$out" | grep -q 'TOKEN=tok' && [ "$(cat "$T/n")" -ge 3 ] || r=1; rm -rf "$T"; exit $r
  tags: [paperless, spec, implemented]
- label: paperless-gpt entrypoint creates each tag in HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS via POST /api/tags/ only when GET /api/tags/?name__iexact=<tag> reports count 0, so a fresh stack needs no manual tag setup and existing tags are not duplicated
  id: 5pg
  command: T=$(mktemp -d); mkdir -p "$T/app"; printf '#!/bin/sh\necho "wget $*" >> %s/log\ncase "$*" in *"/api/token/"*) printf %%s "{\\"token\\": \\"tok\\"}";; *"name__iexact=missing"*) printf %%s "{\\"count\\": 0, \\"results\\": []}";; *"name__iexact="*) printf %%s "{\\"count\\": 1}";; esac\n' "$T" > "$T/wget"; printf '#!/bin/sh\nexit 0\n' > "$T/app/entrypoint.sh"; chmod +x "$T/wget" "$T/app/entrypoint.sh"; sed "s#exec /app/entrypoint.sh#exec $T/app/entrypoint.sh#" services/paperless-gpt/entrypoint.sh > "$T/ep.sh"; PATH="$T:$PATH" PAPERLESS_API_TOKEN=tok PAPERLESS_BASE_URL=http://probe:8000 HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS="present missing" sh "$T/ep.sh" >/dev/null 2>&1; r=$?; [ $r = 0 ] && [ "$(grep -c 'post-data {"name":"missing","matching_algorithm":0} http://probe:8000/api/tags/' "$T/log")" = 1 ] && ! grep -q '"name":"present"' "$T/log" && grep -q -- '--header Authorization: Token tok http://probe:8000/api/tags/?name__iexact=present' "$T/log" || r=1; rm -rf "$T"; exit $r
  tags: [paperless, spec, implemented]
- label: HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS defaults to 'paperless-gpt paperless-gpt-auto' in profiles/default.env and is documented in the paperless-gpt doc
  command: grep -q '^HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS="paperless-gpt paperless-gpt-auto"$' profiles/default.env && grep -q 'HARBOR_PAPERLESS_GPT_BOOTSTRAP_TAGS' docs/2.3.95-Satellite-Paperless-GPT.md
  tags: [paperless, spec, implemented]
- label: compose.x.paperless-gpt.ollama.yml resolves LLM_PROVIDER=ollama, OLLAMA_HOST=HARBOR_OLLAMA_INTERNAL_URL, LLM_MODEL=HARBOR_PAPERLESS_GPT_OLLAMA_MODEL and OLLAMA_THINK=false, and makes paperless-gpt depend on ollama
  id: h2w
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_OLLAMA_INTERNAL_URL=http://probe-ollama:1 HARBOR_PAPERLESS_GPT_OLLAMA_MODEL=probe-model C paperless-gpt ollama | jq -e '.services["paperless-gpt"] | .environment.LLM_PROVIDER == "ollama" and .environment.OLLAMA_HOST == "http://probe-ollama:1" and .environment.LLM_MODEL == "probe-model" and .environment.OLLAMA_THINK == "false" and (.depends_on | has("ollama"))' >/dev/null
  tags: [paperless, spec, implemented]
- label: compose.x.paperless-gpt.llamacpp.yml resolves LLM_PROVIDER=openai, OPENAI_BASE_URL=http://llamacpp:8080/v1, a non-empty OPENAI_API_KEY and LLM_MODEL=HARBOR_PAPERLESS_GPT_LLAMACPP_MODEL, and makes paperless-gpt depend on llamacpp
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_PAPERLESS_GPT_LLAMACPP_MODEL=probe-gguf C paperless-gpt llamacpp | jq -e '.services["paperless-gpt"] | .environment.LLM_PROVIDER == "openai" and .environment.OPENAI_BASE_URL == "http://llamacpp:8080/v1" and (.environment.OPENAI_API_KEY | length > 0) and .environment.LLM_MODEL == "probe-gguf" and (.depends_on | has("llamacpp"))' >/dev/null
  tags: [paperless, spec, implemented]
- label: without a backend cross-file, paperless-gpt's LLM settings come from HARBOR_PAPERLESS_GPT_LLM_PROVIDER / LLM_MODEL / OPENAI_URL / OPENAI_KEY / OLLAMA_URL / LANGUAGE
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_PAPERLESS_GPT_LLM_PROVIDER=p1 HARBOR_PAPERLESS_GPT_LLM_MODEL=m1 HARBOR_PAPERLESS_GPT_OPENAI_URL=u1 HARBOR_PAPERLESS_GPT_OPENAI_KEY=k1 HARBOR_PAPERLESS_GPT_OLLAMA_URL=o1 HARBOR_PAPERLESS_GPT_LANGUAGE=l1 C paperless-gpt | jq -e '.services["paperless-gpt"].environment | .LLM_PROVIDER == "p1" and .LLM_MODEL == "m1" and .OPENAI_BASE_URL == "u1" and .OPENAI_API_KEY == "k1" and .OLLAMA_HOST == "o1" and .LLM_LANGUAGE == "l1"' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless-gpt mounts <HARBOR_PAPERLESS_GPT_WORKSPACE>/prompts, /config and /db at /app/prompts, /app/config and /app/db, with the workspace defaulting to gitignored ./services/paperless-gpt/data
  id: 2dc
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; grep -q '^HARBOR_PAPERLESS_GPT_WORKSPACE="./services/paperless-gpt/data"$' profiles/default.env && grep -q '^data/' services/paperless-gpt/.gitignore && HARBOR_PAPERLESS_GPT_WORKSPACE=/probe/gws C paperless-gpt | jq -e '[.services["paperless-gpt"].volumes[] | select(.type == "bind")] | ([.[] | select(.source == "/probe/gws/prompts" and .target == "/app/prompts")] | length == 1) and ([.[] | select(.source == "/probe/gws/config" and .target == "/app/config")] | length == 1) and ([.[] | select(.source == "/probe/gws/db" and .target == "/app/db")] | length == 1)' >/dev/null
  tags: [paperless, spec, implemented]
- label: the paperless-gpt-init alpine sidecar runs services/paperless-gpt/workspace-init.sh with TARGET_UID/TARGET_GID=HARBOR_USER_ID/HARBOR_GROUP_ID on the whole workspace, and paperless-gpt waits for it with condition service_completed_successfully
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_USER_ID=4242 HARBOR_GROUP_ID=4343 HARBOR_PAPERLESS_GPT_WORKSPACE=/probe/gws C paperless-gpt | jq -e '.services["paperless-gpt"].depends_on["paperless-gpt-init"].condition == "service_completed_successfully" and (.services["paperless-gpt-init"] | (.image | startswith("alpine:")) and .entrypoint == ["/bin/sh", "/init.sh"] and .environment.TARGET_UID == "4242" and .environment.TARGET_GID == "4343" and ([.volumes[] | select(.target == "/init.sh" and (.source | endswith("/services/paperless-gpt/workspace-init.sh")))] | length == 1) and ([.volumes[] | select(.source == "/probe/gws" and .target == "/workspace")] | length == 1))' >/dev/null
  tags: [paperless, ownership4, spec, implemented]
- label: services/paperless-gpt/workspace-init.sh, executed under stubbed mkdir/chown, creates /workspace/prompts, /workspace/config and /workspace/db before chown -R TARGET_UID:TARGET_GID /workspace, so the sub-mounts are never created root-owned by Docker on a fresh checkout
  command: T=$(mktemp -d); printf '#!/bin/sh\necho "chown $*" >> %s/log\n' "$T" > "$T/chown"; printf '#!/bin/sh\necho "mkdir $*" >> %s/log\n' "$T" > "$T/mkdir"; chmod +x "$T/chown" "$T/mkdir"; sh -n services/paperless-gpt/workspace-init.sh && PATH="$T:$PATH" TARGET_UID=4242 TARGET_GID=4343 sh services/paperless-gpt/workspace-init.sh && r=0 || r=1; if [ $r = 0 ]; then for d in prompts config db; do grep -qx "mkdir -p /workspace/$d" "$T/log" || r=1; done; [ "$(tail -n1 "$T/log")" = "chown -R 4242:4343 /workspace" ] || r=1; fi; rm -rf "$T"; exit $r
  tags: [paperless, ownership4, spec, implemented]
- label: compose.x.traefik.paperless.yml and compose.x.traefik.paperless-gpt.yml route paperless.<HARBOR_TRAEFIK_DOMAIN> to port 8000 and paperless-gpt.<HARBOR_TRAEFIK_DOMAIN> to port 8080 over the traefik-public network
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_TRAEFIK_DOMAIN=probe.test C paperless paperless-gpt traefik | jq -e '(.services.paperless | .labels["traefik.http.routers.paperless.rule"] == "Host(`paperless.probe.test`)" and .labels["traefik.http.services.paperless.loadbalancer.server.port"] == "8000" and (.networks | has("traefik-public"))) and (.services["paperless-gpt"] | .labels["traefik.http.routers.paperless-gpt.rule"] == "Host(`paperless-gpt.probe.test`)" and .labels["traefik.http.services.paperless-gpt.loadbalancer.server.port"] == "8080" and (.networks | has("traefik-public")))' >/dev/null
  tags: [paperless, spec, implemented]
- label: paperless and paperless-gpt have serviceMetadata entries tagged HST.satellite and HST.tools, each with a logo, the upstream projectUrl and the wikiUrl of its doc (2.3.94-Satellite-Paperless / 2.3.95-Satellite-Paperless-GPT)
  id: 3p6
  command: b(){ awk -v k="$1" '$0 ~ "^    "k": \\{" {p=1} p {print} p && /^    \},/ {exit}' app/src/serviceMetadata.ts; }; for pair in "paperless|paperless-ngx/paperless-ngx|2.3.94-Satellite-Paperless" "'paperless-gpt'|icereed/paperless-gpt|2.3.95-Satellite-Paperless-GPT"; do k=${pair%%|*}; rest=${pair#*|}; repo=${rest%%|*}; wiki=${rest#*|}; blk=$(b "$k"); echo "$blk" | grep -Eq "tags: \[HST\.satellite, HST\.tools\]" && echo "$blk" | grep -q "projectUrl: 'https://github.com/$repo'" && echo "$blk" | grep -Eq "logo: 'https://[^']+'" && echo "$blk" | grep -q "wikiUrl: \`\${wikiUrl}/$wiki\`" || { echo "bad $k"; exit 1; }; done
  tags: [paperless, spec, implemented]
- label: docs/2.3.94-Satellite-Paperless.md and docs/2.3.95-Satellite-Paperless-GPT.md exist, each embeds its screenshot (docs/harbor-paperless.png / docs/harbor-paperless-gpt.png) and the two docs link to each other
  command: test -f docs/harbor-paperless.png && test -f docs/harbor-paperless-gpt.png && grep -q '](./harbor-paperless.png)' docs/2.3.94-Satellite-Paperless.md && grep -q '](./harbor-paperless-gpt.png)' docs/2.3.95-Satellite-Paperless-GPT.md && grep -q '2.3.95-Satellite-Paperless-GPT.md' docs/2.3.94-Satellite-Paperless.md && grep -q '2.3.94-Satellite-Paperless.md' docs/2.3.95-Satellite-Paperless-GPT.md
  tags: [paperless, spec, implemented]
- label: both paperless docs follow the Windmill format in order: **Key Features** list, then #### Starting, #### Configuration, #### Integration with Harbor (paperless) / ##### Backend integration (paperless-gpt), #### Troubleshooting, #### Links
  id: vkc
  command: [ "$(grep -E '^(\*\*Key Features|#### )' docs/2.3.94-Satellite-Paperless.md | tr '\n' '|')" = '**Key Features:**|#### Starting|#### Configuration|#### Integration with Harbor|#### Troubleshooting|#### Links|' ] && [ "$(grep -E '^(\*\*Key Features|#### |##### Backend integration)' docs/2.3.95-Satellite-Paperless-GPT.md | tr '\n' '|')" = '**Key Features:**|#### Starting|#### Configuration|##### Backend integration|#### Troubleshooting|#### Links|' ]
  tags: [paperless, spec, implemented]
- label: every HARBOR_PAPERLESS_* and HARBOR_PAPERLESS_GPT_* variable in profiles/default.env is documented in the matching doc's Environment Variables block
  command: for v in $(grep -o '^HARBOR_PAPERLESS_GPT_[A-Z_]*' profiles/default.env); do grep -Eq "^$v( |$)" docs/2.3.95-Satellite-Paperless-GPT.md || { echo "$v"; exit 1; }; done; for v in $(grep -o '^HARBOR_PAPERLESS_[A-Z_]*' profiles/default.env | grep -v '^HARBOR_PAPERLESS_GPT_'); do grep -Eq "^$v( |$)" docs/2.3.94-Satellite-Paperless.md || { echo "$v"; exit 1; }; done
  tags: [paperless, spec, implemented]
- label: the paperless doc warns next to the default admin/admin credentials to change them before exposing the service, and states the password change applies to an existing stack
  id: kfo
  command: grep -q '^Log in with `admin` / `admin`\. Change it before exposing' docs/2.3.94-Satellite-Paperless.md && grep -q 'the change also applies to an existing stack' docs/2.3.94-Satellite-Paperless.md
  tags: [paperless, spec, implemented]
- label: the paperless-gpt doc states its UI has no login of its own while holding a full-access admin token, and tells the reader to change the default paperless credentials before exposing it
  command: grep -q '^Security note: the Paperless-GPT UI on port `35051` has no login of its own but holds a full-access admin token' docs/2.3.95-Satellite-Paperless-GPT.md && grep -q 'change the default `admin` / `admin` Paperless credentials' docs/2.3.95-Satellite-Paperless-GPT.md
  tags: [paperless, spec, implemented]
- label: the paperless doc's Troubleshooting no longer claims that HARBOR_PAPERLESS_ADMIN_* changes do not update the existing user; it states that config wins on every start, that a changed ADMIN_USER creates a second superuser, and that only the password syncs
  command: ! grep -q 'does not update the existing user' docs/2.3.94-Satellite-Paperless.md && sed -n '/^#### Troubleshooting/,/^#### Links/p' docs/2.3.94-Satellite-Paperless.md | grep -q '^- `HARBOR_PAPERLESS_ADMIN_\*` wins on every start: .*`HARBOR_PAPERLESS_ADMIN_USER` creates a second superuser.*only the password syncs'
  tags: [paperless, spec, implemented]
- label: the paperless-gpt doc documents OLLAMA_THINK=false being set by the ollama cross-file
  command: grep -q '`OLLAMA_THINK=false`' docs/2.3.95-Satellite-Paperless-GPT.md && grep -q 'OLLAMA_THINK=false' services/compose.x.paperless-gpt.ollama.yml
  tags: [paperless, spec, implemented]
- label: the hand-maintained compose.x.paperless.traefik.yml (not the seeder-generated compose.x.traefik.paperless.yml) overrides PAPERLESS_URL to https://paperless.HARBOR_TRAEFIK_DOMAIN and adds HARBOR_PAPERLESS_URL to PAPERLESS_CSRF_TRUSTED_ORIGINS, so a login through the traefik hostname is not rejected by Django's origin check while http://localhost stays trusted; the doc covers the traefik hostname and the override
  command: ! grep -q PAPERLESS_URL services/compose.x.traefik.paperless.yml && $(./harbor.sh cmd --no-defaults paperless) config --format json | jq -e '.services.paperless.environment | .PAPERLESS_URL == "http://localhost:35050" and (has("PAPERLESS_CSRF_TRUSTED_ORIGINS") | not)' >/dev/null && HARBOR_TRAEFIK_DOMAIN=probe.test $(./harbor.sh cmd --no-defaults paperless traefik) config --format json | jq -e '.services.paperless.environment | .PAPERLESS_URL == "https://paperless.probe.test" and .PAPERLESS_CSRF_TRUSTED_ORIGINS == "http://localhost:35050"' >/dev/null && grep -q 'https://paperless.<HARBOR_TRAEFIK_DOMAIN>' docs/2.3.94-Satellite-Paperless.md && grep -q 'compose.x.paperless.traefik.yml' docs/2.3.94-Satellite-Paperless.md
  tags: [paperless, traefik, spec, implemented]

- label: the hand-maintained compose.x.paperless-gpt.traefik.yml (not the seeder-generated compose.x.traefik.paperless-gpt.yml) overrides PAPERLESS_PUBLIC_URL to https://paperless.HARBOR_TRAEFIK_DOMAIN so the UI's open-in-Paperless links land on the traefik hostname, while without traefik it stays HARBOR_PAPERLESS_URL
  command: ! grep -q PAPERLESS_PUBLIC_URL services/compose.x.traefik.paperless-gpt.yml && $(./harbor.sh cmd --no-defaults paperless-gpt) config --format json | jq -e '.services["paperless-gpt"].environment.PAPERLESS_PUBLIC_URL == "http://localhost:35050"' >/dev/null && HARBOR_TRAEFIK_DOMAIN=probe.test $(./harbor.sh cmd --no-defaults paperless-gpt traefik) config --format json | jq -e '.services["paperless-gpt"].environment.PAPERLESS_PUBLIC_URL == "https://paperless.probe.test"' >/dev/null && grep -q "compose.x.paperless-gpt.traefik.yml" docs/2.3.95-Satellite-Paperless-GPT.md
  tags: [paperless, traefik, spec, implemented]
- label: with traefik selected, the paperless-gpt router carries the paperless-gpt-auth basicauth middleware whose users come from HARBOR_PAPERLESS_GPT_TRAEFIK_USERS (default admin:<apr1 hash of admin>, $ doubled in default.env), so https://paperless-gpt.HARBOR_TRAEFIK_DOMAIN answers 401 without credentials; the doc documents the variable and the htpasswd+sed set command
  command: ! grep -q middlewares services/compose.x.traefik.paperless-gpt.yml && grep -q '^HARBOR_PAPERLESS_GPT_TRAEFIK_USERS="admin:\$\$apr1\$\$' profiles/default.env && $(./harbor.sh cmd --no-defaults paperless-gpt traefik) config --format json | jq -e '.services["paperless-gpt"].labels | .["traefik.http.routers.paperless-gpt.middlewares"] == "paperless-gpt-auth" and (.["traefik.http.middlewares.paperless-gpt-auth.basicauth.users"] | startswith("admin:$$apr1$$"))' >/dev/null && grep -q "HARBOR_PAPERLESS_GPT_TRAEFIK_USERS" docs/2.3.95-Satellite-Paperless-GPT.md && grep -q "htpasswd -nbB" docs/2.3.95-Satellite-Paperless-GPT.md
  tags: [paperless, traefik, spec, implemented]
- label: the paperless-gpt doc states that tag and correspondent suggestions are limited to values already existing in Paperless-ngx and tells the reader to create them first, and its llamacpp bullet mentions the 400 model not found hint and the first-request model load time
  command: grep -q "already exist in Paperless-ngx" docs/2.3.95-Satellite-Paperless-GPT.md && grep -q "not found" docs/2.3.95-Satellite-Paperless-GPT.md && grep -qi "loads the model" docs/2.3.95-Satellite-Paperless-GPT.md
  tags: [paperless, spec, implemented]

## whishper
- label: whishper starts via services/whishper/entrypoint.sh (compose entrypoint /bin/sh /harbor/entrypoint.sh, bind-mounted read-only) which, executed under stubs against the image's nginx.conf, rewrites the hardcoded proxy_pass http://translate:5000 to http://127.0.0.1:5000 when TRANSLATION_ENDPOINT is unset (other proxy_pass lines untouched) and writes the result back to /etc/nginx/nginx.conf, so the container serves the UI without libretranslate
  id: r3y
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e '.services.whishper | .entrypoint==["/bin/sh","/harbor/entrypoint.sh"] and ([.volumes[]|select(.target=="/harbor/entrypoint.sh" and .read_only==true and (.source|endswith("/services/whishper/entrypoint.sh")))]|length==1)' >/dev/null && services/whishper/check-entrypoint.sh nginx-default
  tags: [whishper, spec, implemented]
- label: with TRANSLATION_ENDPOINT=libretranslate:5000 the entrypoint's nginx rewrite (executed under stubs) yields proxy_pass http://libretranslate:5000; in /etc/nginx/nginx.conf
  id: 8sx
  command: services/whishper/check-entrypoint.sh nginx-endpoint
  tags: [whishper, spec, implemented]
- label: whishper entrypoint, executed under stubs, runs mkdir -p /workspace/{uploads,models}, rm -rf /app/{uploads,models} and ln -s /workspace/<d> /app/<d> (exactly two symlinks) so the image's hardcoded paths land in the single /workspace bind mount
  id: pj3
  command: services/whishper/check-entrypoint.sh symlinks
  tags: [whishper, spec, implemented]
- label: the resolved whishper healthcheck is CMD-SHELL curl -sf on 127.0.0.1:80/api/transcriptions AND 127.0.0.1:8000/healthcheck/ with interval 10s, retries 30 and start_period 30m, so healthy means the worker has finished downloading all WHISPER_MODELS and jobs will not fail with status -1
  id: h9k
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e '.services.whishper.healthcheck | .test==["CMD-SHELL","curl -sf http://127.0.0.1:80/api/transcriptions >/dev/null && curl -sf http://127.0.0.1:8000/healthcheck/ >/dev/null"] and .interval=="10s" and .retries==30 and .start_period=="30m0s"' >/dev/null
  tags: [whishper, spec, implemented]
- label: resolved config: whishper-mongo's healthcheck is CMD-SHELL mongosh ping (db.runCommand({ ping: 1 }).ok piped to grep -q 1, interval 5s, start_period 20s) and whishper depends on whishper-mongo with condition service_healthy, so the Go backend never boots against an unready database
  id: epv
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e '.services["whishper-mongo"].healthcheck as $h | ($h.test==["CMD-SHELL","mongosh --quiet --eval '"'"'db.runCommand({ ping: 1 }).ok'"'"' | grep -q 1"]) and $h.interval=="5s" and $h.start_period=="20s" and .services.whishper.depends_on["whishper-mongo"].condition=="service_healthy"' >/dev/null
  tags: [whishper, spec, implemented]
- label: resolved config: whishper-init is an alpine:3.20 sidecar running /bin/sh /init.sh from a read-only bind of services/whishper/workspace-init.sh with TARGET_UID/TARGET_GID=HARBOR_USER_ID/HARBOR_GROUP_ID and the same /workspace source as whishper, and whishper depends on it with condition service_completed_successfully
  id: 58u
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e --arg u "$(ev HARBOR_USER_ID)" --arg g "$(ev HARBOR_GROUP_ID)" '.services as $s | ($s.whishper.depends_on["whishper-init"].condition=="service_completed_successfully") and ($s["whishper-init"] | .image=="alpine:3.20" and .entrypoint==["/bin/sh","/init.sh"] and .environment.TARGET_UID==$u and .environment.TARGET_GID==$g and ([.volumes[]|select(.target=="/init.sh" and .read_only==true and (.source|endswith("/services/whishper/workspace-init.sh")))]|length==1) and ([.volumes[]|select(.target=="/workspace")][0].source == ($s.whishper.volumes[]|select(.target=="/workspace")|.source)))' >/dev/null
  tags: [whishper, spec, implemented]
- label: services/whishper/workspace-init.sh, executed under stubbed chown/mkdir with TARGET_UID=4242 TARGET_GID=4343, chowns /workspace non-recursively and mkdir -p + chown -R exactly uploads, models and logs (4 chowns total), never touching db/ which belongs to the mongo user
  id: q2m
  command: services/whishper/check-entrypoint.sh init
  tags: [whishper, spec, implemented]
- label: resolved config with the libretranslate cross-file: whishper gets TRANSLATION_ENDPOINT=libretranslate:5000 and depends on libretranslate with condition service_healthy, and libretranslate's healthcheck is a python urllib.request.urlopen of http://127.0.0.1:5000/languages (the image has no curl) with start_period 30m, so whishper stays Created on a cold LibreTranslate cache and only reports healthy once the TRANSLATE modal can list languages
  id: wyd
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper libretranslate | jq -e '.services as $s | $s.whishper.environment.TRANSLATION_ENDPOINT=="libretranslate:5000" and $s.whishper.depends_on.libretranslate.condition=="service_healthy" and ($s.libretranslate.healthcheck | .test[0]=="CMD-SHELL" and (.test[1]|test("^python -c .*urllib\\.request\\.urlopen\\(.http://127\\.0\\.0\\.1:5000/languages.")) and .start_period=="30m0s" and .retries==30)' >/dev/null && $(./harbor.sh cmd --no-defaults whishper) config --format json | jq -e '.services.whishper | (.environment|has("TRANSLATION_ENDPOINT")|not) and (.depends_on|has("libretranslate")|not)' >/dev/null
  tags: [whishper, spec, implemented]
- label: timing with services/libretranslate/data/local moved aside: libretranslate health starting and whishper Created for ~100s, then in the same 10s tick LT healthy, whishper running/healthy and GET :35060/languages 200 (never 502 while whishper was up)
  id: oxy
  tags: [whishper, spec, implemented, manual]
- label: whishper's entrypoint adds group/passwd entries for HARBOR_USER_ID:HARBOR_GROUP_ID (HOME=/workspace) when getent finds none, and its supervisord rewrite, executed under stubs against the image's supervisord.conf, adds user=<uid> to exactly the transcription, backend and frontend programs (nginx stays root for :80) and execs supervisord -c /tmp/supervisord.conf, so uploads and models written mid-session are host-owned without re-running whishper-init
  id: 27y
  command: services/whishper/check-entrypoint.sh passwd && services/whishper/check-entrypoint.sh supervisord
  tags: [whishper, spec, implemented]
- label: [runtime: whishper up] after POST /api/transcriptions the new HARBOR_WHISHPER_WORKSPACE/uploads/*.wav is owned by the host uid and /proc in the container shows /bin/whishper, node and python3 running non-root while nginx master and supervisord are root (services/whishper/check-runtime.sh ownership; SKIPs with exit 1 when harbor.whishper is not healthy)
  id: ag7
  command: services/whishper/check-runtime.sh ownership
  tags: [whishper, spec, implemented]
- label: resolved config: PUBLIC_API_HOST equals HARBOR_WHISHPER_PUBLIC_URL from .env (default "" in profiles/default.env = same-origin, so the browser fetches /api relative to the page URL from localhost, a LAN IP and the traefik route alike), PUBLIC_INTERNAL_API_HOST is http://127.0.0.1:80, and an override HARBOR_WHISHPER_PUBLIC_URL=http://x:1 propagates; the variable is documented in the whishper doc
  id: sxv
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e --arg v "$(ev HARBOR_WHISHPER_PUBLIC_URL)" '.services.whishper.environment | .PUBLIC_API_HOST==$v and .PUBLIC_INTERNAL_API_HOST=="http://127.0.0.1:80"' >/dev/null && HARBOR_WHISHPER_PUBLIC_URL=http://x:1 C whishper | jq -e '.services.whishper.environment.PUBLIC_API_HOST=="http://x:1"' >/dev/null && grep -q '^HARBOR_WHISHPER_PUBLIC_URL=""$' profiles/default.env && grep -q 'HARBOR_WHISHPER_PUBLIC_URL' docs/2.3.96-Satellite-Whishper.md
  tags: [whishper, spec, implemented]
- label: end-user workflow via the browser on a non-localhost origin (https://whishper.<domain> through traefik, http://<lan-ip>:35060): the transcription list renders, an uploaded wav reaches status 2, and with libretranslate up the TRANSLATE modal stores an es translation, with every /api call going to the page origin
  id: jdm
  tags: [whishper, spec, implemented, manual]
- label: resolved config with the traefik cross-file: whishper carries traefik.enable=true, router rule Host(`whishper.<HARBOR_TRAEFIK_DOMAIN>`), loadbalancer.server.port=80, entrypoints=websecure, tls=true and joins the traefik-public network
  id: wnr
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper traefik | jq -e --arg d "$(ev HARBOR_TRAEFIK_DOMAIN)" '.services.whishper | .labels["traefik.enable"]=="true" and .labels["traefik.http.routers.whishper.rule"]==("Host(`whishper."+$d+"`)") and .labels["traefik.http.services.whishper.loadbalancer.server.port"]=="80" and .labels["traefik.http.routers.whishper.entrypoints"]=="websecure" and .labels["traefik.http.routers.whishper.tls"]=="true" and (.networks|has("traefik-public"))' >/dev/null
  tags: [whishper, spec, implemented]
- label: every HARBOR_WHISHPER_* variable in profiles/default.env is documented in docs/2.3.96-Satellite-Whishper.md, which also embeds docs/harbor-whishper.png
  id: fbn
  command: for v in $(grep -o '^HARBOR_WHISHPER_[A-Z_]*' profiles/default.env); do grep -q "$v" docs/2.3.96-Satellite-Whishper.md || exit 1; done; grep -q 'harbor-whishper.png' docs/2.3.96-Satellite-Whishper.md && test -f docs/harbor-whishper.png
  tags: [whishper, spec, implemented]
- label: resolved config: the whishper container is named <HARBOR_CONTAINER_PREFIX>.whishper with labels harbor.service=whishper and harbor.port=80 exactly, publishes HARBOR_WHISHPER_HOST_PORT (default 35060) to container port 80, and the compose main service name matches the handle
  id: 8f7
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e --arg p "$(ev HARBOR_CONTAINER_PREFIX)" --arg hp "$(ev HARBOR_WHISHPER_HOST_PORT)" '.services.whishper | .container_name==($p+".whishper") and .labels["harbor.service"]=="whishper" and .labels["harbor.port"]=="80" and ([.ports[]|select(.target==80 and .published==$hp)]|length==1)' >/dev/null && grep -q '^HARBOR_WHISHPER_HOST_PORT=35060$' profiles/default.env
  tags: [whishper, spec, implemented]
- label: [runtime: whishper up] end-user workflow: an espeak-ng wav POSTed to /api/transcriptions (file, language, modelSize=base, device) reaches status 2 with the spoken words in result.text and the check deletes its transcription afterwards (services/whishper/check-runtime.sh transcribe; SKIPs with exit 1 when harbor.whishper is not healthy)
  id: 4zu
  command: services/whishper/check-runtime.sh transcribe
  tags: [whishper, spec, implemented]
- label: [runtime: whishper libretranslate up] GET /api/translate/<id>/es on a fresh transcription returns 200 and stores a translation (services/whishper/check-runtime.sh translate; SKIPs with exit 1 unless both harbor.whishper and harbor.libretranslate are healthy)
  id: t7e
  command: services/whishper/check-runtime.sh translate
  tags: [whishper, spec, implemented]
- label: resolved config: whishper runs HARBOR_WHISHPER_IMAGE:HARBOR_WHISHPER_VERSION (default pluja/whishper:latest) and whishper-mongo runs HARBOR_WHISHPER_MONGO_IMAGE:VERSION (default mongo:7) named <prefix>.whishper-mongo, with the backend pointed at it via DB_ENDPOINT=whishper-mongo:27017 and DB_USER/DB_PASS equal to mongo's MONGO_INITDB_ROOT_USERNAME/PASSWORD from HARBOR_WHISHPER_DB_USER/PASSWORD
  id: 5rs
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e --arg p "$(ev HARBOR_CONTAINER_PREFIX)" --arg i "$(ev HARBOR_WHISHPER_IMAGE):$(ev HARBOR_WHISHPER_VERSION)" --arg mi "$(ev HARBOR_WHISHPER_MONGO_IMAGE):$(ev HARBOR_WHISHPER_MONGO_VERSION)" --arg u "$(ev HARBOR_WHISHPER_DB_USER)" --arg pw "$(ev HARBOR_WHISHPER_DB_PASSWORD)" '.services as $s | $s.whishper.image==$i and $s["whishper-mongo"].image==$mi and $s["whishper-mongo"].container_name==($p+".whishper-mongo") and ($s.whishper.environment | .DB_ENDPOINT=="whishper-mongo:27017" and .DB_USER==$u and .DB_PASS==$pw) and ($s["whishper-mongo"].environment | .MONGO_INITDB_ROOT_USERNAME==$u and .MONGO_INITDB_ROOT_PASSWORD==$pw)' >/dev/null && HARBOR_WHISHPER_DB_USER=zz C whishper | jq -e '.services.whishper.environment.DB_USER=="zz" and .services["whishper-mongo"].environment.MONGO_INITDB_ROOT_USERNAME=="zz"' >/dev/null && grep -q '^HARBOR_WHISHPER_IMAGE="pluja/whishper"$' profiles/default.env && grep -q '^HARBOR_WHISHPER_MONGO_VERSION="7"$' profiles/default.env
  tags: [whishper, spec, implemented]
- label: resolved config: HARBOR_WHISHPER_WORKSPACE (default ./services/whishper/data, gitignored) is bound to /workspace and <workspace>/logs to /var/log/whishper on whishper and <workspace>/db to /data/db on whishper-mongo, with a HARBOR_WHISHPER_WORKSPACE=/tmp/wsx override moving all three, and the entrypoint symlinks uploads/models into /workspace
  id: ik8
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e --arg w "$(ev HARBOR_WHISHPER_WORKSPACE | sed 's#^\./#'"$PWD"'/#')" '.services as $s | ($s.whishper.volumes[]|select(.target=="/workspace")|.source)==$w and ($s.whishper.volumes[]|select(.target=="/var/log/whishper")|.source)==($w+"/logs") and ($s["whishper-mongo"].volumes[]|select(.target=="/data/db")|.source)==($w+"/db")' >/dev/null && HARBOR_WHISHPER_WORKSPACE=/tmp/wsx C whishper | jq -e '.services as $s | ($s.whishper.volumes[]|select(.target=="/workspace")|.source)=="/tmp/wsx" and ($s.whishper.volumes[]|select(.target=="/var/log/whishper")|.source)=="/tmp/wsx/logs" and ($s["whishper-mongo"].volumes[]|select(.target=="/data/db")|.source)=="/tmp/wsx/db"' >/dev/null && grep -q '^HARBOR_WHISHPER_WORKSPACE="./services/whishper/data"$' profiles/default.env && grep -q '^data/' services/whishper/.gitignore
  tags: [whishper, spec, implemented]
- label: resolved config: WHISPER_MODELS equals HARBOR_WHISHPER_MODELS (default tiny,base,small), CPU_THREADS equals HARBOR_WHISHPER_CPU_THREADS (default 4), WHISPER_MODELS_DIR=/app/models and UPLOAD_DIR=/app/uploads, and overrides HARBOR_WHISHPER_MODELS=large-v3 / HARBOR_WHISHPER_CPU_THREADS=9 propagate
  id: 298
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper | jq -e --arg m "$(ev HARBOR_WHISHPER_MODELS)" --arg t "$(ev HARBOR_WHISHPER_CPU_THREADS)" '.services.whishper.environment | .WHISPER_MODELS==$m and .CPU_THREADS==$t and .WHISPER_MODELS_DIR=="/app/models" and .UPLOAD_DIR=="/app/uploads"' >/dev/null && HARBOR_WHISHPER_MODELS=large-v3 HARBOR_WHISHPER_CPU_THREADS=9 C whishper | jq -e '.services.whishper.environment | .WHISPER_MODELS=="large-v3" and .CPU_THREADS=="9"' >/dev/null && grep -q '^HARBOR_WHISHPER_MODELS="tiny,base,small"$' profiles/default.env && grep -q '^HARBOR_WHISHPER_CPU_THREADS=4$' profiles/default.env
  tags: [whishper, spec, implemented]
- label: resolved config with the nvidia cross-file: whishper's image becomes <HARBOR_WHISHPER_IMAGE>:<HARBOR_WHISHPER_VERSION>-gpu, PUBLIC_WHISHPER_PROFILE=gpu (cpu without the cross-file) and deploy.resources.reservations.devices reserves driver nvidia, capabilities [gpu], count all (-1 in resolved config)
  id: sgz
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d '\"'; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C whishper nvidia | jq -e --arg i "$(ev HARBOR_WHISHPER_IMAGE):$(ev HARBOR_WHISHPER_VERSION)-gpu" '.services.whishper | .image==$i and .environment.PUBLIC_WHISHPER_PROFILE=="gpu" and (.deploy.resources.reservations.devices|length==1) and (.deploy.resources.reservations.devices[0] | .driver=="nvidia" and .capabilities==["gpu"] and .count==-1)' >/dev/null && C whishper | jq -e '.services.whishper.environment.PUBLIC_WHISHPER_PROFILE=="cpu" and (.services.whishper|has("deploy")|not)' >/dev/null
  tags: [whishper, spec, implemented]
- label: whishper is registered in serviceMetadata.ts with tags Satellite and Audio, projectUrl github.com/pluja/whishper, a logo and wikiUrl for 2.3.96-Satellite-Whishper, and docs/2.3.96-Satellite-Whishper.md exists with a screenshot
  id: xo0
  command: awk '/^    whishper: \{/,/^    \},/' app/src/serviceMetadata.ts > /tmp/wm.$$; grep -q 'HST.satellite' /tmp/wm.$$ && grep -q 'HST.audio' /tmp/wm.$$ && grep -q 'github.com/pluja/whishper' /tmp/wm.$$ && grep -q 'logo:' /tmp/wm.$$ && grep -q '2.3.96-Satellite-Whishper' /tmp/wm.$$; r=$?; rm -f /tmp/wm.$$; [ $r = 0 ] && test -f docs/2.3.96-Satellite-Whishper.md && test -f docs/harbor-whishper.png
  tags: [whishper, spec, implemented]
- label: whishper's entrypoint, executed under stubs, chowns exactly /workspace, /workspace/uploads, /workspace/models and /app/transcription to HARBOR_USER_ID:HARBOR_GROUP_ID (4 chowns, after mkdir -p) on every start, so a force-recreate or restart without whishper-init (e.g. after wiping models/) never leaves a root-owned directory the host-uid worker cannot write to
  id: gat
  command: services/whishper/check-entrypoint.sh chown
  tags: [whishper, spec, implemented]
- label: whishper's entrypoint supervisord rewrite, executed under stubs against the image's supervisord.conf, sets stderr_logfile=/dev/stderr plus stderr_logfile_maxbytes=0 on exactly the transcription and backend programs (frontend and nginx keep their .err.log files), so model downloads and worker crash-loops appear in docker logs harbor.whishper instead of only in data/logs/*.err.log
  id: hng
  command: services/whishper/check-entrypoint.sh supervisord
  tags: [whishper, spec, implemented]
- label: verified: with services/whishper/data/models moved aside and whishper force-recreated with --no-deps (no init sidecar), models/ and the three faster-whisper-* dirs are created host-owned, the download log shows on docker logs, the container is healthy within ~30s, and a subsequent browser upload reaches status 2 with find services/whishper/data -path '*/db' -prune -o ! -user $USER -print empty
  id: cnw
  tags: [whishper, spec, implemented, manual]
- label: verified: GET /api/translate/<id>/es twice returns 200 then 500 and leaves the row at status 3 with no buttons after a page reload; DELETE /api/transcriptions/<id> returns 200 and removes it, which is what the doc's Translate bullet prescribes as the only recovery
  id: cq9
  command: grep -q "curl -X DELETE http://localhost:35060/api/transcriptions/<id>" docs/2.3.96-Satellite-Whishper.md
  tags: [whishper, spec, manual, implemented]
- label: the whishper doc carries, in order, the headings Starting, Usage, Configuration, Environment Variables, Volumes, Cross-service integrations, Troubleshooting, Check Logs, a status -1 entry, a Translate/language-list entry, a LAN/traefik empty-list entry, Reset the database and Links, and its Usage block shows the POST /api/transcriptions, poll and /api/translate curl workflow
  id: d4p
  command: grep -E '^#{3,5} ' docs/2.3.96-Satellite-Whishper.md | tr -d '#' | sed 's/^ *//' | tr '\n' '|' | grep -q 'Starting|Usage|Configuration|Environment Variables|Volumes|Cross-service integrations|Troubleshooting|Check Logs|Transcription stays pending or fails with status -1|Translate button does nothing / language list empty|UI loads but the transcription list is empty on a LAN or traefik client|Poor accuracy|Reset the database|Links|' && grep -q '^curl -F file=@hello.wav -F language=en -F modelSize=tiny -F device=cpu' docs/2.3.96-Satellite-Whishper.md && grep -q '^curl http://localhost:35060/api/translate/<id>/es' docs/2.3.96-Satellite-Whishper.md
  tags: [whishper, spec, implemented]

# app
- label: Harbor App blocks main UI behind a setup gate until setup detail status is ready
  command: rg -q 'detail\?\.status === "ready"' app/src/setup/HarborSetupContext.tsx
  tags: [implemented]
- label: Harbor App installs the CLI through install.sh on Linux and macOS and install.ps1 on Windows
  command: rg -q 'install.sh' app/src-tauri/src/setup.rs && rg -q 'install.ps1' app/src-tauri/src/setup.rs
  tags: [implemented]
- label: Harbor App setup uses a PTY for installer output and supports forwarding input for interactive prompts
  command: rg -q 'portable_pty|portable-pty' app/src-tauri/Cargo.toml && rg -q 'write_harbor_setup_input' app/src-tauri/src/setup.rs
  tags: [implemented]
- label: Harbor App Windows setup runs Harbor commands through WSL bash with a persisted preferred Ubuntu distro
  command: rg -q 'wsl_bash_args|preferred_wsl_distro' app/src-tauri/src/setup.rs
  tags: [implemented]
- label: Harbor App home lists services from harbor ls and supports bulk harbor up and harbor down
  command: rg -q "useHarbor\\(\\['ls'\\]\\)" app/src/home/useServiceList.tsx && rg -q 'runHarbor' app/src/home/ServiceList.tsx
  tags: [implemented]
- label: Harbor App close hides the window on desktop instead of quitting unless the user chooses Quit from the tray
  command: rg -q 'prevent_close' app/src-tauri/src/lib.rs
  tags: [implemented]
- label: Harbor App setup detection reports ready only when harbor doctor --check passes
  command: rg -q 'doctor --check' app/src-tauri/src/setup.rs
  tags: [implemented]
- Harbor App setup detection runs installer prerequisite blockers only when the CLI is not already installed @implemented
- label: install.ps1 suppresses the PowerShell progress bar during the Docker Desktop installer download to avoid the PS 5.1 Invoke-WebRequest slowdown
  command: rg -q "ProgressPreference = 'SilentlyContinue'" install.ps1
  tags: [implemented]
- label: Harbor App build splits vendor code into react terminal markdown and syntax chunks via Vite manualChunks so no JS chunk exceeds 600 kB
  command: grep -q 'manualChunks' app/vite.config.ts
  tags: [implemented]

# boost
- label: Boost is a FastAPI LLM proxy that exposes OpenAI chat completions and optional Anthropic and Responses compat layers
  command: grep -q 'fastapi' services/boost/pyproject.toml && grep -q '/v1/chat/completions' services/boost/src/main.py
- label: Boost modules are Python plugins with ID_PREFIX and async apply loaded from modules and custom_modules directories
  command: rg -q 'ID_PREFIX' services/boost/src/modules/ && rg -q 'importlib' services/boost/src/mods.py
- label: Boost auth accepts requests when BOOST_AUTH is empty and otherwise requires Authorization or x-api-key
  command: test -f services/boost/src/auth.py && rg -q 'BOOST_AUTH' services/boost/src/config.py
  tags: [implemented]
- label: Boost maps Anthropic Messages to OpenAI chat completions internally and streams Anthropic SSE event envelopes
  command: rg -q '/v1/messages' services/boost/src/anthropic_compat.py && rg -q 'message_start' services/boost/src/anthropic_compat.py
  tags: [implemented]
- label: Boost Responses API converts input to chat messages and emits response.created through response.completed SSE sequences
  command: rg -q '/v1/responses' services/boost/src/responses_compat.py && rg -q 'response.created' services/boost/src/responses_compat.py
  tags: [implemented]
- label: Boost Anthropic and Responses HTTP errors use SDK-specific envelopes based on route path not request body shape
  command: rg -q '_ANTHROPIC_ERROR_TYPE_MAP' services/boost/src/main.py && rg -q 'ERROR_TYPE_MAP' services/boost/src/responses_compat.py
  tags: [implemented]
- label: Boost workflows advertise model IDs as workflow prefixes and execute ordered module steps including system and final completion
  command: test -f services/boost/src/workflows.py && rg -q 'apply_workflow' services/boost/src/workflows.py
  tags: [implemented]
- label: Boost direct-task detection bypasses module serve for known title-generation style prompts when no workflow is set
  command: rg -q 'is_direct_task' services/boost/src/mapper.py && rg -q 'is_direct_task' services/boost/src/main.py
  tags: [implemented]
- label: Boost SSE streaming uses retry intervals keepalive comments and no-buffer headers for proxy compatibility
  command: rg -q 'SSE_KEEPALIVE_INTERVAL' services/boost/src/compat_utils.py && rg -q 'sse_keepalive' services/boost/src/anthropic_compat.py
  tags: [implemented]
- label: Boost BackendError forwards rate-limit headers to clients on 429 in both streaming and non-streaming paths
  command: rg -q 'RATE_LIMIT_FORWARD_HEADERS' services/boost/src/compat_utils.py && rg -q 'BackendError' services/boost/tests/test_streaming_backend_error.py
  tags: [implemented]
- label: autocheck audits coding deliverable drafts with a structured checklist (correctness, completeness, file-path grounding) and revises at most once when the audit verdict is revise; non-deliverable turns pass through unchanged
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAutocheckApply -q --tb=no
  tags: [implemented]
- label: diffscope compares cited file paths in coding deliverable drafts against user-stated scope from recent messages, verifies workspace paths when HARBOR_BOOST_WORKSPACE_ROOT is set, and revises once on out-of-scope or missing paths
  command: cd services/boost && uv run pytest tests/test_diffscope.py::TestDiffscopeApply -q --tb=no
  tags: [implemented]
- label: deliverable heuristics gate agentic modules: coding implementation requests with file paths or code blocks trigger post-processing while purely explanatory questions pass through
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestDeliverableGate -q --tb=no
  tags: [implemented]
- label: read_workspace_file is exposed as a portable tool only when HARBOR_BOOST_WORKSPACE_ROOT is set and rejects paths outside the workspace root
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestWorkspaceFileTool -q --tb=no
  tags: [implemented]
- label: shared research package enforces per-request search URL-read and character budgets and renders gathered results into a research_brief system block
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestResearchBudget tests/test_agentic_infra.py::TestResearchBrief -q --tb=no
  tags: [implemented]
- label: shared research respects HARBOR_BOOST_RESEARCH_NOTES_MAX_CHARS when adding brief notes: trims each search or read failure note to the configured per-note character limit (default 4000); set to 0 to disable truncation
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestResearchFetch::test_trim_note_uses_config_default tests/test_agentic_infra.py::TestResearchBrief::test_add_note_truncates_oversized_notes tests/test_orchestrate.py::TestOrchestrateParallelFetch::test_run_searches_truncates_long_failure_notes -q --tb=no
  tags: [implemented]
- label: grep_workspace is an opt-in portable tool when HARBOR_BOOST_WORKSPACE_ROOT is set: searches workspace files with optional glob and max_matches caps and rejects paths outside the workspace root
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestWorkspaceFileTool::test_grep_workspace_finds_pattern tests/test_agentic_infra.py::TestWorkspaceFileTool::test_grep_workspace_respects_max_matches tests/test_agentic_infra.py::TestWorkspaceFileTool::test_grep_workspace_path_jail tests/test_agentic_infra.py::TestWorkspaceFileTool::test_selected_tools_includes_grep_when_configured -q --tb=no
  tags: [spec, implemented]
- label: list_workspace_files is an opt-in portable tool when HARBOR_BOOST_WORKSPACE_ROOT is set: lists workspace files with optional glob and max_entries caps and rejects paths outside the workspace root
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestWorkspaceFileTool::test_list_workspace_files_lists_files tests/test_agentic_infra.py::TestWorkspaceFileTool::test_list_workspace_files_respects_max_entries tests/test_agentic_infra.py::TestWorkspaceFileTool::test_list_workspace_files_path_jail tests/test_agentic_infra.py::TestWorkspaceFileTool::test_selected_tools_includes_list_when_configured -q --tb=no
  tags: [spec, implemented]
- label: Boost compose bind-mounts the host folder from HARBOR_BOOST_WORKSPACE to /workspace in the container so workspace tools jail paths under HARBOR_BOOST_WORKSPACE_ROOT
  command: rg -q '\$\{HARBOR_BOOST_WORKSPACE' services/compose.boost.yml && rg -q '^HARBOR_BOOST_WORKSPACE=' profiles/default.env && rg -q '^HARBOR_BOOST_WORKSPACE_ROOT=' profiles/default.env
  tags: [spec, implemented]
- label: write_workspace_file is an opt-in portable tool not in the default tool list when HARBOR_BOOST_WORKSPACE_ROOT is set: writes files inside the workspace root with a size cap and rejects paths outside the root
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestWorkspaceFileTool::test_write_workspace_file_writes_file tests/test_agentic_infra.py::TestWorkspaceFileTool::test_write_workspace_file_enforces_size_cap tests/test_agentic_infra.py::TestWorkspaceFileTool::test_default_tools_omits_workspace_writer -q --tb=no
  tags: [spec, implemented]
- label: diffscope grounds scope checks in git repos by collecting changed paths from git diff --name-only and git diff --stat with timeout and nonzero-exit fallback to cited draft paths
  command: cd services/boost && uv run pytest tests/test_diffscope.py::TestGitDiffGrounding -q --tb=no
  tags: [spec, implemented]
- label: research orchestration parallelizes multi-query web_search calls and multi-URL page reads with concurrency caps while respecting per-request search and read budgets
  command: cd services/boost && uv run pytest tests/test_orchestrate.py::TestOrchestrateParallelFetch -q --tb=no
  tags: [spec, implemented]
- label: autocheck runs mechanical pre-audit before the LLM audit on deliverable turns: flags code without cited paths or missing workspace paths, includes git diff stat context, and forces revise when blockers are found
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestMechanicalPreaudit -q --tb=no
  tags: [spec, implemented]
- label: autocheck mechanical pre-audit emits non-blocking linter hints when eslint or ruff configs appear near cited or changed paths: walks up from anchor paths to discover configs, suggests runnable npx eslint or ruff check commands, and includes warn findings in run_mechanical_preaudit
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestLinterHint -q --tb=no
  tags: [implemented]
- label: git_diff_workspace is an opt-in portable tool when HARBOR_BOOST_WORKSPACE_ROOT is set on a git repo: returns git diff --name-only and --stat for workspace paths with optional path scoping jail and 5 second timeout
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestWorkspaceFileTool::test_git_diff_workspace_returns_stat_and_name_only tests/test_agentic_infra.py::TestWorkspaceFileTool::test_git_diff_workspace_scopes_path tests/test_agentic_infra.py::TestWorkspaceFileTool::test_git_diff_workspace_path_jail tests/test_agentic_infra.py::TestWorkspaceFileTool::test_selected_tools_includes_git_diff_when_git_workspace -q --tb=no
  tags: [implemented]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_SHOW_AUDIT and per-request boost show_audit param: when enabled appends an audit footer to deliverable answers and emits an HTML findings summary artifact; show_audit overrides config
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestWorkspacePaths::test_show_audit_footer_reads_boost_params tests/test_autocheck.py::TestWorkspacePaths::test_show_audit_footer_boost_param_overrides_config tests/test_autocheck.py::TestAutocheckApply::test_apply_appends_audit_footer_when_show_audit_enabled tests/test_autocheck.py::TestAutocheckApply::test_apply_skips_audit_artifact_when_show_audit_disabled -q --tb=no
  tags: [implemented]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_STRICT: when true prepends a warning banner to deliverable answers when critical or major findings remain after all revise passes instead of silently shipping
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestWorkspacePaths::test_should_prepend_strict_warning_when_enabled_with_blockers tests/test_autocheck.py::TestWorkspacePaths::test_should_not_prepend_strict_warning_for_warn_only_findings tests/test_autocheck.py::TestAutocheckApply::test_apply_prepends_strict_warning_when_blockers_remain tests/test_autocheck.py::TestAutocheckApply::test_apply_skips_strict_warning_when_disabled -q --tb=no
  tags: [implemented]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_STRICT workspace_unconfigured skip: when true and HARBOR_BOOST_WORKSPACE_ROOT is unset, autocheck skips deliverable audit with workspace_unconfigured gate reason, logs a config error naming both keys, emits Autocheck: skipped (workspace_unconfigured) status, and streams final completion without audit; non-strict mode still triggers on deliverable turns without workspace root
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAutocheckGate::test_strict_mode_requires_workspace_root_gate_reason tests/test_autocheck.py::TestAutocheckGate::test_non_strict_mode_allows_missing_workspace_root tests/test_autocheck.py::TestWorkspacePaths::test_format_skipped_status_includes_gate_reason tests/test_autocheck.py::TestAutocheckApply::test_apply_strict_skips_audit_when_workspace_unconfigured -q --tb=no
  tags: [implemented]
- label: diffscope revise_with_correction prompt includes explicit allowed_paths forbidden_paths out_of_scope_paths and git_evidence sections built from user scope violations and workspace git diff
  command: cd services/boost && uv run pytest tests/test_diffscope.py::TestWorkspaceAndNotes::test_build_revise_scope_sections_lists_allowed_forbidden_and_git_evidence -q --tb=no
  tags: [implemented]
- label: agentic modules record optional per-module debug payloads on request.state via research.debug_metrics: triggered or skipped status, reason, duration_ms, and module-specific extras under keys like quickhop_debug
  command: cd services/boost && uv run pytest tests/test_agentic_debug_metrics.py -q --tb=no
  tags: [implemented]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_MAX_REVISE_PASSES: configurable revise passes after audit verdict revise clamped to 0-2; HARBOR_BOOST_AUTOCHECK_STRICT true adds one extra pass still capped at 2
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAutocheckGate::test_clamp_max_revise_passes_caps_at_two tests/test_autocheck.py::TestAutocheckGate::test_effective_max_revise_passes_defaults_to_one tests/test_autocheck.py::TestAutocheckGate::test_effective_max_revise_passes_adds_one_in_strict_mode tests/test_autocheck.py::TestAutocheckGate::test_effective_max_revise_passes_stays_capped_when_strict_and_config_two -q --tb=no
  tags: [implemented]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_AUDIT_MODEL: configurable model for the structured audit sub-call; when empty autocheck uses the incoming request model, when set audit_llm and run_audit override the cheap LLM client model after stripping whitespace
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAuditAndRevise::test_audit_llm_keeps_request_model_when_unset tests/test_autocheck.py::TestAuditAndRevise::test_audit_llm_overrides_model_when_configured tests/test_autocheck.py::TestAuditAndRevise::test_run_audit_uses_audit_model_override -q --tb=no
  tags: [implemented]
- label: diffscope supports HARBOR_BOOST_DIFFSCOPE_ALLOW_COLLATERAL: when true out-of-scope files against hinted scope emit collateral warnings unless the user said only X; when false any out-of-scope path triggers a scope revision
  command: cd services/boost && uv run pytest tests/test_diffscope.py::TestCollateralViolations::test_partition_treats_hinted_out_of_scope_as_collateral_by_default tests/test_diffscope.py::TestCollateralViolations::test_partition_blocks_hinted_out_of_scope_when_collateral_disabled tests/test_diffscope.py::TestCollateralViolations::test_apply_warns_on_collateral_without_revision tests/test_diffscope.py::TestCollateralViolations::test_apply_revises_hinted_out_of_scope_when_collateral_disabled -q --tb=no
  tags: [implemented]
- label: diffscope treats only-edit and edit-only user scope as allowed-only mode where HARBOR_BOOST_DIFFSCOPE_ALLOW_COLLATERAL is ignored, extracts multi-path only phrases, and excludes allowed paths from hinted scope
  command: cd services/boost && uv run pytest tests/test_diffscope.py::TestUserScope::test_extract_allowed_paths_from_only_edit_phrase tests/test_diffscope.py::TestUserScope::test_extract_allowed_paths_from_edit_only_phrase tests/test_diffscope.py::TestUserScope::test_extract_multiple_allowed_paths_from_only_edit_phrase tests/test_diffscope.py::TestUserScope::test_allowed_paths_are_excluded_from_hinted_scope tests/test_diffscope.py::TestCollateralViolations::test_partition_only_edit_ignores_allow_collateral tests/test_diffscope.py::TestCollateralViolations::test_apply_revises_only_edit_even_when_allow_collateral_true -q --tb=no
  tags: [implemented]
- label: deliverable has_research_signals requires a research keyword plus question mark, a version pattern, a package or product mention, or a URL; bare questions such as What does this code do? do not trigger research modules
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestDeliverableBorderlineCases::test_bare_questions_without_research_context_are_not_research_signals tests/test_agentic_infra.py::TestDeliverableBorderlineCases::test_research_signal_keywords_detected tests/test_agentic_infra.py::TestDeliverableBorderlineCases::test_research_signals_require_keyword_question_version_or_package -q --tb=no
  tags: [implemented]
- label: shared research fetch retries web_search and read_url once after transient HTTP failures (timeout or connect error) with a one second backoff and does not retry non-transient errors
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestResearchFetch::test_web_search_retries_once_on_transient_failure tests/test_agentic_infra.py::TestResearchFetch::test_web_search_does_not_retry_non_transient_failures tests/test_agentic_infra.py::TestResearchFetch::test_read_url_retries_jina_once_on_transient_failure tests/test_agentic_infra.py::TestResearchFetch::test_read_url_retries_direct_once_on_transient_failure -q --tb=no
  tags: [implemented]
- label: diffscope extracts forbidden paths from user scope hints including dont touch, leave X alone, except and except for Z, multi-path lists, and apostrophe-less dont-touch variants
  command: cd services/boost && uv run pytest tests/test_diffscope.py::TestUserScope::test_extract_forbidden_paths_from_dont_touch tests/test_diffscope.py::TestUserScope::test_extract_forbidden_paths_from_leave_alone tests/test_diffscope.py::TestUserScope::test_extract_forbidden_paths_from_except tests/test_diffscope.py::TestUserScope::test_extract_forbidden_paths_from_except_for tests/test_diffscope.py::TestUserScope::test_extract_multiple_forbidden_paths_from_dont_touch tests/test_diffscope.py::TestUserScope::test_extract_multiple_forbidden_paths_from_leave_alone tests/test_diffscope.py::TestUserScope::test_extract_forbidden_without_apostrophe -q --tb=no
  tags: [implemented]
- label: research.workflow anchor_deferred_draft records deferred drafts in chat when defer_final is set, replacing an existing assistant tail instead of appending so downstream diffscope and autocheck in chained workflows audit scoped or revised answers not pre-revision drafts (DIF-003)
  command: cd services/boost && uv run pytest tests/test_agentic_debug_metrics.py::TestAnchorDeferredDraft tests/test_diffscope.py::TestDiffscopeApply::test_apply_anchors_scoped_draft_when_defer_final tests/test_diffscope.py::TestDiffscopeApply::test_apply_replaces_prior_draft_when_defer_final tests/test_autocheck.py::TestAutocheckApply::test_apply_anchors_revised_draft_when_defer_final tests/test_agentic_workflow_chains.py::TestDiffscopeDeliverableChain::test_diffscope_scope_violation_triggers_revise -q --tb=no
  tags: [implemented, bughunt]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_DRAFT_MODEL: configurable model for the draft sub-call; when empty autocheck uses the incoming request model, when set draft_llm and generate_draft override the cheap LLM client model after stripping whitespace
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAuditAndRevise::test_draft_llm_keeps_request_model_when_unset tests/test_autocheck.py::TestAuditAndRevise::test_draft_llm_overrides_model_when_configured tests/test_autocheck.py::TestAuditAndRevise::test_generate_draft_uses_draft_model_override -q --tb=no
  tags: [implemented]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_REVISE_MODEL: configurable model for the revise sub-call; when empty autocheck uses the incoming request model, when set revise_llm and revise_draft override the cheap LLM client model after stripping whitespace
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAuditAndRevise::test_revise_llm_keeps_request_model_when_unset tests/test_autocheck.py::TestAuditAndRevise::test_revise_llm_overrides_model_when_configured tests/test_autocheck.py::TestAuditAndRevise::test_revise_draft_uses_revise_model_override -q --tb=no
  tags: [implemented]
- label: shared research fetch respects HARBOR_BOOST_RESEARCH_FETCH_TIMEOUT_SECONDS for web_search and read_url httpx clients: configurable HTTP timeout in seconds (default 30) applied to Tavily, Jina, and direct read paths
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py::TestResearchFetch::test_research_fetch_timeout_defaults_to_30_seconds tests/test_agentic_infra.py::TestResearchFetch::test_search_tavily_uses_configured_fetch_timeout tests/test_agentic_infra.py::TestResearchFetch::test_read_with_jina_uses_configured_fetch_timeout tests/test_agentic_infra.py::TestResearchFetch::test_read_direct_uses_configured_fetch_timeout -q --tb=no
  tags: [implemented]
- label: autocheck treats HARBOR_BOOST_AUTOCHECK_MAX_PASSES as a backward-compatible alias for HARBOR_BOOST_AUTOCHECK_MAX_REVISE_PASSES: both clamp to 0-2 and effective_max_revise_passes uses the higher value when both are set
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAutocheckGate::test_effective_max_revise_passes_honors_legacy_max_passes_alias -q --tb=no
  tags: [spec, implemented]
- label: HARBOR_BOOST_DEBUG and per-request boost debug param gate a compact Debug: status summary at final completion: when enabled complete_or_defer emits per-module triggered or skipped lines with duration_ms and extras; debug param overrides config
  command: cd services/boost && uv run pytest tests/test_agentic_debug_metrics.py::TestDebugSummaryHelpers::test_debug_enabled_respects_boost_param tests/test_agentic_debug_metrics.py::TestDebugSummaryHelpers::test_debug_enabled_falls_back_to_config tests/test_agentic_debug_metrics.py::TestCompleteOrDeferDebugStatus::test_emits_compact_summary_when_debug_enabled tests/test_agentic_debug_metrics.py::TestCompleteOrDeferDebugStatus::test_skips_summary_when_debug_disabled -q --tb=no
  tags: [spec, implemented]
- label: autocheck supports HARBOR_BOOST_AUTOCHECK_ENABLED: when false needs_autocheck is false and autocheck_gate_reason returns disabled so deliverable turns pass through without audit
  command: cd services/boost && uv run pytest tests/test_autocheck.py::TestAutocheckGate::test_skips_when_disabled -q --tb=no
  tags: [spec, implemented]
- label: diffscope supports HARBOR_BOOST_DIFFSCOPE_ENABLED: when false needs_diffscope is false and diffscope_gate_reason returns disabled so scoped deliverables pass through without scope revision
  command: cd services/boost && uv run pytest tests/test_diffscope.py::TestDiffscopeGate::test_skips_when_disabled -q --tb=no
  tags: [spec, implemented]
- label: research.workflow anchor_and_emit_final anchors a deferred draft via anchor_deferred_draft then emits the text with llm.emit_message unless defer_final is set; autocheck uses it on audit_failed so chained workflows anchor the draft without double-emitting before the explicit final step
  command: cd services/boost && uv run pytest tests/test_agentic_debug_metrics.py::TestAnchorAndEmitFinal tests/test_autocheck.py::TestAutocheckApply::test_apply_audit_failed_defers_emit_when_configured -q --tb=no
  tags: [implemented]
- label: Boost ChatNode preserves non-text multimodal content parts instead of flattening them to text
  command: cd services/boost && uv run pytest tests/test_chat_node.py::TestChatNodeContent -q --tb=short
  tags: [spec, reported-regression, implemented]
- label: caveman injects one level-tagged caveman_style block into the leading system message before completion unless the user or workflow disables the style, preserving single-system-message backend compatibility
  command: cd services/boost && uv run --with pytest --with pytest-asyncio pytest -q tests/test_caveman.py
  tags: [spec, release-validation, implemented]
- label: Boost declares a dev dependency group with pytest pytest-asyncio pytest-xdist and anthropic so bare uv run pytest collects the SDK integration tests and can parallelize with -n auto
  command: cd services/boost && python3 -c "import tomllib;g=tomllib.load(open('pyproject.toml','rb'))['dependency-groups']['dev'];assert {'pytest','pytest-asyncio','pytest-xdist','anthropic'} <= set(g), g"
  tags: [implemented]
- label: style level commands are module scoped: /caveman <level> and stop caveman only affect the caveman module and /ponytail forms only affect ponytail, while normal mode disables both; resolve_style_level receives the calling module id
  command: cd services/boost && uv run pytest tests/test_caveman.py tests/test_ponytail.py -q
  tags: [implemented]
- label: read_url direct fetch follows redirects manually (max 5 hops) and re-validates every hop with require_http_url so a public URL cannot 3xx into private, loopback, link-local, or reserved addresses
  command: cd services/boost && uv run pytest tests/test_agentic_infra.py -q -k redirect
  tags: [implemented]
- label: autocheck gather_workspace_context sanitizes error attributes (quotes and newlines) and neutralizes file-tag markers inside file content so failed or spoofed reads never count as successful workspace evidence
  command: cd services/boost && uv run pytest tests/test_autocheck.py -q
  tags: [implemented]
- label: research orchestrate read_urls schedules at most the remaining URL-read budget, so the 'reading N sources' status line never reports more pages than the budget allows
  command: grep -q 'scheduled_urls = urls\[:remaining_reads\]' services/boost/src/research/orchestrate.py
  tags: [spec, release-validation, implemented]
- label: deephop passes phase labels 'hop 1'/'hop 2' (without the module name) to run_searches and read_urls so emitted status lines read 'Deephop research: hop 2: ...' instead of duplicating 'Deephop'
  command: ! grep -q 'phase="Deephop hop' services/boost/src/modules/deephop.py && grep -q 'phase="hop 2"' services/boost/src/modules/deephop.py
  tags: [spec, release-validation, implemented]
- label: Boost module discovery resolves relative BOOST_FOLDERS entries against the mods.py directory instead of the process cwd so tests and tools can import mods from any working directory
  command: cd /tmp && python3 -c "import sys;sys.path.insert(0,'/home/everlier/code/harbor/services/boost/src');import types;m=types.ModuleType('mapper');m.__stub__=True;sys.modules['mapper']=m;import mods;assert len(mods.registry)>0"
  tags: [implemented]
- label: Boost CI runs the pytest suite in parallel with pytest-xdist -n auto
  command: grep -q 'pytest -q -n auto' .github/workflows/boost-tests.yml
  tags: [implemented]
- label: token_counter encodes texts longer than 16k chars in fixed-size slices so pathological inputs like a 1MB single-character run cannot stall the server for minutes during token counting
  command: cd services/boost && timeout 120 uv run pytest -q tests/test_anthropic_compat.py -k TestSecurityTokenCounting
  tags: [implemented]
- label: the boost test isolation fixture snapshots and restores the plain BOOST_AUTH list so a test that rebinds it cannot leak 401s into unrelated tests
  command: grep -q 'saved_auth' services/boost/tests/conftest.py
  tags: [implemented]
- label: When the backend rejects a chat completion, Boost propagates the backend's HTTP status and an OpenAI-style error JSON to the client instead of 200 with empty content; mid-stream failures emit a final SSE error chunk before DONE
  command: cd services/boost && uv run pytest -q tests/test_backend_error_propagation.py
  tags: [spec, boost-error-propagation, implemented]
- label: Handled BackendErrors log one concise 'Backend error <status>: <message>' line without stack-trace frames (raise sites warn once; _on_task_done and the chat handler stay quiet for them), while unexpected exceptions still log full tracebacks via exc_info
  command: cd services/boost && uv run pytest -q tests/test_backend_error_logging.py
  tags: [spec, implemented, boost-error-logging]

## codemode
- label: codemode advertises exactly one tool __tool_execute_code (single code parameter) and hides every other local tool from the model, rendering each hidden tool as a Python signature with its docstring in the system prompt, absorbing tools registered by earlier workflow steps into the hidden catalog, still executing a hidden tool server-side if the model calls it by name, and leaving client-supplied request tools untouched
  command: cd services/boost && uv run pytest tests/test_codemode.py -q --tb=no -k "TestAdvertisedTools or prompt_renders_signatures"
  tags: [codemode, codemode-boost, implemented]
- label: codemode's execute_code runs the model's program in an isolated subprocess that round-trips hidden tool calls over a JSON-lines protocol, returns program exceptions as traceback text and hidden tool failures as in-program RuntimeErrors, caps captured output at HARBOR_BOOST_CODEMODE_MAX_OUTPUT and hidden calls at HARBOR_BOOST_CODEMODE_MAX_CALLS, kills a program exceeding HARBOR_BOOST_CODEMODE_TIMEOUT together with its whole process group and returns partial output instead of raising, and restores the original local tool registry on every exit path while keeping execute_code advertised when the final completion is deferred
  command: cd services/boost && uv run pytest tests/test_codemode.py -q --tb=no -k "TestExecution or TestRegistryLifecycle or TestSandboxHelpers"
  tags: [codemode, codemode-boost, implemented]
- label: harbor launch accepts --codemode in both launch parsers as sugar for --workflow codemode, rejects it combined with --workflow, routes to a codemode-prefixed model in the launch smoke suite, and is documented in the Boost modules doc, the Boost configuration doc and the CLI reference
  command: grep -q -- '--codemode' harbor.sh && grep -q 'does not support --codemode and --workflow together' harbor.sh && grep -q '^## codemode' docs/5.2.3-Harbor-Boost-Modules.md && grep -q '^## HARBOR_BOOST_CODEMODE_TIMEOUT' docs/5.2.2-Harbor-Boost-Configuration.md && grep -q -- '--codemode' docs/3.-Harbor-CLI-Reference.md && grep -q -- '--codemode' tests/suites/05-launch-smoke.sh && bash tests/suites/05-launch-smoke.sh
  tags: [codemode, codemode-harbor, implemented]
- label: HARBOR_BOOST_CODEMODE_TIMEOUT, MAX_OUTPUT and MAX_CALLS default in services/boost/default.env and are passed through compose with inline defaults, so a stale or empty .env value cannot inject an empty setting and Boost falls back to the built-in defaults
  command: grep -q '^HARBOR_BOOST_CODEMODE_TIMEOUT=30' services/boost/default.env && grep -q '^HARBOR_BOOST_CODEMODE_MAX_OUTPUT=8000' services/boost/default.env && grep -q '^HARBOR_BOOST_CODEMODE_MAX_CALLS=50' services/boost/default.env && grep -q 'HARBOR_BOOST_CODEMODE_TIMEOUT=${HARBOR_BOOST_CODEMODE_TIMEOUT:-30}' services/compose.boost.yml && grep -q 'HARBOR_BOOST_CODEMODE_MAX_OUTPUT=${HARBOR_BOOST_CODEMODE_MAX_OUTPUT:-8000}' services/compose.boost.yml && grep -q 'HARBOR_BOOST_CODEMODE_MAX_CALLS=${HARBOR_BOOST_CODEMODE_MAX_CALLS:-50}' services/compose.boost.yml && cd services/boost && uv run pytest tests/test_codemode.py -q --tb=no -k empty_env_uses_defaults
  tags: [codemode, codemode-harbor, implemented]

# testing
- label: tests use a Deno orchestrator running sequential bash suites across containerized distros
  command: test -f tests/run.ts && test -f tests/suites/01-install.sh && test -f tests/suites/05-launch-smoke.sh
- label: harbor dev test runs the container test matrix documented in tests/README.md
  command: test -f tests/README.md && rg -q 'harbor dev test' tests/README.md
- label: harbor dev test --suite boost-agentic-smoke runs the Boost agentic pytest battery via tests/suites/06-boost-agentic-smoke.sh with container or host mode through tests/lib/boost-agentic.sh
  command: test -f tests/suites/06-boost-agentic-smoke.sh && test -f tests/lib/boost-agentic.sh && rg -q 'boost-agentic-smoke' tests/README.md
  tags: [implemented]
- pending launch CLI, Hugging Face model discovery, and Boost module changes have an agent-executable integration release checklist covering behavior, lint, facts, and user documentation @spec @release-validation @implemented
- the test-boost-module skill live-tests current Boost modules through harbor launch with pi and does not recommend removed modules or obsolete module roles @spec @release-validation @implemented

# lint
- label: harbor dev lint runs a three-pass Deno orchestrator with HARBOR-prefixed bash rules under .scripts/lint
  command: test -f .scripts/lint/run.ts && test -f .scripts/lint/rules.yaml && grep -q 'HARBOR0' .scripts/lint/rules.yaml
  tags: [implemented]
- label: harbor dev lint --strict exits non-zero when any finding is reported, warnings included, so CI can gate on a fully clean lint
  command: rg -q 'case "strict"' .scripts/lint/run.ts && rg -q 'args.strict && reported.length > 0' .scripts/lint/run.ts
  tags: [spec, implemented]

# docs
- label: documentation lives in docs/ with hierarchical numbering and covers 100+ services
  command: test $(find docs -name '*.md' | wc -l) -ge 100
- label: docs are regenerated via harbor dev docs from .scripts/docs.ts
  command: test -f .scripts/docs.ts
- label: README introduces harbor launch with backend model --web --config and --service examples
  command: rg -q 'harbor launch --backend ollama' README.md && rg -q 'harbor launch --service opencode' README.md
  tags: [implemented]
- label: BionicGPT docs explain how to configure Ollama from inside Harbor by using the internal Ollama URL with /v1, a non-empty API key, and an available Ollama model name
  command: grep -q 'http://ollama:11434/v1' 'docs/2.1.8-Frontend&colon-BionicGPT.md' && grep -q 'sk-ollama' 'docs/2.1.8-Frontend&colon-BionicGPT.md' && grep -q 'harbor url -i ollama' 'docs/2.1.8-Frontend&colon-BionicGPT.md' && grep -Eq 'harbor ollama (list|ls)' 'docs/2.1.8-Frontend&colon-BionicGPT.md'
  tags: [implemented]
- label: BionicGPT compose exposes upstream default DNS names for its bundled LLM and embeddings APIs
  command: grep -A10 '^  bionicgpt-llmapi:' services/compose.bionicgpt.yml | grep -q 'llm-api' && grep -A10 '^  bionicgpt-embeddingsapi:' services/compose.bionicgpt.yml | grep -q 'embeddings-api'
  tags: [implemented]

## backends
- label: sglang, lmdeploy, and aphrodite docs each carry an explicit NVIDIA-only note (upstream images are CUDA-only)
  command: bash -c 'grep -qi nvidia "docs/2.2.12-Backend&colon-SGLang.md" && grep -qi nvidia "docs/2.2.10-Backend&colon-lmdeploy.md" && grep -qi nvidia "docs/2.2.5-Backend&colon-Aphrodite-Engine.md"'
  tags: [sweep6, implemented]

# ci
- label: CI includes GitHub Actions workflows for app-release bench-docker boost-docker lint and test
  command: test -f .github/workflows/test.yml && test -f .github/workflows/lint.yml && test -f .github/workflows/boost-docker.yml

# seo
- label: scripts/seo/plan.md documents Harbor SEO keyword priorities for local LLM and Docker Compose intent
  command: test -f scripts/seo/plan.md && rg -q 'local LLM stack' scripts/seo/plan.md
  tags: [seo-plan, implemented]

## guides
- label: all seven Harbor SEO guides 8.1 through 8.7 exist under docs/
  command: for f in docs/8.{1..7}-*.md; do test -f "$f" || exit 1; done
  tags: [seo-guides, implemented]
- label: docs/8.-Guides.md indexes and links every 8.x guide
  command: test -f docs/8.-Guides.md && for n in 1 2 3 4 5 6 7; do rg -q "8.${n}-" docs/8.-Guides.md || exit 1; done
  tags: [seo-guides, implemented]
- label: docs/README.md and README.md link Harbor Guides and the 8.x guide pages
  command: rg -q 'Harbor Guides' docs/README.md README.md && rg -q '8.1-Local-LLM' docs/README.md README.md && rg -q '8.7-Run-Hermes' docs/README.md README.md
  tags: [seo-guides, implemented]
- label: each 8.x SEO guide links back to the Harbor Guides index
  command: for f in docs/8.[1-7]-*.md; do rg -q '\[Harbor Guides\]\(\./8\.-Guides\.md\)' "$f" || exit 1; done
  tags: [seo-guides, implemented]

# dev
## lint
- label: Harbor lint implementation lives under .scripts/lint not scripts/lint
  command: test -f .scripts/lint/run.ts && test ! -e scripts/lint
  tags: [implemented]
- label: harbor dev lint and harbor dev lint-self-test dispatch to .scripts/lint implementations
  command: rg -q './lint/run.ts' .scripts/lint.ts && rg -q './lint/self-test.ts' .scripts/lint-self-test.ts
  tags: [implemented]

# tests
- label: tests/services-integration.sh --list prints the 8 test groups A-H with their services
  command: ./tests/services-integration.sh --list | grep -c '^[A-H] ' | grep -qx 8
  tags: [services-runner, implemented]
- label: tests/services-integration.sh --list includes Group I depth checks (webui chat, searxng categories, litellm proxy, boost modules, jupyter kernel, promptfoo eval, comfyui workflow)
  command: ./tests/services-integration.sh --list | grep -q '^I '
  tags: [services-runner, implemented]
- label: tests/services-integration.sh --list includes Group J ROCm paths (llamacpp/ollama/lemonade/localai/voicebox/vllm), gated behind ROCm-host detection and excluded from the default group list
  command: ./tests/services-integration.sh --list | grep -q '^J .*ROCm' && grep -q 'has_rocm_host' tests/services-integration.sh && ! grep -q 'DEFAULT_GROUPS=.*J' tests/services-integration.sh
  tags: [services-runner, implemented]
- label: the lint file collector excludes service runtime cache dirs via services/*/cache and services/netdata/lib — netdata's workspace is services/netdata itself and its runtime dirs are root-owned, crashing the walk
  command: grep -q 'services/\*/cache' .scripts/lint/util.ts && grep -q 'services/netdata/lib' .scripts/lint/util.ts
  tags: [spec, implemented]
- label: tests: 02-cli asserts default services are webui and llamacpp only, with no ollama-as-default assertions
  command: bash -c '! grep -q "default services include ollama" tests/suites/02-cli.sh && grep -q "default services include llamacpp" tests/suites/02-cli.sh'
  tags: [implemented, defaults]
## app-install
- label: the integration install suite exercises install.sh and first-run llamacpp webui wiring
  command: rg -q 'install.sh' tests/suites/01-install.sh && rg -q 'harbor up --no-defaults llamacpp webui' tests/suites/01-install.sh
  tags: [implemented]
- label: tests/app-native-setup.md documents verifiable native app first-run setup expectations
  command: test -f tests/app-native-setup.md && rg -q 'harbor doctor --check' tests/app-native-setup.md && ! rg -q 'HARBOR_APP_SETUP_SMOKE' tests/app-native-setup.md
  tags: [implemented]

## fix-tests
- label: scripts/specs/fix-tests.md documents the test-run disk exhaustion incident with root-cause analysis and the implemented remediation
  command: test -f scripts/specs/fix-tests.md && grep -q '## Root Cause' scripts/specs/fix-tests.md && grep -q '## Remediation (implemented)' scripts/specs/fix-tests.md
  tags: [spec, implemented]

## runner
- label: the test orchestrator auto-prepends 01-install when a selected suite requires an installed harbor (all except install and boost-agentic-smoke) and install was not selected
  command: grep -q 'SELF_SUFFICIENT_SUITES' tests/run.ts && grep -q 'auto-prepending' tests/run.ts
  tags: [spec, implemented]
- label: the orchestrator forwards host GITHUB_TOKEN or GH_TOKEN into each test row's suite environment so GitHub API calls during --install-source github are authenticated when a token is present
  command: grep -q 'GITHUB_TOKEN' tests/run.ts
  tags: [spec, gh-rate-limit, implemented]
- label: bare deno test -A inside tests/ discovers only tracked test files; staged repos under tests/artifacts are excluded from Deno tooling via the root deno.json exclude
  command: cd tests && deno test -A
  tags: [spec, implemented]

## defaults-up
- label: tests/suites/07-defaults-up.sh asserts a bare 'harbor up' on a fresh install starts webui+llamacpp only (no ollama), prints both first-boot notices, webui reaches healthy, and llamacpp /v1/models answers with an empty HF cache
  command: test -x tests/suites/07-defaults-up.sh && grep -q 'llama.cpp has no local models yet' tests/suites/07-defaults-up.sh && grep -q 'v1/models' tests/suites/07-defaults-up.sh
  tags: [spec, defaults-up, implemented]
- label: 07-defaults-up.sh guards the --flatten merger: merged webui config and the webui config DB table contain flat dot-path per-key rows (openai.enable) and no nested legacy top-level blobs
  command: grep -q 'openai.enable' tests/suites/07-defaults-up.sh && grep -q 'flatten' tests/suites/07-defaults-up.sh
  tags: [spec, defaults-up, implemented]
- label: defaults-up is registered in HEAVY_SUITE_DEFAULTS pinning it to ubuntu-2404 with jobs=1
  command: grep -q 'defaults-up' tests/stage-repo.ts && grep -A1 '"defaults-up"' tests/stage-repo.ts | grep -q ubuntu-2404 || grep '"defaults-up"' tests/stage-repo.ts | grep -q ubuntu-2404
  tags: [spec, defaults-up, implemented]

## install
- label: install.sh resolve_harbor_version sends an Authorization Bearer header on the GitHub releases API call when GITHUB_TOKEN or GH_TOKEN is set
  command: grep -q 'Authorization: Bearer' install.sh
  tags: [spec, gh-rate-limit, implemented]
- label: when --install-source github fails inside a row, 01-install falls back to the staged local install with a clear warning instead of failing the row
  command: grep -q 'falling back to source=local' tests/suites/01-install.sh
  tags: [spec, gh-rate-limit, implemented]

# project > distribution
- label: requirements.sh apt package availability checks verify real package metadata: every apt-cache show probe greps for ^Package: so empty-output false positives (e.g. docker-ce on Ubuntu 24.04 without the Docker repo) fall through to the docker.io / distro fallback
  command: test $(grep -cE "apt-cache show (docker-ce|docker-compose-v2|docker-compose-plugin)" requirements.sh) -gt 0 && ! grep -E "apt-cache show (docker-ce|docker-compose-v2|docker-compose-plugin)" requirements.sh | grep -v "Package:" | grep -q .
  tags: [spec, implemented]
- On a clean Ubuntu 24.04 without the Docker APT repository, the Harbor App guided install completes end-to-end by installing docker.io, with no manual repo configuration required @spec @implemented

## daytona
- tests/app-daytona-install.md exists and covers in-app Harbor install verification on Ubuntu 24.04 and Fedora 42 Daytona sandboxes @implemented @app-daytona-install-spec

# development
- label: release.sh refreshes app/src-tauri/Cargo.lock after the version seed via cargo update --workspace, warning on stderr when cargo is unavailable
  command: grep -q 'cargo update --workspace' .scripts/release.sh && grep -q 'command -v cargo' .scripts/release.sh && grep -qi 'WARNING' .scripts/release.sh
  tags: [spec, release, implemented]

## lint
- label: harbor dev lint --boost flags zero-byte .py module files under services/boost/src/modules and custom_modules with HARBOR011 so truncated writes fail at lint time instead of runtime import
  command: test -f .scripts/lint/passes/boost.ts && rg -q 'HARBOR011' .scripts/lint/passes/boost.ts .github/workflows/lint.yml && test ! -s .scripts/lint/fixtures/boost/zero-byte/fail.py
  tags: [implemented]
- harbor dev lint ignores nested .claude worktrees so their copied source and intentional failure fixtures do not create duplicate findings @spec @release-validation @implemented
- label: A compose service may declare x-harbor-lint-ignore: [rule, ...] to waive specific compose lint rules for that service; the env-file-main pass fixture includes a waived service proving zero findings
  command: grep -q 'x-harbor-lint-ignore' .scripts/lint/passes/compose.ts && grep -q 'x-harbor-lint-ignore' .scripts/lint/fixtures/compose/env-file-main/pass.yml
  tags: [implemented]

# launch
- label: harbor launch grok writes or updates a harbor-<backend> model entry in ~/.grok/config.toml with the selected base_url, model, and env_key, then invokes grok -m harbor-<backend>
  command: rg -q 'launch_grok_config_path' harbor.sh && rg -q 'launch_write_grok_config' harbor.sh && ./harbor.sh launch --help | rg -q grok
  tags: [implemented]
- label: harbor launch grok removes the harbor-<backend> model entry from ~/.grok/config.toml after the grok process exits
  command: rg -q 'launch_remove_grok_config' harbor.sh && rg -q 'trap .*launch_remove_grok_config' harbor.sh && ./harbor.sh launch --help | rg -q grok
  tags: [spec, implemented]

# models
- label: harbor models ls and rm start Ollama automatically when no explicit source is provided
  command: rg -q 'requested_source.*ollama' harbor.sh && rg -q 'run_up --no-defaults ollama' harbor.sh
  tags: [spec, reported-regression, implemented]
- label: listHfModels does not wipe the model list when the hub library scan throws: it proceeds with zero known repos so the rescue directory walk still lists readable repos
  command: rg -q "cacheInfo = \{ repos: \[\] \}" routines/models/hf.ts
  tags: [implemented]

# demos
- label: launch-grid demo supports --duration and exits early when all agent panes finish
  command: ./scripts/demos/launch-grid.sh --help | grep -q -- '--duration' && grep -q 'start_completion_watcher' scripts/demos/launch-grid.sh
  tags: [spec, autonomous-demo, implemented]
- label: launch-grid demo defaults to llamacpp backend, unsloth/Qwen3.6-35B-A3B-GGUF:Q4_K_XL model, caveman workflow, and codex/opencode/hermes/title toolset
  command: grep -q 'HARBOR_DEMO_BACKEND:-llamacpp' scripts/demos/launch-grid.sh && grep -q 'HARBOR_DEMO_MODEL:-unsloth/Qwen3.6-35B-A3B-GGUF:Q4_K_XL' scripts/demos/launch-grid.sh && grep -q 'WORKFLOW="${HARBOR_DEMO_WORKFLOW:-caveman}"' scripts/demos/launch-grid.sh && grep -q 'TOOLS="codex,opencode,hermes,title"' scripts/demos/launch-grid.sh
  tags: [spec, implemented]
- label: launch-grid demo uses viewer-readable per-tool CLI syntax for codex exec, hermes chat -Q -q, and pi --session-dir -p
  command: grep -q 'TOOL_PROMPT_DISPLAY\[codex\]="exec' scripts/demos/launch-grid.sh && grep -q 'TOOL_PROMPT\[hermes\]="chat -Q -q' scripts/demos/launch-grid.sh && grep -q 'TOOL_PROMPT\[pi\]="--session-dir' scripts/demos/launch-grid.sh
  tags: [spec, implemented]
- label: launch-grid demo applies the same Boost workflow to every assistant pane and uses a title pane rendered with glow
  command: grep -q title scripts/demos/launch-grid.sh && grep -q find_glow scripts/demos/launch-grid.sh && grep -q workflow scripts/demos/launch-grid.sh
  tags: [spec, implemented]
- label: launch-grid demo dry-run prints a compact Pane/Command table with human-readable quoted prompts and --workflow on every assistant launch command
  command: out=$(./scripts/demos/launch-grid.sh --dry-run --yes); grep -q "==> Dry run" <<<"$out" && grep -q "Pane         Command" <<<"$out" && grep -q -- "--workflow caveman codex exec" <<<"$out" && grep -q -- "--workflow caveman opencode run" <<<"$out" && grep -q -- "--workflow caveman hermes chat -Q -q" <<<"$out" && grep -q "Tool route. Boost shape. Local model answer." <<<"$out" && ! grep -q "bash -lc" <<<"$out"
  tags: [spec, implemented]
- label: launch-grid help examples use valid grids with at least two assistant tools and keep Boost workflow examples visible
  command: ./scripts/demos/launch-grid.sh --help | grep -q -- "--workflow deephop --tools codex,opencode,hermes,title" && ./scripts/demos/launch-grid.sh --help | grep -q -- "--workflow quickhop --tools codex,opencode,hermes,title" && ./scripts/demos/launch-grid.sh --help | grep -q -- "--panes 3 --tools grok,pi,title" && ! ./scripts/demos/launch-grid.sh --help | grep -q -- "codex,title"
  tags: [spec, implemented]
- label: launch-grid duration mode tracks assistant completion marker files so the intro pane does not prevent early completion
  command: grep -q 'DEMO_DONE_DIR' scripts/demos/launch-grid.sh && grep -q \\*.done scripts/demos/launch-grid.sh && grep -q 'DEMO_ASSISTANT_COUNT' scripts/demos/launch-grid.sh && ! grep -q 'pane_dead' scripts/demos/launch-grid.sh
  tags: [spec, implemented]
- label: launch-grid assistant panes hide adapter stderr unless the command fails, while grok uses --no-wait-for-background for scale demos
  command: grep -q 'TOOL_QUIET_STDERR' scripts/demos/launch-grid.sh && grep -q 'cat .*stderr_log.*>&2' scripts/demos/launch-grid.sh && grep -q -- '--no-wait-for-background' scripts/demos/launch-grid.sh
  tags: [spec, implemented]
- label: launch-grid demo chooses a concise default prompt from the selected Boost workflow when HARBOR_DEMO_TASK is unset
  command: grep -q 'default_task_for_workflow' scripts/demos/launch-grid.sh && grep -q 'HARBOR_DEMO_TASK+x' scripts/demos/launch-grid.sh && grep -q 'Quickhop search brief first' scripts/demos/launch-grid.sh && grep -q 'Deephop checks first pass' scripts/demos/launch-grid.sh
  tags: [spec, implemented]
- label: launch-grid title pane explains harbor launch routing, the selected Boost workflow behavior, and what viewers should compare across tools
  command: grep -q 'What is happening' scripts/demos/launch-grid.sh && grep -q 'harbor launch starts or reuses' scripts/demos/launch-grid.sh && grep -q 'Workflow behavior' scripts/demos/launch-grid.sh && grep -q 'Compare panes' scripts/demos/launch-grid.sh
  tags: [spec, implemented]
- label: launch-walkthrough demo presents harbor launch in tmux as a paced story: status, configuration preview, direct host-tool launch, and web-enabled Boost routing without pre-starting Boost or SearXNG outside the visible web step
  command: out=$(bash scripts/demos/launch-walkthrough.sh --dry-run); grep -q -- '--web' <<<"$out" && grep -q -- '--config' <<<"$out" && bash -n scripts/demos/launch-walkthrough.sh && ! rg -q '^[[:space:]]*ensure_service[[:space:]]+boost$' scripts/demos/launch-walkthrough.sh && ! rg -q '^[[:space:]]*ensure_service[[:space:]]+searxng$' scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough demo rejects unsupported tools and Claude before building the web step
  command: ! ./scripts/demos/launch-walkthrough.sh --dry-run --tool claude >/tmp/harbor-launch-walkthrough-claude.out 2>&1 && grep -q "does not support --web" /tmp/harbor-launch-walkthrough-claude.out && ! ./scripts/demos/launch-walkthrough.sh --dry-run --tool unknown >/tmp/harbor-launch-walkthrough-unknown.out 2>&1 && grep -q "Unsupported demo tool" /tmp/harbor-launch-walkthrough-unknown.out
  tags: [spec, implemented]
- label: launch-walkthrough demo writes narrative text through a temp file and shell-quotes --allow-missing-tool fallback messages
  command: grep -q "NARRATIVE_FILE=" scripts/demos/launch-walkthrough.sh && grep -q "narrative_render_cmd" scripts/demos/launch-walkthrough.sh && grep -q "missing_tool_cmd" scripts/demos/launch-walkthrough.sh && grep -q -- "--allow-missing-tool" scripts/demos/launch-walkthrough.sh && ! grep -q "cat <<'NARRATIVE'" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough demo uses a tmux client-attached hook so the step driver starts only after viewers can see panes update live
  command: grep -q "client-attached" scripts/demos/launch-walkthrough.sh && grep -q "run-shell -b" scripts/demos/launch-walkthrough.sh && grep -q "tmux attach-session" scripts/demos/launch-walkthrough.sh && ! grep -q "run_demo &" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough dry-run validates the selected web-compatible tool and prints the four planned steps without requiring harbor or tmux on PATH
  command: PATH=/usr/bin:/bin bash scripts/demos/launch-walkthrough.sh --dry-run --tool mi --backend test-backend --model test-model --task test | grep -q "Step 4: Web tools" && ! PATH=/usr/bin:/bin bash scripts/demos/launch-walkthrough.sh --dry-run --tool claude >/tmp/launch-walkthrough-claude.out 2>&1 && grep -q "does not support --web" /tmp/launch-walkthrough-claude.out
  tags: [spec, implemented]
- label: launch-walkthrough live mode creates narrative and action panes, installs a tmux client-attached hook, and starts the step driver only after attach
  command: grep -q "client-attached" scripts/demos/launch-walkthrough.sh && grep -q "run-shell -b" scripts/demos/launch-walkthrough.sh && grep -q "tmux attach-session" scripts/demos/launch-walkthrough.sh && grep -q "NARRATIVE_PANE" scripts/demos/launch-walkthrough.sh && grep -q "ACTION_PANE" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough workflow uses a product-focused launch-route opening, explicit action headings, and a web proof step that shows harbor ps before and after harbor launch --web
  command: bash scripts/demos/launch-walkthrough.sh --dry-run | grep -q "Opening: Launch route" && ! bash scripts/demos/launch-walkthrough.sh --dry-run | grep -qi "what to watch\|good TUI\|readable, paced\|Charm glow narrative" && grep -q "run_action_command" scripts/demos/launch-walkthrough.sh && grep -q "Before --web" scripts/demos/launch-walkthrough.sh && grep -q "After --web" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough live mode hides completion markers with PROMPT_COMMAND, records command status, and requires host tools unless --allow-missing-tool is passed
  command: grep -q "PROMPT_COMMAND" scripts/demos/launch-walkthrough.sh && grep -q "STATUS_FILE" scripts/demos/launch-walkthrough.sh && grep -q -- "--allow-missing-tool" scripts/demos/launch-walkthrough.sh && grep -q "Host tool.*is not installed.*--allow-missing-tool" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough renders narrative cards with Charm glow when available and falls back to plain cat when glow is missing
  command: grep -q "find_glow" scripts/demos/launch-walkthrough.sh && grep -q -- "--style dark" scripts/demos/launch-walkthrough.sh && grep -q "printf.*cat %s" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough defaults to slower capture-friendly pacing with a hold delay after visible commands
  command: bash scripts/demos/launch-walkthrough.sh --dry-run | grep -q "Hold delay" && grep -q "HOLD_DELAY" scripts/demos/launch-walkthrough.sh && grep -q "hold_capture" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough reduces action-pane noise by using a minimal prompt, pane titles for action labels, and no printed marker strings
  command: grep -q "PS1=\\$ " scripts/demos/launch-walkthrough.sh && grep -q "select-pane.*-T" scripts/demos/launch-walkthrough.sh && ! grep -q "printf .*HARBOR_WALKTHROUGH_DONE" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough visible titles and explainers describe Harbor behavior, not demo-production advice or meta-commentary
  command: ! bash scripts/demos/launch-walkthrough.sh --dry-run | grep -qi "what to watch\|good TUI\|readable, paced\|honest\|Charm glow narrative\|stable panes\|live output\|capture" && grep -q "Opening: Launch route" scripts/demos/launch-walkthrough.sh && grep -q "harbor launch connects host coding tools" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough narrative pane renders cards by respawning the pane instead of typing shell commands into it
  command: grep -q "respawn-pane" scripts/demos/launch-walkthrough.sh && grep -q "narrative_pane_cmd" scripts/demos/launch-walkthrough.sh && ! grep -q "send-keys -t \"\$NARRATIVE_PANE\" \"clear" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-walkthrough opening and completion cards do not render as numbered steps
  command: bash scripts/demos/launch-walkthrough.sh --dry-run | grep -q "Opening: Launch route" && grep -q "show_card" scripts/demos/launch-walkthrough.sh && ! grep -q "show_narrative 0" scripts/demos/launch-walkthrough.sh && ! grep -q "show_narrative \"\$TOTAL_STEPS\" \"\$TOTAL_STEPS\" \"Demo complete\"" scripts/demos/launch-walkthrough.sh
  tags: [spec, implemented]
- label: launch-tui demo dry-run prints a sequential harbor launch story with status, config, direct, web-before, web, web-after, and recap steps without requiring Textual or harbor on PATH
  command: PATH=/usr/bin:/bin python3 scripts/demos/launch-tui.py --dry-run --tool mi --backend test-backend --model test-model | grep -q 'Step 6: Recap' && PATH=/usr/bin:/bin python3 scripts/demos/launch-tui.py --dry-run --tool mi --backend test-backend --model test-model | grep -q -- 'harbor launch --web --backend test-backend --model test-model mi -p'
  tags: [spec, implemented]
- label: launch-tui demo rejects Claude and unsupported tools before building the web-enabled step
  command: ! python3 scripts/demos/launch-tui.py --dry-run --tool claude >/tmp/harbor-launch-tui-claude.out 2>&1 && grep -q 'does not support --web' /tmp/harbor-launch-tui-claude.out && ! python3 scripts/demos/launch-tui.py --dry-run --tool unknown >/tmp/harbor-launch-tui-unknown.out 2>&1 && grep -q 'Unsupported demo tool' /tmp/harbor-launch-tui-unknown.out
  tags: [spec, implemented]
- label: launch-tui --tui mode is a Textual dashboard with a step sidebar, top route bar, command output panel, and explanation panel, while dry-run avoids importing Textual
  command: python3 -m py_compile scripts/demos/launch-tui.py && grep -q 'class HarborLaunchDemoApp' scripts/demos/launch-tui.py && grep -q 'import_textual' scripts/demos/launch-tui.py && grep -q 'StepList' scripts/demos/launch-tui.py && grep -q 'output-log' scripts/demos/launch-tui.py && grep -q 'explain-panel' scripts/demos/launch-tui.py
  tags: [spec, implemented]
- label: launch-tui demo supports capture-friendly run controls: --allow-missing-tool, --duration, --record, and --capture-mode asciinema
  command: python3 scripts/demos/launch-tui.py --help | grep -q -- '--allow-missing-tool' && python3 scripts/demos/launch-tui.py --help | grep -q -- '--duration' && python3 scripts/demos/launch-tui.py --help | grep -q -- '--record' && python3 scripts/demos/launch-tui.py --help | grep -q -- '--capture-mode' && grep -q 'asciinema rec' scripts/demos/launch-tui.py
  tags: [spec, implemented]
- label: launch-tui demo can be run directly with uv because the script declares its Textual dependency in a PEP 723 metadata block and help shows the uv run command
  command: head -n 20 scripts/demos/launch-tui.py | grep -q 'dependencies = \["textual' && python3 scripts/demos/launch-tui.py --help | grep -q 'uv run scripts/demos/launch-tui.py'
  tags: [spec, implemented]
- label: launch-tui demo opens with a visible product explanation that says it will run real harbor launch commands and shows the config direct and web route before execution
  command: grep -q 'What this demo does' scripts/demos/launch-tui.py && grep -q 'This demo runs real Harbor commands' scripts/demos/launch-tui.py && grep -q 'Config -> Direct -> Web route' scripts/demos/launch-tui.py
  tags: [spec, implemented]
- label: launch-tui demo names the Textual app Harbor Launch Demo and the prelaunch confirmation tells users to run --dry-run first if they only want the command plan
  command: grep -q 'TITLE = "Harbor Launch Demo"' scripts/demos/launch-tui.py && grep -q -- '--dry-run first' scripts/demos/launch-tui.py
  tags: [spec, implemented]
- label: launch-tui demo defaults to a narrated plain-terminal story and reserves the full-screen Textual dashboard for --tui
  command: python3 scripts/demos/launch-tui.py --help | grep -q -- '--tui' && grep -q 'run_story' scripts/demos/launch-tui.py && grep -q 'run_live' scripts/demos/launch-tui.py && python3 scripts/demos/launch-tui.py --dry-run | grep -q 'What this proves'
  tags: [spec, implemented]
- label: launch-tui story mode explains harbor launch as a route builder before commands run and labels every step with what it proves, why it matters, and what to look for
  command: grep -q 'route builder' scripts/demos/launch-tui.py && grep -q 'What this proves' scripts/demos/launch-tui.py && grep -q 'Why it matters' scripts/demos/launch-tui.py && grep -q 'What to look for' scripts/demos/launch-tui.py
  tags: [spec, implemented]
- label: launch-tui story mode displays canonical harbor commands while executing ./harbor.sh as a repo-local fallback when harbor is not on PATH
  command: grep -q 'resolve_harbor_bin' scripts/demos/launch-tui.py && grep -q './harbor.sh' scripts/demos/launch-tui.py && grep -q 'display_command' scripts/demos/launch-tui.py
  tags: [spec, implemented]

# pull
- label: harbor pull passes leading flags like --no-defaults to the compose resolver instead of misrouting them as model specs
  command: bash -c 'source /dev/null; grep -A4 "flag_args=()" harbor.sh | grep -q "service_args=()"' && grep -q 'compose_with_options "${flag_args\[@\]}" "${service_args\[@\]}"' harbor.sh
  tags: [spec, implemented]
- label: harbor pull rejects a bare unknown token (no slash or colon, not a service) with an unknown-service error and starts nothing
  command: timeout 60 ./harbor.sh pull definitely-not-a-real-service-xyz 2>&1 | grep -qi 'unknown service' && ! timeout 60 ./harbor.sh pull definitely-not-a-real-service-xyz >/dev/null 2>&1
  tags: [spec, pull-unknown-reject, implemented]

# tools

## lint
- label: the lint file collector never descends into service runtime dirs (services/*/data, workspace, vectordb, meili_data*) — gitignored container-generated trees that contain root-owned dirs and third-party scripts which crash or fail the scan
  command: grep -q 'services/\*/workspace' .scripts/lint/util.ts && grep -q 'services/\*/vectordb' .scripts/lint/util.ts
  tags: [spec, implemented]
- label: the lint file walker (safeGlob in .scripts/lint/util.ts) never crashes on unreadable or vanished filesystem entries — PermissionDenied, NotFound, and NotADirectory during a walk skip the entry instead of aborting the run; RUNTIME_DIR_EXCLUDES is a performance skip-list only, not the crash defense
  command: grep -q isTransientFsError .scripts/lint/util.ts && ! grep -rq 'import.*expandGlob' .scripts/lint/
  tags: [spec, implemented]

# libretranslate
- label: compose.libretranslate.yml runs the container as the host user (user: HARBOR_USER_ID:HARBOR_GROUP_ID with HOME=/home/libretranslate) — upstream image uid 1032 cannot write workspace mounts chowned to the host user by the init sidecar
  command: grep -q 'user: "${HARBOR_USER_ID}:${HARBOR_GROUP_ID}"' services/compose.libretranslate.yml && grep -q 'HOME=/home/libretranslate' services/compose.libretranslate.yml && grep -q 'XDG_DATA_HOME=/home/libretranslate/.local/share' services/compose.libretranslate.yml
  tags: [spec, implemented]

# install

## macos
- requirements.sh brew_install treats a bare docker CLI without any engine provider (Docker.app/OrbStack.app present, colima installed, or reachable daemon) as missing Docker and installs the Docker Desktop cask instead of proceeding with a broken setup @spec @macos-docker-provider @implemented
- label: Stubbed unit tests in .scripts/test-requirements-macos.sh (harbor dev test-requirements-macos) source requirements.sh with PATH shims for uname/brew/docker/open and assert brew_install branch behavior: no docker -> cask install; bare CLI without provider -> warning + cask install; CLI with provider (OrbStack.app) -> no cask; reachable daemon -> proceed with no warnings; plus all macos_has_docker_provider detection variants
  command: bash .scripts/test-requirements-macos.sh --no-bash32
  tags: [spec, macos-docker-provider, implemented]
- label: requirements.sh executes main only when HARBOR_REQUIREMENTS_SOURCE_ONLY is unset, so test harnesses can source its functions without triggering the install flow
  command: grep -q 'HARBOR_REQUIREMENTS_SOURCE_ONLY' requirements.sh && bash -c 'HARBOR_REQUIREMENTS_SOURCE_ONLY=1 . ./requirements.sh && type brew_install >/dev/null'
  tags: [spec, macos-docker-provider, implemented]

## defaults
- label: The distributed default profile (profiles/default.env) sets HARBOR_SERVICES_DEFAULT to webui;llamacpp — ollama is not a default service, so fresh installs avoid large Ollama downloads
  command: grep -q "HARBOR_SERVICES_DEFAULT=\"webui;llamacpp\"" profiles/default.env
  tags: [spec, default-services-no-ollama, implemented]
- label: Cognee's dependency on ollama-init lives in the cross-file compose.x.cognee.ollama.yml, not the base compose.cognee.yml, so 'harbor up cognee' resolves without ollama in the default services
  command: grep -A2 "depends_on" services/compose.cognee.yml | grep -q ollama-init && exit 1 || grep -q "ollama-init" services/compose.x.cognee.ollama.yml
  tags: [spec, default-services-no-ollama, implemented]

## webui speaches
- label: start_webui.sh runs json_config_merger.py with --flatten so the merged Open WebUI config lands as dot-path keys (audio.tts.engine, openai.api_base_urls) matching Open WebUI's per-key config import
  command: grep -q -- --flatten services/webui/start_webui.sh
  tags: [spec, speaches-webui, implemented]
- label: json_config_merger.py flatten mode stops at Open WebUI's dict-valued registered keys (models.default_params, openai.api_configs, ...) and rewrites legacy openai.enabled/ollama.enabled to openai.enable/ollama.enable
  command: grep -q DICT_VALUE_KEYS shared/json_config_merger.py && grep -q 'openai.enable' shared/json_config_merger.py
  tags: [spec, speaches-webui, implemented]
- On a fresh boot of 'harbor up webui speaches', Open WebUI's config rows point STT and TTS at http://speaches:8000/v1 and TTS/STT requests through Open WebUI's audio API succeed against speaches @spec @speaches-webui @implemented

## webui config fragments
- label: Every services/webui/configs/config.*.json fragment flattens cleanly via json_config_merger flatten_config, and each resulting dot-path key (after the startup rag.web -> web rename) matches a registered Open WebUI per-key config key
  command: python3 -c "import json,sys; sys.path.insert(0,'shared'); from json_config_merger import flatten_config; import os; d='services/webui/configs'; [flatten_config(json.load(open(os.path.join(d,f)))) for f in os.listdir(d) if f.endswith('.json')]"
  tags: [webui-config-fragments, spec, implemented]
- On a fresh webui DB with searxng, openterminal, and comfyui fragments mounted, terminal_server.connections, web.search.*, and image_generation.* config rows land correctly; web search returns real results and the terminal server verify endpoint reports status true @webui-config-fragments @spec @implemented

## env
- label: install.sh defaults its install path to HARBOR_HOME when set (explicit HARBOR_INSTALL_PATH still wins), so a curl-install on a machine with HARBOR_HOME exported lands where harbor.sh resolves its home instead of ~/.harbor
  command: grep -q 'HARBOR_INSTALL_PATH:-${HARBOR_HOME:-' install.sh
  tags: [spec, install-harbor-home, implemented]

## first-run
- label: When 'harbor up' includes llamacpp with no model specifier and no *.gguf in the HF cache, the CLI prints a notice with a concrete 'harbor pull' example instead of leaving frontends with an empty model list
  command: grep -q 'llama.cpp has no local models yet' harbor.sh
  tags: [spec, llamacpp-empty-cache-notice, implemented]
- 'harbor pull <org/repo:quant>' on a default config auto-detects a GGUF HuggingFace repo and downloads it via an ephemeral llamacpp container into HARBOR_HF_CACHE, creating the hub cache layout; verified end-to-end with ggml-org/tiny-llamas:stories15M-q4_0 on an isolated install @spec @pull-first-run @implemented
- label: run_llamacpp_pull runs the ephemeral llamacpp container as the host user (--user id -u:id -g) with HOME remapped to /tmp and the HF and llama.cpp caches mounted at the remapped location, so pulled model blobs land owned by the host user on the host
  command: grep -A12 'llamacpp_cache_path=$(env_manager get llamacpp.cache)' harbor.sh | grep -q -- '--user "$(id -u):$(id -g)"' && grep -A14 'llamacpp_cache_path=$(env_manager get llamacpp.cache)' harbor.sh | grep -q 'HOME=/tmp'
  tags: [spec, pull-ownership, implemented]
- After 'harbor pull ggml-org/tiny-llamas:stories15M-q4_0' into a fresh HARBOR_HF_CACHE, every file under the cache is owned by the host user and a subsequent 'harbor up llamacpp' discovers the model via /v1/models with need_download false @spec @pull-ownership @implemented

## bash32
- label: harbor.sh contains no bash-4-only builtins (mapfile/readarray/associative arrays); run_restart collects active services via word-splitting so 'harbor restart' works on stock macOS bash 3.2
  command: ! grep -nE '\bmapfile\b|\breadarray\b|declare -A' harbor.sh
  tags: [spec, bash32-compat, implemented]
- label: Lint rule HARBOR012 (error severity) flags mapfile/readarray/declare -A in host-run scripts (harbor.sh, install.sh, requirements.sh, shared, tests, .scripts) with fixtures validated by lint-self-test
  command: grep -q 'id: HARBOR012' .scripts/lint/rules.yaml && test -f .scripts/lint/fixtures/HARBOR012/fail.sh && test -f .scripts/lint/fixtures/HARBOR012/pass.sh
  tags: [spec, bash32-compat, implemented]
- label: run_restart word-splits get_active_services output (a single space-separated line), so restarting with multiple active services restarts each service instead of failing on a bogus combined name like 'llamacpp webui'
  command: grep -A3 'emits one space-separated line' harbor.sh | grep -q 'for word in $active_services'
  tags: [spec, restart-multi-service, implemented]
- label: The macOS requirements test battery re-executes itself inside a bash:3.2 container (stock macOS bash), asserting the sourced requirements.sh path and the test harness itself contain no bash-4-only constructs
  command: bash .scripts/test-requirements-macos.sh
  tags: [spec, bash32-compat, implemented]
- label: Host-run scripts (harbor.sh, install.sh, requirements.sh) contain no GNU-only regex escapes: whitespace uses [[:space:]] not \s, word matches use grep -w not \b, alternation uses grep -E not BRE \|, so conditionals behave identically under BSD grep/sed on macOS
  command: ! grep -nE '\b(grep|sed)\b[^|;#]*\\(s|b|w|d|\|)' harbor.sh install.sh requirements.sh
  tags: [spec, bash32-compat, implemented]
- label: Lint rule HARBOR013 (error severity) flags grep -P and GNU-only regex escapes in grep/sed invocations in host-run scripts, with pass/fail fixtures validated by lint-self-test
  command: grep -q HARBOR013 .scripts/lint/rules.yaml && test -f .scripts/lint/fixtures/HARBOR013/fail.sh && test -f .scripts/lint/fixtures/HARBOR013/pass.sh
  tags: [spec, bash32-compat, implemented]

## source-path
- label: When --source-path targets a git work tree, install.sh copies only tracked and untracked-but-not-ignored files (git ls-files --cached --others --exclude-standard piped to tar), so multi-GB gitignored service caches in a live dev tree are not copied into the install
  command: bash -c 'grep -q "git ls-files -z --cached --others --exclude-standard" install.sh'
  tags: [spec, source-path-copy, implemented]

## link
- 'harbor ln' with SHELL=zsh appends the PATH export and completion fpath to ~/.zshrc (creating it if absent) and a second run adds no duplicate lines @spec @zsh-path @implemented

## speaches first boot
- label: The speaches service defines a compose healthcheck against /health with a generous start_period, and speaches-init depends on it via service_healthy, so 'harbor up --wait' gates on the API being up before model pulls begin
  command: grep -A6 'healthcheck:' services/compose.speaches.yml | grep -q '8000/health' && grep -q 'service_healthy' services/compose.speaches.yml
  tags: [spec, speaches-first-boot, implemented]
- label: speaches-init retries the model pull POST, treats 409 as already-present, and exits non-zero on failure so 'docker compose up --wait' surfaces a failed default-model pull instead of silently succeeding
  command: grep -q 'Deno.exit(1)' services/speaches/init_entrypoint.ts && grep -q '409' services/speaches/init_entrypoint.ts
  tags: [spec, speaches-first-boot, implemented]
- When 'harbor up' includes speaches and a configured default STT/TTS model is missing from the HF cache hub layout, run_up prints a first-boot download notice; with a warm cache the notice is suppressed; verified live on a fresh scratch HF cache including a TTS->STT round-trip @spec @speaches-first-boot @implemented

# speaches-webui
- label: tests/suites/08-speaches-webui.sh guards the speaches↔webui integration: up --no-defaults webui speaches prints the speaches first-boot notice, speaches-init exits 0, and webui config rows (merged file and DB) point audio.tts/stt openai api_base_url at http://speaches:8000/v1
  command: test -x tests/suites/08-speaches-webui.sh && grep -q 'First speaches start downloads STT/TTS models' tests/suites/08-speaches-webui.sh && grep -q 'audio.tts.openai.api_base_url' tests/suites/08-speaches-webui.sh && grep -q 'speaches:8000' tests/suites/08-speaches-webui.sh
  tags: [spec, speaches-webui, implemented]
- label: 08-speaches-webui.sh exercises the webui-proxied audio path end to end: signup token, TTS via /api/v1/audio/speech returning real audio (>1000 bytes, not an error payload), and an STT round-trip via /api/v1/audio/transcriptions whose transcript contains hello+harbor
  command: grep -q 'api/v1/audio/speech' tests/suites/08-speaches-webui.sh && grep -q 'api/v1/audio/transcriptions' tests/suites/08-speaches-webui.sh && grep -q 'auths/signup' tests/suites/08-speaches-webui.sh && grep -q 'hello.harbor' tests/suites/08-speaches-webui.sh
  tags: [spec, speaches-webui, implemented]
- label: speaches-webui is registered in HEAVY_SUITE_DEFAULTS pinning it to ubuntu-2404 with jobs=1 and documented in tests/README.md
  command: grep '"speaches-webui"' tests/stage-repo.ts | grep -q ubuntu-2404 && grep -q 'speaches-webui' tests/README.md
  tags: [spec, speaches-webui, implemented]
- label: The speaches container runs as ${HARBOR_USER_ID}:${HARBOR_GROUP_ID} so the bind-mounted HF/ollama/llamacpp/vllm caches stay writable for any invoking uid (image default 'ubuntu' uid 1000 broke root installs with HTTP 500 on model pull)
  command: grep -q 'HARBOR_USER_ID' services/compose.speaches.yml
  tags: [speaches-webui, implemented]

# comfyui
- label: comfyui has a ROCm overlay services/compose.x.comfyui.rocm.yml that mounts /dev/kfd and /dev/dri and switches the image tag to HARBOR_COMFYUI_ROCM_VERSION (default rocm7 on yanwk/comfyui-boot), auto-applied on ROCm hosts
  command: grep -q "/dev/kfd" services/compose.x.comfyui.rocm.yml && grep -q "HARBOR_COMFYUI_ROCM_VERSION" services/compose.x.comfyui.rocm.yml
  tags: [comfyui, rocm, implemented]
- label: comfyui docs document the CPU fallback (comfyui.args --cpu) for hosts where neither GPU path works
  command: grep -qi "\-\-cpu" "docs/2.1.2-Frontend&colon-ComfyUI.md"
  tags: [comfyui, rocm, implemented]
- label: comfyui image repository is configurable via HARBOR_COMFYUI_IMAGE (default yanwk/comfyui-boot); the base compose composes it with HARBOR_COMFYUI_VERSION and the ROCm overlay with HARBOR_COMFYUI_ROCM_VERSION
  command: grep -q "HARBOR_COMFYUI_IMAGE" services/compose.comfyui.yml && grep -q "HARBOR_COMFYUI_IMAGE=\"yanwk/comfyui-boot\"" profiles/default.env
  tags: [comfyui, spec, implemented]
- comfyui docs document the migration off the unmaintained ai-dock image to yanwk/comfyui-boot, including how to roll back via comfyui.image/version/workspace @comfyui @spec @implemented
- label: comfyui default image is yanwk/comfyui-boot (maintained) with CUDA tag cu130-slim-v2; the ROCm overlay tag default is rocm7
  command: grep -q "HARBOR_COMFYUI_IMAGE=\"yanwk/comfyui-boot\"" profiles/default.env && grep -q "HARBOR_COMFYUI_VERSION=\"cu130-slim-v2\"" profiles/default.env && grep -q "HARBOR_COMFYUI_ROCM_VERSION=\"rocm7\"" profiles/default.env
  tags: [comfyui, migration, spec, implemented]
- label: comfyui compose exposes a single port: CLI_ARGS passes --port ${HARBOR_COMFYUI_HOST_PORT} so ComfyUI listens on the host port internally (webui/traefik integrations keep http://comfyui:34031); portal/Syncthing ports and WEB_USER/WEB_PASSWORD auth env are gone
  command: grep -q "CLI_ARGS=--port \${HARBOR_COMFYUI_HOST_PORT}" services/compose.comfyui.yml && ! grep -q SYNCTHING services/compose.comfyui.yml && ! grep -q WEB_USER services/compose.comfyui.yml
  tags: [comfyui, migration, spec, implemented]
- label: comfyui workspace default is ./services/comfyui/root bind-mounted at /root (comfyui-boot layout); the legacy ai-dock ./services/comfyui/workspace dir is left untouched on disk for rollback
  command: grep -q "HARBOR_COMFYUI_WORKSPACE=\"./services/comfyui/root\"" profiles/default.env && grep -q "\${HARBOR_COMFYUI_WORKSPACE}:/root" services/compose.comfyui.yml && test -d services/comfyui/workspace
  tags: [comfyui, migration, spec, implemented]
- label: comfyui provisioning.sh is mounted as /root/user-scripts/pre-start.sh (comfyui-boot hook); when HARBOR_COMFYUI_PROVISIONING=true it idempotently (wget -nc) downloads Flux.1-schnell, text encoders, VAE and ESRGAN models into /root/ComfyUI/models/{diffusion_models,clip,vae,upscale_models} with HF/Civitai token auth
  command: grep -q "user-scripts/pre-start.sh" services/compose.comfyui.yml && grep -q "diffusion_models" services/comfyui/provisioning.sh && grep -q "HARBOR_COMFYUI_PROVISIONING" services/comfyui/provisioning.sh
  tags: [comfyui, migration, spec, implemented]
- label: upstream ComfyUI has no built-in auth: HARBOR_COMFYUI_AUTH/USER/PASSWORD are removed and harbor comfyui user/password/auth print a deprecation notice pointing at harbor tunnel/traefik
  command: ! grep -q "HARBOR_COMFYUI_AUTH" profiles/default.env && grep -q "no built-in authentication" harbor.sh
  tags: [comfyui, migration, spec, implemented]
- label: harbor comfyui workspace sync is deprecated (comfyui-boot persists everything in /root, no venv-sync needed); workspace open/clear and output operate on the new /root layout via comfyui.workspace
  command: grep -q "workspace sync" harbor.sh && ! grep -q "venv-sync comfyui" harbor.sh
  tags: [comfyui, migration, spec, implemented]
- comfyui docs (2.1.2) are rewritten for the comfyui-boot image: single port, no auth, pre-start provisioning, /root workspace layout, rocm7/cpu tags, rollback instructions to ai-dock @comfyui @migration @spec @implemented

# runner
- label: stage-repo.ts exports resolveSuitePlan: with no explicit --distros, heavy suites resolve to their HEAVY_SUITE_DEFAULTS pins while light suites resolve to the full discovered row list; explicit --distros overrides both for all selected suites
  command: grep -q 'resolveSuitePlan' tests/stage-repo.ts && grep -q 'resolveSuitePlan' tests/run.ts
  tags: [spec, per-suite-distros, implemented]
- label: run.ts filters suites per row from the resolved plan so a row only executes suites whose distro list includes it, and logs the per-suite distro plan
  command: grep -q 'suitesForRow' tests/run.ts
  tags: [spec, per-suite-distros, implemented]
- label: run-stage.test.ts covers resolveSuitePlan: mixed light+heavy selection keeps light suites on all rows and heavy suites pinned; explicit distros and jobs overrides respected
  command: deno test -A --quiet tests/run-stage.test.ts
  tags: [spec, per-suite-distros, implemented]

# mistralrs
- label: mistralrs defaults to image version 0.6.0 (newest upstream tag whose cpu- image binary matches its glibc; 0.7.0-0.9.0 cpu images are broken upstream)
  command: grep -q 'HARBOR_MISTRALRS_VERSION="0.6.0"' profiles/default.env
  tags: [implemented]
- label: mistralrs default model specifier omits -a: architecture is auto-detected from model config (explicit -a qwen3 selects a wrong loader in 0.6.0)
  command: grep -q 'HARBOR_MISTRALRS_MODEL_SPECIFIER="plain -m Qwen/Qwen3-4B"' profiles/default.env
  tags: [implemented]

# mcpo
- label: mcpo launches via uvx with an mcp<2 pin because mcpo 0.0.20 breaks on mcp 2.0.0
  command: grep -q "uvx --with 'mcp<2' mcpo" services/mcpo/start_mcpo.sh
  tags: [implemented]

# nexa
- label: the nexa Dockerfile installs the pinned GitHub release installer (HARBOR_NEXA_VERSION, default v0.2.73 — the last NexaAI x86_64 Linux release before the Qualcomm GenieX ARM64-only rebrand) instead of the dead public-storage.nexa4ai.com installer
  command: grep -q 'releases/download/${HARBOR_NEXA_VERSION}/nexa-cli_linux_x86_64.sh' services/nexa/Dockerfile && ! grep -rq 'public-storage.nexa4ai.com' services/nexa
  tags: [services-it, nexa, implemented]
- label: compose.nexa.yml runs a single nexa service ('serve' via entrypoint binding 0.0.0.0:8000 with published HARBOR_NEXA_HOST_PORT); the nexa-proxy sidecar is gone and webui/cognee/optillm cross-configs point at http://nexa:8000/v1
  command: grep -q 'command: serve' services/compose.nexa.yml && ! grep -rq 'nexa-proxy' services/ --include='*.yml' --include='*.json' && [ ! -f services/nexa/proxy_server.py ]
  tags: [services-it, nexa, implemented]
- label: the nexa entrypoint pre-pulls HARBOR_NEXA_MODEL with 'nexa pull --model-type llm' and stdin closed — the flag skips the CLI's interactive Choose-Model-Type prompt which hard-requires a TTY and would hang the container — then execs nexa serve on 0.0.0.0:8000
  command: grep -q -- '--model-type llm' services/nexa/entrypoint.sh && grep -q 'exec nexa serve' services/nexa/entrypoint.sh
  tags: [services-it, nexa, implemented]
- nexa > the nexa OpenAI API accepts the bare HARBOR_NEXA_MODEL repo name (e.g. Qwen/Qwen3-0.6B-GGUF) in completions even though /v1/models reports the quant-suffixed id (:Q8_0), so cross-service consumers (cognee LLM_MODEL, webui, optillm) need no suffix rendering @services-it @nexa @implemented

# presenton
- label: compose.x.presenton.llamacpp.yml wires Presenton's custom provider to llamacpp with CUSTOM_MODEL from HARBOR_PRESENTON_LLAMACPP_MODEL
  command: grep -q 'CUSTOM_MODEL=${HARBOR_PRESENTON_LLAMACPP_MODEL}' services/compose.x.presenton.llamacpp.yml && grep -q 'LLM=custom' services/compose.x.presenton.llamacpp.yml && grep -q HARBOR_PRESENTON_LLAMACPP_MODEL profiles/default.env
  tags: [implemented]

# config

## ports
- label: profiles/default.env assigns every HARBOR_*_HOST_PORT a unique value (no host-port collisions across services)
  command: bash -c '! grep -oE "^HARBOR_[A-Z0-9_]*HOST_PORT=[0-9]+" profiles/default.env | cut -d= -f2 | sort | uniq -d | grep .'
  tags: [sweep6, implemented]

# deerflow
- label: the v2 stack is four containers: deerflow (nginx entry on port 2026 routing /api to the gateway and everything else to the frontend), deerflow-frontend (Next.js, 3000), deerflow-backend (gateway API, 8001, healthcheck /health), and deerflow-redis (stream bridge)
  command: grep -q deerflow-redis services/compose.deerflow.yml && grep -q deerflow-frontend services/compose.deerflow.yml && grep -q "nginx.conf" services/compose.deerflow.yml && grep -q "8001/health" services/compose.deerflow.yml
  tags: [deerflow, v2, spec, implemented]
- label: model access is a single OpenAI-compatible entry in services/deerflow/config.yaml resolved from container env DEERFLOW_MODEL, DEERFLOW_MODEL_BASE_URL, DEERFLOW_MODEL_API_KEY; base defaults point at llamacpp and compose.x.deerflow.ollama.yml overrides base_url to Harbor Ollama with HARBOR_DEERFLOW_MODEL
  command: grep -q DEERFLOW_MODEL_BASE_URL services/compose.deerflow.yml && grep -q DEERFLOW_MODEL_BASE_URL services/compose.x.deerflow.ollama.yml && grep -q DEERFLOW_MODEL_BASE_URL services/deerflow/config.yaml
  tags: [deerflow, v2, spec, implemented]
- label: gateway state (auth users, threads, sqlite) persists in the deerflow workspace data dir mounted at /app/backend/.deer-flow; frontend sessions are signed with HARBOR_DEERFLOW_AUTH_SECRET (32+ chars default)
  command: grep -q HARBOR_DEERFLOW_AUTH_SECRET profiles/default.env && grep -q /app/backend/.deer-flow services/compose.deerflow.yml
  tags: [deerflow, v2, spec, implemented]

# resume-matcher
- label: resume-matcher builds its frontend on node:22-alpine (upstream Next.js requires Node >=20.9) and healthchecks hit /api/v1/health (upstream 2.0 removed /ping)
  command: grep -q 'node:22-alpine' services/compose.resume-matcher.yml && ! grep -q 'node:18-alpine' services/compose.resume-matcher.yml && grep -q 'localhost:8000/api/v1/health' services/compose.resume-matcher.yml && ! grep -q 'localhost:8000/ping' services/compose.resume-matcher.yml
  tags: [sweep9, implemented]

# solo
- label: solo mounts the shared HF cache at /root/.cache/huggingface so solo list/download operate on Harbor's models
  command: grep -q 'HARBOR_HF_CACHE}:/root/.cache/huggingface' services/compose.solo.yml
  tags: [sweep9, implemented]

# services > kotaemon
- label: kotaemon drops from root to the host user via a setpriv entrypoint wrapper (uv python under /root needs traversal opened first) so workspace bind-mount files stay host-owned
  command: grep -q 'harbor-entrypoint.sh' services/compose.kotaemon.yml && grep -q 'setpriv --reuid "${HARBOR_USER_ID:-1000}"' services/kotaemon/entrypoint.sh && grep -q 'HOME=/tmp' services/compose.kotaemon.yml
  tags: [ownership, implemented]

# services > tts
- label: tts runs as the host user behind a tts-init chown sidecar, with the HF cache mounted at /app/.cache/huggingface instead of /root, so voice/config/cache files stay host-owned
  command: grep -q 'user: "${HARBOR_USER_ID}:${HARBOR_GROUP_ID}"' services/compose.tts.yml && grep -q 'tts-init' services/compose.tts.yml && grep -q '/app/.cache/huggingface' services/compose.tts.yml
  tags: [ownership, implemented]

# services > ownership
- label: karakeep and karakeep-meilisearch run as the host user (HARBOR_USER_ID) with an exec tmpfs on /run for rootless s6-overlay, so workspace files (db.db, queue.db, meilisearch) stay host-owned
  command: grep -q 'user: ${HARBOR_USER_ID}:${HARBOR_GROUP_ID}' services/compose.karakeep.yml && grep -q '/run:exec' services/compose.karakeep.yml
  tags: [ownership3, spec, implemented]
- label: chatui-db (mongo) runs as the host user with a chatui-db-init sidecar chowning ./services/chatui/data, so mongo data files stay host-owned
  command: grep -q 'chatui-db-init' services/compose.chatui.yml && test -f services/chatui/db-init.sh
  tags: [ownership3, spec, implemented]
- label: cognee and cognee-mcp both run as the host user (HOME=/tmp) with a cognee-init sidecar chowning the shared workspace, so sqlite/kuzu/lancedb files stay host-owned
  command: grep -q 'cognee-init' services/compose.cognee.yml && ! grep -q 'user: root' services/compose.cognee.yml
  tags: [ownership3, spec, implemented]
- label: presenton starts via a Harbor entrypoint wrapper that chowns app_data and nginx runtime paths then setpriv-drops to the host user; all processes (nginx included) run non-root and app_data writes stay host-owned
  command: grep -q 'harbor-entrypoint.sh' services/compose.presenton.yml && grep -q setpriv services/presenton/entrypoint.sh
  tags: [ownership3, spec, implemented]
- label: pipelines runs as the host user with supplementary gid 0 (upstream /app is setgid group-writable) and a pipelines-init sidecar chowning ./services/pipelines/persistent
  command: grep -q 'group_add' services/compose.pipelines.yml && grep -q 'pipelines-init' services/compose.pipelines.yml
  tags: [ownership3, spec, implemented]
- label: opencode starts via a Harbor entrypoint wrapper that chowns /root (opencode install + data/config bind mounts) then setpriv-drops to the host user, so opencode.db and config stay host-owned
  command: grep -q 'harbor-entrypoint.sh' services/compose.opencode.yml && grep -q setpriv services/opencode/entrypoint.sh
  tags: [ownership3, spec, implemented]
- label: tei runs as the host user (user: HARBOR_USER_ID:HARBOR_GROUP_ID, HOME=/tmp since the image has no passwd entry) with a tei-init alpine sidecar (services/tei/cache-init.sh mounted read-only at /init.sh, depends_on service_completed_successfully); executed under a stubbed chown against a fixture hub cache with TARGET_UID=4242, the script chowns exactly /data itself (non-recursively) plus every entry under models--* and .locks, and never datasets--*, spaces--* or other dirs (services/tei/check-cache-init.sh)
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; C tei | jq -e --arg u "$(ev HARBOR_USER_ID):$(ev HARBOR_GROUP_ID)" '.services.tei | .user==$u and .environment.HOME=="/tmp" and .depends_on["tei-init"].condition=="service_completed_successfully"' >/dev/null && C tei | jq -e --arg u "$(ev HARBOR_USER_ID)" --arg g "$(ev HARBOR_GROUP_ID)" '.services["tei-init"] | (.image|startswith("alpine")) and .entrypoint==["/bin/sh","/init.sh"] and .environment.TARGET_UID==$u and .environment.TARGET_GID==$g and ([.volumes[]|select(.target=="/init.sh" and .read_only==true and (.source|endswith("/services/tei/cache-init.sh")))]|length==1) and (.volumes|map(select(.target=="/data"))|length==1)' >/dev/null && test -x services/tei/cache-init.sh && services/tei/check-cache-init.sh && grep -q "##### Ownership" docs/2.2.26-Backend-Text-Embeddings-Inference.md
  tags: [tei, ownership4, spec, implemented]

# services > hermes
- label: hermes configure-provider.sh execs the image's entrypoint-dispatch.sh (not the deprecated s6 shim docker/entrypoint.sh, which no longer execs CMD and exits 127)
  command: grep -q 'exec /opt/hermes/docker/entrypoint-dispatch.sh' services/hermes/configure-provider.sh
  tags: [hermes-provider, spec, implemented]
- label: hermes compose sets HERMES_UID/HERMES_GID to HARBOR_USER_ID/HARBOR_GROUP_ID so the s6 image remaps the hermes user and workspace files stay host-owned
  command: grep -q 'HERMES_UID=${HARBOR_USER_ID}' services/compose.hermes.yml && grep -q 'HERMES_GID=${HARBOR_GROUP_ID}' services/compose.hermes.yml
  tags: [hermes-provider, spec, implemented]

# services > tei
- label: [runtime: tei up] harbor.tei runs as HARBOR_USER_ID and HARBOR_TEI_MODEL's models--* dir in HARBOR_HF_CACHE/hub contains no entry owned by another uid (SKIPs with exit 1 when harbor.tei is not healthy)
  command: ev(){ grep -m1 "^$1=" .env | cut -d= -f2- | tr -d "\""; }; p=$(ev HARBOR_CONTAINER_PREFIX); [ "$(docker inspect -f "{{.State.Health.Status}}" $p.tei 2>/dev/null)" = healthy ] || { echo "SKIP: $p.tei is not healthy"; exit 1; }; [ "$(docker exec $p.tei id -u)" = "$(ev HARBOR_USER_ID)" ] && d="$(ev HARBOR_HF_CACHE)/hub/models--$(ev HARBOR_TEI_MODEL | sed s#/#--#)" && test -d "$d" && [ "$(find "$d" ! -uid "$(ev HARBOR_USER_ID)" | wc -l)" = 0 ]
  tags: [tei, ownership4, spec, implemented]
- label: services/tei/check-webui-embed.sh records the uploaded file id and deletes it via DELETE /api/v1/files/<id> in an EXIT trap, so a run leaves no probe file in Open WebUI
  command: s=services/tei/check-webui-embed.sh; grep -q "trap cleanup EXIT" $s && grep -q "X DELETE .*api/v1/files/\$file_id" $s
  tags: [tei, spec, implemented]
- label: tei doc tells non-NVIDIA users not to add nvidia to the selection and quotes the daemon error (could not select device driver) it produces
  command: grep -q "could not select device driver" docs/2.2.26-Backend-Text-Embeddings-Inference.md
  tags: [tei, spec, implemented]

# services > linkwarden
- label: linkwarden NEXTAUTH_URL derives from HARBOR_LINKWARDEN_PUBLIC_URL (default http://localhost:HOST_PORT); the hand-maintained compose.x.linkwarden.traefik.yml (not the seeder-generated compose.x.traefik.linkwarden.yml) redefines it to https://linkwarden.HARBOR_TRAEFIK_DOMAIN so a login through traefik stays on the traefik hostname; the doc covers the override
  command: grep -q "NEXTAUTH_URL=\${HARBOR_LINKWARDEN_PUBLIC_URL:-http://localhost:\${HARBOR_LINKWARDEN_HOST_PORT}}/api/v1/auth" services/compose.linkwarden.yml && grep -q "NEXTAUTH_URL=\${HARBOR_LINKWARDEN_PUBLIC_URL:-https://linkwarden.\${HARBOR_TRAEFIK_DOMAIN}}/api/v1/auth" services/compose.x.linkwarden.traefik.yml && ! grep -q NEXTAUTH_URL services/compose.x.traefik.linkwarden.yml && $(./harbor.sh cmd --no-defaults linkwarden traefik) config --format json | jq -e '.services.linkwarden.environment.NEXTAUTH_URL|test("^https://linkwarden\\..*/api/v1/auth$")' >/dev/null && grep -q "^HARBOR_LINKWARDEN_PUBLIC_URL=" profiles/default.env && grep -q "linkwarden.public_url" docs/2.3.97-Satellite-Linkwarden.md
  tags: [linkwarden, spec, implemented]
- label: in the linkwarden image the worker picks the OpenAI-compatible backend (OPENAI_API_KEY and OPENAI_MODEL) before the ollama pair in autoTagLink.ts, which backs the doc's precedence claim
  command: docker run --rm --entrypoint sh ${HARBOR_LINKWARDEN_IMAGE:-ghcr.io/linkwarden/linkwarden}:latest -c 'f=/data/apps/worker/lib/autoTagLink.ts; o=$(grep -n "process.env.OPENAI_API_KEY && process.env.OPENAI_MODEL" $f | head -1 | cut -d: -f1); l=$(grep -n "NEXT_PUBLIC_OLLAMA_ENDPOINT_URL && process.env.OLLAMA_MODEL" $f | head -1 | cut -d: -f1); [ -n "$o" ] && [ -n "$l" ] && [ "$o" -lt "$l" ]'
  tags: [linkwarden, spec, implemented]
- label: the linkwarden healthcheck passes on an empty database: /login only queries the User table inside an if (token) branch of its getServerSideProps, so an unauthenticated curl renders 200 with zero users (verified in gauntlet round 22: true cold boot, healthy 6s after the container started, User count 0)
  command: docker run --rm --entrypoint sh ${HARBOR_LINKWARDEN_IMAGE:-ghcr.io/linkwarden/linkwarden}:latest -c 'f=/data/apps/web/pages/login.tsx; g=$(grep -n "const getServerSideProps" $f | cut -d: -f1); t=$(grep -n "const token = await getToken" $f | cut -d: -f1); u=$(grep -n "prisma.user.findUnique" $f | cut -d: -f1); [ -n "$g" ] && [ -n "$t" ] && [ -n "$u" ] && [ "$g" -lt "$t" ] && [ "$t" -lt "$u" ] && sed -n "${t},${u}p" $f | grep -q "if (token)"'
  tags: [linkwarden, spec, implemented]
- label: [runtime: linkwarden up] services/linkwarden/check-runtime.sh login registers (idempotently) a harbor-check user via POST /api/v1/users and logs in through /api/v1/auth/callback/credentials with the csrf token, ending with a session whose user.id is set; SKIPs with exit 1 when harbor.linkwarden is not healthy
  command: services/linkwarden/check-runtime.sh login
  tags: [linkwarden, spec, implemented]
- label: [runtime: linkwarden up] end-user workflow: a link POSTed to /api/v1/links by the harbor-check user gets lastPreserved set by the archive worker within 180s and the check deletes it afterwards (services/linkwarden/check-runtime.sh link)
  command: services/linkwarden/check-runtime.sh link
  tags: [linkwarden, spec, implemented]
- label: [runtime: linkwarden ollama|llamacpp|vllm|mlx up] after PUT /api/v1/users/<id> sets aiTaggingMethod GENERATE, a newly saved link becomes aiTagged with at least one tag within 180s (services/linkwarden/check-runtime.sh tags; SKIPs when the container has no NEXT_PUBLIC_OLLAMA_ENDPOINT_URL/CUSTOM_OPENAI_BASE_URL env; fails when the worker marks aiTagged with zero tags)
  command: services/linkwarden/check-runtime.sh tags
  tags: [linkwarden, spec, implemented]
- label: the linkwarden doc has an "Archived but never tagged" troubleshooting entry (verify aiTaggingMethod persisted via the Settings Applied toast or psql, look for Auto-tagging link log lines, run check-runtime.sh tags), uses the UI labels Auto-generate Tags / Based on existing Tags / Based on predefined Tags, mentions RSS Subscriptions, notes pgdata is postgres-owned under Volumes, and states the healthcheck passes on an empty database
  command: d=docs/2.3.97-Satellite-Linkwarden.md; grep -q "^##### Archived but never tagged" $d && grep -q "aiTaggingMethod" $d && grep -q "Settings Applied" $d && grep -q "Auto-tagging link" $d && grep -q "check-runtime.sh tags" $d && grep -q "Based on existing Tags" $d && grep -q "Based on predefined Tags" $d && grep -q "RSS Subscriptions" $d && grep -A4 "^##### Volumes" $d | grep "pgdata" | grep -q "postgres" && grep -q "empty database" $d
  tags: [linkwarden, spec, implemented]

# services > chandra
- label: chandra runs Chandra 2 OCR on vLLM as a GPU-only Harbor backend: HARBOR_CHANDRA_IMAGE:HARBOR_CHANDRA_VERSION on host port HARBOR_CHANDRA_HOST_PORT (container 8000), serving HARBOR_CHANDRA_MODEL under the OpenAI model name chandra with --max-num-seqs 16 --max-num-batched-tokens 2048 (vLLM 0.17 cudagraph warm-up asserts on this hybrid model at the default 256) followed by HARBOR_CHANDRA_EXTRA_ARGS verbatim, mounting the shared HuggingFace cache with HUGGING_FACE_HUB_TOKEN so weights download once, turning healthy on GET /health with a >=30m start_period, and reserving NVIDIA GPUs only under 'harbor up chandra nvidia'
  command: C(){ $(./harbor.sh cmd --no-defaults "$@") config --format json; }; HARBOR_CHANDRA_IMAGE=probe/img HARBOR_CHANDRA_VERSION=probe-ver HARBOR_CHANDRA_HOST_PORT=35080 HARBOR_CHANDRA_MODEL=probe-model HARBOR_CHANDRA_EXTRA_ARGS="--probe-flag probe-val" HARBOR_HF_CACHE=/probe-cache HARBOR_HF_TOKEN=probe-tok C chandra | jq -e '.services.chandra | .image=="probe/img:probe-ver" and .container_name==(env.HARBOR_CONTAINER_PREFIX // "harbor")+".chandra" and (.ports|length==1) and .ports[0].target==8000 and .ports[0].published=="35080" and (.networks|has("harbor-network")) and ([.volumes[]|select(.type=="bind" and .source=="/probe-cache" and .target=="/root/.cache/huggingface")]|length==1) and .environment.HUGGING_FACE_HUB_TOKEN=="probe-tok" and (.healthcheck | (.test|join(" ")|test("/health")) and (.test|join(" ")|test("8000")) and ((.start_period|sub("m.*";"")|tonumber) >= 30) and .retries>=10) and (.command | (index("--model") as $i | $i!=null and .[$i+1]=="probe-model") and (index("--served-model-name") as $i | $i!=null and .[$i+1]=="chandra") and (index("--max-num-seqs") as $i | $i!=null and .[$i+1]=="16") and (index("--max-num-batched-tokens") as $i | $i!=null and .[$i+1]=="2048") and (index("--probe-flag") as $i | $i!=null and .[$i+1]=="probe-val" and $i > index("--max-num-seqs")))' >/dev/null && C chandra | jq -e '.services.chandra | (.command|index("datalab-to/chandra-ocr-2")!=null) and .deploy.resources.reservations.devices==null' >/dev/null && C chandra nvidia | jq -e '.services.chandra.deploy.resources.reservations.devices | length==1 and .[0].driver=="nvidia" and .[0].count==-1 and (.[0].capabilities|index("gpu")!=null)' >/dev/null && grep -q "HARBOR_HF_CACHE:-./services/chandra/.hf-cache}:/root/.cache/huggingface" services/compose.chandra.yml
  tags: [chandra, implemented]
- label: chandra's HARBOR_CHANDRA_* defaults (host port 35080, image vllm/vllm-openai, version v0.17.0, model datalab-to/chandra-ocr-2, empty extra args) live in services/chandra/default.env with no copy in profiles/default.env, and the service directory carries an override.env loaded after ./.env plus a .gitignore for the local .hf-cache fallback
  command: test -f services/chandra/override.env && grep -q "^\.hf-cache" services/chandra/.gitignore && grep -Eq "^\s*- \./services/chandra/override\.env" services/compose.chandra.yml && grep -Eq "^\s*- \./\.env" services/compose.chandra.yml && grep -q "^HARBOR_CHANDRA_HOST_PORT=35080$" services/chandra/default.env && grep -q "^HARBOR_CHANDRA_IMAGE=\"vllm/vllm-openai\"$" services/chandra/default.env && grep -q "^HARBOR_CHANDRA_VERSION=\"v0.17.0\"$" services/chandra/default.env && grep -q "^HARBOR_CHANDRA_MODEL=\"datalab-to/chandra-ocr-2\"$" services/chandra/default.env && grep -q "^HARBOR_CHANDRA_EXTRA_ARGS=\"\"$" services/chandra/default.env && [ "$(grep -c HARBOR_CHANDRA profiles/default.env)" = "0" ] && [ "$(./harbor.sh config get chandra.host_port)" = "35080" ]
  tags: [chandra, implemented]
- label: chandra is discoverable and documented: the app catalog registers it as a backend with the datalab-to/chandra project link, README lists it under Backends and in the News section, tests/new-services.md carries a cold-start group skipped on non-NVIDIA hosts, and docs/2.2.27-Backend-Chandra.md follows the Windmill format while stating GPU-only/ROCm-untested, non-gated OpenRAIL-M weights, 16GB VRAM, <think>/HTML-ish raw output, the num_cache_lines troubleshooting entry and the HARBOR_CHANDRA_* env table
  command: d=docs/2.2.27-Backend-Chandra.md; test -f $d && prev=0; for h in "#### Starting" "#### Configuration" "##### Environment Variables" "#### Integration with Harbor" "#### Troubleshooting" "#### Links"; do n=$(grep -n -m1 "^$h$" $d | cut -d: -f1); [ -n "$n" ] && [ "$n" -gt "$prev" ] || { echo "missing or misordered: $h"; exit 1; }; prev=$n; done; [ "$(grep -A6 "^\*\*Key Features" $d | grep -c "^- ")" -ge 3 ] && grep -q "Handle: \`chandra\`" $d && grep -q "localhost:35080" $d && grep -q "harbor up chandra nvidia" $d && grep -qi "rocm" $d && grep -qi "untested" $d && grep -qi "OpenRAIL-M" $d && grep -q "not gated" $d && grep -q "16GB of VRAM" $d && grep -q "num_cache_lines" $d && grep -q "max-num-seqs 2 --max-model-len 8192" $d && grep -q "<think></think>" $d && grep -q "HARBOR_CHANDRA_HOST_PORT" $d && grep -q "HARBOR_CHANDRA_MODEL" $d && grep -q "HARBOR_CHANDRA_EXTRA_ARGS" $d && grep -q "datalab-to/chandra-ocr-2" $d && grep -A7 "^    chandra: {" app/src/serviceMetadata.ts | grep -q "HST.backend" && grep -A7 "^    chandra: {" app/src/serviceMetadata.ts | grep -q "2.2.27-Backend-Chandra\`" && grep -A7 "^    chandra: {" app/src/serviceMetadata.ts | grep -q "projectUrl: .https://github.com/datalab-to/chandra" && sed -n "/^##### Backends/,/^#####/p" README.md | grep -q "\[Chandra\](https://github.com/av/harbor/wiki/2.2.27-Backend-Chandra)" && sed -n "/^## News/,/^## Documentation/p" README.md | grep "^- " | grep -q "Chandra" && grep -q "^## Group [0-9]* — chandra" tests/new-services.md && sed -n "/^## Group [0-9]* — chandra/,/^## Group/p" tests/new-services.md | grep -q "harbor.sh up --no-defaults chandra nvidia" && sed -n "/^## Group [0-9]* — chandra/,/^## Group/p" tests/new-services.md | grep -q "localhost:35080/health" && sed -n "/^## Group [0-9]* — chandra/,/^## Group/p" tests/new-services.md | grep -q "/v1/chat/completions" && sed -n "/^## Group [0-9]* — chandra/,/^## Group/p" tests/new-services.md | grep -qi "skip" && sed -n "/^## Group [0-9]* — chandra/,/^## Group/p" tests/new-services.md | grep -q "harbor.sh down chandra"
  tags: [chandra, implemented]
