diff --git a/.github/icm/.gitignore b/.github/icm/.gitignore new file mode 100644 index 0000000..ef12689 --- /dev/null +++ b/.github/icm/.gitignore @@ -0,0 +1,2 @@ +# Stage outputs belong to one run in one checkout and are never committed. +*/stages/*/output/ diff --git a/.github/icm/01-fix/CLAUDE.md b/.github/icm/01-fix/CLAUDE.md new file mode 100644 index 0000000..81c4be5 --- /dev/null +++ b/.github/icm/01-fix/CLAUDE.md @@ -0,0 +1,11 @@ +# 01-fix — fix a defect + +One run is one defect in the signature, the receiver pipeline, the models or the FastAPI helper, or a model that has fallen behind the deliveries (route `model-drift`): reproduced, fixed at its cause, and proposed as a pull request with the evidence. The history so far is the package itself and the fixes that made its publishing work (`git log --oneline origin/main`); this pipeline is the shape the next change takes. + +A fix that changes what the receiver accepts, or a public name, is a breaking change: the spec of that is stage 01's last step, and the person decides before stage 02. + +Stages, in order; inputs, outputs and checks for each are in [CONTEXT.md](CONTEXT.md): + +1. [Reproduce](stages/01_reproduce/CONTEXT.md) — make it fail on purpose. +2. [Fix](stages/02_fix/CONTEXT.md) — change the cause, watch the reproduction pass. +3. [Verify](stages/03_verify/CONTEXT.md) — the checks, the pull request. diff --git a/.github/icm/01-fix/CONTEXT.md b/.github/icm/01-fix/CONTEXT.md new file mode 100644 index 0000000..663523a --- /dev/null +++ b/.github/icm/01-fix/CONTEXT.md @@ -0,0 +1,9 @@ +# 01-fix — the pipeline + +Make it fail, make it pass for the right reason, prove it where CI will. + +| Stage | Job | Input | Output | Human check | +|---|---|---|---|---| +| [`01_reproduce`](stages/01_reproduce/CONTEXT.md) | reproduce the reported defect | the report | `output/reproduction.md` | A person runs the command or reads its output, confirms it fails for the reported reason, and decides whether a behaviour change is wanted before any fix is written. | +| [`02_fix`](stages/02_fix/CONTEXT.md) | change the cause, not the symptom | 01_reproduce's `reproduction.md` | `output/change.md` | A person reads the diff beside the reproduction: it removes the cause, no verification step was loosened or reordered, and any behaviour change is the one approved. Edit `change.md` if it is wrong; stage 03 reads it. | +| [`03_verify`](stages/03_verify/CONTEXT.md) | checks, pull request | 02_fix's `change.md` | `output/pull-request.md` | A person reads `pull-request.md` and its evidence, then says whether to open it. The reviewer, not the author, merges, once every check on the last commit is green. | diff --git a/.github/icm/01-fix/stages/01_reproduce/CONTEXT.md b/.github/icm/01-fix/stages/01_reproduce/CONTEXT.md new file mode 100644 index 0000000..86ee9db --- /dev/null +++ b/.github/icm/01-fix/stages/01_reproduce/CONTEXT.md @@ -0,0 +1,25 @@ +# 01_reproduce — reproduce the reported defect + +One job: turn the report into a pytest case that fails on demand, for the reason reported. + +## Inputs +- Working (this run): the report — read whole with what it links: an issue (`gh issue view --comments`), a red run (`gh run view --log-failed`), a delivery the receiver rejected or parsed wrongly, or a change to the delivery types in Alteriom/webhook-client. +- Reference (every run): `.github/icm/_shared/architecture.md` +- Reference (every run): `.github/icm/_shared/build-and-test.md` +- Reference (every run): `.github/icm/_shared/gotchas.md` + +Do NOT load: the other pipeline, `.github/icm/_shared/release.md`, other runs' output. + +## Process +1. If the report could be a vulnerability in the verification (a signature, timestamp or size check that can be bypassed), stop before writing anything: this pipeline ends in a public pull request, and a private report through the repository's Security tab is the route. +2. Restate the report as observed versus expected: the input (a delivery with every secret, id and repository name replaced by test values), the step of the pipeline that answered, and what it should have done. +3. Find the step with [architecture.md](../../../_shared/architecture.md) and name the suspected cause as `path:line`. For `model-drift`, name the field and the value the model refuses, and where the TypeScript types or the supplied delivery show it. +4. In a virtual environment with `pip install -e ".[dev]"`, write the failing test in the file [build-and-test.md](../../../_shared/build-and-test.md) "Where a test belongs" names, signing deliveries with `sign_payload` and a current timestamp as the existing tests do. +5. Run it (`pytest tests/ -v`) and keep the failing output. If it passes, the reproduction or the report is wrong: say which, and stop. +6. Say whether the fix will change what the receiver accepts or a public name ([gotchas.md](../../../_shared/gotchas.md) "Deliveries without an aggregate count as duplicates" is one already open). + +## Outputs +- reproduction.md → output/ — the restated report; the suspected cause (`path:line`); the test and command; the failing output; whether the fix is a behaviour change. + +## Human check +A person runs the command or reads its output, confirms it fails for the reported reason, and decides whether a behaviour change is wanted before any fix is written. diff --git a/.github/icm/01-fix/stages/02_fix/CONTEXT.md b/.github/icm/01-fix/stages/02_fix/CONTEXT.md new file mode 100644 index 0000000..65533ce --- /dev/null +++ b/.github/icm/01-fix/stages/02_fix/CONTEXT.md @@ -0,0 +1,23 @@ +# 02_fix — change the cause, not the symptom + +One job: the smallest change at the defect's cause that turns the reproduction green without weakening any check. + +## Inputs +- Working (this run): `.github/icm/01-fix/stages/01_reproduce/output/reproduction.md` +- Reference (every run): `.github/icm/_shared/architecture.md` +- Reference (every run): `.github/icm/_shared/build-and-test.md` + +Do NOT load: the other pipeline, `.github/icm/_shared/release.md`, other runs' output. + +## Process +1. Confirm the cause in the code before changing anything; if it moved, record where it really is. +2. Change the cause, within what the person approved at stage 01. The verification order stays as [architecture.md](../../../_shared/architecture.md) "The modules" lists it; no check is loosened, and the comparison stays constant-time. +3. A model change follows the TypeScript types in Alteriom/webhook-client or the delivery the person supplied: a new field is optional with a default; a new `Literal` value is added, not the `Literal` removed. +4. Keep `__all__` and every public signature unless the person approved otherwise. Do not edit the reproduction to make it pass. +5. Run the reproduction (green now), then `pytest tests/ -v` and `python -m build`. + +## Outputs +- change.md → output/ — the cause in one paragraph; each file changed and why; the reproduction's passing output; the full test and build results; anything deliberately not addressed. + +## Human check +A person reads the diff beside the reproduction: it removes the cause, no verification step was loosened or reordered, and any behaviour change is the one approved. Edit `change.md` if it is wrong; stage 03 reads it. diff --git a/.github/icm/01-fix/stages/03_verify/CONTEXT.md b/.github/icm/01-fix/stages/03_verify/CONTEXT.md new file mode 100644 index 0000000..ae5c52f --- /dev/null +++ b/.github/icm/01-fix/stages/03_verify/CONTEXT.md @@ -0,0 +1,22 @@ +# 03_verify — checks, pull request + +One job: the pull request a reviewer can merge, with the evidence CI will look for. + +## Inputs +- Working (this run): `.github/icm/01-fix/stages/02_fix/output/change.md` +- Reference (every run): `.github/icm/_shared/build-and-test.md` +- Reference (only when the README describes the changed behaviour): `README.md` + +Do NOT load: the other pipeline, other runs' output. + +## Process +1. Run what CI runs ([build-and-test.md](../../../_shared/build-and-test.md) "What CI runs"): the tests on each Python version you have among 3.10 to 3.12 (at least the oldest), and `python -m build`. +2. Update the `README.md` section that described the old behaviour. Do not change `pyproject.toml`'s version: that is a release. +3. Write the pull request: what was wrong, how you know (the reproduction), what the change does, whether it changes what the receiver accepts; the checks run and their results. Title `fix(): `. Nothing in it names a real secret, host or delivery. +4. After the human check, the agent opens it against `main` (`gh pr create --base main --title "" --body-file <this stage's output/pull-request.md>`). The agent never merges. + +## Outputs +- pull-request.md → output/ — the title and body, the checks run with their results, the README change as written. + +## Human check +A person reads `pull-request.md` and its evidence, then says whether to open it. The reviewer, not the author, merges, once every check on the last commit is green. diff --git a/.github/icm/02-release/CLAUDE.md b/.github/icm/02-release/CLAUDE.md new file mode 100644 index 0000000..bcdcf09 --- /dev/null +++ b/.github/icm/02-release/CLAUDE.md @@ -0,0 +1,11 @@ +# 02-release — release a version + +One run is one version of `alteriom-webhook-client`, from the merged changes to the same number on PyPI. One release so far (`v0.1.0`), and it took three runs of `publish.yml` while publishing moved to trusted publishing, which is why the steps are written down. + +A published GitHub release uploads to PyPI, so publishing it is the person's act, after the version bump has merged. The agent plans, prepares the bump, drafts the notes, and confirms the result. It never creates a tag or a release, and never uploads. + +Stages, in order; inputs, outputs and checks for each are in [CONTEXT.md](CONTEXT.md): + +1. [Plan](stages/01_plan/CONTEXT.md) — what ships, and which number. +2. [Prepare](stages/02_prepare/CONTEXT.md) — the version bump pull request and the draft notes. +3. [Publish](stages/03_publish/CONTEXT.md) — the person publishes the release; the agent confirms PyPI. diff --git a/.github/icm/02-release/CONTEXT.md b/.github/icm/02-release/CONTEXT.md new file mode 100644 index 0000000..6ae43f9 --- /dev/null +++ b/.github/icm/02-release/CONTEXT.md @@ -0,0 +1,9 @@ +# 02-release — the pipeline + +Choose the number, bump it in one pull request, let a person publish the release, and confirm PyPI has it. + +| Stage | Job | Input | Output | Human check | +|---|---|---|---|---| +| [`01_plan`](stages/01_plan/CONTEXT.md) | choose what ships and its number | the changes since the previous release | `output/release-plan.md` | A maintainer approves the version number and the notes. The number is permanent once uploaded: PyPI never takes the same version twice. | +| [`02_prepare`](stages/02_prepare/CONTEXT.md) | the version bump pull request | 01_plan's `release-plan.md` | `output/release-pr.md` | A maintainer reviews the bump with every check green, merges it, and then decides to publish: `gh release create vX.Y.Z --target <merge commit on main> --notes-file <notes>` publishes to PyPI. | +| [`03_publish`](stages/03_publish/CONTEXT.md) | the person publishes, the agent confirms | 02_prepare's `release-pr.md` | `output/publication.md` | A person confirms the version is on PyPI and installs, built from `main`, before it is announced. | diff --git a/.github/icm/02-release/stages/01_plan/CONTEXT.md b/.github/icm/02-release/stages/01_plan/CONTEXT.md new file mode 100644 index 0000000..cd49501 --- /dev/null +++ b/.github/icm/02-release/stages/01_plan/CONTEXT.md @@ -0,0 +1,23 @@ +# 01_plan — choose what ships and its number + +One job: a release plan that names the version, justifies its class, and drafts the release notes. + +## Inputs +- Working (this run): the changes since the previous release — `git log --oneline v<previous>..origin/main` and `git diff --stat v<previous> origin/main -- src/ pyproject.toml`. +- Reference (every run): `.github/icm/_shared/release.md` +- Reference (every run): `.github/icm/_shared/gotchas.md` + +Do NOT load: the other pipeline, other runs' output. + +## Process +1. Find the previous tag (`git tag --list 'v*' --sort=-v:refname | head -3`) and what PyPI has (`pip index versions alteriom-webhook-client`). +2. List every merged change and say whether it changes what ships (`src/`, `pyproject.toml` dependencies) or not. Nothing that ships means no release: say so and stop. +3. Classify against [release.md](../../../_shared/release.md) "The number" and say which change decides it. A change to what the receiver accepts is named first. +4. Confirm `main` is green (`gh run list --branch main --workflow ci.yml --limit 3`). +5. Draft the release notes in the shape of `v0.1.0`'s (`gh release view v0.1.0`), with the install line for the new version. + +## Outputs +- release-plan.md → output/ — the version and the change that decides its class; what ships; the state of `main`; the drafted notes. + +## Human check +A maintainer approves the version number and the notes. The number is permanent once uploaded: PyPI never takes the same version twice. diff --git a/.github/icm/02-release/stages/02_prepare/CONTEXT.md b/.github/icm/02-release/stages/02_prepare/CONTEXT.md new file mode 100644 index 0000000..82afc8d --- /dev/null +++ b/.github/icm/02-release/stages/02_prepare/CONTEXT.md @@ -0,0 +1,22 @@ +# 02_prepare — the version bump pull request + +One job: one pull request that sets the version, passes every check, and carries the approved notes for the person to publish. + +## Inputs +- Working (this run): `.github/icm/02-release/stages/01_plan/output/release-plan.md` +- Reference (every run): `.github/icm/_shared/release.md` +- Reference (every run): `.github/icm/_shared/build-and-test.md` + +Do NOT load: the other pipeline, other runs' output. + +## Process +1. Branch `release/X.Y.Z` from current `origin/main`. +2. Set `version` in `pyproject.toml` to `X.Y.Z`; nothing else carries the version ([release.md](../../../_shared/release.md) "The number"). +3. In a fresh virtual environment: `pip install -e ".[dev]"`, `pytest tests/ -v`, `python -m build`, and check that `dist/` holds `alteriom_webhook_client-X.Y.Z` wheel and sdist. +4. Open the pull request against `main`, titled `chore: release vX.Y.Z`; the body is the approved notes, the checks' output, and the steps the person takes after the merge. + +## Outputs +- release-pr.md → output/ — the pull request link, the diff, the checks' output, and the person's steps: tag `vX.Y.Z` on the merge commit, then publish a GitHub release from that tag with the approved notes. + +## Human check +A maintainer reviews the bump with every check green, merges it, and then decides to publish: `gh release create vX.Y.Z --target <merge commit on main> --notes-file <notes>` publishes to PyPI. diff --git a/.github/icm/02-release/stages/03_publish/CONTEXT.md b/.github/icm/02-release/stages/03_publish/CONTEXT.md new file mode 100644 index 0000000..bdf9234 --- /dev/null +++ b/.github/icm/02-release/stages/03_publish/CONTEXT.md @@ -0,0 +1,21 @@ +# 03_publish — the person publishes, the agent confirms + +One job: confirm that the release the person published uploaded the right version from the right commit. The agent runs no publishing command. + +## Inputs +- Working (this run): `.github/icm/02-release/stages/02_prepare/output/release-pr.md` — or, for a check of an earlier release (route `publish-check`), only the tag `vX.Y.Z` the person names; start at step 2. +- Reference (every run): `.github/icm/_shared/release.md` +- Reference (only when the run failed at the upload): `.github/icm/_shared/gotchas.md` + +Do NOT load: the other pipeline, other runs' output, the source tree. + +## Process +1. Confirm the tag points at the merge commit on `main` (`git fetch --tags && git branch -r --contains vX.Y.Z` lists `origin/main`) and that `pyproject.toml` there says `X.Y.Z`. +2. Find the `publish.yml` run for the release (`gh run list --workflow publish.yml --limit 3`) and read both jobs. A failure in `test` uploaded nothing. A failure at the upload is read against [gotchas.md](../../../_shared/gotchas.md) "Publishing is tied to names on PyPI's side" before anything is re-run; the person decides the re-run. +3. Check PyPI for `X.Y.Z` (`pip index versions alteriom-webhook-client`), and that a clean environment can `pip install alteriom-webhook-client==X.Y.Z` and import `WebhookReceiver`. + +## Outputs +- publication.md → output/ — the tag and its commit; the workflow run with each job's result; the version on PyPI with a link; the install check. + +## Human check +A person confirms the version is on PyPI and installs, built from `main`, before it is announced. diff --git a/.github/icm/_shared/CONTEXT.md b/.github/icm/_shared/CONTEXT.md new file mode 100644 index 0000000..0c100b3 --- /dev/null +++ b/.github/icm/_shared/CONTEXT.md @@ -0,0 +1,13 @@ +# Shared reference + +What every job on this repository draws on. Each file points at the repository file that owns a fact; follow the pointer for the detail. + +| File | Answers | +|---|---| +| [architecture.md](architecture.md) | where each part of the package lives, and what it must agree with in the TypeScript client | +| [build-and-test.md](build-and-test.md) | how to install, test and build, where a test belongs, what CI runs | +| [release.md](release.md) | how a version is chosen and published, and what only a person does | +| [gotchas.md](gotchas.md) | traps already in the code or the history, each with its source | +| [documents.md](documents.md) | the repository's documents | + +Each stage's contract names the files it loads. diff --git a/.github/icm/_shared/architecture.md b/.github/icm/_shared/architecture.md new file mode 100644 index 0000000..0cb7f59 --- /dev/null +++ b/.github/icm/_shared/architecture.md @@ -0,0 +1,22 @@ +--- +verified_at: 2026-10-15 +verified_commit: 337bb90fc61fd822113654f550d491de4e76aa38 +--- + +# Architecture — where each part of the package lives + +## The modules + +- `src/alteriom_webhook_client/signature.py` — `sign_payload` and `verify_signature`: HMAC-SHA256 over `"{timestamp}." + body` (the raw bytes), as `sha256=<hex>`, compared with `hmac.compare_digest`. +- `src/alteriom_webhook_client/receiver.py` — `WebhookReceiver.receive` (`src/alteriom_webhook_client/receiver.py:71`) runs the pipeline in order: payload size (413), the two signature headers, timestamp age, signature, JSON, model validation (400 for both), then duplicate detection (`src/alteriom_webhook_client/receiver.py:129`) in an in-memory LRU with a one-hour TTL (`src/alteriom_webhook_client/receiver.py:26`). Every failure is a `WebhookVerificationError` with a status code. +- `src/alteriom_webhook_client/models.py` — Pydantic v2 models `SubscriptionDelivery`, `Aggregate`, `Enrichment`, `WebhookEvent`. +- `src/alteriom_webhook_client/fastapi.py` — `webhook_receiver(...)` returns a FastAPI dependency that reads the raw body and turns the error into an `HTTPException`. FastAPI is an optional extra (`pyproject.toml:17`); only this module imports it. +- `src/alteriom_webhook_client/__init__.py` — the public names (`__all__`); the FastAPI helper is imported from its own module. + +## What it must agree with + +The signature format and the delivery models mirror the TypeScript package in Alteriom/webhook-client (its `src/receiver.ts` and `src/types.ts`), as the module docstrings say (`src/alteriom_webhook_client/signature.py:3`, `src/alteriom_webhook_client/models.py:3`). The service that sends the deliveries is private: a model or header change starts from the TypeScript package or from a delivery the person supplies, never from guesses about the service. + +## The package + +Hatchling builds the wheel from `src/alteriom_webhook_client` (`pyproject.toml:26`). The only runtime dependency is `pydantic>=2.0` (`pyproject.toml:12`); Python 3.10 or later (`pyproject.toml:11`). The version is `pyproject.toml:7`; the package does not expose `__version__`. diff --git a/.github/icm/_shared/build-and-test.md b/.github/icm/_shared/build-and-test.md new file mode 100644 index 0000000..aed4bd2 --- /dev/null +++ b/.github/icm/_shared/build-and-test.md @@ -0,0 +1,20 @@ +--- +verified_at: 2026-10-15 +verified_commit: 337bb90fc61fd822113654f550d491de4e76aa38 +--- + +# Build and test — the commands, and what CI runs + +## Locally + +- In a virtual environment: `pip install -e ".[dev]"` (pytest, pytest-asyncio, httpx, FastAPI, uvicorn: `pyproject.toml:18`), then `pytest tests/ -v`. Async tests run without markers (`asyncio_mode = "auto"`, `pyproject.toml:29`). +- `pip install build && python -m build` makes the sdist and wheel in `dist/`, as CI does. +- There is no linter or type checker configured; follow the style of the module you touch. + +## Where a test belongs + +One file per module: `tests/test_signature.py` (format and comparison), `tests/test_receiver.py` (each pipeline step and its status code, with deliveries signed by `sign_payload` and a current timestamp), `tests/test_fastapi.py` (the dependency through FastAPI's test client over httpx). No test reaches a network or a real secret. + +## What CI runs + +`ci.yml` on pull requests to `main` and pushes to `main`: the tests on Python 3.10, 3.11 and 3.12 (`.github/workflows/ci.yml:14`), then a build whose `dist/` is uploaded as an artifact. `publish.yml` runs the tests again before it publishes ([release.md](release.md)). `icm-check.yml` runs when an entry file or `.github/icm/` changes; CodeQL runs on its schedule. diff --git a/.github/icm/_shared/documents.md b/.github/icm/_shared/documents.md new file mode 100644 index 0000000..848aea8 --- /dev/null +++ b/.github/icm/_shared/documents.md @@ -0,0 +1,18 @@ +--- +verified_at: 2026-10-15 +verified_commit: 337bb90fc61fd822113654f550d491de4e76aa38 +--- + +# Documents — what this repository has for people + +The agent-facing files are the root `AGENTS.md` (the entry) and this `.github/icm/` folder. The one user document restates facts the code owns, and the code is right when they disagree. + +| Document | What it answers | Owner of the facts | +|---|---|---| +| [README.md](../../../README.md) | install, the FastAPI quick start, manual and low-level use, configuration, the models | `src/alteriom_webhook_client/` | + +There is no changelog, contributing guide or security policy file; the GitHub release notes record what changed, and private vulnerability reporting is enabled on the repository. + +## Known drift + +None found at the commit above: the README's defaults (five-minute age, 10 MB) match `WebhookReceiver` (`src/alteriom_webhook_client/receiver.py:51`). diff --git a/.github/icm/_shared/gotchas.md b/.github/icm/_shared/gotchas.md new file mode 100644 index 0000000..b987089 --- /dev/null +++ b/.github/icm/_shared/gotchas.md @@ -0,0 +1,22 @@ +--- +verified_at: 2026-10-15 +verified_commit: 337bb90fc61fd822113654f550d491de4e76aa38 +--- + +# Gotchas — traps already in the code or the history + +## Deliveries without an aggregate count as duplicates + +The duplicate key is `subscription_id` plus the aggregate's id, or the literal `none` when there is no aggregate (`src/alteriom_webhook_client/receiver.py:131`). Within the cache's one-hour TTL, a second correctly signed delivery for the same subscription with no aggregate is therefore rejected with "Duplicate delivery detected" (401). Reproduced 2026-10-15: two deliveries with different `delivered_at` and no aggregate, the second refused. The TypeScript receiver in Alteriom/webhook-client builds its key the same way. Changing it changes what both SDKs accept: a decision, not a quiet fix. + +## The signature is over the raw bytes + +`verify_signature` signs the body exactly as received (`src/alteriom_webhook_client/signature.py:13`). Any framework code that parses and re-serialises the JSON before verifying will fail every signature; the FastAPI helper reads `request.body()` for that reason (`src/alteriom_webhook_client/fastapi.py`). Headers are matched case-insensitively (`receiver.py` lowercases them). + +## Publishing is tied to names on PyPI's side + +Trusted publishing (`.github/workflows/publish.yml:25`) works only while PyPI's trusted-publisher entry names this repository, `publish.yml` and the `pypi` environment. Renaming the workflow file or the environment breaks the next release at the upload, after the tests pass. Changing them is a release-day decision for a person, who also updates PyPI. + +## The models refuse unknown enum values + +`Enrichment.risk_level` and `WebhookEvent.processing_status` are `Literal`s (`src/alteriom_webhook_client/models.py`), so a new value the service starts sending fails validation with a 400 until the model follows. A delivery the receiver rejects for its shape, not its signature, is a `model-drift` report. diff --git a/.github/icm/_shared/release.md b/.github/icm/_shared/release.md new file mode 100644 index 0000000..eb0dd59 --- /dev/null +++ b/.github/icm/_shared/release.md @@ -0,0 +1,20 @@ +--- +verified_at: 2026-10-15 +verified_commit: 337bb90fc61fd822113654f550d491de4e76aa38 +--- + +# Releasing — from a version bump to PyPI + +## The number + +Semantic versioning on `pyproject.toml:7`, the only place the version lives. A fix is a patch; a new optional field, model or keyword argument is a minor; a removed or renamed public name (`src/alteriom_webhook_client/__init__.py` `__all__`), a field made required, or a check that rejects what it used to accept is a breaking change, said first in the release notes; while the version is below 1.0, the person decides at the plan whether it is a minor or 1.0. There is no changelog file: the GitHub release notes are the record (`v0.1.0` is the one so far). + +## What publishes + +`publish.yml` runs when a GitHub release is published (`.github/workflows/publish.yml:5`): the tests on 3.12, `python -m build`, then `pypa/gh-action-pypi-publish` with PyPI trusted publishing (OIDC, `id-token: write`) in the `pypi` environment (`.github/workflows/publish.yml:25`). There is no token secret. Publishing the GitHub release is the publish decision, and a person makes it, after the version bump has merged to `main`, on a `vX.Y.Z` tag at that merge. + +Nothing checks that the tag matches `pyproject.toml`: the version uploaded is the one in `pyproject.toml` at the tagged commit, and PyPI refuses a version it already has, so a release without a bump fails at the upload step. + +## Afterwards + +PyPI: `pip index versions alteriom-webhook-client` or the project page. A run that failed before the upload published nothing; once a version is uploaded it is there for good, because PyPI never accepts the same version twice. A broken upload is followed by a new patch version, never by a re-tag. The first release needed three runs while the workflow moved to trusted publishing (`git log --oneline -- .github/workflows/publish.yml`). diff --git a/.github/icm/_system/config.json b/.github/icm/_system/config.json new file mode 100644 index 0000000..dd38a6b --- /dev/null +++ b/.github/icm/_system/config.json @@ -0,0 +1,11 @@ +{ + "standard": "1.1.0", + "repository": "Alteriom/alteriom-webhook-client-python", + "project": "alteriom-webhook-client-python", + "entry": "AGENTS.md", + "pointer": "CLAUDE.md", + "review_days": 90, + "documents_index": "_shared/documents.md", + "document_id_prefix": "doc-", + "forms": "umbrella" +} diff --git a/.github/icm/_system/generate.py b/.github/icm/_system/generate.py new file mode 100644 index 0000000..aeef33d --- /dev/null +++ b/.github/icm/_system/generate.py @@ -0,0 +1,692 @@ +#!/usr/bin/env python3 +"""The ICM standard, as one script every repository vendors unchanged. + +Vendored at ``<home>/_system/generate.py`` (``<home>`` is ``.github/icm`` or ``icm``), +configured by ``<home>/_system/config.json`` beside it, and run from anywhere in the +repository: + + python3 .github/icm/_system/generate.py # rewrite the generated files + python3 .github/icm/_system/generate.py --check # exit 1 if they are out of date or the lint fails + python3 .github/icm/_system/generate.py lint # the structural checks alone, any form + python3 .github/icm/_system/generate.py --check --strict # also fail on facts past review + +Generated: ``<home>/icm.source.json`` and each pipeline's ``CONTEXT.md`` stage table. Each +stage's inputs, outputs and human check are read from its contract, so the contract is +the only place they are written by hand. A fact file names the commit it was verified at +in its frontmatter (``verified_at``, ``verified_commit``); the repository files it cites in +backticks (a bare workflow file name is looked up in ``.github/workflows/``; a ``path:line`` +suffix is ignored) or links become its evidence. A node without frontmatter keeps the +dates, revision and evidence it already has in the source file, so adopting this script +loses nothing. Keys it does not derive and the Git sync resolves (``repositories``, ``links``, +``workspaces``) and ``packages`` are carried over from the existing source file unchanged. +``relationships`` and ``inclusions`` belong to knowledge (service) workspaces, declared by +a person in the dashboard; the Git sync does not resolve them, so the lint refuses them. + +The lint is what the service's compiler enforces, run before the pull request: entry +under 120 lines, twins byte-equal or one a one-line pointer, every fact dated and cited, +no placeholder left, no secret-looking string, stage contracts parseable. Keep this file +byte-identical to ``app/icm/standard/generate.py`` in memoryrelay/api; the repository's +particulars live in ``config.json``. Python 3.9+, standard library only. +""" + +import fnmatch +import json +import re +import subprocess +import sys +from datetime import date, datetime, timedelta, timezone +from pathlib import Path + +STANDARD_VERSION = "1.1.0" + +HERE = Path(__file__).resolve() +HOME_DIR = HERE.parents[1] # <root>/<home>: .github/icm or icm + + +def _root() -> Path: + for p in HERE.parents: + if (p / ".git").exists(): + return p + return HOME_DIR.parents[1] if HOME_DIR.parent.name == ".github" else HOME_DIR.parent + + +ROOT = _root() +ICM = HOME_DIR.relative_to(ROOT).as_posix() +SOURCE = f"{ICM}/icm.source.json" +CONFIG = HERE.parent / "config.json" + +TABLE_HEADER = "| Stage | Job | Input | Output | Human check |\n|---|---|---|---|---|\n" +MAX_ENTRY_LINES, ENTRY_TARGET_LINES = 120, 60 +MAX_EVIDENCE = 50 # the compiler's cap on evidence entries per node + +CODE = re.compile(r"`([^`\n]+)`") +PATHLIKE = re.compile(r"^[\w.-]+(/[\w.-]+)*/?$") +LINE_SUFFIX = re.compile(r":\d+(-\d+)?$") +LINK = re.compile(r"\]\(\s*<?([^)\s>]+)>?\s*\)") +INPUT = re.compile(r"^- (Working|Reference) \(([^)]*)\): (.*)$") +OUTPUT = re.compile(r"^- (\S+) → output/") +FRONT = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL) +PLACEHOLDER = re.compile(r"\{…\}|\{\{[^}]*\}\}|\{TBD\}|\{TODO[^}]*\}") +SECRETS = re.compile( + r"(mem_(?:prod|live|test|dev)_[A-Za-z0-9]{12,}|nr_live_[A-Za-z0-9]{12,}|sk_live_[A-Za-z0-9]{12,}" + r"|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|AKIA[0-9A-Z]{16}" + r"|-----BEGIN [A-Z ]*PRIVATE KEY-----" + r"|(?i:(?:api[_-]?key|secret|password|token)\s*[:=]\s*['\"][A-Za-z0-9+/_\-]{16,}['\"])" + r"|postgres(?:ql)?(?:\+\w+)?://[^:\s/]+:[^@\s]+@)" +) + +DEFAULTS = { + "repository": None, + "project": None, + "entry": "CLAUDE.md", + "pointer": "AGENTS.md", + "review_days": 90, + "runtimes": ["claude_code", "codex_cli"], + "sub_entries": [], + "instruction_files": [], + "documents_index": "_shared/documents.md", + "decisions": [], + "document_id_prefix": "doc-", + "forms": "umbrella", +} + + +class Problem(Exception): + pass + + +def fail(message): + sys.exit(f"generate.py: {message}") + + +def load_config(): + if not CONFIG.is_file(): + fail( + f"{CONFIG.relative_to(ROOT).as_posix()} is missing; it names the repository and the project" + ) + cfg = dict(DEFAULTS) + cfg.update(json.loads(CONFIG.read_text(encoding="utf-8"))) + for key in ("repository", "project"): + if not cfg.get(key): + fail(f"config.json needs {key!r}") + return cfg + + +def read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def exists(rel): + return bool(PATHLIKE.match(rel)) and (ROOT / rel.rstrip("/")).exists() + + +def section(text, name, where): + match = re.search(rf"^## {name}\n(.*?)(?=^## |\Z)", text, re.MULTILINE | re.DOTALL) + if not match: + raise Problem(f"{where} has no '## {name}' section") + return match.group(1).strip() + + +def frontmatter(text): + match = FRONT.match(text) + if not match: + return {} + return dict(line.split(": ", 1) for line in match.group(1).splitlines() if ": " in line) + + +def node_id(path, cfg): + if path.startswith(ICM + "/"): + rel = path[len(ICM) + 1 :] + elif path in (cfg["entry"], cfg["pointer"]) or path in cfg["sub_entries"]: + rel = path + else: + rel = cfg["document_id_prefix"] + path + return re.sub(r"[^a-zA-Z0-9._-]", "-", rel).strip("-._")[:120] + + +def linked_files(path, cfg): + """Repository files a Markdown file links, relative to the root.""" + base = str(Path(path).parent) + found = set() + for link in LINK.findall(read(path)): + if "://" in link or link.startswith("#"): + continue + resolved = (ROOT / base / link.split("#", 1)[0]).resolve() + if resolved.is_file() and ROOT in resolved.parents: + rel = resolved.relative_to(ROOT).as_posix() + if not rel.startswith(ICM) and rel not in (cfg["entry"], cfg["pointer"]): + found.add(rel) + return found + + +def cited_files(path, cfg): + """Repository paths a fact file cites, outside the home: (files, directories). + + Backticked paths (a bare workflow file name is looked up in .github/workflows/, a + ``path:line`` suffix is ignored) and Markdown links count.""" + files, dirs = set(), set() + for span in CODE.findall(read(path)): + token = span.split()[0].removeprefix("./") if span.strip() else "" + token = LINE_SUFFIX.sub("", token) + for candidate in (token, f".github/workflows/{token}"): + if not PATHLIKE.match(candidate) or candidate.startswith(ICM) or candidate == ".git": + continue + if candidate in (cfg["entry"], cfg["pointer"]) or candidate in cfg["sub_entries"]: + continue # the entry routes; it owns no fact + target = ROOT / candidate.rstrip("/") + if target.is_file(): + files.add(candidate) + break + if target.is_dir() and "/" in candidate.rstrip("/"): + dirs.add(candidate.rstrip("/") + "/") + break + files |= linked_files(path, cfg) + return files, dirs + + +def without_code(text): + """``text`` with fenced blocks and code spans removed, for prose-only checks.""" + text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + return CODE.sub("", text) + + +# --- stage contracts -------------------------------------------------------------------- + + +def parse_contract(pipeline, folder): + contract = f"{pipeline}/stages/{folder}/CONTEXT.md" + text = read(contract) + title = text.splitlines()[0] + if " — " not in title: + raise Problem(f"{contract}: the title is '# {folder} — <the job>'") + stage = { + "contract": contract, + "job": title.split(" — ", 1)[1].strip(), + "working": [], + "labels": [], + "reference": [], + "optional": [], + "do_not_load": [], + } + for line in section(text, "Inputs", contract).splitlines(): + match = INPUT.match(line) + if match: + kind, when, value = match.groups() + path = CODE.match(value).group(1) if value.startswith("`") else None + if kind == "Working": + # A working input is an earlier stage's output; anything else named here + # (a repository file, a report) is the row's label, not a pinned input. + if path and "/output/" in path: + stage["working"].append(path) + stage["labels"].append(path) + else: + stage["labels"].append(value.split(" — ", 1)[0].strip()) + continue + if not path or not exists(path): + raise Problem( + f"{contract}: reference input {value!r} is not a file in the repository" + ) + stage["optional" if when.startswith("only") else "reference"].append(path) + elif line.startswith("Do NOT load:"): + stage["do_not_load"] = [p for p in CODE.findall(line) if exists(p)] + outputs = [ + m.group(1) for m in map(OUTPUT.match, section(text, "Outputs", contract).splitlines()) if m + ] + if len(outputs) != 1: + raise Problem(f"{contract}: '## Outputs' names {len(outputs)} files; a stage writes one") + stage["output"] = f"{pipeline}/stages/{folder}/output/{outputs[0]}" + check = " ".join(section(text, "Human check", contract).split()) + if not 1 <= len(check) <= 500: + raise Problem(f"{contract}: the human check is {len(check)} characters (1-500)") + stage["human_check"] = check + return stage + + +def input_cell(pipeline, stage): + cells = [] + for label in stage["labels"]: + prefix = f"{pipeline}/stages/" + if label.startswith(prefix) and "/output/" in label: + folder, name = label[len(prefix) :].split("/output/", 1) + cells.append(f"{folder}'s `{name}`") + else: + cells.append(label) + return " and ".join(cells) + + +def pipelines_of(): + return sorted( + f"{ICM}/{p.name}" + for p in HOME_DIR.iterdir() + if p.is_dir() and re.match(r"^\d\d-[a-z-]+$", p.name) and (p / "stages").is_dir() + ) + + +# --- build ----------------------------------------------------------------------------- + + +# Keys of the source file this script does not derive: written in a reviewed pull request +# and resolved by the Git sync (or, for packages, read as written). Carried over unchanged. +CARRIED_KEYS = ("repositories", "links", "workspaces", "packages") +# Keys only a knowledge (service) workspace carries, declared by a person in the dashboard. +# A Git sync does not resolve them and the compile fails on them, so the lint refuses them. +SERVICE_ONLY_KEYS = ("relationships", "inclusions") + + +def previous_source(): + if not (ROOT / SOURCE).is_file(): + return {} + try: + data = json.loads(read(SOURCE)) + except ValueError: + return {} + return data if isinstance(data, dict) else {} + + +def existing_nodes(): + data = previous_source() + return {n["id"]: n for n in data.get("nodes", []) if isinstance(n, dict) and "id" in n} + + +def build(cfg): + pipelines = pipelines_of() + stages, contracts, tables, in_place = {}, set(), {}, set() + for pipeline in pipelines: + prefix = pipeline.rsplit("/", 1)[1].split("-", 1)[1] + folders = sorted( + p.name + for p in (ROOT / pipeline / "stages").iterdir() + if p.is_dir() and re.match(r"^\d\d_[a-z-]+$", p.name) + ) + rows = [] + for order, folder in enumerate(folders, 1): + stage = parse_contract(pipeline, folder) + contracts.add(stage["contract"]) + others = [f"{p}/" for p in pipelines if p != pipeline] + in_place.update( + p for p in stage["reference"] + stage["optional"] if not p.startswith(ICM + "/") + ) + entry = { + "order": order, + "contract": stage["contract"], + "inputs": {"working": stage["working"], "reference": stage["reference"]}, + "do_not_load": others + [p for p in stage["do_not_load"] if p not in others], + "outputs": [stage["output"]], + "human_check": stage["human_check"], + "required": [node_id(stage["contract"], cfg)], + } + if stage["optional"]: + entry["optional"] = [node_id(p, cfg) for p in stage["optional"]] + stages[f"{prefix}-{folder.split('_', 1)[1]}"] = entry + rows.append( + f"| [`{folder}`](stages/{folder}/CONTEXT.md) | {stage['job']} " + f"| {input_cell(pipeline, stage)} | `output/{stage['output'].rsplit('/', 1)[1]}` " + f"| {stage['human_check']} |" + ) + path = f"{pipeline}/CONTEXT.md" + text = read(path) + if TABLE_HEADER not in text: + raise Problem(f"{path} has no stage table header") + start = text.index(TABLE_HEADER) + len(TABLE_HEADER) + end = text.find("\n\n", start) + tables[path] = text[:start] + "\n".join(rows) + (text[end:] if end != -1 else "\n") + + # Repository documents named in place: the entry's links, the documents index's links, + # stage references outside the home, and the other agents' instruction files. + in_place |= linked_files(cfg["entry"], cfg) + index = f"{ICM}/{cfg['documents_index']}" if cfg["documents_index"] else None + if index and (ROOT / index).is_file(): + in_place |= linked_files(index, cfg) + for pattern in cfg["instruction_files"]: + matches = [p for p in ROOT.glob(pattern) if p.is_file()] + if not matches: + raise Problem(f"instruction file pattern {pattern!r} matches nothing") + in_place.update(p.relative_to(ROOT).as_posix() for p in matches) + for sub in cfg["sub_entries"]: + if not (ROOT / sub).is_file(): + raise Problem(f"sub_entries names {sub!r}, which is not a file") + in_place -= set(cfg["sub_entries"]) + files = [cfg["entry"]] + if (ROOT / cfg["pointer"]).is_file(): + files.append(cfg["pointer"]) + files += [*cfg["sub_entries"], *sorted(in_place)] + files += sorted( + p.relative_to(ROOT).as_posix() + for p in HOME_DIR.rglob("*.md") + if "_system" not in p.parts and "output" not in p.parts + ) + + facts = {} + for path in files: + meta = frontmatter(read(path)) + if "verified_at" in meta: + facts[path] = meta + if not facts: + raise Problem("no fact file carries verified_at") + newest = max(date.fromisoformat(m["verified_at"]) for m in facts.values()) + previous = existing_nodes() + + nodes = [] + for path in files: + name = path.rsplit("/", 1)[-1] + if path in (cfg["entry"], cfg["pointer"]): + kind, layer = "route", "L0" + elif path in cfg["sub_entries"]: + kind, layer = "route", "L1" + elif path in contracts: + kind, layer = "stage", "L2" + elif path.startswith(ICM + "/") and name in ("CLAUDE.md", "AGENTS.md", "CONTEXT.md"): + kind, layer = "route", "L1" + elif any(fnmatch.fnmatch(path, g) for g in cfg["decisions"]): + kind, layer = "decision", "L3" + else: + kind, layer = "fact", "L3" + nid = node_id(path, cfg) + old = previous.get(nid, {}) + meta = facts.get(path, {}) + if meta: + verified = date.fromisoformat(meta["verified_at"]) + review_after = (verified + timedelta(days=cfg["review_days"])).isoformat() + elif old.get("verified_at"): + verified = date.fromisoformat(old["verified_at"]) + review_after = ( + old.get("review_after") + or (verified + timedelta(days=cfg["review_days"])).isoformat() + ) + else: + verified = newest + review_after = (verified + timedelta(days=cfg["review_days"])).isoformat() + node = { + "id": nid, + "revision": old.get("revision", 1), + "kind": kind, + "layer": layer, + "state": "accepted", + "project_id": cfg["project"], + "verified_at": verified.isoformat(), + "review_after": review_after, + "files": [path], + } + if meta.get("verified_commit"): + cited, _ = cited_files(path, cfg) + if not cited: + # A fact that cites only folders (or nothing the scan can see) keeps the + # evidence a person curated; a fact that cites files is authoritative, so a + # file it stops citing stops being watched and a split fact fits the cap. + cited = { + e["path"] + for e in old.get("evidence") or [] + if e["path"] != ".git" and (ROOT / e["path"]).is_file() + } + if not cited: + raise Problem(f"{path} names verified_commit but cites no repository file") + node["evidence"] = [ + {"repo": cfg["repository"], "path": p, "commit": meta["verified_commit"]} + for p in sorted(cited)[:MAX_EVIDENCE] + ] + elif old.get("evidence"): + node["evidence"] = old["evidence"] + if old.get("dependencies"): + node["dependencies"] = old["dependencies"] + if old.get("links"): + node["links"] = old["links"] + if old.get("revision") and old.get("files") == node["files"] and _changed(old, node): + node["revision"] = old["revision"] + 1 + nodes.append(node) + + forms = [{"form": "umbrella", "root": "", "pipelines": pipelines, "unit": "run"}] + forms += [{"form": "pipeline", "root": p, "unit": "run"} for p in pipelines] + source = { + "workspace_root": "", + "schema_version": 2, + "access": "workspace", + "entrypoint": cfg["entry"], + "runtimes": cfg["runtimes"], + "projects": [cfg["project"]], + "nodes": nodes, + "forms": forms, + "stages": stages, + } + carried = previous_source() + for key in CARRIED_KEYS: + if carried.get(key): + source[key] = carried[key] + written = {SOURCE: json.dumps(source, indent=2, ensure_ascii=False) + "\n", **tables} + return written, files, contracts + + +def _changed(old, new): + """Whether a node's dates or evidence moved; the revision then advances once.""" + for key in ("verified_at", "review_after"): + if old.get(key) != new.get(key): + return True + return (old.get("evidence") or []) != (new.get("evidence") or []) + + +# --- lint ------------------------------------------------------------------------------ + + +def git_has_commit(sha): + """True/False when the repository can answer, None when it cannot (no git, or a shallow + clone such as a CI checkout with fetch-depth 1, where an older commit is simply absent).""" + + def git(*args): + return subprocess.run( + ["git", "-C", str(ROOT), *args], capture_output=True, text=True, timeout=10, check=False + ) + + try: + shallow = git("rev-parse", "--is-shallow-repository") + if shallow.returncode != 0 or shallow.stdout.strip() == "true": + return None + return git("cat-file", "-e", f"{sha}^{{commit}}").returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return None + + +def lint(cfg, strict=False): + """Structural checks, any form: (errors, warnings).""" + errors, warnings = [], [] + today = datetime.now(timezone.utc).date() # noqa: UP017 + entry = cfg["entry"] + if not (ROOT / entry).is_file(): + return [f"entry {entry!r} is missing"], warnings + entry_lines = read(entry).count("\n") + 1 + if entry_lines > MAX_ENTRY_LINES: + errors.append( + f"{entry} has {entry_lines} lines; the entry routes and holds no payload (max {MAX_ENTRY_LINES})" + ) + elif entry_lines > ENTRY_TARGET_LINES: + warnings.append( + f"{entry} has {entry_lines} lines; the reference targets under {ENTRY_TARGET_LINES}" + ) + if "## Route by what just happened" not in read(entry): + errors.append(f"{entry} has no '## Route by what just happened' table") + + # Twins: every folder holding both CLAUDE.md and AGENTS.md. + folders = {} + for p in [ + ROOT / "CLAUDE.md", + ROOT / "AGENTS.md", + *HOME_DIR.rglob("*.md"), + *(ROOT / s for s in cfg["sub_entries"]), + *(ROOT / s for s in _twins_of(cfg["sub_entries"])), + ]: + if p.is_file() and p.name in ("CLAUDE.md", "AGENTS.md"): + folders.setdefault(p.parent, {})[p.name] = p.read_bytes() + for folder, twins in folders.items(): + if len(twins) == 2 and twins["CLAUDE.md"] != twins["AGENTS.md"]: + + def pointer(text, other): + lines = [l for l in text.decode("utf-8", "replace").splitlines() if l.strip()] + return len(lines) == 1 and other in lines[0] + + if not ( + pointer(twins["CLAUDE.md"], "AGENTS.md") or pointer(twins["AGENTS.md"], "CLAUDE.md") + ): + where = folder.relative_to(ROOT).as_posix() or "the root" + errors.append( + f"CLAUDE.md and AGENTS.md in {where} differ; make one a one-line pointer" + ) + + # Facts: dated, cited, not stale, evidence commit real. + home_md = sorted( + p for p in HOME_DIR.rglob("*.md") if "_system" not in p.parts and "output" not in p.parts + ) + seen_commits = {} + for p in home_md: + rel = p.relative_to(ROOT).as_posix() + text = p.read_text(encoding="utf-8") + meta = frontmatter(text) + is_fact = p.name not in ("CLAUDE.md", "AGENTS.md", "CONTEXT.md") and "/stages/" not in rel + if is_fact and "_shared" in p.parts: + if "verified_at" not in meta or "verified_commit" not in meta: + errors.append( + f"{rel}: a fact file carries verified_at and verified_commit in its frontmatter" + ) + else: + try: + verified = date.fromisoformat(meta["verified_at"]) + except ValueError: + errors.append(f"{rel}: verified_at {meta['verified_at']!r} is not a date") + verified = None + if verified and verified > today: + errors.append(f"{rel}: verified_at {verified} is in the future") + if verified and verified + timedelta(days=cfg["review_days"]) < today: + (errors if strict else warnings).append( + f"{rel}: past review (verified {verified}, review every {cfg['review_days']} days)" + ) + files_cited, dirs_cited = cited_files(rel, cfg) + if not files_cited and not dirs_cited: + errors.append( + f"{rel}: cites no repository path; a fact points at the file that owns it" + ) + is_index = cfg["documents_index"] and rel == f"{ICM}/{cfg['documents_index']}" + if len(files_cited) > MAX_EVIDENCE and not is_index: + # The documents index links every pinned document; each is a node of its + # own and drifts on its own, so the cap on the index's evidence is harmless. + warnings.append( + f"{rel}: cites {len(files_cited)} files; the compiler pins {MAX_EVIDENCE} per " + "node (the first alphabetically) and the rest are not watched for drift. " + "Split the fact." + ) + sha = meta["verified_commit"] + if not re.fullmatch(r"[0-9a-f]{40}", sha): + errors.append(f"{rel}: verified_commit {sha!r} is not a full 40-character sha") + elif sha not in seen_commits: + seen_commits[sha] = git_has_commit(sha) + if seen_commits.get(sha) is False: + errors.append( + f"{rel}: verified_commit {sha[:12]} is not a commit of this repository" + ) + if PLACEHOLDER.search(without_code(text)): + errors.append(f"{rel}: a placeholder ({{…}}) is a question, not an answer") + for m in SECRETS.finditer(text): + errors.append(f"{rel}: looks like a secret: {m.group(0)[:24]}…") + for rel in (entry, cfg["pointer"], *cfg["sub_entries"]): + if (ROOT / rel).is_file(): + text = read(rel) + if PLACEHOLDER.search(without_code(text)): + errors.append(f"{rel}: a placeholder ({{…}}) is left") + for m in SECRETS.finditer(text): + errors.append(f"{rel}: looks like a secret: {m.group(0)[:24]}…") + + declared = previous_source() + for key in SERVICE_ONLY_KEYS: + if declared.get(key): + errors.append( + f"{SOURCE} declares {key!r}; a Git workspace's sync does not resolve it (only " + "repositories, links and workspaces). Declare it on a knowledge workspace in the " + "dashboard instead." + ) + + # Umbrella shape: pipelines, contracts, the ignore rule. + pipelines = pipelines_of() + if pipelines: + ignore = HOME_DIR / ".gitignore" + if not ignore.is_file() or "*/stages/*/output/" not in ignore.read_text(encoding="utf-8"): + errors.append( + f"{ICM}/.gitignore must ignore */stages/*/output/ (stage outputs are run product)" + ) + for pipeline in pipelines: + for f in ("CLAUDE.md", "CONTEXT.md"): + if not (ROOT / pipeline / f).is_file(): + errors.append(f"{pipeline}/{f} is missing") + for folder in sorted( + p.name for p in (ROOT / pipeline / "stages").iterdir() if p.is_dir() + ): + if not re.match(r"^\d\d_[a-z-]+$", folder): + errors.append(f"{pipeline}/stages/{folder}: stage folders are NN_name") + continue + try: + parse_contract(pipeline, folder) + except Problem as exc: + errors.append(str(exc)) + return errors, warnings + + +def _twins_of(paths): + out = [] + for p in paths: + folder, name = p.rsplit("/", 1) if "/" in p else ("", p) + other = "AGENTS.md" if name == "CLAUDE.md" else "CLAUDE.md" + out.append(f"{folder}/{other}" if folder else other) + return out + + +# --- main ------------------------------------------------------------------------------ + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + for stream in ( + sys.stdout, + sys.stderr, + ): # the messages carry "{…}"; a cp1252 console must not crash + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") + check = "--check" in argv + strict = "--strict" in argv + lint_only = "lint" in argv + if "--version" in argv: + print(f"icm standard {STANDARD_VERSION}") + return 0 + cfg = load_config() + + errors, warnings = lint(cfg, strict=strict) + for w in warnings: + print(f"warn {w}") + for e in errors: + print(f"FAIL {e}") + if lint_only: + print("lint: " + ("clean" if not errors else f"{len(errors)} problem(s)")) + return 1 if errors else 0 + + if cfg["forms"] != "umbrella": + print(f"form {cfg['forms']!r}: lint only; this repository generates its source another way") + return 1 if errors else 0 + try: + written, _, _ = build(cfg) + except Problem as exc: + print(f"FAIL {exc}") + return 1 + stale = [] + for path, text in written.items(): + current = read(path) if (ROOT / path).exists() else None + if current != text: + stale.append(path) + if not check: + (ROOT / path).write_text(text, encoding="utf-8", newline="\n") + if check: + if stale: + print("FAIL out of date: " + ", ".join(stale) + " (run without --check)") + if stale or errors: + return 1 + print("up to date; lint clean") + return 0 + print("up to date" if not stale else "rewrote " + ", ".join(stale)) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/icm/icm.source.json b/.github/icm/icm.source.json new file mode 100644 index 0000000..e1c169c --- /dev/null +++ b/.github/icm/icm.source.json @@ -0,0 +1,551 @@ +{ + "workspace_root": "", + "schema_version": 2, + "access": "workspace", + "entrypoint": "AGENTS.md", + "runtimes": [ + "claude_code", + "codex_cli" + ], + "projects": [ + "alteriom-webhook-client-python" + ], + "nodes": [ + { + "id": "AGENTS.md", + "revision": 1, + "kind": "route", + "layer": "L0", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + "AGENTS.md" + ] + }, + { + "id": "CLAUDE.md", + "revision": 1, + "kind": "route", + "layer": "L0", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + "CLAUDE.md" + ] + }, + { + "id": "doc-README.md", + "revision": 1, + "kind": "fact", + "layer": "L3", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + "README.md" + ] + }, + { + "id": "01-fix-CLAUDE.md", + "revision": 1, + "kind": "route", + "layer": "L1", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/01-fix/CLAUDE.md" + ] + }, + { + "id": "01-fix-CONTEXT.md", + "revision": 1, + "kind": "route", + "layer": "L1", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/01-fix/CONTEXT.md" + ] + }, + { + "id": "01-fix-stages-01_reproduce-CONTEXT.md", + "revision": 1, + "kind": "stage", + "layer": "L2", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/01-fix/stages/01_reproduce/CONTEXT.md" + ] + }, + { + "id": "01-fix-stages-02_fix-CONTEXT.md", + "revision": 1, + "kind": "stage", + "layer": "L2", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/01-fix/stages/02_fix/CONTEXT.md" + ] + }, + { + "id": "01-fix-stages-03_verify-CONTEXT.md", + "revision": 1, + "kind": "stage", + "layer": "L2", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/01-fix/stages/03_verify/CONTEXT.md" + ] + }, + { + "id": "02-release-CLAUDE.md", + "revision": 1, + "kind": "route", + "layer": "L1", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/02-release/CLAUDE.md" + ] + }, + { + "id": "02-release-CONTEXT.md", + "revision": 1, + "kind": "route", + "layer": "L1", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/02-release/CONTEXT.md" + ] + }, + { + "id": "02-release-stages-01_plan-CONTEXT.md", + "revision": 1, + "kind": "stage", + "layer": "L2", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/02-release/stages/01_plan/CONTEXT.md" + ] + }, + { + "id": "02-release-stages-02_prepare-CONTEXT.md", + "revision": 1, + "kind": "stage", + "layer": "L2", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/02-release/stages/02_prepare/CONTEXT.md" + ] + }, + { + "id": "02-release-stages-03_publish-CONTEXT.md", + "revision": 1, + "kind": "stage", + "layer": "L2", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/02-release/stages/03_publish/CONTEXT.md" + ] + }, + { + "id": "shared-CONTEXT.md", + "revision": 1, + "kind": "route", + "layer": "L1", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/_shared/CONTEXT.md" + ] + }, + { + "id": "shared-architecture.md", + "revision": 1, + "kind": "fact", + "layer": "L3", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/_shared/architecture.md" + ], + "evidence": [ + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "pyproject.toml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/__init__.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/fastapi.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/models.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/receiver.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/signature.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + } + ] + }, + { + "id": "shared-build-and-test.md", + "revision": 1, + "kind": "fact", + "layer": "L3", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/_shared/build-and-test.md" + ], + "evidence": [ + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": ".github/workflows/ci.yml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": ".github/workflows/icm-check.yml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": ".github/workflows/publish.yml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "pyproject.toml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "tests/test_fastapi.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "tests/test_receiver.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "tests/test_signature.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + } + ] + }, + { + "id": "shared-documents.md", + "revision": 1, + "kind": "fact", + "layer": "L3", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/_shared/documents.md" + ], + "evidence": [ + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "README.md", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/receiver.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + } + ] + }, + { + "id": "shared-gotchas.md", + "revision": 1, + "kind": "fact", + "layer": "L3", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/_shared/gotchas.md" + ], + "evidence": [ + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": ".github/workflows/publish.yml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/fastapi.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/models.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/receiver.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/signature.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + } + ] + }, + { + "id": "shared-release.md", + "revision": 1, + "kind": "fact", + "layer": "L3", + "state": "accepted", + "project_id": "alteriom-webhook-client-python", + "verified_at": "2026-10-15", + "review_after": "2027-01-13", + "files": [ + ".github/icm/_shared/release.md" + ], + "evidence": [ + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": ".github/workflows/publish.yml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "pyproject.toml", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + }, + { + "repo": "Alteriom/alteriom-webhook-client-python", + "path": "src/alteriom_webhook_client/__init__.py", + "commit": "337bb90fc61fd822113654f550d491de4e76aa38" + } + ] + } + ], + "forms": [ + { + "form": "umbrella", + "root": "", + "pipelines": [ + ".github/icm/01-fix", + ".github/icm/02-release" + ], + "unit": "run" + }, + { + "form": "pipeline", + "root": ".github/icm/01-fix", + "unit": "run" + }, + { + "form": "pipeline", + "root": ".github/icm/02-release", + "unit": "run" + } + ], + "stages": { + "fix-reproduce": { + "order": 1, + "contract": ".github/icm/01-fix/stages/01_reproduce/CONTEXT.md", + "inputs": { + "working": [], + "reference": [ + ".github/icm/_shared/architecture.md", + ".github/icm/_shared/build-and-test.md", + ".github/icm/_shared/gotchas.md" + ] + }, + "do_not_load": [ + ".github/icm/02-release/", + ".github/icm/_shared/release.md" + ], + "outputs": [ + ".github/icm/01-fix/stages/01_reproduce/output/reproduction.md" + ], + "human_check": "A person runs the command or reads its output, confirms it fails for the reported reason, and decides whether a behaviour change is wanted before any fix is written.", + "required": [ + "01-fix-stages-01_reproduce-CONTEXT.md" + ] + }, + "fix-fix": { + "order": 2, + "contract": ".github/icm/01-fix/stages/02_fix/CONTEXT.md", + "inputs": { + "working": [ + ".github/icm/01-fix/stages/01_reproduce/output/reproduction.md" + ], + "reference": [ + ".github/icm/_shared/architecture.md", + ".github/icm/_shared/build-and-test.md" + ] + }, + "do_not_load": [ + ".github/icm/02-release/", + ".github/icm/_shared/release.md" + ], + "outputs": [ + ".github/icm/01-fix/stages/02_fix/output/change.md" + ], + "human_check": "A person reads the diff beside the reproduction: it removes the cause, no verification step was loosened or reordered, and any behaviour change is the one approved. Edit `change.md` if it is wrong; stage 03 reads it.", + "required": [ + "01-fix-stages-02_fix-CONTEXT.md" + ] + }, + "fix-verify": { + "order": 3, + "contract": ".github/icm/01-fix/stages/03_verify/CONTEXT.md", + "inputs": { + "working": [ + ".github/icm/01-fix/stages/02_fix/output/change.md" + ], + "reference": [ + ".github/icm/_shared/build-and-test.md" + ] + }, + "do_not_load": [ + ".github/icm/02-release/" + ], + "outputs": [ + ".github/icm/01-fix/stages/03_verify/output/pull-request.md" + ], + "human_check": "A person reads `pull-request.md` and its evidence, then says whether to open it. The reviewer, not the author, merges, once every check on the last commit is green.", + "required": [ + "01-fix-stages-03_verify-CONTEXT.md" + ], + "optional": [ + "doc-README.md" + ] + }, + "release-plan": { + "order": 1, + "contract": ".github/icm/02-release/stages/01_plan/CONTEXT.md", + "inputs": { + "working": [], + "reference": [ + ".github/icm/_shared/release.md", + ".github/icm/_shared/gotchas.md" + ] + }, + "do_not_load": [ + ".github/icm/01-fix/" + ], + "outputs": [ + ".github/icm/02-release/stages/01_plan/output/release-plan.md" + ], + "human_check": "A maintainer approves the version number and the notes. The number is permanent once uploaded: PyPI never takes the same version twice.", + "required": [ + "02-release-stages-01_plan-CONTEXT.md" + ] + }, + "release-prepare": { + "order": 2, + "contract": ".github/icm/02-release/stages/02_prepare/CONTEXT.md", + "inputs": { + "working": [ + ".github/icm/02-release/stages/01_plan/output/release-plan.md" + ], + "reference": [ + ".github/icm/_shared/release.md", + ".github/icm/_shared/build-and-test.md" + ] + }, + "do_not_load": [ + ".github/icm/01-fix/" + ], + "outputs": [ + ".github/icm/02-release/stages/02_prepare/output/release-pr.md" + ], + "human_check": "A maintainer reviews the bump with every check green, merges it, and then decides to publish: `gh release create vX.Y.Z --target <merge commit on main> --notes-file <notes>` publishes to PyPI.", + "required": [ + "02-release-stages-02_prepare-CONTEXT.md" + ] + }, + "release-publish": { + "order": 3, + "contract": ".github/icm/02-release/stages/03_publish/CONTEXT.md", + "inputs": { + "working": [ + ".github/icm/02-release/stages/02_prepare/output/release-pr.md" + ], + "reference": [ + ".github/icm/_shared/release.md" + ] + }, + "do_not_load": [ + ".github/icm/01-fix/" + ], + "outputs": [ + ".github/icm/02-release/stages/03_publish/output/publication.md" + ], + "human_check": "A person confirms the version is on PyPI and installs, built from `main`, before it is announced.", + "required": [ + "02-release-stages-03_publish-CONTEXT.md" + ], + "optional": [ + "shared-gotchas.md" + ] + } + } +} diff --git a/.github/workflows/icm-check.yml b/.github/workflows/icm-check.yml new file mode 100644 index 0000000..137e89b --- /dev/null +++ b/.github/workflows/icm-check.yml @@ -0,0 +1,53 @@ +# ICM check: the workspace files compile to the standard before a pull request merges, +# and once a week on the default branch so a fact past its review date fails loudly. +# +# Vendored unchanged from memoryrelay/api `app/icm/standard/icm-check.yml` into +# `.github/workflows/icm-check.yml`. Edit `HOME` below if the workspace lives at `icm/` +# instead of `.github/icm/`. Standard library Python only; no dependencies, no secrets. +name: ICM check + +on: + pull_request: + paths: + - "CLAUDE.md" + - "AGENTS.md" + - "**/CLAUDE.md" + - "**/AGENTS.md" + - ".github/icm/**" + - "icm/**" + - ".github/workflows/icm-check.yml" + push: + branches: [main, master, dev] + paths: + - "CLAUDE.md" + - "AGENTS.md" + - ".github/icm/**" + - "icm/**" + schedule: + - cron: "17 6 * * 1" # Mondays 06:17 UTC: facts past review fail the weekly run + workflow_dispatch: + +permissions: + contents: read + +env: + HOME_DIR: .github/icm + +jobs: + icm: + name: generated files up to date, lint clean + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0, pinned by commit + with: + fetch-depth: 0 # verified_commit values are checked against the repository's history + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0, pinned by commit + with: + python-version: "3.12" + - name: "Pull request and push: generated files current, lint clean" + if: github.event_name != 'schedule' + run: python "$HOME_DIR/_system/generate.py" --check + - name: "Weekly: the same, and no fact past its review date" + if: github.event_name == 'schedule' + run: python "$HOME_DIR/_system/generate.py" --check --strict diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..37b9359 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# alteriom-webhook-client-python + +`alteriom-webhook-client` on PyPI: the Python receiving side for deliveries from Alteriom/alteriom-webhook-connector. It verifies the HMAC-SHA256 signature and timestamp, checks size and duplicates, parses the payload into Pydantic v2 models, and offers a FastAPI dependency (MIT, Python 3.10+). It covers only the receiving side; the REST and stream clients are TypeScript only, in Alteriom/webhook-client. What leaves this repository is a reviewed pull request or a version on PyPI. + +## Where things live + +| Path | What it holds | +|---|---| +| `src/alteriom_webhook_client/` | signature, receiver, models, FastAPI helper — mapped in [architecture.md](.github/icm/_shared/architecture.md) | +| `tests/` | pytest, one file per module | +| `pyproject.toml` | the package, its version, dependencies and extras, the pytest settings | +| `.github/workflows/` | CI (tests on 3.10 to 3.12, build), publish to PyPI on a GitHub release | +| [README.md](README.md) | install and usage, the only user document | +| [`.github/icm/_shared/`](.github/icm/_shared/CONTEXT.md) | what every job below draws on, one file per question | +| [`.github/icm/01-fix/`](.github/icm/01-fix/CLAUDE.md) | fix a defect, or bring a model level with the deliveries | +| [`.github/icm/02-release/`](.github/icm/02-release/CLAUDE.md) | release a version to PyPI; a person publishes the GitHub release | + +## Route by what just happened + +| Route | If | Go to | Then stop at | +|---|---|---|---| +| triage | an issue arrives and its kind is unclear | read it whole (`gh issue view <n> --comments`) and take the row below that fits | the row chosen, said to the person | +| fix | a defect is reported (issue, failing test, a delivery verified or parsed wrongly) | [01_reproduce](.github/icm/01-fix/stages/01_reproduce/CONTEXT.md) | a person has seen the reproduction fail | +| ci-red | `CI` or `Publish to PyPI` is red | [01_reproduce](.github/icm/01-fix/stages/01_reproduce/CONTEXT.md), with the run log as the report | a person has seen the reproduction fail | +| model-drift | a delivery the service sends fails validation, or Alteriom/webhook-client changed its delivery types | [01_reproduce](.github/icm/01-fix/stages/01_reproduce/CONTEXT.md), with the payload or the change as the report | a person has seen the reproduction fail | +| security | a report may be a vulnerability in the verification | a private report to the maintainers through the repository's Security tab, never a public issue or pull request | the maintainer has the private report | +| release | a version is to be released | [01_plan](.github/icm/02-release/stages/01_plan/CONTEXT.md) | a person approves the version number | +| publish-check | a GitHub release was published and PyPI must be confirmed | [03_publish](.github/icm/02-release/stages/03_publish/CONTEXT.md), from step 2 | a person has seen the version on PyPI | +| next | a stage's output was approved | the next numbered stage of the same job | a person reads that stage's output | +| status | asked for status | scan `.github/icm/*/stages/*/output/` | report what exists | + +## Agents connecting remotely + +This repository is to be the ICM workspace `alteriom-webhook-client-python` on MemoryRelay. A person creates it from this repository's `main`; the Alteriom hub then delegates to it. Until it exists, and on any clone, an agent needs nothing: this file is the entry, the table above is the route, and the files it links are read directly. + +- Once the workspace exists, an agent that is not on a clone reaches the same files over MCP at `https://api.memoryrelay.net/mcp` and starts with `icm_context_build` for the route that fits (`{kind: "route", id: "fix"}`), not with the whole repository. +- Report what you read with `icm_report_reads` when a stage ends, and what the task came to with `icm_report_outcome`. +- Never write through MCP: a change is a branch and a pull request here, read and merged by a person. + +## Rules + +- Nothing moves to the next stage until a person has read the output of the last one. +- A stage writes to its own `output/`, which is not committed: one run per worktree or branch, and clear that job's `stages/*/output/` when its pull request merges or is abandoned. +- A job reads only its own run's output; moving to another job is a route above, started by the person. +- No agent publishes: a published GitHub release uploads to PyPI, and a person publishes it. +- This repository is public, and the connector is private: no host, secret or connector internals go into a file here. +- `.github/icm/` points at the files that own each fact. When they disagree, the owning file is right: correct `.github/icm/` in the same pull request, and bump its `verified_commit`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e58e0c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Read [AGENTS.md](AGENTS.md): it is the entry for every agent on this repository.