Skip to content

Commit dc24abc

Browse files
dmealingclaude
andcommitted
feat(csharp): port agent-context staleness nudge to the .NET CLI
When an adopter upgrades MetaObjects but doesn't re-scaffold the copied-in .claude/skills agent context, `dotnet meta gen`/`verify` now print ONE advisory line nudging a re-scaffold. Mirrors the TS reference. - Stamp `generatedBy` (the installed assembly's InformationalVersion, with a "0.0.0" fallback) into the .metaobjects/.agent-context.json manifest when agent-docs writes it. Same JSON key as the other ports so a polyglot repo can cross-read the stamp; UnsafeRelaxedJsonEscaping encoder unchanged. - Pure, unit-testable AgentContextScaffold.AgentContextStaleness(manifest, current): null manifest -> null; exact-equal version -> null (any drift nudges, no semver parsing); otherwise a one-line message naming both versions + 'dotnet meta agent-docs'. - Wire the check into gen + verify (AgentContextStalenessCheck.WarnIfStale): read the cwd manifest if present and write the message to stderr. Advisory only — never throws, never changes the exit code; missing/corrupt manifest is silently ignored. The manifest is not part of the byte-gated assembled file set, so the agent-context conformance gate is unaffected (4/4 still green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f19adb7 commit dc24abc

6 files changed

Lines changed: 238 additions & 3 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// AgentContextStalenessTests — the staleness-nudge feature (port of the TS
2+
// agent-context-staleness wiring).
3+
//
4+
// Three things are exercised:
5+
// 1. The stamp — `dotnet meta agent-docs` writes a `generatedBy` into the manifest.
6+
// 2. The pure decision — AgentContextScaffold.AgentContextStaleness(manifest, current):
7+
// null manifest -> null (no agent context here)
8+
// manifest.GeneratedBy == cur -> null (in sync; exact equality on purpose)
9+
// differs / null GeneratedBy -> a one-line nudge naming both versions + agent-docs
10+
// 3. The version source — the installed-assembly version is read, never hardcoded.
11+
12+
using System.Text.Json;
13+
using MetaObjects.AgentContext;
14+
using MetaObjects.Cli;
15+
using Xunit;
16+
17+
namespace MetaObjects.Cli.Tests;
18+
19+
public sealed class AgentContextStalenessTests
20+
{
21+
// ---- 1. the stamp -------------------------------------------------------
22+
23+
[Fact]
24+
public void AgentDocs_stamps_generatedBy_into_the_manifest()
25+
{
26+
var tmp = Path.Combine(Path.GetTempPath(), "meta-stale-" + Guid.NewGuid().ToString("N"));
27+
Directory.CreateDirectory(tmp);
28+
try
29+
{
30+
// A *.csproj triggers the csharp-server stack auto-detection.
31+
File.WriteAllText(Path.Combine(tmp, "App.csproj"), "<Project />");
32+
33+
var exit = AgentDocsCommand.Run(new[] { "--out", tmp });
34+
Assert.Equal(0, exit);
35+
36+
var manifestPath = Path.Combine(tmp, AgentContextScaffold.ManifestPath);
37+
Assert.True(File.Exists(manifestPath), "manifest was not written");
38+
39+
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
40+
Assert.True(doc.RootElement.TryGetProperty("generatedBy", out var gb),
41+
"manifest is missing the generatedBy property");
42+
var stamped = gb.GetString();
43+
Assert.False(string.IsNullOrEmpty(stamped));
44+
// It must be the live installed version, not a literal.
45+
Assert.Equal(AgentContextStalenessCheck.CurrentVersion(), stamped);
46+
}
47+
finally
48+
{
49+
try { Directory.Delete(tmp, recursive: true); } catch { }
50+
}
51+
}
52+
53+
// ---- 2. the pure decision ----------------------------------------------
54+
55+
[Fact]
56+
public void Null_manifest_is_not_stale()
57+
{
58+
Assert.Null(AgentContextScaffold.AgentContextStaleness(null, "1.2.3"));
59+
}
60+
61+
[Fact]
62+
public void Matching_generatedBy_is_not_stale()
63+
{
64+
var m = ManifestWith("1.2.3");
65+
Assert.Null(AgentContextScaffold.AgentContextStaleness(m, "1.2.3"));
66+
}
67+
68+
[Fact]
69+
public void Differing_generatedBy_nudges_naming_both_versions()
70+
{
71+
var m = ManifestWith("1.0.0");
72+
var msg = AgentContextScaffold.AgentContextStaleness(m, "2.0.0");
73+
Assert.NotNull(msg);
74+
Assert.Contains("1.0.0", msg);
75+
Assert.Contains("2.0.0", msg);
76+
Assert.Contains("dotnet meta agent-docs", msg);
77+
}
78+
79+
[Fact]
80+
public void Null_generatedBy_nudges_with_older_phrasing()
81+
{
82+
var m = ManifestWith(null);
83+
var msg = AgentContextScaffold.AgentContextStaleness(m, "2.0.0");
84+
Assert.NotNull(msg);
85+
Assert.Contains("an older MetaObjects", msg);
86+
Assert.Contains("2.0.0", msg);
87+
Assert.Contains("dotnet meta agent-docs", msg);
88+
}
89+
90+
// Even a prerelease/build-metadata difference nudges (exact equality, not semver).
91+
[Fact]
92+
public void Any_drift_nudges_no_semver_parsing()
93+
{
94+
var m = ManifestWith("1.2.3");
95+
Assert.NotNull(AgentContextScaffold.AgentContextStaleness(m, "1.2.3-rc.1"));
96+
}
97+
98+
// ---- 3. version source --------------------------------------------------
99+
100+
[Fact]
101+
public void CurrentVersion_reads_a_nonempty_value_from_the_assembly()
102+
{
103+
var v = AgentContextStalenessCheck.CurrentVersion();
104+
Assert.False(string.IsNullOrEmpty(v));
105+
}
106+
107+
// ---- helper -------------------------------------------------------------
108+
109+
private static AgentContextScaffold.Manifest ManifestWith(string? generatedBy) =>
110+
new(1, generatedBy, Array.Empty<string>(), Array.Empty<string>(),
111+
new Dictionary<string, string>());
112+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// AgentContextStalenessCheck — the side-effecting half of the agent-context staleness
2+
// nudge (the pure decision lives in MetaObjects.AgentContext.AgentContextScaffold).
3+
//
4+
// Port of the TS cli lib (version.ts + agent-context-staleness.ts): read the installed
5+
// MetaObjects version from the assembly, read the consumer's sidecar manifest if present,
6+
// and print ONE advisory line to stderr when the scaffolded agent context predates this
7+
// build. Advisory only — never throws, never blocks, never writes, never changes the exit
8+
// code. A missing/corrupt manifest is silently ignored (this is a reminder, not a gate).
9+
10+
using System.Reflection;
11+
using System.Text.Json;
12+
using MetaObjects.AgentContext;
13+
14+
namespace MetaObjects.Cli;
15+
16+
/// <summary>Reads the manifest + installed version and emits the staleness nudge (advisory).</summary>
17+
public static class AgentContextStalenessCheck
18+
{
19+
/// <summary>
20+
/// The installed MetaObjects version, read from the assembly (the
21+
/// <c>AssemblyInformationalVersion</c>, falling back to the assembly version), with a
22+
/// safe <c>"0.0.0"</c> fallback when neither is present. Never a hardcoded literal.
23+
/// </summary>
24+
public static string CurrentVersion()
25+
{
26+
var asm = typeof(AgentContextScaffold).Assembly;
27+
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
28+
if (!string.IsNullOrEmpty(info)) return info;
29+
var ver = asm.GetName().Version?.ToString();
30+
return string.IsNullOrEmpty(ver) ? "0.0.0" : ver;
31+
}
32+
33+
/// <summary>
34+
/// If a scaffolded MetaObjects agent context under <paramref name="cwd"/> predates this
35+
/// build, write a one-line nudge to stderr. Never throws — an absent or corrupt manifest
36+
/// is silently ignored. Advisory: the caller's exit code is unaffected.
37+
/// </summary>
38+
public static void WarnIfStale(string cwd)
39+
{
40+
try
41+
{
42+
var path = Path.Combine(cwd, AgentContextScaffold.ManifestPath);
43+
if (!File.Exists(path)) return; // no agent context here → nothing to nudge
44+
45+
AgentContextScaffold.Manifest? manifest;
46+
try
47+
{
48+
manifest = ReadManifest(path);
49+
}
50+
catch
51+
{
52+
return; // unreadable / corrupt — say nothing
53+
}
54+
55+
var msg = AgentContextScaffold.AgentContextStaleness(manifest, CurrentVersion());
56+
if (msg is not null) Console.Error.WriteLine(msg);
57+
}
58+
catch
59+
{
60+
// Belt-and-suspenders: an advisory must never break the command it rides on.
61+
}
62+
}
63+
64+
private static AgentContextScaffold.Manifest? ReadManifest(string path)
65+
{
66+
using var doc = JsonDocument.Parse(File.ReadAllText(path));
67+
var root = doc.RootElement;
68+
// Only generatedBy is load-bearing for the decision; the rest is read for fidelity.
69+
var version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number
70+
? v.GetInt32() : 1;
71+
var generatedBy = root.TryGetProperty("generatedBy", out var g) ? g.GetString() : null;
72+
return new AgentContextScaffold.Manifest(
73+
version, generatedBy, Array.Empty<string>(), Array.Empty<string>(),
74+
new Dictionary<string, string>());
75+
}
76+
}

server/csharp/MetaObjects.Cli/AgentDocsCommand.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public static int Run(string[] rest)
8585
var prior = ReadPriorManifest(manifestPath);
8686

8787
var decision = AgentContextScaffold.Plan(stack, assembled, prior,
88-
rel => ReadCurrent(target, rel));
88+
rel => ReadCurrent(target, rel), AgentContextStalenessCheck.CurrentVersion());
8989

9090
foreach (var w in decision.Writes)
9191
{
@@ -121,11 +121,12 @@ public static int Run(string[] rest)
121121
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
122122
var root = doc.RootElement;
123123
var version = root.TryGetProperty("version", out var v) ? v.GetInt32() : 1;
124+
var generatedBy = root.TryGetProperty("generatedBy", out var g) ? g.GetString() : null;
124125
var files = new Dictionary<string, string>();
125126
if (root.TryGetProperty("files", out var f) && f.ValueKind == JsonValueKind.Object)
126127
foreach (var prop in f.EnumerateObject())
127128
files[prop.Name] = prop.Value.GetString() ?? "";
128-
return new AgentContextScaffold.Manifest(version, JsonStrings(root, "servers"),
129+
return new AgentContextScaffold.Manifest(version, generatedBy, JsonStrings(root, "servers"),
129130
JsonStrings(root, "clients"), files);
130131
}
131132
catch (JsonException)
@@ -162,6 +163,10 @@ private static void WriteManifest(string manifestPath, AgentContextScaffold.Mani
162163
var doc = new
163164
{
164165
version = manifest.Version,
166+
// Same JSON key as the TS/Java/Python references ("generatedBy") so a polyglot
167+
// repo can cross-read the stamp. The installed MetaObjects version, read from
168+
// the assembly — nudges a re-scaffold once the package moves ahead.
169+
generatedBy = manifest.GeneratedBy,
165170
servers = manifest.Servers,
166171
clients = manifest.Clients,
167172
files = manifest.Files,

server/csharp/MetaObjects.Cli/MetaObjects.Cli.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
<ProjectReference Include="../MetaObjects.Codegen/MetaObjects.Codegen.csproj" />
2121
</ItemGroup>
2222

23+
<!-- The test project drives the internal command entry points (e.g. AgentDocsCommand). -->
24+
<ItemGroup>
25+
<InternalsVisibleTo Include="MetaObjects.Cli.Tests" />
26+
</ItemGroup>
27+
2328
<!-- Bundle the repo-root agent-context/ content tree so `dotnet meta agent-docs`
2429
resolves it both in dev (run-from-output) and when installed as a .NET tool.
2530
Copied under <output>/agent-context/, where ContentRoot.Resolve looks first. -->

server/csharp/MetaObjects.Cli/Program.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ static int RunGen(string[] rest)
7474
return 2;
7575
}
7676

77+
// Advisory: nudge a re-scaffold if the copied-in agent context predates this build.
78+
// Never throws, never changes the exit code (a missing/corrupt manifest is ignored).
79+
AgentContextStalenessCheck.WarnIfStale(Directory.GetCurrentDirectory());
80+
7781
var generatorNames = generatorsCsv
7882
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
7983

@@ -156,6 +160,10 @@ static int RunVerify(string[] rest)
156160
return 2;
157161
}
158162

163+
// Advisory: nudge a re-scaffold if the copied-in agent context predates this build.
164+
// Never throws, never changes the exit code (a missing/corrupt manifest is ignored).
165+
AgentContextStalenessCheck.WarnIfStale(Directory.GetCurrentDirectory());
166+
159167
var generators = generatorsCsv
160168
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
161169

server/csharp/MetaObjects/AgentContext/AgentContextScaffold.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ public sealed record Write(string Path, string Contents);
2727
public sealed record Conflict(string Path, string NewPath, string Contents);
2828

2929
/// <summary>Tracks what the assembler last wrote, so re-runs can detect hand-edits.</summary>
30+
/// <param name="GeneratedBy">
31+
/// The MetaObjects version that last scaffolded this agent context. Used to nudge a
32+
/// re-scaffold when the installed version moves ahead (the skills/docs ship with the
33+
/// package, so an upgrade can leave the copied-in context stale). Nullable for
34+
/// back-compat with manifests written before version tracking existed.
35+
/// </param>
3036
public sealed record Manifest(
3137
int Version,
38+
string? GeneratedBy,
3239
IReadOnlyList<string> Servers,
3340
IReadOnlyList<string> Clients,
3441
IReadOnlyDictionary<string, string> Files);
@@ -51,11 +58,13 @@ public static string HashContents(string s)
5158
/// Decide what to write for a (re-)scaffold. Pure: filesystem access is via the
5259
/// <paramref name="readCurrent"/> function (returns on-disk contents, or <c>null</c> if absent).
5360
/// </summary>
61+
/// <param name="generatedBy">The MetaObjects version doing the scaffold — stamped into the manifest.</param>
5462
public static ScaffoldDecision Plan(
5563
Stack stack,
5664
IReadOnlyList<AssembledFile> assembled,
5765
Manifest? prior,
58-
Func<string, string?> readCurrent)
66+
Func<string, string?> readCurrent,
67+
string generatedBy)
5968
{
6069
var writes = new List<Write>();
6170
var conflicts = new List<Conflict>();
@@ -91,9 +100,29 @@ public static ScaffoldDecision Plan(
91100

92101
var manifest = new Manifest(
93102
1,
103+
generatedBy,
94104
stack.Servers.ToList(),
95105
stack.Clients.ToList(),
96106
files);
97107
return new ScaffoldDecision(writes, conflicts, manifest, removed);
98108
}
109+
110+
/// <summary>
111+
/// A one-line nudge if the scaffolded agent context predates the installed MetaObjects
112+
/// (so <c>gen</c>/<c>verify</c> can remind the user to refresh the skills after an
113+
/// upgrade), or <c>null</c> when there is nothing to say — no agent context scaffolded,
114+
/// or it is in sync. Advisory only: never throws, never blocks, never writes. Pure.
115+
/// </summary>
116+
public static string? AgentContextStaleness(Manifest? manifest, string currentVersion)
117+
{
118+
if (manifest is null) return null; // no agent context here → nothing to nudge
119+
// Exact-equality on purpose: ANY drift nudges (a re-scaffold is cheap + idempotent).
120+
// Don't "fix" this into a semver compare — a prerelease/build-metadata difference is
121+
// still a reason to refresh, and the nudge is advisory, never a gate.
122+
if (manifest.GeneratedBy == currentVersion) return null; // in sync
123+
var from = manifest.GeneratedBy ?? "an older MetaObjects";
124+
return
125+
$"MetaObjects agent context was generated by {from}; you're on {currentVersion}. " +
126+
"Re-run 'dotnet meta agent-docs' to refresh the .claude/skills docs.";
127+
}
99128
}

0 commit comments

Comments
 (0)