foundry-implementation-actor 0.1.0__tar.gz → 0.2.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 (40) hide show
  1. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/.github/workflows/ci.yml +10 -0
  2. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/.github/workflows/release.yml +9 -0
  3. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/CLAUDE.md +21 -1
  4. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/PKG-INFO +40 -3
  5. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/README.md +39 -2
  6. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/pyproject.toml +1 -1
  7. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/__init__.py +5 -2
  8. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/cards/actor-data.yaml +38 -0
  9. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/cards/actor-message.yaml +17 -0
  10. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/cards/actor-synchronous-messaging.yaml +21 -0
  11. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/cards/actor.yaml +19 -0
  12. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/cli.py +11 -1
  13. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/config.py +16 -0
  14. foundry_implementation_actor-0.2.1/src/foundry_implementation_actor/conformance.py +119 -0
  15. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor-data.yaml +38 -0
  16. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor-message.yaml +17 -0
  17. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor-synchronous-messaging.yaml +21 -0
  18. foundry_implementation_actor-0.2.1/tests/fixtures/valid/actor.yaml +8 -0
  19. foundry_implementation_actor-0.2.1/tests/test_cards.py +49 -0
  20. foundry_implementation_actor-0.2.1/tests/test_conformance.py +111 -0
  21. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/uv.lock +1 -1
  22. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/.gitignore +0 -0
  23. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/adr/ADR-FIA-0001-the-machinery-leaves-the-capability.md +0 -0
  24. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/adr/README.md +0 -0
  25. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/adr/template.md +0 -0
  26. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/scripts/probe_grounding.py +0 -0
  27. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/correlation.py +0 -0
  28. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/engine.py +0 -0
  29. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/grounding.py +0 -0
  30. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/handler.py +0 -0
  31. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/src/foundry_implementation_actor/schemas/agentic-context.schema.yaml +0 -0
  32. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/conftest.py +0 -0
  33. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/fixtures/broken/actor-agentic-context.yaml +0 -0
  34. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/fixtures/valid/actor-agentic-context.yaml +0 -0
  35. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/test_cli.py +0 -0
  36. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/test_config.py +0 -0
  37. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/test_engine.py +0 -0
  38. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/test_grounding.py +0 -0
  39. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/test_handler.py +0 -0
  40. {foundry_implementation_actor-0.1.0 → foundry_implementation_actor-0.2.1}/tests/test_portability.py +0 -0
@@ -32,6 +32,16 @@ jobs:
32
32
  --registry reg.example.com | tee /tmp/show.txt
33
33
  grep -q 'reg.example.com/acme.parts/sup.007.wid/backend:<version>' /tmp/show.txt
34
34
 
35
+ - name: the wheel must carry the actor's own cards
36
+ # The `-actor` suffix asserts a papeete-actor underneath, which lint-card can check
37
+ # (ADR-ECO-0022). The suite proves the cards are conformant in a source checkout; this
38
+ # proves they SHIPPED. A card folder left out of the build passes every test in tests/ and
39
+ # makes the name a claim the artifact cannot honour. `papeete-actor-synchronous-messaging`
40
+ # is already a runtime dependency, so its gate is in the probe venv with nothing to add.
41
+ run: |
42
+ /tmp/probe/bin/papeete-actor-synchronous-messaging lint-card \
43
+ "$(/tmp/probe/bin/python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
44
+
35
45
  - name: the gate must run
36
46
  # A gate that cannot fail is not a gate. Lint a deliberately non-conformant sidecar with
37
47
  # the INSTALLED wheel and require a non-zero exit.
@@ -29,6 +29,15 @@ jobs:
29
29
  uv pip install --python /tmp/probe/bin/python -q dist/*.whl
30
30
  /tmp/probe/bin/foundry-implementation-actor lint tests/fixtures/valid
31
31
 
32
+ - name: the wheel must carry the actor's own cards
33
+ # Same reason as the line above, for the other half of what this package ships. The
34
+ # `-actor` suffix asserts a papeete-actor underneath (ADR-ECO-0022); a wheel published
35
+ # without its cards makes the name a claim the artifact cannot honour, and a published
36
+ # version is not something to discover that from.
37
+ run: |
38
+ /tmp/probe/bin/papeete-actor-synchronous-messaging lint-card \
39
+ "$(/tmp/probe/bin/python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
40
+
32
41
  - name: grounding renders a CLAUDE.md whose imports resolve
33
42
  # BEFORE the upload, not after. A wheel that ships without its schema, or whose grounding
34
43
  # emits an @-import to a file it never wrote, produces sessions grounded in nothing that
@@ -29,7 +29,7 @@ There is no separate lint/format command configured in this repo.
29
29
 
30
30
  ## Architecture
31
31
 
32
- Six modules under `src/foundry_implementation_actor/`:
32
+ Seven modules under `src/foundry_implementation_actor/`:
33
33
 
34
34
  - **`config.py`** — `CapabilityConfig`. The heart. Loads the sidecar and derives **every**
35
35
  rendering of the capability id from two declared fields (`capability`, `source_repo`). Also
@@ -44,10 +44,30 @@ Six modules under `src/foundry_implementation_actor/`:
44
44
  opens a pull request.
45
45
  - **`correlation.py`** — the two ids and the step vocabulary. Moved **verbatim** from the repo this
46
46
  was extracted from; its code below the docstring is byte-identical.
47
+ - **`conformance.py`** — `check(folder)`. Compares a use's cards against the definition's, on
48
+ the derived wire contract only (door ids, each door's request/completion schema and engine).
49
+ Prose and `actor.yaml`'s `name:` are deliberately not compared — a use should name its own
50
+ capability. `lint` runs it beside the sidecar gate.
47
51
  - **`cli.py`** — argparse wiring only, no logic of its own.
48
52
 
53
+ Beside them, two folders of committed contract, both shipped in the wheel:
54
+
55
+ - **`schemas/agentic-context.schema.yaml`** — the sidecar contract this package owns, which
56
+ `lint` checks a use against.
57
+ - **`cards/`** — the actor's own four cards: what this actor IS, as opposed to which capability a
58
+ use of it serves. `cards_path()` returns the folder. They name no capability, and they are what
59
+ a spawned instance would be rendered from once a use is not a static repository.
60
+
49
61
  ## Core invariants that any change must preserve
50
62
 
63
+ - **A use's cards are a hand copy, and hand copies drift.** Both folders pass `lint-card`
64
+ independently, and neither gate looks at the other — so a definition that gains a field leaves
65
+ every un-updated use linting green and refusing callers at runtime. `conformance.check` is the
66
+ only thing that catches it. Compare the DERIVED contract, never the prose.
67
+ - **The `-actor` suffix is a claim, and it is checked.** `ADR-ECO-0022`: a package ending in
68
+ `-actor` asserts a `papeete-actor` underneath, and one that ships no conformant card is
69
+ misnamed. `tests/test_cards.py` runs `lint-card` on `cards/` in the suite; CI and the release
70
+ workflow run it again against the built wheel, so the cards cannot silently stop shipping.
51
71
  - **No capability literal, ever.** `tests/test_portability.py` greps `src/` for the originating
52
72
  instance's identifiers *and* for any knowledge tool name (`kpack`, `kontract`, …), and fails on
53
73
  either. The tools a capability grounds itself in are the consumer's dependencies. A fourth
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: foundry-implementation-actor
3
- Version: 0.1.0
3
+ Version: 0.2.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>
@@ -34,8 +34,8 @@ pip install foundry-implementation-actor
34
34
 
35
35
  ## What it is
36
36
 
37
- The machinery. It carries **no capability of its own** — the capability it serves arrives in a
38
- sidecar the consuming repo writes:
37
+ The actor's **definition** — its four cards, and the machinery behind them. It carries **no
38
+ capability of its own**: the capability it serves arrives in a sidecar the consuming repo writes:
39
39
 
40
40
  ```yaml
41
41
  # actor-agentic-context.yaml
@@ -74,6 +74,43 @@ actor = Actor.from_card(".", mailbox=mailbox,
74
74
  A second capability instantiates the same actor by writing that file. Nothing here is subclassed,
75
75
  hooked, or configured with a strategy object — there is one shape, and it is this one.
76
76
 
77
+ ## The definition, and a use
78
+
79
+ This package is where the actor is **defined**. `src/foundry_implementation_actor/cards/` holds its
80
+ four cards — who it is, the data it knows, the messages it exchanges, and the one `implement-task`
81
+ door it answers — and they ship in the wheel, reachable as `cards_path()`. They name no capability,
82
+ because which capability an instance serves is not part of what the actor *is*.
83
+
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.
89
+
90
+ The split is what the name asserts. `ADR-ECO-0022` makes the `-actor` suffix an obligation: a
91
+ package ending in `-actor` claims a `papeete-actor` underneath, *"and a `<use>-<tier>-actor` that
92
+ ships no conformant card is misnamed, not merely unusual."* `tests/test_cards.py` runs that check
93
+ in the suite, and CI runs it again against the built wheel:
94
+
95
+ ```bash
96
+ papeete-actor-synchronous-messaging lint-card \
97
+ "$(python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
98
+ ```
99
+
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.
106
+
107
+ Prose is not compared, on purpose: a use *should* name its real capability and its real peers, and
108
+ `actor.yaml`'s `name:` is its own identity and is required to differ.
109
+
110
+ Because the cards sit under `src/`, `tests/test_portability.py` greps them too — a capability id or
111
+ a knowledge tool name written into the actor's own definition fails the build exactly as it would
112
+ in the code.
113
+
77
114
  ## What one request does
78
115
 
79
116
  ```
@@ -14,8 +14,8 @@ pip install foundry-implementation-actor
14
14
 
15
15
  ## What it is
16
16
 
17
- The machinery. It carries **no capability of its own** — the capability it serves arrives in a
18
- sidecar the consuming repo writes:
17
+ The actor's **definition** — its four cards, and the machinery behind them. It carries **no
18
+ capability of its own**: the capability it serves arrives in a sidecar the consuming repo writes:
19
19
 
20
20
  ```yaml
21
21
  # actor-agentic-context.yaml
@@ -54,6 +54,43 @@ actor = Actor.from_card(".", mailbox=mailbox,
54
54
  A second capability instantiates the same actor by writing that file. Nothing here is subclassed,
55
55
  hooked, or configured with a strategy object — there is one shape, and it is this one.
56
56
 
57
+ ## The definition, and a use
58
+
59
+ This package is where the actor is **defined**. `src/foundry_implementation_actor/cards/` holds its
60
+ four cards — who it is, the data it knows, the messages it exchanges, and the one `implement-task`
61
+ door it answers — and they ship in the wheel, reachable as `cards_path()`. They name no capability,
62
+ because which capability an instance serves is not part of what the actor *is*.
63
+
64
+ A **use** of this actor is one capability's own folder: its own copy of those four cards, named for
65
+ the capability it serves, beside the `actor-agentic-context.yaml` that binds it to that capability's
66
+ repository and knowledge base. Today that folder is a static repository, and the copy is made by
67
+ hand. Once an instance can be spawned from a capability id alone, the cards here are what it would
68
+ be rendered from — which is why they live in the wheel rather than in an `examples/` folder.
69
+
70
+ The split is what the name asserts. `ADR-ECO-0022` makes the `-actor` suffix an obligation: a
71
+ package ending in `-actor` claims a `papeete-actor` underneath, *"and a `<use>-<tier>-actor` that
72
+ ships no conformant card is misnamed, not merely unusual."* `tests/test_cards.py` runs that check
73
+ in the suite, and CI runs it again against the built wheel:
74
+
75
+ ```bash
76
+ papeete-actor-synchronous-messaging lint-card \
77
+ "$(python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
78
+ ```
79
+
80
+ A use's copy is made by hand, so it can drift: both folders pass `lint-card` independently, and
81
+ neither gate has an opinion about the other. `foundry-implementation-actor lint` therefore runs a
82
+ second check — `conformance.check` — comparing the use's cards against the definition's on the
83
+ **derived wire contract**: the set of doors, and each door's `request_schema`, `completion_schema`
84
+ and `engine`. Those derivations already fold in the data dictionary and the message catalog, so a
85
+ renamed item or a changed reference lands in the payload a caller is validated against.
86
+
87
+ Prose is not compared, on purpose: a use *should* name its real capability and its real peers, and
88
+ `actor.yaml`'s `name:` is its own identity and is required to differ.
89
+
90
+ Because the cards sit under `src/`, `tests/test_portability.py` greps them too — a capability id or
91
+ a knowledge tool name written into the actor's own definition fails the build exactly as it would
92
+ in the code.
93
+
57
94
  ## What one request does
58
95
 
59
96
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "foundry-implementation-actor"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "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
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -18,10 +18,11 @@ Wiring one up is four lines:
18
18
  `correlation` is exported too — an entrypoint installs its filter on the root logger's handlers
19
19
  after configuring observability, so every record the process emits carries this request's ids.
20
20
  """
21
- from .config import CapabilityConfig, Component, ConfigError, Grounding, Report, lint
21
+ from .config import (CapabilityConfig, Component, ConfigError, Grounding, Report,
22
+ cards_path, lint)
22
23
  from .engine import ClaudeCodeEngine
23
24
  from .handler import HandlerError, make_implement_task
24
- from . import correlation, grounding
25
+ from . import conformance, correlation, grounding
25
26
 
26
27
  __all__ = [
27
28
  "CapabilityConfig",
@@ -31,6 +32,8 @@ __all__ = [
31
32
  "Grounding",
32
33
  "HandlerError",
33
34
  "Report",
35
+ "cards_path",
36
+ "conformance",
34
37
  "correlation",
35
38
  "grounding",
36
39
  "lint",
@@ -0,0 +1,38 @@
1
+ # WHAT DATA EXISTS. `papeete-actor-data/v0`, owned by `papeete-actor-message` — a named, minimally
2
+ # typed dictionary. Every field here belongs to the ACTOR, not to any capability: a task id, the
3
+ # caller's statement of the task, and what came back. Nothing names a capability, a component or a
4
+ # knowledge tool, because none of those are known until a sidecar supplies them.
5
+ data: papeete-actor-data/v0
6
+ items:
7
+ - name: task_id
8
+ type: string
9
+ description: the TASK-NNN card id this actor was asked about (e.g. "TASK-009")
10
+ - name: title
11
+ type: string
12
+ description: the task's short title/summary, supplied by the caller — no card lookup is performed here
13
+ - name: context
14
+ type: string
15
+ description: free-text elaboration beyond the title — background, constraints, links; optional
16
+ - name: definition_of_done
17
+ type: list
18
+ description: the acceptance criteria this task must satisfy, as the caller states them
19
+ - name: remediation_context
20
+ type: string
21
+ description: >-
22
+ on a retry, the prior attempt's failing test criteria — what the implementing session should
23
+ fix this time; optional, absent on a first attempt
24
+ - name: accepted
25
+ type: boolean
26
+ description: whether the implement-task request was accepted, pushed, and its images published
27
+ - name: because
28
+ type: string
29
+ description: why a request was refused (containment violation, nothing staged, ...)
30
+ - name: branch
31
+ type: string
32
+ description: the branch the implementation was pushed to (impl/TASK-NNN)
33
+ - name: images
34
+ type: list
35
+ description: >-
36
+ the full name:version ref of each touched component's published image (e.g.
37
+ "acme.parts.cap.sup.007.wid-backend:0.1.0-TASK-011-a1b2c3d"), by convention — no other actor
38
+ is ever told these tags explicitly
@@ -0,0 +1,17 @@
1
+ # WHAT MESSAGES EXIST. `papeete-actor-message/v1`, owned by `papeete-actor-message` — named
2
+ # messages, each a pure regrouping of references into the data dictionary, under an intent.
3
+ # Wire-agnostic by that package's own design: WHAT a message is, never HOW it is carried.
4
+ message: papeete-actor-message/v1
5
+ messages:
6
+ - name: implement-task-cmd
7
+ intent: ask this actor to implement a TASK-NNN card for the capability it serves
8
+ references: [task_id, title, definition_of_done, context, remediation_context]
9
+ optional: [context, remediation_context]
10
+
11
+ - name: task-implemented-result
12
+ intent: report that the task was implemented, pushed, and its touched components' images published
13
+ references: [accepted, branch, images]
14
+
15
+ - name: task-refused-result
16
+ intent: report that the task was not implemented, and why
17
+ references: [accepted, because]
@@ -0,0 +1,21 @@
1
+ # THE DOORS. `synchronous-messaging-doors/v1` — one non-deterministic door, and no queries.
2
+ #
3
+ # `engine: claude-code` is this actor kind's declared engine. A use's sidecar repeats it in its own
4
+ # `engine:` field, and the entrypoint registers the engine under exactly that key — so a use whose
5
+ # sidecar names a different engine is caught by its own card rather than at the first request.
6
+ manifest: synchronous-messaging-doors/v1
7
+ actions:
8
+ - id: implement-task
9
+ means: >-
10
+ the door for "implement TASK-NNN for the capability I serve". Send it here with the task id,
11
+ title, definition of done, and (optionally) context and remediation_context — the caller
12
+ supplies everything, no task card is looked up here. I clone my capability's repository into
13
+ my own private copy, ground myself in that capability's own standing context, judge how to
14
+ implement the task (writing only under whichever of my declared components it touches), then
15
+ commit and push to impl/TASK-NNN, and build each touched component's image in the cluster's
16
+ shared buildkit and push it to the registry, named and versioned by convention. I never open
17
+ a pull request — an orchestrating actor does, once a testing actor also confirms.
18
+ completion: whether I accepted, pushed, and published images (with the branch and image refs), or refused, and why.
19
+ door_schema: implement-task-cmd
20
+ completion_schema: [task-implemented-result, task-refused-result]
21
+ engine: claude-code
@@ -0,0 +1,19 @@
1
+ # WHO. The actor this package defines — `papeete-actor-manifest/v0`, owned by `papeete-actor`.
2
+ #
3
+ # THIS FOLDER IS THE DEFINITION, NOT A USE. The four cards here say what a foundry implementation
4
+ # actor IS: the data it knows, the messages it exchanges, and the one door it answers. They name no
5
+ # capability, because which capability an instance serves is not part of what the actor is — that
6
+ # is `actor-agentic-context.yaml`, supplied per use.
7
+ #
8
+ # One use of this actor is a folder carrying its own copy of these four cards, named for the
9
+ # capability it serves, beside the sidecar that binds it to that capability's knowledge base. Today
10
+ # that folder is a static repository; the cards here are what a spawned instance would be rendered
11
+ # from once it is not.
12
+ manifest: papeete-actor-manifest/v0
13
+ name: foundry-implementation-actor
14
+ description: >-
15
+ Implements one TASK-NNN card for the business capability its sidecar names. Clones that
16
+ capability's own repository into a private copy, grounds the session in the capability's own
17
+ standing context, and writes only under the components the sidecar declares — then commits,
18
+ pushes a branch, and publishes one image per component the task actually touched. Never opens a
19
+ pull request: an orchestrating actor does that, once a testing actor also confirms.
@@ -13,13 +13,22 @@ import argparse
13
13
  import sys
14
14
  from pathlib import Path
15
15
 
16
+ from . import conformance
16
17
  from .config import CapabilityConfig, ConfigError, lint
17
18
 
18
19
  _REGISTRY_PLACEHOLDER = "<registry>"
19
20
 
20
21
 
21
22
  def _cmd_lint(args: argparse.Namespace) -> int:
23
+ # Two gates, one command. The sidecar says which capability this use serves; the cards say
24
+ # which actor it claims to be. A use can be wrong about either independently, and the second
25
+ # check is the only thing standing between a hand-copied card set and a caller being refused
26
+ # at a door — see `conformance.py`.
22
27
  report = lint(Path(args.folder))
28
+ conformance_report = conformance.check(Path(args.folder))
29
+ report.oks.extend(conformance_report.oks)
30
+ report.warns.extend(conformance_report.warns)
31
+ report.errors.extend(conformance_report.errors)
23
32
  for warning in report.warns:
24
33
  print(f" ! {warning}")
25
34
  for error in report.errors:
@@ -29,7 +38,8 @@ def _cmd_lint(args: argparse.Namespace) -> int:
29
38
  return 1
30
39
  for line in report.oks:
31
40
  print(f" ok {line}")
32
- print("✓ sidecar conforms")
41
+ checked_cards = any((Path(args.folder) / name).exists() for name in conformance.CARD_FILES)
42
+ print("✓ sidecar and cards conform" if checked_cards else "✓ sidecar conforms")
33
43
  return 0
34
44
 
35
45
 
@@ -45,6 +45,7 @@ CONTRACT = "foundry-implementation-actor/agentic-context/v1"
45
45
  SIDECAR = "actor-agentic-context.yaml"
46
46
 
47
47
  _SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "agentic-context.schema.yaml"
48
+ _CARDS_PATH = Path(__file__).resolve().parent / "cards"
48
49
 
49
50
  # The segment a capability id carries to say "capability". It is dropped from the registry path
50
51
  # because the path position already says it — every other token of the id survives, across
@@ -56,6 +57,21 @@ _CAPABILITY_SEGMENT = "cap"
56
57
  _PLACEHOLDER = re.compile(r"\{([a-z_]+)\}")
57
58
 
58
59
 
60
+ def cards_path() -> Path:
61
+ """The folder holding this actor's own four cards — its definition, shipped in the wheel.
62
+
63
+ The cards say what a foundry implementation actor IS: its data dictionary, its message
64
+ catalog, and the one door it answers. They name no capability, because which capability an
65
+ instance serves is not part of what the actor is — that is the sidecar, supplied per use.
66
+
67
+ A use is today a static folder carrying its own copy of these four, named for the capability
68
+ it serves. This path is what a spawned instance would be rendered from once it is not, and it
69
+ is what `papeete-actor-synchronous-messaging lint-card` is pointed at to check that the
70
+ `-actor` suffix in this package's name is a claim it actually honours (ADR-ECO-0022).
71
+ """
72
+ return _CARDS_PATH
73
+
74
+
59
75
  def load_schema() -> dict:
60
76
  """The contract, as committed source inside this package.
61
77
 
@@ -0,0 +1,119 @@
1
+ """Does this use still answer the same doors as the actor it claims to be?
2
+
3
+ WHY THIS EXISTS. This package ships the actor's DEFINITION — four cards under `cards/` saying what
4
+ a foundry implementation actor is. A USE is one capability's own folder, carrying its own copy of
5
+ those four, named for the capability it serves. The copy is made by hand, because a use is a static
6
+ repository today rather than something spawned from a capability id.
7
+
8
+ A hand copy drifts. Both folders pass `lint-card` independently — each is a conformant actor — and
9
+ neither gate has any opinion about the other. So the day the definition gains a field, renames a
10
+ message, or changes a door's completion set, a use that was not updated keeps linting green and
11
+ starts refusing callers at runtime, with the rejection surfacing at the door rather than here.
12
+
13
+ WHAT IS COMPARED, AND WHAT IS NOT. Only what a caller can observe: the set of doors, and for each
14
+ one its derived `request_schema`, its `completion_schema`, and the `engine` key it resolves
15
+ through. Those derivations already fold in the data dictionary and the message catalog — a renamed
16
+ data item, a changed type, a reference added to a message, all of it lands in the schema a caller is
17
+ validated against — so comparing them separately would be comparing the same fact twice.
18
+
19
+ PROSE IS NOT COMPARED, ON PURPOSE. A use's `means:` should name its real capability and its real
20
+ peer actors; the definition's cannot, because it serves no capability and its cards are grepped for
21
+ exactly such literals. The use's wording is the better one for anyone reading `describe`, and
22
+ flattening it to the definition's would delete information. Nor is `actor.yaml`'s `name:`: that is
23
+ the use's own identity, and it is REQUIRED to differ.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+
29
+ from papeete_actor_synchronous_messaging import card as pas_card
30
+
31
+ from .config import Report, cards_path
32
+
33
+ CARD_FILES = ("actor.yaml", "actor-data.yaml", "actor-message.yaml",
34
+ "actor-synchronous-messaging.yaml")
35
+
36
+
37
+ def check(folder: str | Path = ".") -> Report:
38
+ """Compare one use's cards against the definition this package ships."""
39
+ use = Path(folder)
40
+ report = Report(oks=[], warns=[], errors=[])
41
+
42
+ present = [name for name in CARD_FILES if (use / name).exists()]
43
+ if not present:
44
+ # Not an error. `lint` is also run against a sidecar on its own — in this package's own CI,
45
+ # among other places — and a folder with no cards is not a malformed use, just not a
46
+ # complete one.
47
+ report.warns.append(
48
+ f"{use}: no cards here, so nothing to compare — a use carries its own copy of the "
49
+ f"actor's four cards beside its sidecar"
50
+ )
51
+ return report
52
+ if len(present) != len(CARD_FILES):
53
+ missing = [name for name in CARD_FILES if name not in present]
54
+ report.errors.append(
55
+ f"{use}: an incomplete card set — missing {', '.join(missing)}. `Actor.from_card` "
56
+ f"opens exactly these four and never globs, so a use missing one does not boot."
57
+ )
58
+ return report
59
+
60
+ definition = cards_path()
61
+ if any(not (definition / name).exists() for name in CARD_FILES):
62
+ # A wheel that lost its cards. `lint-card` in CI and in the release workflow exists to stop
63
+ # that reaching PyPI; this turns the leftover case into a report rather than a traceback
64
+ # from inside a gate a consumer is running.
65
+ report.errors.append(
66
+ f"{definition}: this package's own cards are missing, so there is nothing to compare "
67
+ f"against. The build shipped without them — report it against the release."
68
+ )
69
+ return report
70
+
71
+ try:
72
+ theirs = pas_card.load(use)
73
+ except ValueError as e:
74
+ report.errors.append(f"{use}: its own cards do not pass the restriction, so they cannot "
75
+ f"be compared: {e}")
76
+ return report
77
+ ours = pas_card.load(definition)
78
+
79
+ report.errors.extend(_compare(ours, theirs, "actions", use))
80
+ report.errors.extend(_compare(ours, theirs, "queries", use))
81
+ if not report.errors:
82
+ doors = ", ".join(sorted(theirs.actions) + sorted(theirs.queries))
83
+ report.oks.append(f"{use} answers the definition's doors, unchanged: {doors}")
84
+ return report
85
+
86
+
87
+ def _compare(ours, theirs, kind: str, use: Path) -> list[str]:
88
+ """The differences in one door family, as messages. Empty means they agree."""
89
+ defined, used = getattr(ours, kind), getattr(theirs, kind)
90
+ errors = []
91
+
92
+ for extra in sorted(set(used) - set(defined)):
93
+ errors.append(f"{use}: {kind[:-1]} '{extra}' is not a door the definition declares — a use "
94
+ f"answers the actor's doors, it does not add its own")
95
+ for absent in sorted(set(defined) - set(used)):
96
+ errors.append(f"{use}: {kind[:-1]} '{absent}' is missing — the definition declares it, so a "
97
+ f"caller addressing this actor may send it")
98
+
99
+ for door in sorted(set(defined) & set(used)):
100
+ mine, yours = defined[door], used[door]
101
+ if mine.request_schema != yours.request_schema:
102
+ errors.append(
103
+ f"{use}: {kind[:-1]} '{door}' accepts a different payload than the definition. "
104
+ f"Its `door_schema` message and the data items that message references are what "
105
+ f"derive this, so one of the two drifted:\n"
106
+ f" definition {mine.request_schema}\n"
107
+ f" this use {yours.request_schema}")
108
+ if mine.completion_schema != yours.completion_schema:
109
+ errors.append(
110
+ f"{use}: {kind[:-1]} '{door}' replies against a different completion set than the "
111
+ f"definition:\n"
112
+ f" definition {mine.completion_schema}\n"
113
+ f" this use {yours.completion_schema}")
114
+ if mine.engine != yours.engine:
115
+ errors.append(
116
+ f"{use}: {kind[:-1]} '{door}' resolves through engine '{yours.engine}', the "
117
+ f"definition through '{mine.engine}' — the entrypoint registers the engine under "
118
+ f"the sidecar's `engine:` key, so these three must agree")
119
+ return errors
@@ -0,0 +1,38 @@
1
+ # WHAT DATA EXISTS. `papeete-actor-data/v0`, owned by `papeete-actor-message` — a named, minimally
2
+ # typed dictionary. Every field here belongs to the ACTOR, not to any capability: a task id, the
3
+ # caller's statement of the task, and what came back. Nothing names a capability, a component or a
4
+ # knowledge tool, because none of those are known until a sidecar supplies them.
5
+ data: papeete-actor-data/v0
6
+ items:
7
+ - name: task_id
8
+ type: string
9
+ description: the TASK-NNN card id this actor was asked about (e.g. "TASK-009")
10
+ - name: title
11
+ type: string
12
+ description: the task's short title/summary, supplied by the caller — no card lookup is performed here
13
+ - name: context
14
+ type: string
15
+ description: free-text elaboration beyond the title — background, constraints, links; optional
16
+ - name: definition_of_done
17
+ type: list
18
+ description: the acceptance criteria this task must satisfy, as the caller states them
19
+ - name: remediation_context
20
+ type: string
21
+ description: >-
22
+ on a retry, the prior attempt's failing test criteria — what the implementing session should
23
+ fix this time; optional, absent on a first attempt
24
+ - name: accepted
25
+ type: boolean
26
+ description: whether the implement-task request was accepted, pushed, and its images published
27
+ - name: because
28
+ type: string
29
+ description: why a request was refused (containment violation, nothing staged, ...)
30
+ - name: branch
31
+ type: string
32
+ description: the branch the implementation was pushed to (impl/TASK-NNN)
33
+ - name: images
34
+ type: list
35
+ description: >-
36
+ the full name:version ref of each touched component's published image (e.g.
37
+ "acme.parts.cap.sup.007.wid-backend:0.1.0-TASK-011-a1b2c3d"), by convention — no other actor
38
+ is ever told these tags explicitly
@@ -0,0 +1,17 @@
1
+ # WHAT MESSAGES EXIST. `papeete-actor-message/v1`, owned by `papeete-actor-message` — named
2
+ # messages, each a pure regrouping of references into the data dictionary, under an intent.
3
+ # Wire-agnostic by that package's own design: WHAT a message is, never HOW it is carried.
4
+ message: papeete-actor-message/v1
5
+ messages:
6
+ - name: implement-task-cmd
7
+ intent: ask this actor to implement a TASK-NNN card for the capability it serves
8
+ references: [task_id, title, definition_of_done, context, remediation_context]
9
+ optional: [context, remediation_context]
10
+
11
+ - name: task-implemented-result
12
+ intent: report that the task was implemented, pushed, and its touched components' images published
13
+ references: [accepted, branch, images]
14
+
15
+ - name: task-refused-result
16
+ intent: report that the task was not implemented, and why
17
+ references: [accepted, because]
@@ -0,0 +1,21 @@
1
+ # THE DOORS. `synchronous-messaging-doors/v1` — one non-deterministic door, and no queries.
2
+ #
3
+ # `engine: claude-code` is this actor kind's declared engine. A use's sidecar repeats it in its own
4
+ # `engine:` field, and the entrypoint registers the engine under exactly that key — so a use whose
5
+ # sidecar names a different engine is caught by its own card rather than at the first request.
6
+ manifest: synchronous-messaging-doors/v1
7
+ actions:
8
+ - id: implement-task
9
+ means: >-
10
+ the door for "implement TASK-NNN for the capability I serve". Send it here with the task id,
11
+ title, definition of done, and (optionally) context and remediation_context — the caller
12
+ supplies everything, no task card is looked up here. I clone my capability's repository into
13
+ my own private copy, ground myself in that capability's own standing context, judge how to
14
+ implement the task (writing only under whichever of my declared components it touches), then
15
+ commit and push to impl/TASK-NNN, and build each touched component's image in the cluster's
16
+ shared buildkit and push it to the registry, named and versioned by convention. I never open
17
+ a pull request — an orchestrating actor does, once a testing actor also confirms.
18
+ completion: whether I accepted, pushed, and published images (with the branch and image refs), or refused, and why.
19
+ door_schema: implement-task-cmd
20
+ completion_schema: [task-implemented-result, task-refused-result]
21
+ engine: claude-code
@@ -0,0 +1,8 @@
1
+ # A USE's identity card. The other three are copied verbatim from the definition this package
2
+ # ships, which is exactly what `conformance.check` asserts — only this file is a use's own, and
3
+ # only its `name:` and `description:` may differ.
4
+ manifest: papeete-actor-manifest/v0
5
+ name: ACME.PARTS.CAP.SUP.007.WID-implementation
6
+ description: >-
7
+ Implements TASK-NNN cards for the fictional capability ACME.PARTS.CAP.SUP.007.WID. Exists so the
8
+ gates that run against the installed wheel have a complete use to check, not a sidecar alone.
@@ -0,0 +1,49 @@
1
+ """The `-actor` suffix, checked rather than asserted.
2
+
3
+ `ADR-ECO-0022` makes the suffix an obligation: "A package ending in `-actor` asserts a
4
+ `papeete-actor` underneath, which lint-card can check. A future `<use>-<tier>-actor` that ships no
5
+ conformant card is misnamed, not merely unusual."
6
+
7
+ This file is that check. It runs the same gate `papeete-actor-synchronous-messaging lint-card` runs,
8
+ against the cards this package ships — so the claim in the name is a test, not a sentence in a
9
+ record.
10
+
11
+ The cards live under `src/`, which means `test_portability.py` greps them too: a capability id or a
12
+ knowledge tool name written into the actor's own definition fails the build exactly as it would in
13
+ the code.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from papeete_actor_synchronous_messaging import card
18
+
19
+ from foundry_implementation_actor import cards_path
20
+
21
+
22
+ def test_the_package_ships_the_four_cards():
23
+ """A card folder is four files; `Actor.from_card` opens exactly these and never globs."""
24
+ folder = cards_path()
25
+ missing = [name for name in ("actor.yaml", "actor-data.yaml", "actor-message.yaml",
26
+ "actor-synchronous-messaging.yaml")
27
+ if not (folder / name).exists()]
28
+ assert not missing, f"the actor's definition is incomplete — missing: {', '.join(missing)}"
29
+
30
+
31
+ def test_the_shipped_cards_are_conformant():
32
+ """The whole of the `-actor` claim: this folder is a papeete-actor, or the name is wrong."""
33
+ report = card.lint(cards_path())
34
+ assert not report.errors, ("the cards this package ships are not conformant:\n "
35
+ + "\n ".join(report.errors))
36
+
37
+
38
+ def test_the_door_names_the_engine_a_sidecar_must_declare():
39
+ """The card's door and a use's sidecar name the same engine key.
40
+
41
+ The entrypoint registers the engine under the sidecar's `engine:`, and the door resolves
42
+ through the card's. They are two statements of one fact, in two repos; this pins the half that
43
+ lives here so a use's `lint` failure is the only way they can disagree.
44
+ """
45
+ loaded = card.load(cards_path())
46
+ assert set(loaded.actions) == {"implement-task"}, (
47
+ f"expected the one implement-task door, got {sorted(loaded.actions)}")
48
+ engines = {offer.engine for offer in loaded.actions.values()}
49
+ assert engines == {"claude-code"}, f"expected the one claude-code door, got {engines}"
@@ -0,0 +1,111 @@
1
+ """A use answers the actor's doors, or it is not a use of that actor.
2
+
3
+ The definition and a use are two folders, each independently conformant, and neither gate has any
4
+ opinion about the other. `conformance.check` is the one that does — see `conformance.py` for what
5
+ it compares and, just as deliberately, what it does not.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+
11
+ import pytest
12
+ import yaml
13
+
14
+ from foundry_implementation_actor import cards_path, conformance
15
+
16
+ FIXTURE = "tests/fixtures/valid"
17
+
18
+
19
+ def _use(tmp_path, source=None):
20
+ """A complete use folder, copied so a test can bend one thing in it."""
21
+ folder = tmp_path / "use"
22
+ folder.mkdir()
23
+ for name in conformance.CARD_FILES:
24
+ shutil.copy((source or cards_path()) / name, folder / name)
25
+ return folder
26
+
27
+
28
+ def test_a_verbatim_copy_conforms(tmp_path):
29
+ assert conformance.check(_use(tmp_path)).ok
30
+
31
+
32
+ def test_the_committed_fixture_conforms():
33
+ """The fixture the wheel gates run against is a real use, not a sidecar on its own."""
34
+ report = conformance.check(FIXTURE)
35
+ assert report.ok, report.errors
36
+ assert any("answers the definition's doors" in line for line in report.oks)
37
+
38
+
39
+ def test_no_cards_warns_rather_than_fails(tmp_path):
40
+ """`lint` is also run against a bare sidecar. That is incomplete, not malformed."""
41
+ (tmp_path / "empty").mkdir()
42
+ report = conformance.check(tmp_path / "empty")
43
+ assert report.ok
44
+ assert any("no cards here" in w for w in report.warns)
45
+
46
+
47
+ def test_an_incomplete_card_set_fails(tmp_path):
48
+ folder = _use(tmp_path)
49
+ (folder / "actor-message.yaml").unlink()
50
+ report = conformance.check(folder)
51
+ assert not report.ok
52
+ assert "actor-message.yaml" in report.errors[0]
53
+
54
+
55
+ def test_a_drifted_payload_is_caught(tmp_path):
56
+ """The whole point: the use still lints, and still answers a different door.
57
+
58
+ A reference dropped from the door's own message is the cheapest realistic drift — the card set
59
+ remains internally valid, so every existing gate stays green.
60
+ """
61
+ folder = _use(tmp_path)
62
+ path = folder / "actor-message.yaml"
63
+ doc = yaml.safe_load(path.read_text())
64
+ doc["messages"][0]["references"].remove("title")
65
+ path.write_text(yaml.safe_dump(doc, sort_keys=False))
66
+
67
+ report = conformance.check(folder)
68
+ assert not report.ok
69
+ assert any("accepts a different payload" in e for e in report.errors)
70
+
71
+
72
+ def test_a_dropped_completion_outcome_is_caught(tmp_path):
73
+ folder = _use(tmp_path)
74
+ path = folder / "actor-synchronous-messaging.yaml"
75
+ doc = yaml.safe_load(path.read_text())
76
+ doc["actions"][0]["completion_schema"] = ["task-implemented-result"]
77
+ path.write_text(yaml.safe_dump(doc, sort_keys=False))
78
+
79
+ report = conformance.check(folder)
80
+ assert not report.ok
81
+ assert any("different completion set" in e for e in report.errors)
82
+
83
+
84
+ def test_a_different_engine_is_caught(tmp_path):
85
+ folder = _use(tmp_path)
86
+ path = folder / "actor-synchronous-messaging.yaml"
87
+ doc = yaml.safe_load(path.read_text())
88
+ doc["actions"][0]["engine"] = "some-other-engine"
89
+ path.write_text(yaml.safe_dump(doc, sort_keys=False))
90
+
91
+ report = conformance.check(folder)
92
+ assert not report.ok
93
+ assert any("resolves through engine" in e for e in report.errors)
94
+
95
+
96
+ def test_prose_and_identity_are_not_compared(tmp_path):
97
+ """A use SHOULD name its own capability and its own peers. That is not drift."""
98
+ folder = _use(tmp_path)
99
+
100
+ identity = folder / "actor.yaml"
101
+ doc = yaml.safe_load(identity.read_text())
102
+ doc["name"] = "ACME.PARTS.CAP.SUP.007.WID-implementation"
103
+ doc["description"] = "Implements TASK-NNN cards for ACME.PARTS.CAP.SUP.007.WID."
104
+ identity.write_text(yaml.safe_dump(doc, sort_keys=False))
105
+
106
+ doors = folder / "actor-synchronous-messaging.yaml"
107
+ doc = yaml.safe_load(doors.read_text())
108
+ doc["actions"][0]["means"] = "the door for 'implement TASK-NNN for ACME.PARTS.CAP.SUP.007.WID'."
109
+ doors.write_text(yaml.safe_dump(doc, sort_keys=False))
110
+
111
+ assert conformance.check(folder).ok
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "foundry-implementation-actor"
16
- version = "0.1.0"
16
+ version = "0.2.1"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "papeete-actor-synchronous-messaging" },