# AGENTS.md — ppm (Project-Tick Package Manager)

This file is the contract between human maintainers and AI coding agents
working on `ppm`. Read it before touching anything in this directory.

**Living document.** The sections *Status*, *Roadmap*, and *Changelog* near
the bottom of this file MUST be updated as part of every meaningful change.
If you finish a milestone, tick it off. If you start a new one, add it.
If your patch changes user-visible behaviour, add a Changelog entry. No
exceptions.

## TL;DR for agents

- Language: **C23 with GNU extensions** (`-std=gnu2x`, GCC 13+ / Clang 16+).
- Build system: **GNU Autotools** (autoconf + automake + libtool). No CMake. No Meson.
- License: **GPL-2.0-only**. Every new source file MUST start with the SPDX header
  `// SPDX-License-Identifier: GPL-2.0-only` (or `/* ... */` for headers).
- Target: POSIX-like systems. **MSVC is explicitly not supported.** Do not add
  Windows-specific code or `#ifdef _MSC_VER` branches.
- Crypto: **SHA-512** via libsodium. Never roll your own.
- Registry: a **git repository**, Nix-channel style. Every commit is an immutable
  snapshot of the package index.

## Design invariants (do not violate)

These are the load-bearing decisions of the project. If a change would break one
of them, stop and ask the human first.

1. **One-version rule.** At any point in time, a given package name has exactly
   one active version in a profile. Multi-version coexistence is a non-goal.
2. **Content-addressed store.** Every artifact lives under
   `$PPM_STORE/<sha512-hex>/` where the hash is computed over the canonical
   tarball. Same hash ⇒ same bytes ⇒ never re-downloaded.
3. **Atomic transactions.** Install/remove/update must be all-or-nothing. Use
   a write-ahead journal plus `rename(2)` on the profile symlink. A `SIGKILL`
   mid-operation must leave the system in a valid prior state.
4. **Immutable manifests.** Once a version is published to the registry, its
   manifest and tarball hash MUST NOT change. The resolver trusts this.
5. **Deterministic resolution.** Same `ppm.toml` + same registry commit ⇒
   same `ppm.lock`, byte-for-byte. The resolver is **PubGrub**; do not replace
   it with naive backtracking.
6. **Signed everything.** Tarballs are signed with Ed25519. Unsigned or
   invalid-signature artifacts are refused, no override flag in the default build.
7. **No bundled dependencies at runtime.** ppm links against system
   `libcurl`, `libarchive`, `libsodium`, `libgit2`. Vendoring is forbidden
   except for tiny header-only helpers explicitly listed in `third_party/`.

## Coding style

- C23 + GNU extensions. Use them. `__attribute__((cleanup))`, statement
  expressions, `__builtin_expect`, designated initializers, `typeof`, etc.
- Indentation: 4 spaces, no tabs. Braces on same line for functions and blocks
  (K&R-ish, Linux-kernel-adjacent).
- Naming: `snake_case` for functions and variables, `SCREAMING_SNAKE` for
  macros and enum members, `ppm_` prefix for all public symbols.
- One module = one `.c` + one `.h`. Public headers live in `include/ppm/`,
  internal ones next to their `.c`.
- Every function that can fail returns `ppm_error_t` (an `int` enum). Output
  parameters come last. No `errno` leaking across module boundaries.
- Memory ownership is documented in the header with `/* takes ownership */`
  or `/* borrowed */` comments. When in doubt, use `__attribute__((cleanup))`.
- No `malloc` without a matching `free` path on every branch. Run tests under
  ASan + UBSan in CI; leaks fail the build.
- No `strcpy`, `strcat`, `sprintf`, `gets`. Use the bounded variants or our
  `ppm_strbuf_*` helpers.
- Logging goes through `ppm_log_*`. No bare `printf` outside `src/cli.c`.

## Build & test workflow

```sh
./bootstrap.sh        # runs autoreconf -fi, only needed from a fresh checkout
./configure --enable-debug --enable-asan
make -j
make check            # runs the test suite
```

A change is not done until `make check` is green with ASan + UBSan enabled.

## File layout (authoritative)

```
include/ppm/      public API headers
src/cmd/          one file per subcommand (install, remove, update, ...)
src/core/         manifest, lockfile, resolver, store, registry, crypto, archive, transaction
src/util/         log, error, path, strbuf, toml
tests/            unit tests, one per module under test
docs/             scdoc sources for man pages, plus design notes
packaging/        distro packaging templates (debian, rpm, arch, alpine)
examples/         example projects with ppm.toml
m4/               autoconf macros
```

## Subcommand surface (MVP)

Implement in this order. Do not start the next one until the previous passes tests.

1. `ppm --version` / `ppm --help`
2. `ppm init`              — scaffold a `ppm.toml`
3. `ppm search <query>`    — query the local registry mirror
4. `ppm info <pkg>`        — show manifest for a package
5. `ppm install <pkg...>`  — resolve, fetch, verify, link atomically
6. `ppm remove <pkg...>`
7. `ppm update [pkg...]`
8. `ppm list`
9. `ppm verify`            — re-check store integrity (SHA-512 + signatures)
10. `ppm gc`               — garbage-collect unreferenced store entries

## Registry layout (git repo)

```
<registry-root>/
├── index.toml                          # registry metadata (schema version, signers)
├── signers/                            # Ed25519 public keys, one file per signer
│   └── <key-id>.pub
└── packages/
    └── <first-two-chars>/<name>/
        ├── versions.toml               # list of versions + hashes + signers
        └── <version>/
            ├── manifest.toml           # immutable
            └── manifest.toml.sig       # Ed25519 signature
```

The tarballs themselves live wherever the manifest's `source.url` points
(HTTP/HTTPS), but the SHA-512 in `versions.toml` is the source of truth.

## What agents MUST NOT do

- Do not add a new build system or replace autotools.
- Do not add a dependency without updating `configure.ac` and `README.md`.
- Do not vendor large libraries.
- Do not weaken crypto (no MD5, no SHA-1, no unsigned installs).
- Do not introduce per-package version pinning that breaks the one-version rule.
- Do not write Windows or MSVC compatibility code.
- Do not commit generated autotools output (`configure`, `Makefile.in`,
  `aclocal.m4`, `autom4te.cache/`). They are produced by `./bootstrap.sh`.
- Do not silently catch errors. Propagate `ppm_error_t` up.

## What agents SHOULD do

- Prefer small, focused commits with conventional messages
  (`feat(resolver): ...`, `fix(store): ...`).
- Add or update a test for every behavior change.
- Update the relevant man page (`docs/*.scd`) when changing user-visible behavior.
- When adding a new module, also add its header to `include/ppm/`, its source
  to `src/core/Makefile.am` (or the appropriate subdir), and a stub test.
- Keep functions short. If a function does not fit on a screen, split it.

## Questions to ask the human before acting

- Anything that changes the on-disk format of the store or lockfile.
- Anything that changes the registry layout.
- Anything that adds a runtime dependency.
- Anything that changes the threat model (crypto, signing, sandboxing).

Welcome aboard. Keep it small, keep it sharp, keep it boring.

---

## Status

Snapshot of what is real vs. what is still a stub. Update this every time a
module flips from stub to implemented (or back, if you have to revert).

Legend: ✅ done · 🚧 in progress · ⏳ planned · ❌ blocked

| Area                   | State | Notes                                                            |
|------------------------|-------|------------------------------------------------------------------|
| Autotools build        | ✅    | `bootstrap.sh`, `configure.ac`, libtool, sanitizer flags         |
| C23 + GNU extensions   | ✅    | `-std=gnu23` confirmed on GCC 13+/Clang 16+                       |
| Public API headers     | ✅    | `include/ppm/*.h` frozen for v0.1.x                              |
| CLI dispatcher         | ✅    | `ppm --version`, `--help`, subcommand routing                    |
| `ppm init`             | ✅    | Scaffolds a working `ppm.toml`                                   |
| Logging (`util/log`)   | ✅    | Levels, colour autodetect, printf-format checked                 |
| Error codes            | ✅    | `ppm_error_t` + `ppm_strerror`                                   |
| SHA-512                | ✅    | One-shot, streaming, hex, constant-time equal                    |
| Ed25519 verify         | ✅    | libsodium-backed; ENFORCED on every registry manifest read       |
| SemVer parse/compare   | ✅    | Major/minor/patch + prerelease + build metadata                  |
| Tests (13)             | ✅    | + build_chain (libbar reads $dep_libfoo, deterministic chain hash) |
| TOML parser            | ✅    | `src/util/toml.{c,h}` — strings, arrays of strings, nested tables  |
| Manifest load/validate | ✅    | Full mapping from TOML; LOCAL vs PUBLISHED validation modes        |
| `ppm info <path>`      | ✅    | Reads + pretty-prints a local `ppm.toml`                          |
| Path utilities         | ✅    | `src/util/path.{c,h}` — mkdirs, rm -rf, fsync dir, path join      |
| Archive (tar.zst)      | ✅    | libarchive: zstd+gzip, sandboxed (no absolute/.. paths, no symlinks) |
| Store (CAS)            | ✅    | `open`/`has`/`path`/`ingest_tarball`/`verify` — atomic via rename(2) |
| Transaction journal    | ✅    | Profile generations, WAL journal, replay/rollback, flock          |
| Lockfile load/save     | ✅    | TOML schema 1: `[package.<i>]` sub-tables; atomic via temp+rename |
| Version requirements   | ✅    | `^`, `~`, `=`, `>=`, `>`, `<=`, `<`, conjunctions, `*`            |
| Registry (libgit2)     | ✅    | open/clone/fetch (FF only)/head_commit; manifest reads gated by Ed25519 sig |
| Resolver               | ✅    | BFS + one-version rule + constraint accumulation + readable report |
| HTTP downloader        | ✅    | libcurl, file://+http+https, stage to .part then rename            |
| `ppm install <pkg>...` | ✅    | resolve → fetch → ingest → txn commit → save lockfile             |
| `ppm list`             | ✅    | reads active generation manifest                                   |
| `ppm search <query>`   | ✅    | registry name walk, with newest version per hit                    |
| `ppm remove <pkg>...`  | ✅    | drops from ppm.toml, re-runs install pipeline                      |
| `ppm update`           | ✅    | registry FF + re-install (re-resolves)                             |
| `ppm verify`           | ✅    | store layout consistency (full re-hash deferred)                   |
| System-wide install    | ✅    | Hard-coded paths + registry URL; no per-user override              |
| Build from source      | ✅    | `[build]` derivations: deterministic output hash, packaged sh only |
| Build dep wiring       | ✅    | `$dep_<name>` + `$deps` env; build-deps in PATH; topo install      |
| Multi-line TOML strings| ✅    | `"""..."""` syntax; backslash line-continuation; escape sequences  |
| **First real package** | ✅    | `optional-bare 1.0.0` builds + installs from ftp.projecttick.org   |
| `ppm gc`               | ⏳    | Mark-and-sweep across all profiles                               |
| Trust anchor           | ⏳    | signer set = registry signers/; pinned anchor + signed commits pending |
| Man pages              | ✅    | `ppm.1`, `ppm.toml.5` build via scdoc                            |
| Packaging templates    | ✅    | Debian, RPM, Arch, Alpine                                        |

## Roadmap

Milestones are intentionally small. Do not start a milestone until the previous
one is ✅ in *Status* and has at least one new test.

### M1 — Manifest layer (✅ done)
- ✅ Minimal TOML parser (strings, arrays of strings, nested tables)
- ✅ `ppm_manifest_parse`, `ppm_manifest_load`, real implementations
- ✅ `ppm_manifest_validate(m, LOCAL|PUBLISHED)` with mode-specific rules
- ✅ `ppm info <path>` reads + pretty-prints a `ppm.toml`
- ✅ Tests: full manifests, minimal manifests, duplicate keys, unterminated
  strings, unsupported value kinds, heterogeneous arrays, missing
  `[package]`, invalid name, invalid SemVer, non-sha512 hash prefix.

### M2 — Content-addressed store (✅ done)
- ✅ Layout auto-creation on `ppm_store_open` (`sha512/`, `tmp/`, `profiles/`)
- ✅ `ppm_store_has`, `ppm_store_path` (CAS path: `sha512/<hh>/<full>/`)
- ✅ `ppm_archive_extract` over libarchive (tar.zst + tar.gz), sandboxed:
  rejects absolute paths, `..` components, escaping symlinks, and
  non-empty destinations
- ✅ `ppm_store_ingest_tarball` end-to-end: hash check → stage in
  `tmp/stage.<rand>/payload/` → fsync → `rename(2)` into final CAS path.
  Lost rename races (same hash from another `ppm`) collapse to no-op.
- ✅ `ppm_store_verify` walks the bucket tree and validates hex names
  (full re-hash arrives once we record the canonical tarball hash in M3)
- ✅ Path helpers in `src/util/path.{c,h}`: `mkdirs`, `rmrf`, `fsync_dir`,
  `path_join`, `is_dir`. POSIX-only, `openat`/`unlinkat`-based.
- ✅ Three new tests built against on-the-fly tarballs created with
  libarchive at `make check` time. Both happy paths and malicious archives
  are exercised.

### M3 — Transactions (✅ done)
- ✅ Generation layout: `<store>/profiles/<name>/gen-<n>/manifest.toml`,
  `current` symlink resolved through `ppm_txn_current`.
- ✅ Journal format: append-only, tab-separated, `fdatasync` after every
  record, lives at `<store>/tmp/txn-<rand>.journal`.
- ✅ `ppm_txn_begin`/`stage`/`commit`/`abort`/`recover`/`current`.
- ✅ One-version rule enforced at stage time (`PPM_ERR_CONFLICT`).
- ✅ Cross-process serialisation via `flock(LOCK_EX)` on the profile dir.
- ✅ Symlink swap via `rename(2)` over `current.new` (POSIX-atomic).
- ✅ Pending manifest written via temp file + rename so a crash never
  yields a half-written manifest.
- ✅ `recover` replays journals with `ready` (forward) and rolls back those
  without (backward). Idempotent; runs at every `ppm_txn_begin`.
- ✅ Tests: happy path, multi-generation, abort cleanup, conflict, unknown
  hash, rollback recovery, forward replay recovery — 7 scenarios.

Deferred to a later patch:
- Crash-injection tests that actually kill child processes mid-commit.
  Current tests inject hand-crafted journals, which covers the recovery
  logic; the kill-and-restart variant will be added once we have an
  end-to-end `ppm install` to drive.

### M4 — Registry (libgit2)
- `ppm_registry_clone`/`open`/`fetch`
- Pin the lockfile to a specific commit
- Verify signed registry commits against trust anchor (when configured)
- Read `packages/<aa>/<name>/versions.toml` and per-version manifests
- `ppm search`, `ppm info <pkg>` against a local mirror

### M5 — Resolver + working commands (✅ done)
- ✅ `ppm_version_req_match` — `^`, `~`, `=`, `>=`/`>`/`<=`/`<`, conjunctions, `*`.
- ✅ Git-backed `ppm_registry_{open,clone,fetch,head_commit,versions,manifest,search}`
  via libgit2. Fetch refuses to merge a divergent history (FF-only).
- ✅ Pragmatic resolver: BFS over the dependency graph with constraint
  accumulation under the one-version rule. Picks newest matching version.
  Emits a human-readable report on `PPM_ERR_UNSAT` listing constraints and
  candidates tried. (Full PubGrub backjumping deferred to M5.5.)
- ✅ `ppm_lockfile_load`/`save` over TOML schema 1, atomic via temp+rename.
- ✅ libcurl HTTP downloader at `src/util/http.{c,h}` (file/http/https only).
- ✅ `ppm install <pkg...>`: edits `ppm.toml`, resolves, downloads each
  tarball into the store, ingests with hash check, commits a new
  generation, writes `ppm.lock`. Supports `name@spec` syntax.
- ✅ `ppm list` reads the active generation's manifest and prints it.
- ✅ `ppm search` walks the registry and shows the newest version per hit.
- ✅ `ppm remove` drops top-level deps from `ppm.toml` and re-runs install.
- ✅ `ppm update` fast-forwards the registry then re-runs install.
- ✅ `ppm verify` walks the store and flags malformed entries.
- ✅ Defaults to `$XDG_DATA_HOME/ppm/{store,registry}` (or `$HOME/.local/share/...`);
  overridable via `PPM_STORE`, `PPM_REGISTRY`, `PPM_REGISTRY_URL`.
- ✅ Two new tests: `test_version_req`, `test_resolver` (builds a real
  git repo fixture with libgit2 and resolves against it).

### M5.5 — PubGrub upgrade (⏳ planned)
- Term / incompatibility data structures
- Conflict-driven backjumping
- Property tests for diamonds, cycles, equally-good candidates

### M6 — Polish & realism (🚧 next)
- ✅ Host-side `tools/make-bootstrap-tools.sh` creates the trusted
  `bootstrap-tools` seed tarball plus registry manifest skeleton.
- `ppm gc`: mark-and-sweep across profile generations
- `ppm verify`: full re-hash of every stored object (record canonical
  tarball hash in the generation manifest so re-hashing is meaningful)
- ✅ Hard Ed25519 enforcement: `ppm_registry_manifest` refuses any manifest
  not covered by a valid `manifest.toml.sig` from a key in the registry's
  `signers/` directory (invariant #6, no override).
- **Residual trust-anchor gap (next):** the trust set is currently "any key
  in the mirror's `signers/` dir", so a wholesale malicious mirror that
  swaps both manifests and signer keys is not yet defended against. Close it
  with a user-configured trust anchor (pinned key) plus signed-git-commit
  verification of the registry HEAD.
- Rollback subcommand (`ppm rollback` — symlink back to gen-(N-1))
- More CLI nicety: per-subcommand `--help`, exit codes, progress UI
- **`[build-dependencies]` separated from runtime deps**: today only
  `[dependencies]` are wired into build scripts. We should also walk
  `[build-dependencies]` and add their `$out/bin` directories to PATH
  so derivations can call `cmake`, `gcc`, etc. as packaged tools rather
  than as system binaries.
- **Stronger build isolation**: namespaces / bwrap so a build script
  cannot reach outside `$out`, the network, or `/home`.

### M7 — Polish & packaging
- Reproducible-build verification (`SOURCE_DATE_EPOCH`)
- Distro CI: build the Debian, RPM, Arch, Alpine recipes in containers
- First tagged release: `v0.1.0`

## Changelog

Newest entries on top. One entry per logical change (not per file edit).
Format: short imperative summary, optionally followed by a sentence of
context. Use Conventional-Commits style scopes for the leading tag.

### Unreleased

- `feat(crypto,registry)`: **hard enforcement of invariant #6 ("signed
  everything").** `ppm_registry_manifest` now reads the raw `manifest.toml`
  bytes, verifies a detached Ed25519 signature (`manifest.toml.sig`,
  hex-encoded 64 bytes) against the trusted signer set
  (`<registry>/signers/*.pub`, hex-encoded 32-byte keys) BEFORE parsing,
  and refuses with `PPM_ERR_SIG_INVALID` if the `.sig` is missing,
  malformed, there are no signer keys, or no trusted key validates it.
  There is no override flag. Because the manifest records the tarball's
  SHA-512, a valid manifest signature transitively secures both binary
  installs and from-source builds — no separate tarball signature is
  needed. New crypto helpers `ppm_ed25519_pk_from_hex` /
  `ppm_ed25519_sig_from_hex` decode fixed-width hex (whitespace/newline
  tolerant). Wires up `ppm_ed25519_verify`, which existed but was never
  called. Residual gap (on the M6 roadmap): trust is "any key in the
  mirror's signers/ dir"; a full malicious-mirror substitution still needs
  a pinned trust anchor + signed-git-commit verification.
- `test`: the libgit2 registry fixtures (`test_resolver`,
  `test_build_chain`) now generate an Ed25519 keypair, publish
  `signers/test.pub`, and sign every manifest they write, so the suite
  stays green under the new enforcement. `test_resolver` adds two negative
  cases: a tampered manifest and a missing `.sig` must both be refused with
  `PPM_ERR_SIG_INVALID`.
- `fix(util/path)`: add `ppm_path_fdatasync(int fd)` portable wrapper in
  `src/util/path.{c,h}`. On macOS 26 SDK (and other platforms where
  `fdatasync` is present in the library but not declared in public headers),
  the call compiled without issue via an `extern int fdatasync(int)` prototype
  guarded by `HAVE_FDATASYNC` from `config.h`. Falls back to `fsync(2)` when
  `fdatasync` is not found by configure. Fixes build on Apple clang 21 / arm64.
- `fix(core/transaction,core/lockfile)`: replace direct `fdatasync()` calls
  with `ppm_path_fdatasync()` so the portability guard is applied uniformly.
- `fix(tests/test_resolver)`: add missing `#include <unistd.h>` so `mkdtemp`
  is declared (macOS 26 SDK moved it from `<stdlib.h>` to `<unistd.h>`).

- `feat(bootstrap)`: add `tools/make-bootstrap-tools.sh`, a maintainer
  script that produces the trusted `bootstrap-tools` binary seed
  tarball and registry skeleton. The seed contains packaged `bin/sh`,
  LLVM frontend/binutils, `make`, and the minimal POSIX userland needed
  for the musl → llvm-stage1 → corebinutils → llvm-stage2 bootstrap path.
- `feat(build)`: builds no longer see the host toolchain or host
  userland. `$PATH` is constructed exclusively from explicit
  `[build-dependencies]` (`<dep>/bin` entries). The build script is
  executed with the first packaged `bin/sh` found in those deps; if no
  dependency provides `bin/sh`, the build fails with a clear error.
  This removes accidental usage of system gcc/glibc/coreutils. The
  remaining bootstrap exception is the trusted `bootstrap-tools` seed,
  which will provide the initial packaged shell + LLVM + minimal tools.
- `feat(resolver,install)`: `[build-dependencies]` now participate in
  resolution, derivation hashing, topological install ordering, and
  build environment wiring exactly like runtime deps. A change in a
  build-time input invalidates every dependent derivation hash.
- **🎉 First real package built and installed: `optional-bare 1.0.0`.**
  Manifest committed to `git.projecttick.org/project-tick/packaging/ppm-pkgs`.
  Source pulled from `https://ftp.projecttick.org/Project-Tick/optional-bare/...`.
  Full pipeline exercised end-to-end against the canonical infrastructure:
  registry clone → manifest read → tarball fetch over HTTPS → SHA-512
  verify → libarchive extract → CMake build under `/bin/sh -e` →
  atomic ingest into CAS → transaction commit → second install is a
  cache hit. Path layout used by every future package:
      packages/<aa>/<name>/versions.toml
      packages/<aa>/<name>/<version>/manifest.toml
- `feat(toml)`: multi-line basic strings `"""..."""`. TOML-compliant:
  the immediate newline after the opening `"""` is stripped, backslash
  followed by whitespace + newline performs line continuation, and the
  same escape sequences as single-line basic strings (`\n`, `\t`, `\"`,
  `\\`) are honoured. Required so build scripts can be written
  naturally inside a manifest.
- `feat(build,install,resolver)`: **runtime dependencies are now wired
  into build scripts.** Every `[dependencies]` entry of a derivation
  becomes an environment variable `$dep_<sanitised-name>` pointing at
  the dependency's CAS path; a `$deps` env var carries the same paths
  joined with `;` so they drop straight into
  `-DCMAKE_PREFIX_PATH=$deps`. The resolver now computes derivation
  output hashes in topological order and embeds dependency hashes in
  the parent's hash, so cache invalidation is correct end-to-end
  (touch a leaf, every derivation that transitively depends on it
  rebuilds; touch nothing, nothing rebuilds). Install walks the
  lockfile in topological order via iterative DFS with cycle
  detection. New test `test_build_chain` covers a two-package chain
  (libbar reads `$dep_libfoo` and produces `got=FOO`) and asserts the
  resolver's pre-computed hash matches the real build output.
- `feat(build)`: **ppm now builds packages from source.** A manifest
  with a `[build]` section becomes a *derivation*: ppm fetches the
  source tarball, verifies its SHA-512, extracts it, runs the build
  script under `/bin/sh -e` with `$src`/`$out` wired up, and deposits
  the result in the content-addressed store. The CAS path is keyed by
  `ppm_build_output_hash(name, version, src_url, src_hash, system,
  script, sorted build-deps)`, so identical inputs ⇒ identical output
  path ⇒ cache hit (no rebuild on subsequent installs across machines).
- `feat(manifest)`: optional `[build]` section with `script` (required,
  POSIX `/bin/sh`) and `system` (optional, defaults to detected host).
  Documented in `ppm.toml(5)`.
- `feat(store)`: `ppm_store_ingest_dir(store, dir, hash)` — atomically
  publishes a prepared directory tree under a caller-chosen hash.
  Used by the builder to deposit `$out` once the script succeeds.
- `feat(resolver)`: when a selected manifest carries `[build]`, the
  lockfile records the deterministic derivation hash instead of the
  source tarball hash. Build-deps are not yet plumbed through (empty
  set for the first cut); follow-up will resolve them recursively.
- `feat(cmd)`: `ppm install` now dispatches per entry: derivations build
  via `ppm_build_run`, binary packages download via libcurl. Cache hits
  on a previously-built derivation skip the script entirely.
- `test`: new `test_build` exercises the full derivation pipeline against
  a hand-crafted source tarball: build → verify files in CAS → re-run
  is a cache hit → mutating the script changes the hash. Total tests:
  11 → 12.
- `feat(build)`: **ppm is now a true system package manager.** The
  registry git URL is baked in at configure time via
  `--with-registry-url=URL`, defaulting to
  `https://git.projecttick.org/project-tick/packaging/ppm-pkgs`.
  Removed `PPM_STORE`, `PPM_REGISTRY`, `PPM_REGISTRY_URL` environment
  overrides — there is exactly one canonical registry per build,
  consistent with apt/dnf/pacman philosophy.
- `feat(cmd)`: store/registry paths now come from
  `PPM_STORE_DIR`/`PPM_REGISTRY_DIR`/`PPM_REGISTRY_URL` C macros set by
  the build, never from environment. `ppm --version` now displays all
  three so users can see exactly where ppm is looking.
- `feat(cmd)`: install/remove/update commands now run a root-or-writable
  check up front and print a friendly "re-run with sudo" hint instead of
  failing with EACCES deeper in.
- `feat(cmd)`: registry is auto-cloned from the configure-time URL into
  the configure-time directory on the first invocation that needs it.
- `feat(cmd)`: **`ppm install <pkg>...` works end-to-end.** Edits
  `ppm.toml`, resolves through the registry, downloads each tarball,
  ingests into the SHA-512 store, commits a new generation, and writes
  `ppm.lock`. Supports `name@spec` syntax (e.g. `foo@^1.2`).
- `feat(cmd)`: `ppm list` reads the active generation's manifest.
- `feat(cmd)`: `ppm search <q>` walks the registry, shows newest version.
- `feat(cmd)`: `ppm remove <pkg>...` trims `ppm.toml` and re-resolves.
- `feat(cmd)`: `ppm update` fast-forwards the registry then re-installs.
- `feat(cmd)`: `ppm verify` checks store layout integrity.
- `feat(cmd)`: store/registry locations default to `$XDG_DATA_HOME/ppm/...`;
  overridable via `PPM_STORE`, `PPM_REGISTRY`, `PPM_REGISTRY_URL` env vars.
- `feat(resolver)`: BFS-based resolver with one-version-rule enforcement
  and constraint accumulation. Returns a human-readable report when no
  satisfying assignment exists (constraints + candidates tried).
- `feat(registry)`: git-backed via libgit2. `open` / `clone` /
  `fetch` (FF-only, refuses divergence) / `head_commit` /
  `versions` (newest-first) / `manifest` (validates PUBLISHED mode) /
  `search` (substring over names).
- `feat(lockfile)`: real `load`/`save` over TOML schema 1. Uses
  `[package.<i>]` sub-tables for portability with our minimal parser.
  Writes via `<path>.tmp` + `rename(2)` for crash safety.
- `feat(util)`: tiny libcurl wrapper at `src/util/http.{c,h}`. file://,
  http://, https:// only; staged download via `.part` + rename.
- `feat(core)`: SemVer requirement matcher (`^`, `~`, comparators,
  conjunctions, `*`) at `src/core/version_req.{c,h}`.
- `fix(transaction)`: write generation `manifest.toml` using
  `[package.<i>]` sub-tables instead of `[[package]]` so the minimal
  TOML parser can read it back (matches the lockfile convention).
- `test`: new `test_version_req` (req grammar) and `test_resolver`
  (builds a real git registry with libgit2 and runs four scenarios:
  unconstrained newest, caret constraint, transitive deps with mutual
  bounds, unsatisfiable). Total tests: 9 → 11.
- `feat(transaction)`: real implementation of the WAL-journal transaction
  layer. `ppm_txn_begin`/`stage`/`commit`/`abort` create Nix-style
  generations under `<store>/profiles/<name>/gen-<n>/`, flip the
  `current` symlink atomically via `rename(2)` over a staging
  `current.new`, and record every step in a `txn-<rand>.journal`
  file under `<store>/tmp/`. Records are `fdatasync`ed after each write,
  so a mid-commit crash leaves either a journal without `ready`
  (rolled back on the next run) or with `ready` (forward-replayed).
- `feat(transaction)`: `ppm_txn_recover` is automatically invoked from
  every `ppm_txn_begin`, so any leftover journals get processed before
  a new transaction can start. Idempotent.
- `feat(transaction)`: enforce the one-version rule at stage time —
  staging the same package name twice in a single transaction returns
  `PPM_ERR_CONFLICT` with a clear message.
- `feat(transaction)`: per-profile `flock(LOCK_EX)` serialises concurrent
  `ppm` processes operating on the same profile.
- `feat(transaction)`: `ppm_txn_current(store, profile, &path)` resolves
  the active generation directory; callers use this to find the
  currently-installed manifest.
- `test`: new `test_transaction` covers commit, multi-gen, abort cleanup,
  one-version conflict, unknown-hash rejection, rollback recovery, and
  forward-replay recovery. Total tests: 8 → 9.
- `feat(store)`: `ppm_store_ingest_tarball` is real. Hashes the tarball
  via streaming SHA-512, refuses on mismatch, stages extraction in
  `tmp/stage.<rand>/payload/`, then atomically `rename(2)`s into
  `sha512/<hh>/<full>/`. Same-hash races resolve to no-op. fsyncs the
  staging directory and the parent bucket so installs survive crash.
- `feat(archive)`: `ppm_archive_extract` wraps libarchive with sandboxing.
  Supports tar.zst and tar.gz. Refuses absolute paths, `..` components,
  symlink escapes, and non-empty destination directories.
- `feat(util)`: new path helper module `src/util/path.{c,h}` providing
  `mkdirs`, `rmrf` (openat/unlinkat-based, symlink-safe), `fsync_dir`,
  `path_join`, `is_dir`.
- `test`: three new tests built against fixture tarballs (libarchive at
  `make check` time): `test_path`, `test_archive`, `test_store`. Both
  happy paths and malicious archives (`/etc/passwd`, `../escape.txt`)
  are covered. Total tests: 5 → 8.
- `feat(cmd)`: `ppm info <path>` reads a local `ppm.toml` and pretty-prints
  every populated field, including SHA-512 source hash and dependency tables.
- `feat(manifest)`: `ppm_manifest_parse`/`load` are real now. Map TOML
  document → `ppm_manifest_t`, validate name characters, parse SemVer,
  decode `sha512:` hash prefix, collect runtime + build dependencies.
- `feat(manifest)`: split validation into `LOCAL` (source optional) and
  `PUBLISHED` (source.url + source.hash required) modes.
- `feat(util)`: minimal TOML parser at `src/util/toml.{c,h}`. Strings
  (basic + literal), arrays of strings, nested tables, comments, position-
  tracked errors. Deliberately rejects features we do not need (integers,
  floats, datetimes, inline tables, dotted keys) with a clear message.
- `test`: add `test_toml` and `test_manifest`, both green under
  ASan+UBSan+LeakSanitizer. Total tests: 3 → 5.
- `chore(docs)`: AGENTS.md is now a living document with Status / Roadmap /
  Changelog sections. All future patches must update the relevant rows.
- `feat(core)`: add `ppm_version_dispose` and use it in `ppm_manifest_free`
  and `ppm_lockfile_free` so ownership of prerelease/build strings is clear
  and ASan-clean.
- `chore(build)`: factor sanitizer flags into `PPM_SAN_{CFLAGS,LDFLAGS}` so
  `--enable-asan --enable-ubsan` works through libtool.
- `chore(build)`: add `AM_PROG_AR` to `configure.ac` (required by
  automake 1.16+ for libtool convenience archives).
- `feat`: initial project scaffold — autotools build, CLI dispatcher with
  9 subcommands (init implemented, others stubbed), SHA-512 + Ed25519 via
  libsodium, SemVer parser, error/log utilities, man pages (`ppm.1`,
  `ppm.toml.5`), distro packaging templates (deb/rpm/arch/alpine), and
  three unit tests passing under ASan + UBSan + LeakSanitizer.
