diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml
index a8d68c73..fe9d23eb 100644
--- a/.github/workflows/check-docs.yml
+++ b/.github/workflows/check-docs.yml
@@ -34,14 +34,30 @@ jobs:
Your task: check whether the project documentation is still accurate
and complete given the code changes introduced by this PR.
+ The documentation is structured, and each kind of change has a home:
+ - A verb's flags, output, JSON shape, or exit codes → its page in docs/cli/
+ (one page per verb, plus the overview's exit-code contract).
+ - An engine module's responsibility, key symbols, or invariants → its page
+ in docs/api/ (hand-written module tours) and, for cross-cutting shifts,
+ docs/architecture.md.
+ - User-visible behavior (scaffold contents, states, refusal messages,
+ environment model, SLURM, publication) → docs/user/ (getting-started
+ quotes real console output; troubleshooting quotes real refusals) and
+ README.md's quick start.
+ - Test structure, dev workflow, or conventions → docs/contributing/.
+
Steps to follow:
1. Run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }}
to get the list of changed files.
- 2. Read the changed source files (focus on .py, .ts, .js, .go, .rs files).
- 3. Read existing documentation files (README*, docs/**/*.md, CHANGELOG*, CONTRIBUTING*).
- 4. SKIP any files under skills/ or named SKILL.md — these are not user-facing docs.
- 5. Identify any documentation that is now inaccurate, incomplete, or missing
- given the code changes.
+ 2. Read the changed source files (focus on src/**/*.py and the workflows).
+ 3. Read the documentation pages the map above points at for those changes.
+ 4. SKIP CLAUDE.md and evals/ — agent instructions and the eval harness are
+ maintained separately, not user-facing docs.
+ 5. Identify documentation that is now inaccurate, incomplete, or missing.
+ Two failure modes matter most here: a quoted console output or refusal
+ message that no longer matches what the CLI prints, and a documented
+ flag, verb, state, or file that no longer exists (the docs must never
+ describe more than the code delivers — no foreshadowing).
Then:
- Post a comment on PR #${{ github.event.pull_request.number }} summarising
diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml
index 0973decb..949205c9 100644
--- a/.github/workflows/eval.yml
+++ b/.github/workflows/eval.yml
@@ -3,11 +3,14 @@ name: Eval
# Minimal agentic eval: scaffold a project from the eval seed on the
# runner, let Claude Code (with the astra plugin) build it using the
# branch's lc/astra, then check the results with astra validate and
-# lc status.
+# lc materialize --check.
on:
- pull_request:
workflow_dispatch:
+ pull_request:
+ # `ready_for_review` beside the defaults, so flipping a draft to
+ # ready triggers the run the draft guard below skips.
+ types: [opened, synchronize, reopened, ready_for_review]
# Only one eval per PR at a time — cancel in-progress runs
concurrency:
@@ -28,20 +31,35 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
+ with:
+ # Full history so hatch-vcs sees the tags and stamps a truthful
+ # dev version into the installed engine (and every manifest's
+ # lc_version) instead of a 0.1.dev fallback
+ fetch-depth: 0
- name: Set up uv
+ # No python-version input: it would export an ambient UV_PYTHON,
+ # which lc's install-settings scrub then rightly warns about on
+ # every single invocation the agent makes. The interpreter is
+ # pinned per tool install below instead.
uses: astral-sh/setup-uv@v6
with:
- python-version: "3.12"
enable-cache: true
- activate-environment: true
- - name: Install lightcone-cli (branch under test)
+ - name: Install lightcone-cli + astra (uv tools)
+ # The engine is the host's uv tool — install the branch under
+ # test the way a user installs a release: `uv tool install`,
+ # which links `lc` and (via the mirrored entry points) the
+ # git-annex executables into ~/.local/bin. The commit under test
+ # is the checkout itself, stamped into the version by hatch-vcs.
+ # astra's executable belongs to astra-tools, so it is its own
+ # tool install, pinned to the version the branch depends on. No
+ # venv is activated: the agent's shell sees the tools exactly as
+ # an end user's would.
run: |
- uv sync
- # Scientific deps the task's analysis scripts will need — the
- # agent can add more with `uv pip install`
- uv pip install numpy scipy matplotlib
+ uv tool install --python 3.12 "$GITHUB_WORKSPACE"
+ uv tool install --python 3.12 "astra-tools==$(grep -oP 'astra-tools==\K[0-9][0-9.]*' pyproject.toml)"
+ echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install Claude Code + astra plugin
run: |
@@ -70,6 +88,12 @@ jobs:
rm -f universes/baseline.yaml
astra universe generate -n baseline \
-d 'Default configuration using standard practices'
+ # No scientific deps are pre-seeded — declaring what the
+ # recipes import (`uv add`) is part of what the eval measures
+ # lc materialize refuses a dirty tree — commit the seed so the
+ # agent starts from a clean one
+ git add -A
+ git commit -q -m 'Seed the eval task'
- name: Run Claude Code
id: agent
@@ -92,11 +116,14 @@ jobs:
working-directory: ${{ env.PROJECT }}
run: |
astra validate astra.yaml
- lc status --universe baseline
- # Fail unless every materializable output is ok
- lc status --json | jq -e \
- '[.universes[].outputs[] | select(.status != "alias")]
- | length > 0 and all(.status == "ok")'
+ lc status
+ # The gate: nothing failed, nothing left to make — and at least
+ # one output actually got materialized
+ lc materialize --check --json | jq -e \
+ '.up_to_date and ((.current | length) > 0)'
+ # The publication view: declaring a license and materializing
+ # once more is part of the task
+ test -f ro-crate-metadata.json
- name: Build report
if: always()
@@ -136,7 +163,7 @@ jobs:
echo 'lc status
'
echo ""
echo '```'
- (cd "$PROJECT" && lc status --universe baseline) || echo "unavailable"
+ (cd "$PROJECT" && lc status) || echo "unavailable"
echo '```'
echo ""
echo ' '
@@ -199,11 +226,12 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: eval-project
- # include-hidden-files keeps the .lightcone-manifest.json
- # provenance files; the venv and snakemake scratch are excluded
+ # include-hidden-files keeps the manifests, the run records in
+ # .git, and the annexed results; the venv and lc's per-run
+ # machine state are excluded
path: |
${{ env.PROJECT }}
!${{ env.PROJECT }}/.venv/**
- !${{ env.PROJECT }}/.snakemake/**
+ !${{ env.PROJECT }}/.lightcone/**
include-hidden-files: true
if-no-files-found: warn
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 24fa006b..a7181621 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -4,6 +4,10 @@ on:
push:
branches: [main]
pull_request:
+ # `ready_for_review` is not in the default set, and this job is gated
+ # on `draft == false` — so without it a PR opened as a draft and then
+ # marked ready never runs at all until someone happens to push again.
+ types: [opened, synchronize, reopened, ready_for_review]
jobs:
lint:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 57f91804..632ea2ba 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -4,14 +4,42 @@ on:
push:
branches: [main]
pull_request:
+ # `ready_for_review` is not in the default set, and this job is gated
+ # on `draft == false` — so without it a PR opened as a draft and then
+ # marked ready never runs at all until someone happens to push again.
+ types: [opened, synchronize, reopened, ready_for_review]
jobs:
test:
if: github.event_name == 'push' || github.event.pull_request.draft == false
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
+ # Both mechanisms are enforced by the same suite, so a failure on one
+ # OS is exactly the information the other run is there to provide.
+ fail-fast: false
matrix:
+ os: [ubuntu-latest]
python-version: ["3.11", "3.12", "3.13"]
+ include:
+ # macOS is where Seatbelt is the mechanism, and the only place
+ # the generated SBPL is ever executed. One interpreter is
+ # enough: what differs here is the kernel, not the python.
+ - os: macos-latest
+ python-version: "3.13"
+
+ env:
+ # Enforcement tests skip themselves where no mechanism exists. On CI
+ # that must be a failure instead: a runner that quietly loses
+ # Landlock or Seatbelt would otherwise report green while testing
+ # nothing at all.
+ LC_SANDBOX_TESTS_REQUIRED: "1"
+ # Same rule for the container smoke tests, on the runners that carry
+ # the runtimes (ubuntu ships podman and a running docker daemon).
+ # macOS runners have no podman machine, so there the suite skips.
+ LC_CONTAINER_TESTS_REQUIRED: ${{ matrix.os == 'ubuntu-latest' && '1' || '' }}
+ # And for the crate validation: rocrate-validator is in the dev
+ # group, so every runner has it and none may skip.
+ LC_CRATE_TESTS_REQUIRED: "1"
steps:
- uses: actions/checkout@v4
@@ -26,5 +54,33 @@ jobs:
- name: Install dependencies
run: uv sync --group dev
+ - name: Report the sandbox this runner can build
+ # Printed before the suite so a failure downstream can be read
+ # against what this host could actually enforce, and against the
+ # exec set it resolved. The allowlist resolves off a fixed search
+ # path, so where a tool lives differs per OS and a missing one is
+ # otherwise a very confusing "Operation not permitted".
+ run: |
+ python -c "
+ import shutil, tempfile
+ from pathlib import Path
+ from lightcone.engine.sandbox import detect
+ from lightcone.engine.sandbox.policy import (
+ EXEC_ALLOWLIST, _UTILITY_PATH, exec_policy,
+ )
+ c = detect().capability
+ print(f'mechanism: {c.kind} abi: {c.landlock_abi} {c.detail}')
+ print(f'utility search path: {_UTILITY_PATH}')
+ policy = exec_policy(Path(tempfile.mkdtemp()) / 'proj')
+ granted = set(policy.execute)
+ for name in EXEC_ALLOWLIST:
+ found = shutil.which(name, path=_UTILITY_PATH)
+ if found is None:
+ print(f' {name:10} not on this host')
+ elif Path(found).resolve() not in granted:
+ print(f' {name:10} {found} -> {Path(found).resolve()} NOT GRANTED')
+ print(f'exec set: {len(policy.execute)} paths, write: {len(policy.write)}')
+ "
+
- name: Run tests
run: pytest
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index e69de29b..00000000
diff --git a/CLAUDE.md b/CLAUDE.md
index f5ed817e..7c73800b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,161 +2,2363 @@
## Project Overview
-**lightcone-cli** is Lightcone Research's execution layer for ASTRA (Agentic Schema for Transparent Research Analysis). It ships the `lc` executable — an agent-agnostic CLI; it bundles no agent-specific skills, hooks, or plugins.
+**lightcone-cli** is Lightcone Research's execution layer for ASTRA
+(Agentic Schema for Transparent Research Analysis). It ships the `lc`
+executable — an agent-agnostic CLI; it bundles no agent-specific skills,
+hooks, or plugins.
-- **ASTRA** = pure specification: schema, validation, prior insights & findings, evidence verification, helpers, minimal CLI
-- **lightcone-cli** = execution layer: project scaffolding, **Snakemake-based execution**, container builds
+- **ASTRA** = pure specification: schema, validation, prior insights &
+ findings, evidence verification, helpers, minimal CLI
+- **lightcone-cli** = execution layer: project scaffolding, execution,
+ environment identity, hermeticity enforcement
-lightcone-cli depends on ASTRA. The `astra` CLI handles spec operations; the `lc` CLI handles execution.
+lightcone-cli depends on ASTRA. The `astra` CLI handles spec operations;
+the `lc` CLI handles execution.
-### Namespace contract
+That split is enforced in code, not just described: everything about what
+a spec *means* — scoping, `from:` references, conditional outputs,
+universe resolution, the recipe placeholder grammar — is answered by
+`astra.resolve` and validated by `astra.validation` before lc acts on it.
+lc's whole ASTRA surface is ten functions; see Key Invariants (layer 4).
-`lightcone-cli` ships the `lightcone.*` namespace via PEP 420 implicit namespace packages. **`src/lightcone/` must not contain an `__init__.py`** — that would turn the namespace into a regular package and break coexistence with future sibling distributions (`lightcone-ui`, etc.).
+## ⚠️ This repository is a clean rebuild in progress
-Any new `lightcone-*` package must:
+The codebase is being **re-added layer by layer** on top of the design
+spec:
-1. Use src-layout (`src/lightcone//…`).
-2. Not create `src/lightcone/__init__.py`.
-3. Ship only its own subpackage under `src/lightcone//`.
+> **`../lightcone-cli/docs/design/execution-environment.md`** — *"the
+> locked environment is the execution environment"*, v6.1. Read it before
+> adding anything — but read it as a **reference, not gospel**: the
+> rebuild deliberately drifts from it as implementation teaches better
+> answers, and where this file's Recorded decisions disagree with the
+> spec, the decisions win. It lives in the sibling checkout for now
+> (branch `redesign_prototype`), alongside its decision records
+> (rationale, substrate tradeoffs, hermeticity enforcement, the v6
+> review). It stays in the sibling checkout and is **dropped when the
+> rebuild completes** (decision, 2026-08): the design records are not
+> imported into this repo's docs — the rewritten `docs/` carries the
+> current design, and this file carries the decisions.
+
+The pre-rebuild codebase (Snakemake shim, authored Containerfiles,
+`container:` in `astra.yaml`, vendored dask executor plugin, WRROC export)
+was stripped deliberately. Functionality comes back **one layer at a
+time**, each layer landing with its own tests and dependencies — never
+speculatively.
+
+### Layers
+
+| # | Layer | State |
+|---|-------|-------|
+| 1 | **Project scaffolding** — `lc init` | ✅ **done** |
+| 2 | **Environment layer** — `env_version`, lock scan, manifest schema | ✅ **done** |
+| 3 | ~~The `lc` entrypoint — launcher~~ | ❌ **removed by decision** (2026-08) — see Recorded decisions: the host `lc` is the engine, so there is nothing to delegate to |
+| 4 | **Fabric** — `lc materialize`, worker sequence, mid-run relock gate | ✅ **done** |
+| 5 | **Sandbox layer** — Landlock / Seatbelt, exec-shim, denial UX, `lc run` | ✅ **done** |
+| 6 | **Container hatch** — `[tool.lightcone.image]`, `lc build`, OCI runtimes as the exec boundary, the image archived in the dataset | ✅ **done** |
+| 7 | **Venues** — SLURM in-allocation execution, login guard, podman-hpc | 🔶 **landed; Perlmutter spike pending** — hub/GKE and Cloud Build deferred to their own layer |
+| 8 | **Publication view** — the RO-Crate converged by materialize, foreign writes stale by history; **no `lc verify`, no `lc export`, by decision** | ✅ **done** |
+
+`lc status` landed with the invalidation model rather than at layer 8:
+once an output can be *behind*, something has to say which ones are, and
+the verb is the same classification walk `--check` already does.
+
+Layer 5 landed **out of order**, ahead of 2–4: `lc run` is the spec's
+*probe* verb, and a probe has no output, so it needs neither manifests
+(layer 2) nor the fabric (layer 4) — only project discovery, which came
+with it. That makes it the smallest honest consumer of the exec boundary,
+and the boundary is what layer 4 will then plug recipes into.
+
+The spec's §11 (Migration) is the reference ordering; the table above is
+the working map, and the spec is a reference the rebuild deliberately
+drifts from — reversals land as Recorded decisions here rather than
+waiting on a spec rewrite. **Layer boundaries are also dependency
+boundaries** — a dependency enters `pyproject.toml` with the layer that
+needs it, not before.
+
+### Rules while rebuilding
-## Architecture
+Each of these has been asked for in review at least once; none is optional.
-The execution layer is a thin shim over Snakemake. The integrity layer (per-output content-addressed manifests) is the only thing we own substantively.
+- **Never reference the design spec in code or comments.** No `spec §7`,
+ no section numbers, no "the spec says". Code and its comments must
+ stand on their own; design rationale lives in the design documents.
+ (This file is the exception — it is *about* the design.)
+- **No backward-compatibility code.** Nothing exists to honor the
+ behavior of an older CLI, an older wire format, or trained fingers.
+ If old behavior isn't promised, don't guard, version, or migrate it.
+- **No foreshadowing.** No code, comment, flag, or user-facing message
+ may mention a verb, layer, or feature that does not exist yet. The
+ codebase is consistent with the project *at this point in time*.
+- **No escape hatches around guarantees.** A feature that enforces
+ something ships without a flag to turn the enforcement off.
+- **Prefer literal behavior over invented convenience.** The current
+ directory is the project root; erroring beats walking up or guessing.
+- **Nothing waits on a human.** A verb is run by an agent more often
+ than by a person, so no interactive prompt and no interactive shell —
+ either is a hang, not a UX choice.
+- **Streamline before shipping.** No small helper functions or
+ rendering layers where a few inline lines read fine; consolidate.
+- **Be honest about provenance.** Third-party material we adapt is
+ "inspired from" upstream, clearly marked, with its license named —
+ never passed off as verbatim, never left unattributed.
+- **Leave working files alone.** Don't edit files that are fine just
+ because a change nearby made them look touchable.
+
+- **No dead code.** If nothing in the current layer calls it, it doesn't
+ land yet. `lc --help` advertises only verbs that work.
+- **`docs/` is live again** (rewritten 2026-08, PRs #185–#188; the
+ freeze is over). The site is two tracks — user guide + developer
+ corner — and a change now lands with its docs: a new or changed verb
+ updates its `docs/cli/` page, an engine change updates its
+ `docs/api/` module page, and user-visible behavior updates the user
+ guide. The docs' own rules match this file's: document only what
+ exists, quote refusals from real runs, and verify every command
+ block by executing it. `check-docs.yml` reviews each merged PR for
+ drift.
+- **Port with intent.** Prior implementations (this repo's git history,
+ and the `redesign_prototype` branch of the sibling `lightcone-cli`
+ checkout) are references, not sources of truth. Neither is the spec by
+ itself: the spec plus this file's Recorded decisions is the current
+ design, and the decisions override the spec where they disagree.
+- **Every layer ships tests.** See the per-layer test list in spec §11.
+
+## Architecture (target)
+
+A project is `pyproject.toml` + `uv.lock` + `.python-version` — **uv is
+the only environment substrate**. Mode is *derived, not configured*:
```
-astra.yaml ── snakefile generator ──> .lightcone/Snakefile
- │
- snakemake (CLI subprocess)
- │
- ┌───────────────────────────────┼───────────────────────────────┐
- │ │ │ │ │
- DAG resolution staleness cluster submission container exec conda
- (Snakemake) (mtime+code) (slurm plugin) (apptainer/docker)
- │
- └─── per-rule run: block: shell() recipe + write_manifest()
- │
- results///...
- results///.lightcone-manifest.json
+direct mode (default) containerized mode
+───────────────────── ──────────────────
+.venv in the project tree [tool.lightcone.image] declared
+no image, ever ⇒ content-addressed image is the
+recipes run under Landlock/ execution world: driver, workers,
+ Seatbelt recipes, probes all run in-image
+ from the baked /opt/venv
```
-**What Snakemake owns** (we do not write code for any of this): DAG construction, topological execution, parallelism (`--cores`, `--jobs`), cluster submission (`snakemake-executor-plugin-slurm`), per-rule resources, profiles, dry-run, DAG visualization, staleness detection (`--rerun-triggers`), locking, log capture, retry, container runtime invocation.
+Identity: `env_version = sha256(uv.lock ‖ .python-version ‖ canonical
+install-settings ‖ canonical [tool.lightcone.image] ‖ Containerfile.extra
+hash)`, recorded beside `definition_version` rather than folded into it.
+Every output records what
+enforcement it actually ran under (`hermeticity`). See spec §1–§3, §7.
+
+### Namespace contract
+
+`lightcone-cli` ships the `lightcone.*` namespace via PEP 420 implicit
+namespace packages. **`src/lightcone/` must not contain an
+`__init__.py`** — that would turn the namespace into a regular package
+and break coexistence with future sibling distributions
+(`lightcone-ui`, etc.).
-**What we own**: a Snakefile generator, the manifest layer (write/read/verify), a status walker, and a verify routine.
+Any new `lightcone-*` package must:
+
+1. Use src-layout (`src/lightcone//…`).
+2. Not create `src/lightcone/__init__.py`.
+3. Ship only its own subpackage under `src/lightcone//`.
-## Repository Structure
+## Repository Structure (current)
```
src/lightcone/ # namespace — NO __init__.py
-├── cli/ # Click surface
-│ ├── __init__.py # exposes main()
-│ └── commands.py # init, run, status, verify, build
-├── engine/ # execution substrate — Snakemake-based
-│ ├── __init__.py
-│ ├── manifest.py # write_manifest, sha256_dir, code_version — the integrity layer
-│ ├── snakefile.py # generates .lightcone/Snakefile from astra.yaml
-│ ├── container.py # Content-addressed container builds (Docker, podman-hpc, apptainer)
-│ ├── cloudbuild.py # GCP Cloud Build backend (JupyterHub deployments; no local OCI runtime)
-│ ├── status.py # Manifest-driven status walker (no Snakemake import)
-│ ├── verify.py # Recompute hashes; validate provenance chain
-│ ├── tree.py # Sub-analysis tree traversal (kept from before)
-│ ├── validation.py # Post-materialization output shape checks
-│ └── site_registry.py # Known HPC site defaults (Perlmutter, etc.)
-
-evals/ # Agentic eval: prompt.md + tasks// seed files;
- # driven by .github/workflows/eval.yml (no Python harness)
-tests/ # pytest — mirrors src/ structure
-pyproject.toml # hatchling + hatch-vcs, ASTRA + Snakemake as deps
+├── _sandbox_exec.py # the Landlock shim — stdlib only, zero lightcone imports
+├── cli/ # the CLI only: flags, rendering, exit codes
+│ ├── __init__.py # exposes main(), lazily
+│ └── commands.py # lc init, lc run, lc materialize, lc status
+└── engine/
+ ├── __init__.py # docstring only
+ ├── project.py # what a project is: convergence, discovery, mode
+ ├── dataset.py # the git + git-annex seam: how a project stores
+ ├── identity.py # env_version, definition_version, the lock scan
+ ├── image.py # the system layer: declaration, Containerfile, tag — pure
+ ├── container.py # runtimes, the build, the archived image — impure
+ ├── crate.py # the publication view: the repo as an RO-Crate — pure
+ ├── assets.py # an output: its directory, its manifest, its state
+ ├── plan.py # the spec, read as a graph of tasks
+ ├── worker.py # making one output; also the `python -m` entry point
+ ├── materialize.py # the driver: dirty gate, Dask, the save/restore loop
+ ├── run.py # what `lc run` is: the probe + the uv hop
+ ├── sandbox/ # the exec boundary
+ │ ├── __init__.py # the public surface (detect, run, scope, the types)
+ │ ├── model.py # Policy · Capability · Attestation · Backend protocol
+ │ ├── policy.py # what a probe and a recipe may touch
+ │ ├── boundary.py # detect() + run(): the mechanism-blind half
+ │ ├── landlock.py # Linux backend
+ │ ├── seatbelt.py # macOS backend
+ │ ├── oci.py # containerized backend: the mount table as mechanism
+ │ └── denial.py # the denial UX
+ └── templates/ # the scaffold's file content
+ ├── __init__.py # loader; a renderer only where there is a value to decide
+ └── files/*.tmpl # the templates themselves, as real files
+
+evals/ # agentic eval seed: prompt.md + tasks//
+tests/ # pytest — mirrors src/
```
## Documentation versioning (mike)
The whole docs site is versioned with [mike](https://github.com/squidfunk/mike) — specifically squidfunk's fork, which Zensical's versioning provider depends on. Each release deploys a full copy of the site to a subdirectory of the `gh-pages` branch (`/0.0.9/`, `/latest/`, etc.). Mike is enabled via `[project.extra.version] provider = "mike"` in `zensical.toml`; the version dropdown in the header is rendered natively.
-Release flow: `.github/workflows/docs-deploy.yml` runs on every published release — it runs `mike deploy --push --update-aliases X.Y.Z latest` (version taken from the tag) followed by `mike set-default --push latest`, so the bare site root always redirects to `/latest/`. For an intermediate redeploy of an existing version, trigger the workflow manually from the Actions tab. The `just docs-deploy` / `docs-set-default` / `docs-versions` / `docs-delete-version` recipes wrap the same mike commands for local use.
+Release flow: `.github/workflows/docs-deploy.yml` runs on every published release — it runs `mike deploy --push --update-aliases X.Y.Z latest` (version taken from the tag) followed by `mike set-default --push latest`, so the bare site root always redirects to `/latest/`. For an intermediate redeploy of an existing version, trigger the workflow manually from the Actions tab. For local/manual operations, run the same mike commands directly (`uv run mike list`, `uv run mike deploy ...`, `uv run mike delete ...` — the docs dependency group installs mike).
Hosting: mike pushes to `gh-pages`. GitHub Pages (which serves docs.lightconeresearch.org) must be configured to "Deploy from a branch" / `gh-pages` in the repo's Pages settings, not via the Actions artifact deploy. Without this, `mike deploy` runs successfully but the site doesn't pick up versioned URLs in production.
## Development Commands
```bash
-uv sync --group dev # installs pytest, ruff, mypy
+uv sync --group dev # pytest (+ pytest-cov), ruff, mypy, datalad
uv run pytest
-uv run ruff check src/ tests/
+uv run ruff check src/ tests/ # --fix to apply
uv run mypy src/
+uv build # wheel + sdist (CI runs this only to publish)
```
-A `justfile` is available for common tasks — run `just` to see all recipes:
+Test, lint and type-check are the whole loop, and they are what
+`.github/workflows/{tests,lint}.yml` run. There is deliberately no task
+runner in between — the pre-rebuild `justfile` was 90 lines of wrappers
+around them. The docs build with `uv sync --group docs && uv run
+zensical build`. The other workflows are `eval.yml` (the agentic eval,
+on dispatch or the `run-eval` PR label; re-trigger by re-adding the
+label), `check-docs.yml` (doc-drift review on merged PRs),
+`pypi-publish.yaml`, and `docs-deploy.yml` (deploys on release, so the
+site tracks the released CLI).
-```bash
-just test # run pytest
-just lint # ruff + mypy
-just docs # build the documentation site
-```
+## Key Invariants (layer 1)
-## Architecture & Data Flow
+**The CLI/engine split.** `cli/commands.py` owns flags, console rendering,
+and exit codes — nothing else. Everything about *what a project is* lives
+in `engine/project.py`:
-```
-astra.yaml ── snakefile.generate() ──> .lightcone/Snakefile + .lightcone/snakefile-config.json
- │
- snakemake -s ... -d ...
- │
- per-rule run:
- │
- shell(recipe) ────────────────► write_manifest()
- (in container if container: set; (host-side)
- Snakemake handles invocation)
- │
- results///data.txt
- results///.lightcone-manifest.json
-```
+| Symbol | Role |
+|---|---|
+| `converge(dir, *, write)` | The whole scaffold operation |
+| `ConvergenceReport` | `created` / `repaired` / `unchanged` / `blocked` / `warnings`, plus `.converged` and `.as_dict()` |
+| `project_name(dir)` | PEP 503-ish name from the directory name |
+| `ProjectError` | The one engine exception; `_EngineErrorGroup` in the CLI turns it into a clean `ClickException` |
+
+The engine never imports click and never prints. `converge(write=False)`
+is check mode — the *same* decision path with side effects switched off,
+which is what keeps `--check` honest rather than a second implementation.
+Every item therefore routes through `_Converger.item`, `.file`, or
+`.blocked`; **nothing writes or records outside that mechanism.** `.file`
+takes a *thunk*, so check mode renders no template at all.
+
+Two report distinctions that matter: a **warning** is something
+convergence can see but must not fix (advisory — never affects
+`converged`); a **blocked** item is one convergence cannot complete, and it
+does count, so a report can never claim a project is converged while
+something it owns is absent.
+
+**There is no project discovery, by decision.** `lc init` is handed its
+directory (defaulting to `.`); `lc run` assumes the current directory is
+the project root — `project.current_project()` checks only that the
+environment is there (`pyproject.toml`, `uv.lock`, `.venv`) and does not
+require an `astra.yaml`, so any uv project can be probed. No walk-up:
+the directory you invoke from is the project, or it is a clean error.
+
+**Two questions about a project root, not one** — the same shape as
+`_in_repository` / `_can_ask_git` below. `declared_project()` wants only
+what the repository carries (`pyproject.toml`, `uv.lock`);
+`current_project()` adds `.venv`. The split is named rather than a
+`synced=` flag on one function, because "what makes a directory a
+project" should not be negotiable per call site, and because a slice of
+a constant would make the answer depend on the order its entries happen
+to be in. The weaker question has exactly one caller — the worker entry
+point, which builds the `.venv` a moment later — and that is the whole
+reason it exists.
+
+**CLI startup stays cheap.** `commands.py` imports the engine *inside* the
+command callbacks and builds the rich console lazily, so `lc --help` and
+shell completion pay for neither. Keep this up as verbs land: a module-scope
+engine import would make every invocation pay for the heaviest layer.
+
+**The scaffold comes from `astra.scaffold`, not `astra.cli`.** Both export
+`create_boilerplate`, and the second drags Click, Rich and the validation
+stack — measured, 37 ms against 4 ms. `astra.scaffold` is stdlib-only
+(checked: it pulls no linkml_runtime, click, rich, pydantic or
+jsonschema), which is why this one astra import sits at module scope in
+`project.py` where `astra.validation` and `astra.resolve` must not.
+
+**Templates are files, not string literals.** `engine/templates/files/*.tmpl`
+are package data, loaded through `importlib.resources`. Placeholders are
+`string.Template` (`${name}`) — **not** `str.format` — because several
+templates legitimately contain braces (TOML tables, MyST `{astra}` roles).
+Substitution is strict, so a missing key raises.
+
+**A template gets a function only when there is something to decide** — a
+value the caller supplies (`pyproject`, `datalad_config`, `index_md`) or a
+merge policy for a file the user already owns (`gitignore_repair`,
+`gitattributes_repair`). Everything else is read by name:
+`templates.read("myst.yml.tmpl")`, or `partial(templates.read, …)` where
+convergence wants a thunk. This reverses the earlier "one function per
+scaffolded file" rule, which had five of the module's twenty functions
+doing nothing but rename a file — a second place for the name to be
+wrong, and one the type checker cannot catch. The two `*_repair`
+functions stay named because `_Converger.file` hands them the text alone,
+so the template name has to be bound before the call site.
+
+**No engine constants for the environment — in direct mode.** The
+scaffolded `.python-version` is the interpreter `lc` itself is running
+on, and `requires-python` is that interpreter's minor as a floor. Both
+come from one place, so they can't conflict, and neither is a number to
+maintain. Identity follows the project's files from there: a direct
+project's `env_version` hashes `.python-version`'s bytes, not anything
+in the engine. The scoping is deliberate: containerized mode's default
+base and uv digests *are* engine constants, by spec-§2 design — see the
+layer-6 invariants for what moves when they do.
+
+**`.gitignore` and `.gitattributes` converge entry-wise, not by marker.**
+`templates.entries(name)` is a template minus comments and blanks;
+`templates.missing(name, text)` is what a repair appends, in template
+order. Idempotency is therefore structural — a pattern already in the file
+is never re-added, whoever wrote it — and a pattern introduced by a later
+lc release still reaches projects that already have a `.gitignore`, which
+a "marker present ⇒ done" check would have skipped. The header comment
+(`templates.header`) is cosmetic only; never make correctness depend on
+it.
+
+**What `lc init` converges** — idempotently, never overwriting a file the
+user owns:
+
+| Path | Role |
+|---|---|
+| `astra.yaml` + `universes/baseline.yaml` | astra's boilerplate spec, verbatim, as **one item keyed on `astra.yaml`** — the baseline references the boilerplate's example decision, so it must never land beside a user-authored spec. Its `container:` key is ignored outright — see Recorded decisions |
+| `pyproject.toml` | The uv project: **virtual** (no `[build-system]`), no dependencies — the engine is the host's uv tool, never a project dependency (see Recorded decisions), so the lock carries only what the analysis imports |
+| `.python-version` | The exact patch of the interpreter `lc` is running on |
+| `uv.lock`, `.venv` | **Derived** — converged by correctness, not existence: `uv lock --check` / `uv sync --locked --exact --check` decide, then `uv lock` / `uv sync --locked --exact --compile-bytecode` repair |
+| `.gitignore` | One managed block of patterns; convergence ensures each is present |
+| `.git` + the annex | `git init` then `git annex init` — results are versioned in the project's own repository |
+| `.gitattributes` | The storage policy: what git-annex holds and what git carries. Line-managed, like `.gitignore` |
+| `.datalad/config` | A `datalad.dataset.id` UUID, generated once. Read back only by `dataset.dataset_id`, through `git config -f`, for the run record's `dsid` |
+| `data/` + `README.md` | Where declared inputs live; annexed, and committed before anything computes on them |
+| `results/` + `README.md` | Where outputs land; the README states the materialize-don't-hand-write contract |
+| `myst.yml`, `index.md` | Template MyST report referencing `astra.yaml` *by path* |
+
+- **Only what git can carry is converged.** No `src/`, and no empty
+ `universes/`: git does not track empty directories, so converging one
+ reports drift on every fresh clone, forever. astra dropped `src/` for the
+ same reason (astra-tools#100) — where analysis code lives is the user's
+ layout, and the boilerplate's `python src/main.py` is a placeholder.
+ Universes are discovered by `glob("*.yaml")`, which is empty-not-error on
+ a missing directory. `tests/test_project.py::test_a_clone_of_a_converged_project_is_converged`
+ pins this: a clone must need nothing but `.venv` and `git annex init`.
+ Those two are the exemptions, and for one reason — they are local state
+ git does not clone.
+- **Convergence, not scaffolding.** Each item is created if missing,
+ offered to a conservative `repair(text) -> str | None` hook otherwise,
+ and left alone when the hook returns `None`. `--check` computes the
+ same report without writing (exit 1 when not converged); `--json`
+ emits `{converged, created, repaired, unchanged, blocked, warnings}`.
+- **Derived artifacts converge by correctness, not existence.** `uv.lock`
+ and `.venv` go through `_Converger.item`'s optional `is_current=`
+ predicate, which is uv's own no-write verification (`uv lock --check`,
+ `uv sync --locked --exact --check` — both confirmed read-only against uv
+ 0.12.3). A lock that no
+ longer matches `pyproject.toml`, or an environment that no longer matches
+ the lock, is exactly as unconverged as a missing one and reports as
+ `repaired`. Existence alone made `converge()` a no-op on drift, and
+ everything that converges before acting — `lc materialize`'s sync, the
+ worker entry point's — would have silently inherited it.
+ - The probe is skipped when the artifact is absent (nothing to ask), so a
+ fresh project costs none and the created/repaired split falls out of the
+ same check.
+ - Check mode may *probe* but never mutates; `test_check_mode_only_probes`
+ pins that every uv call it makes carries `--check`.
+ - **Honest limitation** (measured, uv 0.12.3): `uv sync --check` catches
+ packages the lock requires and the environment lacks, but not *extras* —
+ a hand-installed package leaves it reporting "would make no changes".
+ Set-level, not byte-level, as spec §3 already accepts; what bounds what
+ a recipe can import is the sandbox, not this probe.
+- **An authored `Containerfile` is left alone.** Spec §8 has `lc init`
+ refuse one; we don't. Images are generated from the lock, so a
+ hand-written file is simply not an input to anything — treating it as a
+ blocking half-state was more ceremony than the situation earns. Revisit
+ if the container hatch (layer 6) turns out to need the disambiguation.
+- **Nothing about uv's caching or linking is overridden**, because uv's
+ defaults are already right: it clones (copy-on-write) or hard links
+ package content out of a global cache, so projects share it. Measured
+ here: two environments cost 284 MB together rather than 432 MB. The docs
+ discourage forcing `symlink` mode (it couples every environment to the
+ cache's survival), and **`--system-site-packages` must never be used** —
+ it would make packages outside the lock importable, which is exactly what
+ the environment model exists to prevent (spec §7, G6).
+ - The sharing silently stops working when the cache and the project are
+ on **different filesystems** (uv falls back to full copies). uv warns;
+ `tool_warnings()` lifts that warning out of uv's progress output into
+ the report, so it reaches both the console and `--json`. This is why
+ the site registry supplies `UV_CACHE_DIR` on Perlmutter (spec §4).
+ - `--compile-bytecode` is the one genuinely per-project cost: bytecode is
+ generated into the venv, never linked (~55 MB of 216 MB here). It is a
+ deliberate trade: paying compilation once here beats paying it on the
+ first import of every run.
+- **Every external tool goes through one seam**, `project._run`, which
+ tests monkeypatch — so the suite never shells out, and every call is
+ inspectable. `_check_call` turns a nonzero exit into a `ProjectError`:
+ nothing convergence invokes is allowed to fail silently. Every uv
+ invocation carries an explicit `--project` — uv's own walk-up discovery
+ is never trusted (spec §4).
+- **uv, git and git-annex are all required.** Each is a refusal, not a
+ warning: uv is the environment substrate and git + git-annex are the
+ storage substrate, and results are versioned in the repository, so there
+ is no useful project without any of them. git is the one tool uv cannot
+ install and the single admitted exception to a uv-installable stack;
+ git-annex ships as a wheel and is therefore a dependency of the lc tool
+ itself, which makes its wheel platforms the CLI's install floor.
+ (This reverses layer 1's original "git is optional" — a project without
+ version control had nowhere to put a result.)
+- **Two questions about git, not one.** `_in_repository` is a pure
+ filesystem walk-up and answers for a directory that does not exist yet,
+ which is what check mode needs. `_can_ask_git` adds `is_dir()`, because
+ every git *invocation* needs an existing working directory — inside an
+ enclosing repository the walk-up says "in a repository" for a
+ directory that is not there, and running git in it raises
+ `FileNotFoundError` out of `Popen` rather than answering anything.
+- **`git init` checks for an *enclosing* work tree**, not just a `.git` in
+ the directory, so `lc init subdir/` inside a repository can't create a
+ nested one. (`.git` may be a file — linked worktree or submodule — so the
+ test is `exists`, not `is_dir`.) The annex asks git-annex's own question,
+ `git config --get annex.uuid`, so an enclosing repository that already
+ has one is adopted rather than re-initialized.
+- **`lc init` has exactly two flags**, `--check` and `--json`. `--no-git`
+ and `--no-sync` were deleted: neither had a caller outside the test suite,
+ `--no-git` was a workaround for the missing enclosing-repo check, and
+ `--no-sync`'s real home is containerized mode (layer 6), where the host
+ `.venv` is inert. Don't add a flag whose only user is a test — stub
+ `project._run` instead.
+## Key Invariants (storage)
+
+**Results are versioned in the project's own repository**, on the DataLad
+model: **git carries the pointers and the history, git-annex carries the
+bytes.** `engine/dataset.py` is the whole seam, and every command in it
+goes through `project._run` — the same one convergence uses, so there is
+one monkeypatch point and the `tools` fixture already covers git.
+
+**git-annex is a wheel, and that sets the CLI's install floor.**
+`manylinux_2_34` on x86_64/aarch64, macOS 14+ arm64 or 15+ x86_64,
+win_amd64 — and **no sdist**, so a host below the floor fails to install
+rather than building from source. Because git-annex is a *hard* runtime
+dependency, that floor gates installing the lc tool at all, including
+`lc run`, which never touches the annex — but only the tool: projects no
+longer depend on lightcone-cli, so the floor never constrains a project's
+own resolution. If it ever bites a real user, the hard dependency is the
+thing to revisit — an extra, or a probed requirement like git — not the
+floor.
+
+**Perlmutter clears it** (checked 2026-08-19, login node): the wheel
+installs and `git annex version` runs. That was the open question this
+stack was most likely to fail on, so it is written down rather than
+re-derived. Two things it does *not* settle, both layer 7's:
+
+- **Lustre/GPFS behaviour is unmeasured.** arXiv:2505.06558 documents
+ symlink, many-small-files and inode pressure on parallel filesystems.
+ Correctness is not the worry; cost is, and one output is one file plus
+ one hard link to its object. Results are already thin (above); adjusted
+ (unlocked) branches remain the untried lever, as does whether Lustre
+ makes the hard link behave differently. Time `git annex add` and
+ `git annex get` on `$SCRATCH` and `$CFS` before designing around
+ either.
+- **`git-annex-shell` is not on the default remote PATH.** `git annex get`
+ from a laptop dispatches to it over a non-login ssh session, and the
+ wheel installs it beside the tool's interpreter. Configuration, not
+ design: `git config remote..annex-shell `.
+
+**`filter=annex` is what makes an ordinary `git add` do the right thing.**
+`git annex init` configures the smudge/clean filter itself; the template
+adds `* filter=annex`, and `annex.largefiles` then decides what counts as
+content. So a researcher types `git add -A . && git commit` — the same
+git they already know — and a 200 KB input lands in the annex as a
+101-byte pointer while `src/main.py` and the manifests stay real. Nothing
+lc scaffolds, prints or documents asks anyone to run a git-annex command,
+and `dataset.save` does not run one either.
+
+**The `annex.largefiles=nothing` default is load-bearing.** It has to come
+first, with outputs and inputs opting out; last matching line wins.
+Without it `filter=annex` routes *everything* into the annex, analysis
+code included. `tests/test_dataset.py::test_analysis_code_stays_in_git_and_stays_writable`
+pins it against a real annex.
-- `snakefile.generate(project, universes=[...])` reads `astra.yaml`, writes `.lightcone/Snakefile`, and writes a sidecar JSON keyed by `(rule, universe)` containing the recipe text, container image, decisions, and precomputed `code_version`.
-- The Snakefile body for each rule is a `run:` block: `shell(params.cfg["recipe"])` then `write_manifest(...)`.
-- `code_version = sha256(recipe + container_image + decisions)`. Embedded in the rule's shell command literally so Snakemake's built-in `code` rerun-trigger detects drift.
-- `data_version = sha256_dir(output_dir)`. Written into the manifest after the recipe completes; used by `lc verify` to detect tampering. Excludes the manifest file itself and `.snakemake_timestamp`.
-- The manifest is a *declared output* of every rule. A missing manifest causes Snakemake to re-run the rule, blocking the agent-faked-file scenario.
+**Manifests stay in git, deliberately.** `**/.lightcone-manifest.json` is
+exempted back out of the annex so it is readable on a clone that has
+fetched no annex content at all — which is what lets `lc materialize
+--check` classify a whole project on a laptop that holds none of the
+bytes.
-## Key Invariants
+**An unfetched file exists, and that is the trap — in two shapes.**
+`assets.data_version` refuses both with `ContentNotFetchedError`, naming
+`git annex get`, and it checks for both regardless of which one lc's own
+writes produce: `annex.thin` and `git annex lock` are the researcher's to
+set on their clone, so the shape a file arrives in is not ours to assume.
-**Spec & execution:**
-- `astra.yaml` is the single source of truth — all inputs, outputs, recipes, decisions, containers
-- Output paths are always `results//