foundry-implementation-actor 0.2.1__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. foundry_implementation_actor-0.5.1/.github/workflows/ci.yml +132 -0
  2. foundry_implementation_actor-0.5.1/.github/workflows/release.yml +210 -0
  3. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/.gitignore +1 -0
  4. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/CLAUDE.md +14 -4
  5. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/PKG-INFO +133 -20
  6. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/README.md +129 -19
  7. foundry_implementation_actor-0.5.1/adr/ADR-FIA-0002-testing-leaves-this-actors-contract.md +104 -0
  8. foundry_implementation_actor-0.5.1/adr/ADR-FIA-0003-the-dev-test-agreement-has-no-home-yet.md +117 -0
  9. foundry_implementation_actor-0.5.1/adr/ADR-FIA-0004-the-three-amigos-round.md +141 -0
  10. foundry_implementation_actor-0.5.1/adr/ADR-FIA-0005-the-actor-ships-an-image.md +133 -0
  11. foundry_implementation_actor-0.5.1/adr/ADR-FIA-0006-the-image-is-published-to-two-registries.md +104 -0
  12. foundry_implementation_actor-0.5.1/adr/README.md +24 -0
  13. foundry_implementation_actor-0.5.1/docker/Dockerfile +105 -0
  14. foundry_implementation_actor-0.5.1/examples/ACME.PARTS.CAP.SUP.007.WID-implementation/Dockerfile +46 -0
  15. foundry_implementation_actor-0.5.1/examples/ACME.PARTS.CAP.SUP.007.WID-implementation/actor-agentic-context.yaml +44 -0
  16. foundry_implementation_actor-0.5.1/examples/README.md +269 -0
  17. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/pyproject.toml +9 -1
  18. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/__init__.py +13 -2
  19. {foundry_implementation_actor-0.2.1/tests/fixtures/valid → foundry_implementation_actor-0.5.1/src/foundry_implementation_actor/cards}/actor-data.yaml +26 -0
  20. foundry_implementation_actor-0.5.1/src/foundry_implementation_actor/cards/actor-message.yaml +32 -0
  21. foundry_implementation_actor-0.5.1/src/foundry_implementation_actor/cards/actor-synchronous-messaging.yaml +56 -0
  22. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/cli.py +63 -6
  23. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/config.py +18 -7
  24. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/conformance.py +28 -14
  25. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/engine.py +273 -17
  26. foundry_implementation_actor-0.5.1/src/foundry_implementation_actor/instance.py +129 -0
  27. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/schemas/agentic-context.schema.yaml +16 -9
  28. foundry_implementation_actor-0.5.1/src/foundry_implementation_actor/serve.py +129 -0
  29. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/conftest.py +2 -4
  30. foundry_implementation_actor-0.5.1/tests/test_cards.py +96 -0
  31. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/test_config.py +18 -4
  32. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/test_conformance.py +61 -8
  33. foundry_implementation_actor-0.5.1/tests/test_engine.py +385 -0
  34. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/test_handler.py +1 -2
  35. foundry_implementation_actor-0.5.1/tests/test_instance.py +136 -0
  36. foundry_implementation_actor-0.5.1/uv.lock +396 -0
  37. foundry_implementation_actor-0.2.1/.github/workflows/ci.yml +0 -58
  38. foundry_implementation_actor-0.2.1/.github/workflows/release.yml +0 -49
  39. foundry_implementation_actor-0.2.1/adr/README.md +0 -19
  40. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/cards/actor-data.yaml +0 -38
  41. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/cards/actor-message.yaml +0 -17
  42. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/cards/actor-synchronous-messaging.yaml +0 -21
  43. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor-agentic-context.yaml +0 -28
  44. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor-message.yaml +0 -17
  45. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor-synchronous-messaging.yaml +0 -21
  46. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor.yaml +0 -8
  47. foundry_implementation_actor-0.2.1/tests/test_cards.py +0 -49
  48. foundry_implementation_actor-0.2.1/tests/test_engine.py +0 -144
  49. foundry_implementation_actor-0.2.1/uv.lock +0 -191
  50. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/adr/ADR-FIA-0001-the-machinery-leaves-the-capability.md +0 -0
  51. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/adr/template.md +0 -0
  52. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/scripts/probe_grounding.py +0 -0
  53. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/cards/actor.yaml +0 -0
  54. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/correlation.py +0 -0
  55. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/grounding.py +0 -0
  56. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/handler.py +0 -0
  57. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/fixtures/broken/actor-agentic-context.yaml +0 -0
  58. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/test_cli.py +0 -0
  59. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/test_grounding.py +0 -0
  60. {foundry_implementation_actor-0.2.1 → foundry_implementation_actor-0.5.1}/tests/test_portability.py +0 -0
@@ -0,0 +1,132 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+
4
+ # NO CREDENTIAL. Nothing here authenticates to anything: no lab repo, no knowledge registry, no
5
+ # PyPI upload, no image push. Everything this pipeline asserts about THIS package, it asserts from
6
+ # source and from artifacts it built itself — the wheel it just built, and an image built from
7
+ # that wheel rather than from a published one.
8
+ #
9
+ # It does reach the network for inputs: public base images and the npm package the engine drives.
10
+ # That is fetching, not authenticating, and the distinction is the one that matters here — no
11
+ # secret is available to this workflow, so nothing it runs can reach a real capability's repo.
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: astral-sh/setup-uv@v5
19
+
20
+ - name: the test suite
21
+ run: uv run --extra dev pytest -q
22
+
23
+ - name: build
24
+ run: uv build
25
+
26
+ # ── the two gates a contract-shipping package runs against its own artifact ────────────
27
+ #
28
+ # The suite above proves the code works in a source checkout. Neither of these does — they
29
+ # prove the WHEEL works, which is a different claim and the one a consumer actually depends
30
+ # on. A schema left out of the build passes every test in tests/ and fails here.
31
+
32
+ - name: the wheel must carry its contract
33
+ run: |
34
+ uv venv /tmp/probe
35
+ uv pip install --python /tmp/probe/bin/python -q dist/*.whl
36
+ /tmp/probe/bin/foundry-implementation-actor lint examples/ACME.PARTS.CAP.SUP.007.WID-implementation
37
+ /tmp/probe/bin/foundry-implementation-actor show examples/ACME.PARTS.CAP.SUP.007.WID-implementation \
38
+ --registry reg.example.com | tee /tmp/show.txt
39
+ grep -q 'reg.example.com/acme.parts/sup.007.wid/backend:<version>' /tmp/show.txt
40
+
41
+ - name: the wheel must carry the actor's own cards
42
+ # The `-actor` suffix asserts a papeete-actor underneath, which lint-card can check
43
+ # (ADR-ECO-0022). The suite proves the cards are conformant in a source checkout; this
44
+ # proves they SHIPPED. A card folder left out of the build passes every test in tests/ and
45
+ # makes the name a claim the artifact cannot honour. `papeete-actor-synchronous-messaging`
46
+ # is already a runtime dependency, so its gate is in the probe venv with nothing to add.
47
+ run: |
48
+ /tmp/probe/bin/papeete-actor-synchronous-messaging lint-card \
49
+ "$(/tmp/probe/bin/python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
50
+
51
+ - name: the gate must run
52
+ # A gate that cannot fail is not a gate. Lint a deliberately non-conformant sidecar with
53
+ # the INSTALLED wheel and require a non-zero exit.
54
+ run: |
55
+ if /tmp/probe/bin/foundry-implementation-actor lint tests/fixtures/broken; then
56
+ echo "::error::lint accepted a non-conformant sidecar — the gate is not running"
57
+ exit 1
58
+ fi
59
+ echo "the gate refused a non-conformant sidecar, as it should"
60
+
61
+ - name: grounding renders a CLAUDE.md whose imports resolve
62
+ # The property the whole design rests on: an @-import naming a file that is not there is a
63
+ # session grounded in nothing, and it looks identical to one grounded correctly.
64
+ run: /tmp/probe/bin/python scripts/probe_grounding.py examples/ACME.PARTS.CAP.SUP.007.WID-implementation
65
+
66
+ # ── the other half of what this package ships ─────────────────────────────────────────────────
67
+ #
68
+ # ADR-FIA-0005: a use is a sidecar, and the image is what turns one into a running actor. The
69
+ # job above proves the WHEEL works. This one proves the claim a consumer actually depends on —
70
+ # that a folder containing one YAML file builds into an actor that boots and answers its doors.
71
+ # Nothing here reaches a registry: the image is built, run and thrown away in the runner.
72
+ image:
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+ - uses: astral-sh/setup-uv@v5
77
+
78
+ - name: build the wheel this image will carry
79
+ # The image installs the wheel from dist/ rather than from PyPI, so that an image tagged
80
+ # <version> cannot contain some other build of <version> — see docker/Dockerfile.
81
+ run: uv build
82
+
83
+ - name: the base image
84
+ run: docker build -f docker/Dockerfile -t foundry-implementation-actor:ci .
85
+
86
+ - name: the image and the wheel are one release
87
+ # Two artifacts, one version. An image whose wheel says something else is the failure this
88
+ # arrangement creates, so it is the one CI asserts.
89
+ run: |
90
+ wheel="$(ls dist/*.whl | sed -E 's/.*-([0-9][^-]*)-py3.*/\1/')"
91
+ in_image="$(docker run --rm --entrypoint python foundry-implementation-actor:ci \
92
+ -c 'from foundry_implementation_actor import version; print(version())')"
93
+ echo "wheel=$wheel image=$in_image"
94
+ [ "$wheel" = "$in_image" ] || { echo "::error::image carries $in_image, wheel is $wheel"; exit 1; }
95
+
96
+ - name: a use is a sidecar
97
+ # The example folder holds exactly one file besides its Dockerfile. If this builds, the
98
+ # claim in the README is true of a real folder rather than of prose.
99
+ run: |
100
+ test "$(ls examples/ACME.PARTS.CAP.SUP.007.WID-implementation | grep -cv '^Dockerfile$')" = "1"
101
+ docker build --build-arg ACTOR_IMAGE=foundry-implementation-actor:ci \
102
+ -t use:ci examples/ACME.PARTS.CAP.SUP.007.WID-implementation
103
+
104
+ - name: the rendered cards are what a caller is validated against
105
+ # Rendering is the construction that replaces the hand copy conformance.check was built to
106
+ # police. Assert it inside the image, against the cards the image actually runs.
107
+ run: |
108
+ docker run --rm use:ci foundry-implementation-actor lint /actor
109
+
110
+ - name: it boots, and answers the doors its cards declare
111
+ # The credentials are deliberately junk. The engine refuses to construct without a
112
+ # GITHUB_TOKEN — which is itself worth having, and is why they are set rather than omitted
113
+ # — and no door is actually exercised here: a 400 means routed and refused the payload, a
114
+ # 404 would mean the route was never built.
115
+ run: |
116
+ docker run -d --name smoke -p 18080:8080 \
117
+ -e GITHUB_TOKEN=ci -e CLAUDE_CODE_OAUTH_TOKEN=ci use:ci
118
+ for _ in $(seq 1 30); do
119
+ curl -fsS -m 2 http://localhost:18080/health >/dev/null 2>&1 && break
120
+ sleep 1
121
+ done
122
+ curl -fsS -m 5 http://localhost:18080/health | grep -q ok
123
+ for door in implement-task assess-task; do
124
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -m 5 -X POST \
125
+ -H 'content-type: application/json' -d '{}' "http://localhost:18080/$door")"
126
+ [ "$code" = "400" ] || { echo "::error::POST /$door answered $code, expected 400 (routed, bad payload)"; exit 1; }
127
+ done
128
+ code="$(curl -sS -o /dev/null -w '%{http_code}' -m 5 -X POST \
129
+ -H 'content-type: application/json' -d '{}' http://localhost:18080/no-such-door)"
130
+ [ "$code" = "404" ] || { echo "::error::an undeclared door answered $code, expected 404"; exit 1; }
131
+ docker logs smoke | grep -q actor-started
132
+ docker rm -f smoke
@@ -0,0 +1,210 @@
1
+ name: release
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ # A REGISTRY ADDED AFTER A TAG WAS ALREADY CUT. The image job below publishes to two registries,
7
+ # and the second one was not always there. Running this workflow by hand backfills the image for
8
+ # a version whose wheel is already on PyPI — without inventing a version number whose only
9
+ # content is "the last release missed a registry".
10
+ #
11
+ # THE TAG IS AN INPUT, NOT THE REF THIS RUNS FROM. GitHub takes the workflow FILE from the ref it
12
+ # is dispatched on, and the tag needing a backfill is by definition older than the workflow that
13
+ # can do it — dispatching on that tag would run the very file that lacks this trigger. So run it
14
+ # from the default branch and name the tag here; the checkout below takes the CONTENT from the
15
+ # tag, and the version gate still refuses a tag that disagrees with the `pyproject.toml` beside
16
+ # it.
17
+ workflow_dispatch:
18
+ inputs:
19
+ tag:
20
+ description: "Existing tag to publish an image for, e.g. v0.5.0"
21
+ required: true
22
+
23
+ # PyPI Trusted Publishing (OIDC) — no token is stored anywhere. The one-time setup is a pending
24
+ # publisher on pypi.org naming this repo and this workflow; after the first release it becomes a
25
+ # normal publisher. See README.
26
+ permissions:
27
+ id-token: write # PyPI Trusted Publishing
28
+ contents: read
29
+ packages: write # the image, to this repo's own container registry
30
+
31
+ jobs:
32
+ publish:
33
+ # ONLY ON A TAG PUSH. A manual run exists to backfill an IMAGE (see `on:` above), and PyPI
34
+ # refuses a version it already holds — so re-uploading is not merely redundant, it fails, and
35
+ # would take the image job down with it.
36
+ if: github.event_name == 'push'
37
+ runs-on: ubuntu-latest
38
+ environment: pypi
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: astral-sh/setup-uv@v5
42
+
43
+ - name: build
44
+ # No fetch step and no external token: the contract is committed here. A release depends
45
+ # on nothing but this checkout and PyPI.
46
+ run: uv build
47
+
48
+ - name: the wheel must carry its contract
49
+ run: |
50
+ uv venv /tmp/probe
51
+ uv pip install --python /tmp/probe/bin/python -q dist/*.whl
52
+ /tmp/probe/bin/foundry-implementation-actor lint examples/ACME.PARTS.CAP.SUP.007.WID-implementation
53
+
54
+ - name: the wheel must carry the actor's own cards
55
+ # Same reason as the line above, for the other half of what this package ships. The
56
+ # `-actor` suffix asserts a papeete-actor underneath (ADR-ECO-0022); a wheel published
57
+ # without its cards makes the name a claim the artifact cannot honour, and a published
58
+ # version is not something to discover that from.
59
+ run: |
60
+ /tmp/probe/bin/papeete-actor-synchronous-messaging lint-card \
61
+ "$(/tmp/probe/bin/python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
62
+
63
+ - name: grounding renders a CLAUDE.md whose imports resolve
64
+ # BEFORE the upload, not after. A wheel that ships without its schema, or whose grounding
65
+ # emits an @-import to a file it never wrote, produces sessions grounded in nothing that
66
+ # look identical to correct ones. That is not something to discover from a published
67
+ # version.
68
+ run: /tmp/probe/bin/python scripts/probe_grounding.py examples/ACME.PARTS.CAP.SUP.007.WID-implementation
69
+
70
+ - name: publish to PyPI
71
+ run: uv publish --trusted-publishing always
72
+
73
+ # ── the image, from the same tag and the same checkout ────────────────────────────────────────
74
+ #
75
+ # TWO ARTIFACTS, ONE RELEASE (ADR-FIA-0005). A use pins an image; an embedder pins a wheel; both
76
+ # are this package at this version. A tag that produced one and not the other leaves a consumer
77
+ # unable to take the release at all — so this job builds the wheel again from the same checkout
78
+ # rather than installing the one just uploaded: the image must not depend on PyPI having already
79
+ # indexed it, and must not be able to pick up a different build of the same version.
80
+ #
81
+ # It runs AFTER the wheel is published, not beside it. A failed upload should not leave an image
82
+ # published for a version that does not exist on PyPI; the reverse — a wheel with the image still
83
+ # to come — is recoverable by re-running this job alone.
84
+ image:
85
+ needs: publish
86
+ # `needs` still orders this after the wheel on a tag push. On a manual backfill `publish` is
87
+ # skipped, and a skipped dependency skips its dependents too unless `always()` says otherwise —
88
+ # so the condition has to name both cases rather than relying on `needs` alone.
89
+ if: always() && (needs.publish.result == 'success' || github.event_name == 'workflow_dispatch')
90
+ runs-on: ubuntu-latest
91
+ steps:
92
+ - uses: actions/checkout@v4
93
+ with:
94
+ # Empty on a tag push, which is `checkout`'s own default — the ref that triggered the
95
+ # run. On a manual backfill it is the tag being republished, so every step below reads
96
+ # that tag's own tree rather than whatever the default branch has moved on to.
97
+ ref: ${{ inputs.tag }}
98
+
99
+ - uses: astral-sh/setup-uv@v5
100
+
101
+ - name: the version is the tag
102
+ # The tag is what a consumer writes in a FROM line. A tag that disagrees with
103
+ # pyproject.toml would publish an image whose own `version()` contradicts its name, which
104
+ # is exactly the confusion the CI gate above exists to prevent for the wheel. On a backfill
105
+ # this compares the named tag against the `pyproject.toml` checked out FROM that tag, so a
106
+ # typo in the input is refused rather than published under the wrong name.
107
+ id: version
108
+ env:
109
+ TAG: ${{ inputs.tag || github.ref_name }}
110
+ run: |
111
+ tag="${TAG#v}"
112
+ declared="$(uv run --quiet python -c 'import tomllib,pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
113
+ [ "$tag" = "$declared" ] || { echo "::error::tag $TAG does not match pyproject version $declared"; exit 1; }
114
+ echo "version=$tag" >> "$GITHUB_OUTPUT"
115
+
116
+ - name: build
117
+ run: uv build
118
+
119
+ - uses: docker/login-action@v3
120
+ with:
121
+ registry: ghcr.io
122
+ username: ${{ github.actor }}
123
+ password: ${{ secrets.GITHUB_TOKEN }}
124
+
125
+ - name: the image, tagged and latest
126
+ env:
127
+ VERSION: ${{ steps.version.outputs.version }}
128
+ # `latest` FOLLOWS THE RELEASE STREAM, NOT A BACKFILL. A manual run exists to give an
129
+ # already-released version an image in a registry it missed; run against an older tag it
130
+ # would otherwise drag `latest` backwards onto it, silently, in whichever registry a
131
+ # consumer happens to pull from. The version tag is the whole point of a backfill —
132
+ # `latest` already names something newer, and stays there.
133
+ MOVE_LATEST: ${{ github.event_name == 'push' }}
134
+ run: |
135
+ image="ghcr.io/${{ github.repository }}"
136
+ docker build -f docker/Dockerfile -t "$image:$VERSION" -t "$image:latest" .
137
+ docker push "$image:$VERSION"
138
+ if [ "$MOVE_LATEST" = "true" ]; then docker push "$image:latest"; fi
139
+
140
+ # ── the product's own registry ────────────────────────────────────────────────────────────
141
+ #
142
+ # TWO REGISTRIES, ONE IMAGE. GHCR is where this package publishes; a product that RUNS uses
143
+ # of it pulls from its own registry, which its cluster already authenticates to and which a
144
+ # `FROM` line inside that cluster's builder can reach without a second credential. The base
145
+ # image has to exist in both, because the two are reached by different things: an embedder
146
+ # writing a Dockerfile reads the README and pins GHCR, while a use built by an in-cluster
147
+ # builder resolves its `FROM` against the registry that builder has a push token for.
148
+ #
149
+ # THE SAME IMAGE, NOT ANOTHER BUILD. `docker tag` re-points the build the step above already
150
+ # pushed, so both registries hold one digest. Building a second time would let them diverge —
151
+ # a base-image refresh between two steps of one job is enough — and nothing downstream would
152
+ # ever notice that `:0.5.0` meant two different things depending on where you pulled it.
153
+ #
154
+ # ONE VARIABLE NAMES THE WHOLE TARGET. `vars.PRODUCT_IMAGE` is the full repository path, e.g.
155
+ # `<name>.azurecr.io/<product>/foundry-implementation-actor`; the login server is its first
156
+ # segment. This package is generic across capabilities and names no product of its own — the
157
+ # same rule `tests/test_portability.py` enforces over `src/` — so the product that hosts a
158
+ # registry names itself in a repository variable rather than in this file.
159
+ #
160
+ # UNSET IS A VALID STATE, AND IT SAYS SO. A fork, or this repo before its credentials were
161
+ # added, publishes to GHCR alone; that is a `::notice`, not a failure. Configured-but-broken
162
+ # IS a failure — the push below is not best-effort, because an image the product's cluster
163
+ # cannot pull is the whole reason this block exists.
164
+ - name: is a product registry configured?
165
+ id: product
166
+ env:
167
+ PRODUCT_IMAGE: ${{ vars.PRODUCT_IMAGE }}
168
+ run: |
169
+ if [ -z "$PRODUCT_IMAGE" ]; then
170
+ echo "::notice::PRODUCT_IMAGE is unset — publishing to GHCR only"
171
+ echo "configured=false" >> "$GITHUB_OUTPUT"
172
+ exit 0
173
+ fi
174
+ case "$PRODUCT_IMAGE" in
175
+ */*/*) : ;;
176
+ *) echo "::error::PRODUCT_IMAGE must be <registry>/<path>/<repository>, got $PRODUCT_IMAGE"; exit 1 ;;
177
+ esac
178
+ echo "server=${PRODUCT_IMAGE%%/*}" >> "$GITHUB_OUTPUT"
179
+ echo "configured=true" >> "$GITHUB_OUTPUT"
180
+
181
+ - uses: docker/login-action@v3
182
+ if: steps.product.outputs.configured == 'true'
183
+ with:
184
+ registry: ${{ steps.product.outputs.server }}
185
+ username: ${{ secrets.ACR_PUSH_USERNAME }}
186
+ password: ${{ secrets.ACR_PUSH_PASSWORD }}
187
+
188
+ - name: the same image, in the product's registry
189
+ if: steps.product.outputs.configured == 'true'
190
+ env:
191
+ PRODUCT_IMAGE: ${{ vars.PRODUCT_IMAGE }}
192
+ VERSION: ${{ steps.version.outputs.version }}
193
+ MOVE_LATEST: ${{ github.event_name == 'push' }}
194
+ run: |
195
+ ghcr="ghcr.io/${{ github.repository }}"
196
+ docker tag "$ghcr:$VERSION" "$PRODUCT_IMAGE:$VERSION"
197
+ docker push "$PRODUCT_IMAGE:$VERSION"
198
+ if [ "$MOVE_LATEST" = "true" ]; then
199
+ docker tag "$ghcr:latest" "$PRODUCT_IMAGE:latest"
200
+ docker push "$PRODUCT_IMAGE:latest"
201
+ fi
202
+
203
+ - name: a use built on it is still an actor
204
+ # BEFORE the image is something anyone can pin — the same reason the grounding probe runs
205
+ # before the upload above. An image that builds but produces a use that does not conform is
206
+ # not something to discover from a published tag.
207
+ run: |
208
+ docker build --build-arg ACTOR_IMAGE=ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} \
209
+ -t use:release examples/ACME.PARTS.CAP.SUP.007.WID-implementation
210
+ docker run --rm use:release foundry-implementation-actor lint /actor
@@ -3,3 +3,4 @@ dist/
3
3
  __pycache__/
4
4
  .venv/
5
5
  .pytest_cache/
6
+ .serena/
@@ -88,6 +88,11 @@ Beside them, two folders of committed contract, both shipped in the wheel:
88
88
  - **The clone is full, never `--depth 1`.** `papeete_version.compute()` runs `git describe --tags`
89
89
  against it and needs the matching tag's commit reachable.
90
90
  - **The generated `CLAUDE.md` appends** to one the repo already commits. Never overwrite.
91
+ - **The image is one build in two registries.** GHCR is where this package publishes; a product's
92
+ own registry is where the in-cluster builder that resolves a use's `FROM` line can actually
93
+ authenticate (ADR-FIA-0006). `release.yml` `docker tag`s one build into both so they hold one
94
+ digest — never add a second `docker build`, and never hardcode a registry: the product names
95
+ itself in `vars.PRODUCT_IMAGE`, the same reason `src/` names no capability.
91
96
  - **Never set `ANTHROPIC_API_KEY` in a container running this.** In `claude -p` non-interactive
92
97
  mode an API key in the environment is always preferred over `CLAUDE_CODE_OAUTH_TOKEN`, silently
93
98
  routing every session through metered billing. There is no warning; the only symptom is the bill.
@@ -104,7 +109,12 @@ similar weight rather than only writing it into code comments.
104
109
  ## Releasing
105
110
 
106
111
  Tag-triggered (`v*`) via `.github/workflows/release.yml`, publishing to PyPI through Trusted
107
- Publishing (OIDC) — no stored token. The release job builds the wheel, installs it into a throwaway
108
- venv, and renders a `CLAUDE.md` from a fixture sidecar, asserting its `@`-imports resolve, before
109
- publishing. `ci.yml` runs the suite plus two gates — *the wheel must carry its contract* and *the
110
- gate must run* — and reaches nothing outside its own checkout.
112
+ Publishing (OIDC) — no stored token. The image goes to GHCR and, when `vars.PRODUCT_IMAGE` names
113
+ one, to a product's own registry as well (ADR-FIA-0006). The workflow also accepts a manual run
114
+ that takes a tag as an INPUT — dispatched from the default branch, because the file comes from the
115
+ dispatched ref and a tag needing a backfill predates the workflow that can do it — to give an
116
+ already-released version an image in a registry it missed; that run skips PyPI and does not move
117
+ `latest`. The release job builds the wheel, installs it into
118
+ a throwaway venv, and renders a `CLAUDE.md` from a fixture sidecar, asserting its `@`-imports
119
+ resolve, before publishing. `ci.yml` runs the suite plus two gates — *the wheel must carry its
120
+ contract* and *the gate must run* — and reaches nothing outside its own checkout.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: foundry-implementation-actor
3
- Version: 0.2.1
3
+ Version: 0.5.1
4
4
  Summary: Runs a headless Claude Code implementation session against one capability's own repo — a papeete-actor for one use, with the capability supplied by a sidecar.
5
5
  Project-URL: Homepage, https://github.com/papeete-hub/foundry-implementation-actor
6
6
  Author-email: Papeete Consulting <yoann.remy@outlook.com>
@@ -16,6 +16,9 @@ Requires-Dist: papeete-version>=0.1.0
16
16
  Requires-Dist: pyyaml>=6.0
17
17
  Provides-Extra: dev
18
18
  Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Provides-Extra: serve
20
+ Requires-Dist: papeete-actor-synchronous-messaging-http>=0.4.0; extra == 'serve'
21
+ Requires-Dist: papeete-observability>=0.1.0; extra == 'serve'
19
22
  Description-Content-Type: text/markdown
20
23
 
21
24
  # foundry-implementation-actor
@@ -32,6 +35,10 @@ features *of* the framework (`ADR-ECO-0022`).
32
35
  pip install foundry-implementation-actor
33
36
  ```
34
37
 
38
+ > **New here?** [`examples/`](examples/) walks through a complete, working use of this actor —
39
+ > what the workflow is, what it is for, and how to instantiate one for your own capability. Every
40
+ > command in it runs with no credentials and no network.
41
+
35
42
  ## What it is
36
43
 
37
44
  The actor's **definition** — its four cards, and the machinery behind them. It carries **no
@@ -45,8 +52,8 @@ capability: ACME.PARTS.CAP.SUP.007.WID
45
52
  source_repo: acme-lab/ACME.PARTS.CAP.SUP.007.WID-implementation
46
53
  registry_repo: acme-lab/acme-governance
47
54
  components:
48
- - {name: backend, path: backend/, tests: backend/tests/, dockerfile: backend/deployment/local}
49
- - {name: stub, path: stub/, tests: stub/tests/, dockerfile: stub/deployment/local}
55
+ - {name: backend, path: backend/, dockerfile: backend/deployment/local}
56
+ - {name: stub, path: stub/, dockerfile: stub/deployment/local}
50
57
  ground_in:
51
58
  - name: business
52
59
  answers: the WHAT/WHY — domain vision, business events, ubiquitous language
@@ -60,7 +67,27 @@ ground_in:
60
67
  load: on-demand
61
68
  ```
62
69
 
63
- and four lines in the consuming entrypoint:
70
+ and, beside it, a four-line Dockerfile:
71
+
72
+ ```dockerfile
73
+ FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.1
74
+ RUN pip install --no-cache-dir kpack==2.0.1 kontract==0.1.0 # what this sidecar's ground_in names
75
+ COPY actor-agentic-context.yaml /actor/
76
+ RUN foundry-implementation-actor render-cards /actor && foundry-implementation-actor lint /actor
77
+ ```
78
+
79
+ **That is the whole repository.** No cards, no entrypoint, no Python. The cards are rendered from
80
+ the definition in the image, the entrypoint is `foundry-implementation-actor serve`, and the only
81
+ hand-written line naming anything is the `pip install` of the knowledge tools this capability's own
82
+ `ground_in:` argv happens to name — which is the consumer's fact by design (ADR-FIA-0005).
83
+
84
+ A second capability instantiates the same actor by writing that sidecar. Nothing here is
85
+ subclassed, hooked, or configured with a strategy object — there is one shape, and it is this one.
86
+
87
+ ### Embedding it instead
88
+
89
+ A consumer that wants its own base image, or the actor inside a larger process, skips all of the
90
+ above and wires it in four lines:
64
91
 
65
92
  ```python
66
93
  from foundry_implementation_actor import CapabilityConfig, ClaudeCodeEngine, make_implement_task
@@ -71,8 +98,10 @@ actor = Actor.from_card(".", mailbox=mailbox,
71
98
  actions={"implement-task": make_implement_task(config)})
72
99
  ```
73
100
 
74
- A second capability instantiates the same actor by writing that file. Nothing here is subclassed,
75
- hooked, or configured with a strategy object — there is one shape, and it is this one.
101
+ `assess-task` needs no entry here: it is a query with an engine and no handler, so the engine's own
102
+ judgement is the reply. `pip install foundry-implementation-actor` is enough for this; the mailbox
103
+ and the observability backend that `serve` needs live in the `[serve]` extra, so an embedder is not
104
+ handed an HTTP server it did not ask for.
76
105
 
77
106
  ## The definition, and a use
78
107
 
@@ -81,11 +110,14 @@ four cards — who it is, the data it knows, the messages it exchanges, and the
81
110
  door it answers — and they ship in the wheel, reachable as `cards_path()`. They name no capability,
82
111
  because which capability an instance serves is not part of what the actor *is*.
83
112
 
84
- A **use** of this actor is one capability's own folder: its own copy of those four cards, named for
85
- the capability it serves, beside the `actor-agentic-context.yaml` that binds it to that capability's
86
- repository and knowledge base. Today that folder is a static repository, and the copy is made by
87
- hand. Once an instance can be spawned from a capability id alone, the cards here are what it would
88
- be rendered from — which is why they live in the wheel rather than in an `examples/` folder.
113
+ A **use** of this actor is one capability's own folder: the `actor-agentic-context.yaml` that binds
114
+ it to that capability's repository and knowledge base, and the four cards named for the capability
115
+ it serves — **rendered from the definition, not copied.** `foundry-implementation-actor
116
+ render-cards` writes them, the image runs it at `docker build` time, and a use's repository
117
+ therefore holds one hand-written YAML file. That is why the cards live in the wheel rather than in
118
+ an `examples/` folder: they are build input, not documentation (ADR-FIA-0005).
119
+
120
+ They used to be copied by hand, which is why the gate below exists.
89
121
 
90
122
  The split is what the name asserts. `ADR-ECO-0022` makes the `-actor` suffix an obligation: a
91
123
  package ending in `-actor` claims a `papeete-actor` underneath, *"and a `<use>-<tier>-actor` that
@@ -97,12 +129,17 @@ papeete-actor-synchronous-messaging lint-card \
97
129
  "$(python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
98
130
  ```
99
131
 
100
- A use's copy is made by hand, so it can drift: both folders pass `lint-card` independently, and
101
- neither gate has an opinion about the other. `foundry-implementation-actor lint` therefore runs a
102
- second check — `conformance.check` — comparing the use's cards against the definition's on the
103
- **derived wire contract**: the set of doors, and each door's `request_schema`, `completion_schema`
104
- and `engine`. Those derivations already fold in the data dictionary and the message catalog, so a
105
- renamed item or a changed reference lands in the payload a caller is validated against.
132
+ A hand copy drifts: both folders pass `lint-card` independently, and neither gate has an opinion
133
+ about the other. `foundry-implementation-actor lint` therefore runs a second check —
134
+ `conformance.check` — comparing the use's cards against the definition's on the **derived wire
135
+ contract**: the set of doors, and each door's `request_schema`, `completion_schema` and `engine`.
136
+ Those derivations already fold in the data dictionary and the message catalog, so a renamed item or
137
+ a changed reference lands in the payload a caller is validated against.
138
+
139
+ Rendering makes that gate cheap to pass rather than redundant, and both are worth having: a use
140
+ still carrying a hand copy from before 0.5.0, or one pinned to an older image, is exactly the case
141
+ that can be wrong — and a construction that cannot drift is still better than a check that catches
142
+ drift, which is why the rendering exists at all.
106
143
 
107
144
  Prose is not compared, on purpose: a use *should* name its real capability and its real peers, and
108
145
  `actor.yaml`'s `name:` is its own identity and is required to differ.
@@ -111,7 +148,16 @@ Because the cards sit under `src/`, `tests/test_portability.py` greps them too
111
148
  a knowledge tool name written into the actor's own definition fails the build exactly as it would
112
149
  in the code.
113
150
 
114
- ## What one request does
151
+ ## Two doors
152
+
153
+ | door | verb | what it does |
154
+ |---|---|---|
155
+ | `implement-task` | request | builds the increment, commits, pushes a branch, publishes one image per touched component |
156
+ | `assess-task` | query | answers whether a proposed acceptance surface can be delivered. Writes nothing |
157
+
158
+ Both name the same engine. `Actor.judge()` hands it the door id, and it dispatches on that.
159
+
160
+ ### `implement-task`
115
161
 
116
162
  ```
117
163
  clone (full, not --depth 1)
@@ -126,6 +172,44 @@ clone (full, not --depth 1)
126
172
  It never opens a pull request. Rendering a verdict and opening one belongs to whichever actor
127
173
  orchestrates the pipeline, once its other members have also confirmed.
128
174
 
175
+ ## The three amigos round
176
+
177
+ Before any of that, the actor that will black-box test the increment says what it intends to
178
+ assert, and this one answers whether that can be delivered:
179
+
180
+ ```
181
+ orchestration ──▶ testing: propose the acceptance surface for this task
182
+ ◀── expectations[] — each with a stable id, a statement, a handle
183
+ orchestration ──▶ implementation: assess-task — can you deliver this?
184
+ ◀── feasible + objections[] + commitments[]
185
+
186
+ agreed → the surface goes to implement-task AND test-task, on every attempt
187
+ disagreed → orchestration stops and hands the objections back to a human
188
+ ```
189
+
190
+ **Exactly one round, then a person.** No counter-proposal and no negotiation loop: a three-amigos
191
+ meeting converges because a human is in the room, and here the human *is* the tie-breaker. *"The
192
+ task does not determine this"* is a legitimate answer, and it is the most useful one.
193
+
194
+ `assess-task` is a **query**, not an action — *"a promise to answer, from this actor's own state
195
+ and nothing invented."* It clones read-only, grounds itself exactly as the implement door does, and
196
+ is invoked with `--tools Read,Glob,Grep`, so `Write`, `Edit` and `Bash` are absent from the session
197
+ rather than merely unapproved (0.5.0 passed only `--allowedTools`, which removes nothing — see
198
+ ADR-FIA-0004's amendment). A door that **cannot** write
199
+ beats a door asked not to. It registers no handler either: with an engine and no handler,
200
+ `Actor.receive()` returns the judged dict as the reply, and there is nothing to contain because
201
+ nothing is produced.
202
+
203
+ What it commits to is a **promise, not a report** — nothing has been built when it answers. That
204
+ distinction is the whole point (`ADR-FIA-0004`): a tester deriving its assertions from what was
205
+ already built can only ever confirm the build. The failure this prevents is real and already in the
206
+ wild — an e2e suite carrying three fixture ids its own comment admits were *"discovered black-box
207
+ against the running container"*, against a task card that never named them.
208
+
209
+ `acceptance_surface` then rides along as an **optional** field on `implement-task`. Without it the
210
+ door behaves exactly as before; with it, where it is more specific than the definition of done, it
211
+ wins.
212
+
129
213
  ## Grounding is a precondition, not a request
130
214
 
131
215
  `CLAUDE.md` at the working directory's root is loaded by the `claude` CLI **before the first
@@ -206,11 +290,14 @@ this can be an ordinary Pod.
206
290
  ## CLI
207
291
 
208
292
  ```bash
209
- foundry-implementation-actor lint . # validate the sidecar
293
+ foundry-implementation-actor lint . # validate the sidecar and the cards
210
294
  foundry-implementation-actor show . --registry reg.example.com # every derived rendering
295
+ foundry-implementation-actor render-cards . # write the four cards from the wheel
296
+ foundry-implementation-actor serve . # boot it (needs the `serve` extra)
211
297
  ```
212
298
 
213
- `lint` is the gate CI runs. A sidecar declaring some other `context:` is read, warned, and not
299
+ `render-cards` and `serve` are what the image runs, and are usable anywhere the wheel is. `lint` is
300
+ the gate CI runs. A sidecar declaring some other `context:` is read, warned, and not
214
301
  checked further — UNMIGRATED is not the same as non-conformant, and migrating is the owning pair's
215
302
  own act.
216
303
 
@@ -244,6 +331,32 @@ move, not a rewrite."*
244
331
 
245
332
  `adr/` records the decisions. Design rationale belongs there, not in commit messages.
246
333
 
334
+ ## Releasing, and which registry to pin
335
+
336
+ A tag (`v*`) publishes two artifacts at one version — the wheel to PyPI, and the image to **two
337
+ registries** holding the same digest (ADR-FIA-0006):
338
+
339
+ | Registry | Pin it when |
340
+ |---|---|
341
+ | `ghcr.io/papeete-hub/foundry-implementation-actor` | you are writing a use's Dockerfile yourself, and build it with your own Docker |
342
+ | a product's own registry, `vars.PRODUCT_IMAGE` | the use is built **in-cluster**, by a builder whose one registry credential is that product's |
343
+
344
+ The second exists because `buildctl` resolves a `FROM` line client-side against the single registry
345
+ credential it was handed — so a use built by the cluster's shared builder can only reach the
346
+ product's own registry, whatever the README says. The push is skipped, with a notice, when
347
+ `vars.PRODUCT_IMAGE` is unset.
348
+
349
+ To give an already-released version an image in a registry it missed, run the workflow by hand from
350
+ the default branch with the tag as its input — it checks that tag out, and refuses it if it
351
+ disagrees with the `pyproject.toml` beside it:
352
+
353
+ ```bash
354
+ gh workflow run release.yml --ref main -f tag=v0.5.0
355
+ ```
356
+
357
+ That run skips PyPI (which refuses a version it already holds) and does not move `latest` in either
358
+ registry.
359
+
247
360
  ## Development
248
361
 
249
362
  ```bash