Skip to content

Commit f19adb7

Browse files
dmealingclaude
andcommitted
feat(maven-plugin): nudge to refresh agent-context when it predates the installed version
Port the agent-context staleness nudge (TS reference) to the JVM port — covers Java AND Kotlin, since Kotlin codegen runs through the same Maven goals. - Stamp the installed MetaObjects version into the agent-context manifest as "generatedBy" (same key as TS for cross-read in a polyglot repo). The version is resolved the idiomatic Maven way: read pom.properties for com.metaobjects:metaobjects-metadata off the classpath, "0.0.0" fallback. - Add a pure, unit-testable AgentContextScaffold.staleness(manifest, current): no manifest -> null; generatedBy exactly equals current -> null (exact equality on purpose, never a semver compare); differs/absent -> a one-line advisory naming both versions and the Java refresh goal 'mvn metaobjects:agent-docs'. - Wire an advisory check into gen + verify (AbstractMetaDataMojo.execute and the verify override) reading .metaobjects/.agent-context.json under the project basedir. Advisory only: never throws, never fails the build, never writes; a missing/corrupt manifest is silently ignored. The manifest is not part of the byte-gated assembled file set, so adding "generatedBy" leaves AgentContextConformanceTest unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 300bb0a commit f19adb7

6 files changed

Lines changed: 222 additions & 15 deletions

File tree

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AbstractMetaDataMojo.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.metaobjects.mojo;
22

3+
import com.google.gson.JsonObject;
4+
import com.google.gson.JsonParser;
35
import com.metaobjects.MetaDataException;
6+
import com.metaobjects.agentcontext.AgentContextScaffold;
47
import com.metaobjects.generator.Generator;
58
import com.metaobjects.loader.LoaderConfigurable;
69
import com.metaobjects.loader.LoaderOptions;
@@ -17,8 +20,12 @@
1720
import java.lang.reflect.InvocationTargetException;
1821
import java.net.URL;
1922
import java.net.URLClassLoader;
23+
import java.nio.charset.StandardCharsets;
24+
import java.nio.file.Files;
25+
import java.nio.file.Path;
2026
import java.util.ArrayList;
2127
import java.util.HashMap;
28+
import java.util.LinkedHashMap;
2229
import java.util.List;
2330
import java.util.Map;
2431

@@ -73,6 +80,8 @@ public void execute() throws MojoExecutionException, MojoFailureException
7380
if ( getLoader() == null ) {
7481
throw new MojoExecutionException( "No <loader> element was defined");
7582
}
83+
warnIfAgentContextStale();
84+
7685
ClassLoader projectClassLoader = createProjectClassLoader();
7786

7887
MetaDataLoader loader = createLoader(projectClassLoader);
@@ -154,6 +163,43 @@ public Map<String, String> mergeAndOverwriteArgs(GeneratorParam g) {
154163
return allargs;
155164
}
156165

166+
/**
167+
* Advisory nudge: if the consumer's copied-in agent context
168+
* ({@code .metaobjects/.agent-context.json} under the project basedir) was scaffolded
169+
* by a different MetaObjects version than the one running, print ONE warning line
170+
* suggesting {@code mvn metaobjects:agent-docs}.
171+
*
172+
* <p>Strictly advisory — never throws, never fails the build, never writes. A missing
173+
* or corrupt manifest is silently ignored (no agent context here → nothing to say).
174+
*/
175+
protected void warnIfAgentContextStale() {
176+
try {
177+
File basedir = project != null ? project.getBasedir() : null;
178+
Path cwd = basedir != null ? basedir.toPath()
179+
: Path.of(System.getProperty("user.dir"));
180+
Path manifestPath = cwd.resolve(AgentContextScaffold.MANIFEST_PATH);
181+
if (!Files.isRegularFile(manifestPath)) {
182+
return; // no agent context scaffolded here → nothing to nudge
183+
}
184+
JsonObject obj = JsonParser.parseString(
185+
new String(Files.readAllBytes(manifestPath), StandardCharsets.UTF_8))
186+
.getAsJsonObject();
187+
int version = obj.has("version") ? obj.get("version").getAsInt() : 1;
188+
String generatedBy = obj.has("generatedBy") && obj.get("generatedBy").isJsonPrimitive()
189+
? obj.get("generatedBy").getAsString() : null;
190+
AgentContextScaffold.Manifest manifest = new AgentContextScaffold.Manifest(
191+
version, generatedBy, new ArrayList<>(), new ArrayList<>(),
192+
new LinkedHashMap<>());
193+
String nudge = AgentContextScaffold.staleness(
194+
manifest, AgentContextScaffold.installedVersion());
195+
if (nudge != null) {
196+
getLog().warn(nudge);
197+
}
198+
} catch (Exception ignored) {
199+
// Advisory only — any failure to read/parse the manifest is silently ignored.
200+
}
201+
}
202+
157203
protected abstract void executeGenerators(MetaDataLoader loader, List<Generator> generatorImpls);
158204

159205
protected MetaDataLoader createLoader(ClassLoader projectClassLoader) {

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/AgentDocsMojo.java

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ public void execute() throws MojoExecutionException {
9898
Path manifestPath = outDir.resolve(AgentContextScaffold.MANIFEST_PATH);
9999
AgentContextScaffold.Manifest prior = readPriorManifest(manifestPath);
100100

101-
AgentContextScaffold.ScaffoldDecision decision =
102-
AgentContextScaffold.plan(stack, assembled, prior, rel -> readCurrent(outDir, rel));
101+
AgentContextScaffold.ScaffoldDecision decision = AgentContextScaffold.plan(
102+
stack, assembled, prior, rel -> readCurrent(outDir, rel),
103+
AgentContextScaffold.installedVersion());
103104

104105
try {
105106
for (AgentContextScaffold.Write w : decision.writes()) {
@@ -156,7 +157,9 @@ private static AgentContextScaffold.Manifest parseManifest(JsonObject obj) {
156157
List<String> srv = jsonStrings(obj, "servers");
157158
List<String> cli = jsonStrings(obj, "clients");
158159
int version = obj.has("version") ? obj.get("version").getAsInt() : 1;
159-
return new AgentContextScaffold.Manifest(version, srv, cli, files);
160+
String generatedBy = obj.has("generatedBy") && obj.get("generatedBy").isJsonPrimitive()
161+
? obj.get("generatedBy").getAsString() : null;
162+
return new AgentContextScaffold.Manifest(version, generatedBy, srv, cli, files);
160163
}
161164

162165
private static List<String> jsonStrings(JsonObject obj, String key) {
@@ -186,9 +189,12 @@ private static void writeFile(Path dest, String contents) throws IOException {
186189

187190
private void writeManifest(Path manifestPath, AgentContextScaffold.Manifest manifest)
188191
throws IOException {
189-
// Build an ordered JSON object: version, servers, clients, files.
192+
// Build an ordered JSON object: version, generatedBy, servers, clients, files.
190193
Map<String, Object> doc = new LinkedHashMap<>();
191194
doc.put("version", manifest.version());
195+
if (manifest.generatedBy() != null) {
196+
doc.put("generatedBy", manifest.generatedBy());
197+
}
192198
doc.put("servers", manifest.servers());
193199
doc.put("clients", manifest.clients());
194200
doc.put("files", manifest.files());

server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
107107
if (getLoader() == null) {
108108
throw new MojoExecutionException("No <loader> element was defined");
109109
}
110+
warnIfAgentContextStale();
110111

111112
String m = mode == null ? MODE_CODEGEN : mode.trim();
112113
if (MODE_TEMPLATES.equalsIgnoreCase(m)) {

server/java/metadata/src/main/java/com/metaobjects/agentcontext/AgentContextScaffold.java

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.metaobjects.agentcontext;
22

3+
import java.io.InputStream;
34
import java.nio.charset.StandardCharsets;
45
import java.security.MessageDigest;
56
import java.security.NoSuchAlgorithmException;
67
import java.util.ArrayList;
78
import java.util.LinkedHashMap;
89
import java.util.List;
910
import java.util.Map;
11+
import java.util.Properties;
1012
import java.util.function.Function;
1113

1214
/**
@@ -26,6 +28,19 @@ public final class AgentContextScaffold {
2628
/** Consumer-relative path of the sidecar manifest that tracks scaffolded files. */
2729
public static final String MANIFEST_PATH = ".metaobjects/.agent-context.json";
2830

31+
/**
32+
* Safe fallback when the installed version can't be resolved from the classpath
33+
* (mirrors the TS reference's {@code "0.0.0"} fallback). Compares unequal to any
34+
* real stamped version, so a real {@code generatedBy} always wins the equality
35+
* check — the nudge stays advisory, never spuriously asserting "in sync".
36+
*/
37+
public static final String UNKNOWN_VERSION = "0.0.0";
38+
39+
/** Maven group of the artifact carrying this class — used to find {@code pom.properties}. */
40+
private static final String MAVEN_GROUP_ID = "com.metaobjects";
41+
/** Maven artifact carrying this class — used to find {@code pom.properties}. */
42+
private static final String MAVEN_ARTIFACT_ID = "metaobjects-metadata";
43+
2944
private AgentContextScaffold() {
3045
}
3146

@@ -37,9 +52,19 @@ public record Write(String path, String contents) {
3752
public record Conflict(String path, String newPath, String contents) {
3853
}
3954

40-
/** Tracks what the assembler last wrote, so re-runs can detect hand-edits. */
41-
public record Manifest(int version, List<String> servers, List<String> clients,
42-
Map<String, String> files) {
55+
/**
56+
* Tracks what the assembler last wrote, so re-runs can detect hand-edits.
57+
*
58+
* <p>{@code generatedBy} is the MetaObjects version that last scaffolded this agent
59+
* context. It is stamped so {@code gen}/{@code verify} can nudge a re-scaffold when
60+
* the installed version moves ahead (the skills/docs ship with the artifact, so an
61+
* upgrade can leave the copied-in context stale). It may be {@code null} for
62+
* back-compat with manifests written before version tracking existed (and for
63+
* cross-read manifests written by another port). Serialized as {@code "generatedBy"}
64+
* — the SAME key as the TS reference — so a polyglot repo can cross-read it.
65+
*/
66+
public record Manifest(int version, String generatedBy, List<String> servers,
67+
List<String> clients, Map<String, String> files) {
4368
}
4469

4570
/** The outcome of planning a (re-)scaffold. */
@@ -73,11 +98,14 @@ public static String hashContents(String s) {
7398
* @param prior the prior manifest, or {@code null} for a fresh scaffold.
7499
* @param readCurrent reads the on-disk contents of a consumer-relative path, or
75100
* {@code null} if absent.
101+
* @param generatedBy the installed MetaObjects version doing the scaffold — stamped
102+
* into the manifest so later runs can nudge a stale re-scaffold.
76103
* @return the scaffold decision (writes, conflicts, manifest, removed).
77104
*/
78105
public static ScaffoldDecision plan(Stack stack, List<AssembledFile> assembled,
79106
Manifest prior,
80-
Function<String, String> readCurrent) {
107+
Function<String, String> readCurrent,
108+
String generatedBy) {
81109
List<Write> writes = new ArrayList<>();
82110
List<Conflict> conflicts = new ArrayList<>();
83111
Map<String, String> files = new LinkedHashMap<>();
@@ -112,9 +140,64 @@ public static ScaffoldDecision plan(Stack stack, List<AssembledFile> assembled,
112140

113141
Manifest manifest = new Manifest(
114142
1,
143+
generatedBy,
115144
List.copyOf(stack.servers()),
116145
List.copyOf(stack.clients()),
117146
files);
118147
return new ScaffoldDecision(writes, conflicts, manifest, removed);
119148
}
149+
150+
/**
151+
* A one-line nudge if the scaffolded agent context predates the installed MetaObjects
152+
* (so {@code gen}/{@code verify} can remind the user to refresh the skills after an
153+
* upgrade), or {@code null} when there is nothing to say — no agent context
154+
* scaffolded, or it is in sync.
155+
*
156+
* <p>Pure + advisory: never throws, never blocks, never writes. Direct port of the TS
157+
* {@code agentContextStaleness}. The equality check is EXACT on purpose — any drift
158+
* (including a prerelease/build-metadata difference) nudges, because a re-scaffold is
159+
* cheap + idempotent. Don't "fix" this into a semver compare.
160+
*
161+
* @param manifest the parsed prior manifest, or {@code null} if none on disk.
162+
* @param currentVersion the installed MetaObjects version (see {@link #installedVersion()}).
163+
* @return the nudge message, or {@code null} when nothing needs saying.
164+
*/
165+
public static String staleness(Manifest manifest, String currentVersion) {
166+
if (manifest == null) {
167+
return null; // no agent context here → nothing to nudge
168+
}
169+
if (currentVersion.equals(manifest.generatedBy())) {
170+
return null; // in sync
171+
}
172+
String from = manifest.generatedBy() != null ? manifest.generatedBy() : "an older MetaObjects";
173+
return "MetaObjects agent context was generated by " + from + "; you're on "
174+
+ currentVersion + ". Re-run 'mvn metaobjects:agent-docs' to refresh "
175+
+ "the .claude/skills docs.";
176+
}
177+
178+
/**
179+
* The installed MetaObjects version, resolved the idiomatic Maven way: read
180+
* {@code /META-INF/maven/com.metaobjects/metaobjects-metadata/pom.properties} from the
181+
* classpath (Maven stamps this into the built jar). Falls back to
182+
* {@link #UNKNOWN_VERSION} when it can't be resolved (e.g. running from an exploded
183+
* classes dir in a test/IDE) — mirroring the TS reference's {@code "0.0.0"} fallback.
184+
* Never throws.
185+
*
186+
* @return the installed version, or {@link #UNKNOWN_VERSION}.
187+
*/
188+
public static String installedVersion() {
189+
String resource = "/META-INF/maven/" + MAVEN_GROUP_ID + "/" + MAVEN_ARTIFACT_ID
190+
+ "/pom.properties";
191+
try (InputStream in = AgentContextScaffold.class.getResourceAsStream(resource)) {
192+
if (in == null) {
193+
return UNKNOWN_VERSION;
194+
}
195+
Properties props = new Properties();
196+
props.load(in);
197+
String v = props.getProperty("version");
198+
return (v == null || v.isBlank()) ? UNKNOWN_VERSION : v.trim();
199+
} catch (Exception e) {
200+
return UNKNOWN_VERSION;
201+
}
202+
}
120203
}

server/java/metadata/src/test/java/com/metaobjects/agentcontext/AgentContextScaffoldTest.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ private static List<AssembledFile> assembled() {
2727
@Test
2828
public void absentFilesAreWrittenFresh() {
2929
AgentContextScaffold.ScaffoldDecision d =
30-
AgentContextScaffold.plan(STACK, assembled(), null, rel -> null);
30+
AgentContextScaffold.plan(STACK, assembled(), null, rel -> null, "9.9.9");
3131
assertEquals(2, d.writes().size());
3232
assertTrue(d.conflicts().isEmpty());
3333
assertTrue(d.removed().isEmpty());
@@ -36,28 +36,35 @@ public void absentFilesAreWrittenFresh() {
3636
d.manifest().files().get(".metaobjects/AGENTS.md"));
3737
}
3838

39+
@Test
40+
public void manifestRecordsGeneratedBy() {
41+
AgentContextScaffold.ScaffoldDecision d =
42+
AgentContextScaffold.plan(STACK, assembled(), null, rel -> null, "9.9.9");
43+
assertEquals("9.9.9", d.manifest().generatedBy());
44+
}
45+
3946
@Test
4047
public void unmodifiedSinceManifestIsRefreshed() {
4148
AgentContextScaffold.Manifest prior = new AgentContextScaffold.Manifest(
42-
1, List.of("java"), List.of("react"),
49+
1, "1.0.0", List.of("java"), List.of("react"),
4350
Map.of(".metaobjects/AGENTS.md", AgentContextScaffold.hashContents("alpha")));
4451
// On disk still holds the prior-recorded contents → safe to overwrite.
4552
AgentContextScaffold.ScaffoldDecision d = AgentContextScaffold.plan(
4653
STACK, assembled(), prior,
47-
rel -> rel.equals(".metaobjects/AGENTS.md") ? "alpha" : null);
54+
rel -> rel.equals(".metaobjects/AGENTS.md") ? "alpha" : null, "9.9.9");
4855
assertEquals(2, d.writes().size());
4956
assertTrue(d.conflicts().isEmpty());
5057
}
5158

5259
@Test
5360
public void handEditedFileBecomesConflict() {
5461
AgentContextScaffold.Manifest prior = new AgentContextScaffold.Manifest(
55-
1, List.of("java"), List.of("react"),
62+
1, "1.0.0", List.of("java"), List.of("react"),
5663
Map.of(".metaobjects/AGENTS.md", AgentContextScaffold.hashContents("alpha")));
5764
// On disk differs from the recorded hash → preserve it, emit a .new.
5865
AgentContextScaffold.ScaffoldDecision d = AgentContextScaffold.plan(
5966
STACK, assembled(), prior,
60-
rel -> rel.equals(".metaobjects/AGENTS.md") ? "USER EDIT" : null);
67+
rel -> rel.equals(".metaobjects/AGENTS.md") ? "USER EDIT" : null, "9.9.9");
6168
assertEquals(1, d.writes().size());
6269
assertEquals(1, d.conflicts().size());
6370
assertEquals(".metaobjects/AGENTS.md.new", d.conflicts().get(0).newPath());
@@ -66,10 +73,10 @@ STACK, assembled(), prior,
6673
@Test
6774
public void priorPathNoLongerAssembledIsReportedRemoved() {
6875
AgentContextScaffold.Manifest prior = new AgentContextScaffold.Manifest(
69-
1, List.of("java"), List.of("react"),
76+
1, "1.0.0", List.of("java"), List.of("react"),
7077
Map.of(".claude/skills/gone/SKILL.md", "deadbeef"));
7178
AgentContextScaffold.ScaffoldDecision d =
72-
AgentContextScaffold.plan(STACK, assembled(), prior, rel -> null);
79+
AgentContextScaffold.plan(STACK, assembled(), prior, rel -> null, "9.9.9");
7380
assertEquals(List.of(".claude/skills/gone/SKILL.md"), d.removed());
7481
}
7582
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.metaobjects.agentcontext;
2+
3+
import org.junit.Test;
4+
5+
import java.util.List;
6+
import java.util.Map;
7+
8+
import static org.junit.Assert.assertEquals;
9+
import static org.junit.Assert.assertNull;
10+
import static org.junit.Assert.assertTrue;
11+
12+
/**
13+
* Unit coverage for the pure agent-context staleness decision — mirrors the TS
14+
* reference {@code agentContextStaleness}
15+
* ({@code server/typescript/packages/sdk/src/agent-context/scaffold.ts}):
16+
*
17+
* <ul>
18+
* <li>no manifest → {@code null} (nothing scaffolded here, nothing to nudge);</li>
19+
* <li>{@code generatedBy} EQUALS current → {@code null} (in sync; exact-equality
20+
* on purpose, NOT a semver compare — any drift nudges);</li>
21+
* <li>{@code generatedBy} DIFFERS → a one-line nudge naming both versions and the
22+
* Java refresh goal {@code mvn metaobjects:agent-docs};</li>
23+
* <li>{@code generatedBy} ABSENT (legacy manifest) → a nudge with "an older
24+
* MetaObjects" as the from-version.</li>
25+
* </ul>
26+
*
27+
* <p>Advisory only — the production method never throws and never blocks.
28+
*/
29+
public class AgentContextStalenessTest {
30+
31+
private static AgentContextScaffold.Manifest manifest(String generatedBy) {
32+
return new AgentContextScaffold.Manifest(
33+
1, generatedBy, List.of("java"), List.of("react"), Map.of());
34+
}
35+
36+
@Test
37+
public void noManifestIsNull() {
38+
assertNull(AgentContextScaffold.staleness(null, "7.2.1"));
39+
}
40+
41+
@Test
42+
public void inSyncIsNull() {
43+
assertNull(AgentContextScaffold.staleness(manifest("7.2.1"), "7.2.1"));
44+
}
45+
46+
@Test
47+
public void differingVersionNudgesNamingBoth() {
48+
String msg = AgentContextScaffold.staleness(manifest("7.1.0"), "7.2.1");
49+
assertEquals(
50+
"MetaObjects agent context was generated by 7.1.0; you're on 7.2.1. "
51+
+ "Re-run 'mvn metaobjects:agent-docs' to refresh the .claude/skills docs.",
52+
msg);
53+
}
54+
55+
@Test
56+
public void absentGeneratedByNudgesAsOlder() {
57+
String msg = AgentContextScaffold.staleness(manifest(null), "7.2.1");
58+
assertTrue("should name the current version", msg.contains("7.2.1"));
59+
assertTrue("should describe an older MetaObjects",
60+
msg.contains("an older MetaObjects"));
61+
assertTrue("should name the agent-docs refresh goal",
62+
msg.contains("mvn metaobjects:agent-docs"));
63+
}
64+
}

0 commit comments

Comments
 (0)