Skip to content

Commit 0ca3742

Browse files
dmealingclaude
andcommitted
feat(python): agent-context staleness nudge in gen/verify
Port the TS agent-context staleness advisory to the Python port. When an adopter upgrades MetaObjects but does not re-scaffold the copied-in .claude/skills agent context, `metaobjects gen`/`verify` now print ONE stderr line nudging a re-scaffold. Three parts (mirrors server/typescript/packages/sdk/src/agent-context/scaffold.ts): 1. Stamp `generatedBy` (SAME key as TS — polyglot cross-read) into the .metaobjects/.agent-context.json manifest when `agent-docs` scaffolds it. Version resolved via importlib.metadata.version("metaobjects"), falling back to "0.0.0" on PackageNotFoundError (mirrors the TS fallback). 2. Pure decision `agent_context_staleness(manifest, current_version)`: no manifest -> None; generatedBy == current -> None (exact equality on purpose — any drift nudges, never a semver compare); differs/absent -> a one-line message naming both versions + the Python refresh command ('metaobjects agent-docs'). Unit-testable, no FS. 3. gen + verify read <cwd>/.metaobjects/.agent-context.json near their start and print the message to stderr if stale. Advisory only: never raises, never changes the exit code, never writes; missing/corrupt manifest is silently ignored. The manifest is not in the byte-gated assembled file set, so adding generatedBy leaves agent-context conformance goldens unchanged. Tests: pure decision (none/equal/differ/absent), the stamp, and gen+verify stderr wiring (stale -> nudge + exit 0; absent/corrupt -> silent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dc24abc commit 0ca3742

5 files changed

Lines changed: 272 additions & 8 deletions

File tree

server/python/src/metaobjects/agent_context/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
AGENT_CONTEXT_MANIFEST_PATH,
2323
Manifest,
2424
ScaffoldDecision,
25+
agent_context_staleness,
2526
hash_contents,
27+
installed_metaobjects_version,
2628
plan_scaffold,
2729
)
2830
from .types import (
@@ -43,8 +45,10 @@
4345
"SKILL_NAMES",
4446
"ScaffoldDecision",
4547
"Stack",
48+
"agent_context_staleness",
4649
"assemble",
4750
"hash_contents",
51+
"installed_metaobjects_version",
4852
"make_stack",
4953
"plan_scaffold",
5054
"resolve_agent_context_root",

server/python/src/metaobjects/agent_context/scaffold.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import hashlib
1515
from collections.abc import Callable
1616
from dataclasses import dataclass, field
17+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
1718

1819
from .assemble import AssembledFile
1920
from .types import Stack
@@ -22,6 +23,20 @@
2223
AGENT_CONTEXT_MANIFEST_PATH = ".metaobjects/.agent-context.json"
2324

2425

26+
def installed_metaobjects_version() -> str:
27+
"""The installed ``metaobjects`` distribution version, or ``"0.0.0"`` if absent.
28+
29+
Resolved idiomatically via :func:`importlib.metadata.version`; a
30+
``PackageNotFoundError`` (e.g. running straight from a source checkout that
31+
was never installed) falls back to ``"0.0.0"`` — mirroring the TS reference's
32+
fallback so the stamp/nudge never crashes.
33+
"""
34+
try:
35+
return _pkg_version("metaobjects")
36+
except PackageNotFoundError:
37+
return "0.0.0"
38+
39+
2540
@dataclass
2641
class Manifest:
2742
"""Tracks what the assembler last wrote, so re-runs can detect hand-edits."""
@@ -31,14 +46,21 @@ class Manifest:
3146
clients: list[str]
3247
#: consumer-relative path → sha256 of the contents as last scaffolded.
3348
files: dict[str, str]
49+
#: The MetaObjects version that last scaffolded this context. Drives the
50+
#: staleness nudge (an upgrade can leave the copied-in skills/docs stale).
51+
#: Optional for back-compat with manifests written before version tracking.
52+
#: Serialized as ``generatedBy`` — the SAME key as the TS reference, so a
53+
#: polyglot repo can cross-read the manifest regardless of which port wrote it.
54+
generated_by: str | None = None
3455

3556
def to_json(self) -> dict[str, object]:
36-
return {
37-
"version": self.version,
38-
"servers": list(self.servers),
39-
"clients": list(self.clients),
40-
"files": dict(self.files),
41-
}
57+
out: dict[str, object] = {"version": self.version}
58+
if self.generated_by is not None:
59+
out["generatedBy"] = self.generated_by
60+
out["servers"] = list(self.servers)
61+
out["clients"] = list(self.clients)
62+
out["files"] = dict(self.files)
63+
return out
4264

4365
@staticmethod
4466
def from_json(data: dict[str, object]) -> "Manifest":
@@ -50,11 +72,13 @@ def from_json(data: dict[str, object]) -> "Manifest":
5072
)
5173
servers = data.get("servers", [])
5274
clients = data.get("clients", [])
75+
generated_by = data.get("generatedBy")
5376
return Manifest(
5477
version=int(data.get("version", 1)), # type: ignore[arg-type]
5578
servers=[str(x) for x in servers] if isinstance(servers, list) else [],
5679
clients=[str(x) for x in clients] if isinstance(clients, list) else [],
5780
files=files,
81+
generated_by=str(generated_by) if generated_by is not None else None,
5882
)
5983

6084

@@ -95,8 +119,13 @@ def plan_scaffold(
95119
assembled: list[AssembledFile],
96120
prior: Manifest | None,
97121
read_current: Callable[[str], str | None],
122+
generated_by: str | None = None,
98123
) -> ScaffoldDecision:
99-
"""Decide what to write for a (re-)scaffold (pure; FS via ``read_current``)."""
124+
"""Decide what to write for a (re-)scaffold (pure; FS via ``read_current``).
125+
126+
``generated_by`` is the MetaObjects version doing the scaffold — stamped into
127+
the persisted manifest so a later ``gen``/``verify`` can detect staleness.
128+
"""
100129
writes: list[_Write] = []
101130
conflicts: list[_Conflict] = []
102131
files: dict[str, str] = {}
@@ -130,6 +159,33 @@ def plan_scaffold(
130159
servers=list(stack.servers),
131160
clients=list(stack.clients),
132161
files=files,
162+
generated_by=generated_by,
133163
),
134164
removed=removed,
135165
)
166+
167+
168+
def agent_context_staleness(
169+
manifest: dict[str, object] | None, current_version: str
170+
) -> str | None:
171+
"""One-line nudge if the scaffolded agent context predates the install.
172+
173+
Returns ``None`` when there is nothing to say — no agent context here, or it
174+
is in sync — and a one-line advisory message otherwise. Pure + advisory:
175+
never raises, never blocks, never writes.
176+
177+
The comparison is **exact equality** on purpose: ANY drift nudges (a
178+
re-scaffold is cheap + idempotent). Do NOT "fix" this into a semver compare —
179+
a prerelease/build-metadata difference is still a reason to refresh.
180+
"""
181+
if manifest is None:
182+
return None # no agent context here → nothing to nudge
183+
generated_by = manifest.get("generatedBy")
184+
if generated_by == current_version:
185+
return None # in sync
186+
frm = generated_by if generated_by else "an older MetaObjects"
187+
return (
188+
f"MetaObjects agent context was generated by {frm}; "
189+
f"you're on {current_version}. Re-run 'metaobjects agent-docs' to "
190+
f"refresh the .claude/skills docs."
191+
)

server/python/src/metaobjects/cli.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@
4444
from metaobjects.agent_context import (
4545
AGENT_CONTEXT_MANIFEST_PATH,
4646
Manifest,
47+
agent_context_staleness,
4748
assemble,
49+
installed_metaobjects_version,
4850
make_stack,
4951
plan_scaffold,
5052
resolve_agent_context_root,
@@ -161,11 +163,35 @@ def _cmd_list(_args: argparse.Namespace) -> int:
161163
return 0
162164

163165

166+
def _warn_if_agent_context_stale() -> None:
167+
"""Print ONE advisory line to stderr if the scaffolded agent context is stale.
168+
169+
Reads ``<cwd>/.metaobjects/.agent-context.json`` (if present), compares its
170+
stamped ``generatedBy`` to the installed version, and nudges a re-scaffold on
171+
any drift. Advisory only: never raises, never changes the exit code, never
172+
writes — a missing or corrupt manifest is silently ignored.
173+
"""
174+
try:
175+
manifest_path = Path.cwd() / AGENT_CONTEXT_MANIFEST_PATH
176+
if not manifest_path.is_file():
177+
return
178+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
179+
if not isinstance(manifest, dict):
180+
return
181+
msg = agent_context_staleness(manifest, installed_metaobjects_version())
182+
if msg is not None:
183+
print(msg, file=sys.stderr)
184+
except Exception: # noqa: BLE001 — advisory; any failure is silently ignored
185+
return
186+
187+
164188
def _cmd_gen(args: argparse.Namespace) -> int:
165189
# `--list` is a pure discoverability path: print the registry and exit, no codegen.
166190
if getattr(args, "list", False):
167191
return _cmd_list(args)
168192

193+
_warn_if_agent_context_stale()
194+
169195
if args.metadata_dir is None or args.out is None:
170196
print(
171197
"error: gen requires <metadata_dir> and --out (or use --list).",
@@ -395,6 +421,8 @@ def _cmd_verify(args: argparse.Namespace) -> int:
395421
max (non-zero if ANY mode drifts). Bare ``verify`` (no subverb) keeps the
396422
historical default = ``--codegen`` + a one-line note advertising the subverbs.
397423
"""
424+
_warn_if_agent_context_stale()
425+
398426
run_db = args.db is not None
399427
run_templates = bool(args.templates)
400428
run_codegen = bool(args.codegen)
@@ -505,7 +533,13 @@ def _read_current(rel: str) -> str | None:
505533
p = out_dir / rel
506534
return p.read_bytes().decode("utf-8") if p.is_file() else None
507535

508-
decision = plan_scaffold(stack, assembled, prior, _read_current)
536+
decision = plan_scaffold(
537+
stack,
538+
assembled,
539+
prior,
540+
_read_current,
541+
generated_by=installed_metaobjects_version(),
542+
)
509543

510544
for w in decision.writes:
511545
dest = out_dir / w.path
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""``gen``/``verify`` print the agent-context staleness nudge to stderr.
2+
3+
Advisory only: a stale ``.metaobjects/.agent-context.json`` in the cwd causes ONE
4+
stderr line but never changes the exit code, never writes, and a missing/corrupt
5+
manifest is silently ignored.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from pathlib import Path
12+
13+
from metaobjects.cli import main
14+
15+
FIXTURE = (
16+
Path(__file__).parents[4]
17+
/ "fixtures"
18+
/ "persistence-conformance"
19+
/ "canonical"
20+
/ "meta.fitness.json"
21+
)
22+
23+
24+
def _meta_dir(tmp_path: Path) -> str:
25+
d = tmp_path / "meta"
26+
d.mkdir()
27+
(d / "meta.fitness.json").write_text(FIXTURE.read_text())
28+
return str(d)
29+
30+
31+
def _write_manifest(cwd: Path, generated_by: str | None) -> None:
32+
m: dict[str, object] = {"version": 1, "servers": ["python"], "clients": [], "files": {}}
33+
if generated_by is not None:
34+
m["generatedBy"] = generated_by
35+
p = cwd / ".metaobjects" / ".agent-context.json"
36+
p.parent.mkdir(parents=True, exist_ok=True)
37+
p.write_text(json.dumps(m, indent=2) + "\n")
38+
39+
40+
def test_gen_nudges_on_stale_manifest(tmp_path, capsys, monkeypatch) -> None:
41+
meta_dir = _meta_dir(tmp_path)
42+
out = tmp_path / "out"
43+
monkeypatch.chdir(tmp_path)
44+
_write_manifest(tmp_path, "0.0.1-old")
45+
46+
rc = main(["gen", meta_dir, "--out", str(out)])
47+
assert rc == 0 # advisory: never changes the exit code
48+
err = capsys.readouterr().err
49+
assert "0.0.1-old" in err
50+
assert "metaobjects agent-docs" in err
51+
52+
53+
def test_verify_nudges_on_stale_manifest(tmp_path, capsys, monkeypatch) -> None:
54+
meta_dir = _meta_dir(tmp_path)
55+
out = tmp_path / "out"
56+
monkeypatch.chdir(tmp_path)
57+
# Generate AND verify under the same cwd so verify --codegen is in-sync (no
58+
# DRIFT) — isolating the nudge from the codegen exit code.
59+
main(["gen", meta_dir, "--out", str(out)])
60+
capsys.readouterr() # drain
61+
62+
_write_manifest(tmp_path, "0.0.1-old")
63+
64+
rc = main(["verify", meta_dir, "--codegen", "--out", str(out)])
65+
assert rc == 0
66+
err = capsys.readouterr().err
67+
assert "0.0.1-old" in err
68+
assert "metaobjects agent-docs" in err
69+
70+
71+
def test_gen_silent_when_no_manifest(tmp_path, capsys, monkeypatch) -> None:
72+
meta_dir = _meta_dir(tmp_path)
73+
out = tmp_path / "out"
74+
monkeypatch.chdir(tmp_path) # no manifest in cwd
75+
76+
rc = main(["gen", meta_dir, "--out", str(out)])
77+
assert rc == 0
78+
err = capsys.readouterr().err
79+
assert "metaobjects agent-docs" not in err
80+
81+
82+
def test_gen_silent_on_corrupt_manifest(tmp_path, capsys, monkeypatch) -> None:
83+
meta_dir = _meta_dir(tmp_path)
84+
out = tmp_path / "out"
85+
monkeypatch.chdir(tmp_path)
86+
p = tmp_path / ".metaobjects" / ".agent-context.json"
87+
p.parent.mkdir(parents=True, exist_ok=True)
88+
p.write_text("{ this is not valid json ") # corrupt → silently ignored
89+
90+
rc = main(["gen", meta_dir, "--out", str(out)])
91+
assert rc == 0
92+
err = capsys.readouterr().err
93+
assert "metaobjects agent-docs" not in err
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Unit tests for the agent-context staleness nudge (cross-port feature).
2+
3+
Port of the TS reference (``server/typescript/packages/sdk/src/agent-context/
4+
scaffold.ts`` — ``agentContextStaleness`` + ``generatedBy`` manifest stamping).
5+
6+
When an adopter upgrades MetaObjects but does not re-scaffold the copied-in
7+
``.claude/skills`` agent context, ``gen``/``verify`` should print ONE advisory
8+
line nudging a re-scaffold. The decision is a pure function (exact-equality on
9+
the stamped version — ANY drift nudges, never a semver compare), and the manifest
10+
written by ``agent-docs`` carries the installed version under ``generatedBy``
11+
(SAME key as TS — a polyglot repo may cross-read the manifest).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
from pathlib import Path
18+
19+
from metaobjects.agent_context.scaffold import (
20+
agent_context_staleness,
21+
installed_metaobjects_version,
22+
)
23+
from metaobjects.cli import main
24+
25+
26+
# ---- the pure decision -----------------------------------------------------
27+
28+
29+
def test_no_manifest_returns_none() -> None:
30+
# No agent context scaffolded here → nothing to nudge.
31+
assert agent_context_staleness(None, "0.7.0") is None
32+
33+
34+
def test_matching_version_returns_none() -> None:
35+
# In sync → silent.
36+
manifest = {"version": 1, "generatedBy": "0.7.0", "servers": [], "clients": [], "files": {}}
37+
assert agent_context_staleness(manifest, "0.7.0") is None
38+
39+
40+
def test_differing_version_nudges_naming_both_versions_and_command() -> None:
41+
manifest = {"version": 1, "generatedBy": "0.6.1", "servers": [], "clients": [], "files": {}}
42+
msg = agent_context_staleness(manifest, "0.7.0")
43+
assert msg is not None
44+
assert "0.6.1" in msg # the from-version
45+
assert "0.7.0" in msg # the current version
46+
assert "agent-docs" in msg # names the Python refresh command
47+
assert "metaobjects agent-docs" in msg
48+
49+
50+
def test_absent_generated_by_nudges_with_older_phrase() -> None:
51+
# A manifest written before version tracking existed → still nudge.
52+
manifest = {"version": 1, "servers": [], "clients": [], "files": {}}
53+
msg = agent_context_staleness(manifest, "0.7.0")
54+
assert msg is not None
55+
assert "an older MetaObjects" in msg
56+
assert "0.7.0" in msg
57+
assert "metaobjects agent-docs" in msg
58+
59+
60+
def test_installed_version_is_a_string() -> None:
61+
# Resolved via importlib.metadata; falls back to "0.0.0" off the package.
62+
v = installed_metaobjects_version()
63+
assert isinstance(v, str)
64+
assert v # non-empty
65+
66+
67+
# ---- the stamp -------------------------------------------------------------
68+
69+
70+
def test_agent_docs_stamps_generated_by(tmp_path: Path) -> None:
71+
rc = main(["agent-docs", "--server", "python", "--out", str(tmp_path)])
72+
assert rc == 0
73+
manifest = json.loads(
74+
(tmp_path / ".metaobjects" / ".agent-context.json").read_text()
75+
)
76+
assert "generatedBy" in manifest
77+
assert manifest["generatedBy"] == installed_metaobjects_version()

0 commit comments

Comments
 (0)