Files
cozystack/AGENTS.md
Aleksei Sviridkin 5137d98e71 test(bats): refuse a negated assertion across every suite
A negated assertion is the natural way to spell "this must not
appear", and an absence is what nothing else in a test covers, so the
errexit exemption lands where it is least likely to be noticed and
invisible in a green run. Switching runners does not lift it: the ERR
trap the bats binary reports through carries the same exemption, so
neither runner sees a negated assertion that has any statement below
it.

Add a suite that scans every hack/**/*.bats, subdirectories included,
and fails on a statement whose first token is `!` -- naming the file,
the line the statement opens on, and the form to use instead.

Statement boundaries are the POSIX separators, each measured against
the runner before being admitted: `;` `&` `&&` `||` `{` `(`, the `)`
closing a case pattern, and the words `then` `do` `else`. A keyword may
legally be preceded only by whitespace or by `;` -- every other
separator in front of one is a shell syntax error, checked with `sh -n`
rather than recalled -- so the scan admits exactly those two and that
part of the class is closed rather than patched a spelling at a time.
Both spellings are vacuous under the runner, so missing either is a
silent miss, the one direction this scan must not err in. For the same
reason the scan takes a `!` with no space before its subshell,
`!(cmd)`, which is vacuous on dash and bash alike, and reads through a
`time` or `time -p` prefix, which is vacuous under bats and under
cozytest where /bin/sh is bash. A scan that wants `!` and then
whitespace as the first token misses both. On a dash /bin/sh `time` is
not a keyword and `time ! cmd` fails, so there it is an over-count.
Five over-counts remain, all erring loud, each named in the header:
they are enumerated rather than removed, because a removed error
leaves no trace while a named one tells the next reader what they are
looking at.

Inverted conditions are untouched because errexit is not what decides
a condition. `[ ! -f x ]` is untouched for a different reason and
stays a normal failing assertion: the `!` there is an operator of
`test`, not negation of a command, so nothing exempts its status.

Each claim the suite makes is pinned against removing the thing it
claims, and the separator class is measured branch by branch rather than
only at its newest entries. The recursion is exercised through the audit
function on a fixture in a subdirectory rather than through a second
copy of its find expression, because a copy passes while the function
loses its recursion -- and an audit that returns empty reads as a clean
tree. The condition-strip carries fixtures that put the negation after
an `&&` inside the condition, where only the strip can save it. The
continuation fold is pinned by a fixture whose negation sits on the
continued line, so folded and unfolded name different lines. Both
keyword spellings, the `time` prefix and `!(cmd)` have fixtures of
their own. The subshell over-count carries a fixture for both of its
halves -- that the scan reports the shape, and that the shape really
does fail.

Both halves of the premise are pinned, not just the half about the
banned form. A test asserts the runner still passes a bare negated
assertion; a second asserts the prescribed `if cmd; then ...; false; fi`
does fail, under both runners, and fails AT the form rather than
somewhere before it. Every converted absence rests on that second
half, and its failure mode is asymmetric: were `false` in a `then`
body to stop propagating, the first test would still pass while every
conversion went silently green.

The rule joins the E2E conventions doc as its own section, and the
inline list in AGENTS.md that an agent may read instead of the doc --
it belongs beside the EXIT-trap ban for the same reason, both leaving
a green suite that checks nothing.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-10-02 15:09:20 +03:00

11 KiB

AI Agents Overview

This file provides structured guidance for AI coding assistants and agents working with the Cozystack project.

Activation

CRITICAL: When the user asks you to do something that matches the scope of a documented process, you MUST read the corresponding documentation file and follow the instructions exactly as written.

  • Commits, PRs, git operations (e.g., "create a commit", "make a PR", "fix review comments", "rebase", "cherry-pick")

    • Read: contributing.md
    • Action: Read the entire file and follow ALL instructions step-by-step
  • Changelog generation (e.g., "generate changelog", "create changelog", "prepare changelog for version X")

    • Read: changelog.md
    • Action: Read the entire file and follow ALL steps in the checklist. Do NOT skip any mandatory steps
  • Release creation (e.g., "create release", "prepare release", "tag release", "make a release")

    • Read: releasing.md
    • Action: Read the file and follow the referenced release process in docs/release.md
  • Image references, tags and digests (e.g., "why is this image tagged rc", "add an image to a package", "change how an image is stamped", "promotion/nightly missed an image", "where do image refs live")

    • Read: image-refs.md
    • Action: Read the entire file. Image refs live in three storage shapes and carry three classes of tag; a tool that knows only one shape skips images silently rather than failing. hack/lib/image-refs.sh is the enumeration shared by the promote, retag, mirror and candidate-verify tooling — extend it there rather than teaching an individual script a new path. The one exception is hack/overlay-main-images.sh, which reads refs but walks the tree itself, so a newly declared file does not reach it; hack/promote-packages-artifact.sh sources nothing because it publishes the whole tree and consumes no refs at all — the doc carries both cases
  • Retiring an application (e.g., "remove the X app", "deprecate X", "drop X from the catalog", "what happens to instances people already created")

    • Read: deprecating-applications.md
    • Action: Read the entire file before touching the tree. Deleting a chart is not a deletion: pruning its PackageSource garbage-collects the ArtifactGenerator and both ExternalArtifacts, which are the chartRef targets of the rd release and of every tenant release, so surviving instances sit Ready=False for good and HelmReleaseNotReady fires on every cluster carrying the bundle. An application with a successor migrates onto it; one without is frozen, per cluster, on the artifact that cluster already renders from, with the digest read off the live OCIRepository at hook time rather than written into the migration. The tenant RBAC grant comes out of the chart and is re-applied by the migration on the clusters that froze
  • Project structure, conventions, code layout (e.g., "where should I put X", "what's the convention for Y", "how is the project organized")

    • Read: overview.md
    • Action: Read relevant sections to understand project structure and conventions
  • E2E tests and E2E CI (e.g., "write/fix an e2e test", "stabilize a flaky test", "add a bats test", "change the e2e workflow", "why did e2e fail")

    • Read: e2e-testing.md
    • Action: Read the entire file and follow the conventions exactly — no new retries on deterministic steps, event-driven backstops before every kubectl wait, no test-level EXIT/RETURN traps (a self-contained one inside a Chainsaw script step or an explicit subshell is fine — see the doc), no assertion beginning with ! (errexit exempts an inverted status, so it cannot fail — write if cmd; then echo "FAIL: …"; false; fi), fail-fast on HelmRelease readiness
  • General questions about contributing

    • Read: contributing.md
    • Action: Read the file to understand git workflow, commit format, PR process
  • Issue and PR labeling, triage (e.g., "label this issue", "what label should I use", "triage this", "categorize")

    • Read: .github/labels.yml
    • Action: Use labels defined there. Conventions follow the Kubernetes scheme — kind/* (type), area/* (subsystem), priority/* (urgency), triage/* (review state), lifecycle/* (auto-close), do-not-merge/* (PR blockers), security/* (severity)
    • For triage/*: .github/workflows/issue-triage.yaml labels an issue on arrival and sweeps daily for any open issue carrying none, so change a triage decision by replacing the label rather than removing it — remove the only triage/* label an issue has and the next sweep puts one back
    • A feature request that needs design first gets triage/needs-proposal, is re-filed in cozystack/community as an issue or a design-proposal PR, and is closed here with a link to it (re-file rather than transfer, so the label stays on the closed issue as the record); work in this repository starts only once that proposal is accepted, from a new issue that references it
    • For area/*: accuracy outweighs reuse. If no existing area/* truly fits the change, propose a new one via PR (extend .github/labels.yml and the scope mapping in .github/workflows/pr-labeler.yaml) — do not shoehorn the change into a wrong area. area/uncategorized is the auto-labeler fallback; treat it as a signal to pick a fit, create a new area, or correct the PR title
    • PR titles: a Conventional Commits header (type(scope): description, types from contributing.md) auto-applies kind/* and area/* via .github/workflows/pr-labeler.yaml. Append ! (or add a BREAKING CHANGE: footer) to apply kind/breaking-change

Important rules:

  • ✅ ONLY read the file if the task matches the documented process scope - do not read files for tasks that don't match their purpose
  • ✅ ALWAYS read the file FIRST before starting the task (when applicable)
  • ✅ Follow instructions EXACTLY as written in the documentation
  • ✅ Do NOT skip mandatory steps (especially in changelog.md)
  • ✅ Do NOT assume you know the process - always check the documentation when the task matches
  • ❌ Do NOT read files for tasks that are outside their documented scope
  • 📖 Note: overview.md can be useful as a reference to understand project structure and conventions, even when not explicitly required by the task

Project Overview

Cozystack is a Kubernetes-based platform for building cloud infrastructure with managed services (databases, VMs, K8s clusters), multi-tenancy, and GitOps delivery.

Quick Reference

Code Structure

  • packages/core/ - Core platform charts (installer, platform)
  • packages/system/ - System components (CSI, CNI, operators)
  • packages/apps/ - User-facing applications in catalog
  • packages/extra/ - Tenant-specific modules
  • cmd/, internal/, pkg/ - Go code
  • api/ - Kubernetes CRDs

Conventions

  • Helm Charts: Umbrella pattern, vendored upstream charts in charts/
  • Go Code: Controller-runtime patterns, kubebuilder style
  • Git Commits: Conventional Commits (type(scope): description) with --signoff; an agent-assisted commit adds one Assisted-by: LLM trailer
  • Comments and commit bodies: WHY, not a narration of WHAT; see Review Blockers in contributing.md
  • Prose: One continuous line per paragraph — see Prose Formatting

What NOT to Do

  • ❌ Edit /vendor/, zz_generated.*.go, upstream charts directly
  • ❌ Modify go.mod/go.sum manually (use go get)
  • ❌ Force push to main/master
  • ❌ Commit built artifacts from _out
  • ❌ Credit an AI model or its vendor by name in a commit trailer or in an authorship line in a PR description or comment, or put a Claude-Session: trailer or a session URL into any of them
  • ❌ Hardwrap a prose paragraph in markdown, a PR body, or an issue comment

Prose Formatting

Markdown files and everything an agent publishes to GitHub — PR bodies, issue bodies, review comments, release notes — use one continuous line per paragraph. Renderers collapse a soft line break into a space, so a paragraph hardwrapped at ~80 columns renders exactly like the same paragraph on one line, while breaking narrow viewports, mangling nested list and table rendering, and turning a one-word edit into a diff that reflows the entire block. The renderer decides where the line ends; the author does not.

Line breaks stay wherever they carry meaning: code fences, one item per list line, tables, headings, blockquotes, front matter, and README badge stacks. The rule governs prose paragraphs and nothing else. Commit messages are the deliberate exception — git log and the tooling around it still expect a body wrapped at ~72 columns.

This is the rule LLM agents break most often, because an ~80-column wrap is correct in the places they spend most of their time (Go comments, commit bodies) and the reflex leaks from there into documentation and GitHub. Treat it as a mechanical check at the moment of writing, not as a style preference to weigh: before saving a markdown file or passing --body to gh, look at each paragraph and confirm it is a single line.

The rule applies to prose this repository owns, and to new or changed paragraphs. Much of the existing tree predates it and is wrapped throughout; that is not a defect to go and fix. Vendored upstream chart docs (packages/*/*/charts/**) must stay verbatim — make update regenerates them — and published changelogs are historical records. Neither is in scope.

.claude/hooks/md-no-hardwrap.py enforces the rule for agents that support PreToolUse hooks (registered for Claude Code in .claude/settings.json). It refuses a Write/Edit/MultiEdit that would add a hardwrapped paragraph, list item or blockquote to a markdown file, and a gh command that would publish one to GitHub. A break counts as added only when the edit wrote both lines it joins, so fixing a typo inside a paragraph that was already wrapped goes through, while rewriting the same file as fresh hardwrapped prose does not. Anything it cannot read with certainty it allows — a guard that blocks legitimate work gets switched off, and then it guards nothing — which includes a body the shell assembles at runtime, and a --body-file written by the very command being checked, since the file does not exist yet when the hook runs. hack/md-no-hardwrap.bats covers it under make unit-tests and requires python3. Agents on other harnesses are held to the same rule without the hook.

.claude/settings.json is tracked, so keep personal Claude Code overrides in .claude/settings.local.json, which is not. A personal .claude/settings.json predating this will be overwritten on pull without warning, because git had been ignoring it.