Skip to content

Commit 32a72e4

Browse files
dmealingclaude
andcommitted
feat(cli): nudge to refresh agent-context when it predates the installed CLI
Closes the version-drift gap in the scaffolded-skills model (ADR-0024 review follow-up): the .claude/skills agent-context ships with the package, so an upgrade can leave the copied-in copy stale with nothing telling the adopter. - sdk: the agent-context manifest now records `generatedBy` (the MetaObjects version that scaffolded it; optional-on-read for back-compat, required-on-write). New pure `agentContextStaleness({manifest, currentVersion})` → a one-line nudge when the manifest predates (or lacks) the installed version, else null. - cli: `meta init` stamps `generatedBy`; `meta gen` + `meta verify` print the advisory nudge near the top (after --list/arg-parse exits). Never throws, never blocks — a missing/corrupt manifest is silent. Extracted `cliVersion()` to a shared lib (was inline in index.ts). TS pilot. The manifest schema (`version: 1` + optional `generatedBy`) is the cross-port contract: Java/Python/C# agent-docs scaffolds stamp it + mirror the pure staleness check in their gen/verify next. sdk+cli 420/0, typecheck clean, end-to-end smoked (stale→nudge, in-sync→silent). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf58d73 commit 32a72e4

8 files changed

Lines changed: 133 additions & 32 deletions

File tree

server/typescript/packages/cli/src/commands/gen.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { resolveGenConfig } from "../lib/config.js";
55
import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
66
import { formatGenResult, type GenFileEntry, type GenFileStatus } from "../lib/output.js";
77
import { log } from "../lib/log.js";
8+
import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
89
import { loadMemory, DEFAULT_METADATA_DIR } from "@metaobjectsdev/sdk";
910
import { runGen, listGenerators, deriveTraceFields } from "@metaobjectsdev/codegen-ts";
1011
import type { WriteStatus } from "@metaobjectsdev/codegen-ts";
@@ -32,6 +33,9 @@ export async function genCommand(args: string[], cwd: string): Promise<number> {
3233
return listGeneratorsCommand();
3334
}
3435

36+
// Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
37+
warnIfAgentContextStale(cwd);
38+
3539
const projectRoot = cwd;
3640
const cliConfig = resolveGenConfig(flags);
3741

server/typescript/packages/cli/src/commands/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { resolveStack } from "../lib/detect-stack.js";
1111
import { parseInitArgs } from "../lib/args.js";
1212
import { log } from "../lib/log.js";
13+
import { cliVersion } from "../lib/version.js";
1314
import { findWranglerConfig, parseWranglerConfig } from "@metaobjectsdev/migrate-ts";
1415

1516
const META_COMMON_JSON = JSON.stringify(
@@ -107,6 +108,7 @@ async function writeAgentContext(opts: InitOptions, result: InitResult): Promise
107108
const abs = join(opts.cwd, rel);
108109
return existsSyncWrap(abs) ? readFileSyncWrap(abs, "utf8") : undefined;
109110
},
111+
generatedBy: cliVersion(),
110112
});
111113

112114
for (const w of decision.writes) {

server/typescript/packages/cli/src/commands/verify.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { join } from "node:path";
1111
import { parseVerifyArgs } from "../lib/args.js";
1212
import { log } from "../lib/log.js";
13+
import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
1314
import { FileProvider } from "../lib/file-provider.js";
1415
import { derivePayloadFieldTree } from "../lib/payload-field-tree.js";
1516
import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
@@ -48,6 +49,9 @@ export async function verifyCommand(args: string[], cwd: string): Promise<number
4849
return 2;
4950
}
5051

52+
// Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
53+
warnIfAgentContextStale(cwd);
54+
5155
// ADR-0021 D2 — explicit verify subverbs. Each flag selects one drift mode;
5256
// any combination runs each and the overall exit code is the MAX (non-zero on
5357
// any drift). A bare `verify` (no explicit subverb) keeps its documented

server/typescript/packages/cli/src/index.ts

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,10 @@
1-
import { existsSync, readFileSync } from "node:fs";
2-
import { dirname, join, resolve } from "node:path";
3-
import { fileURLToPath } from "node:url";
1+
import { resolve } from "node:path";
42
import { log } from "./lib/log.js";
3+
import { cliVersion } from "./lib/version.js";
54
export { defineConfig } from "@metaobjectsdev/codegen-ts";
65
export type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
76

8-
// Derive the version from the CLI's own package.json so it never goes stale.
9-
// The compiled entry is dist/src/index.js while package.json sits at the package
10-
// root, so walk up from the module location until @metaobjectsdev/cli's manifest.
11-
function readCliVersion(): string {
12-
let dir = dirname(fileURLToPath(import.meta.url));
13-
for (let i = 0; i < 6; i++) {
14-
const candidate = join(dir, "package.json");
15-
if (existsSync(candidate)) {
16-
try {
17-
const pkg = JSON.parse(readFileSync(candidate, "utf8")) as { name?: string; version?: string };
18-
if (pkg.name === "@metaobjectsdev/cli" && pkg.version) return pkg.version;
19-
} catch {
20-
// not our manifest / unreadable — keep walking up
21-
}
22-
}
23-
const parent = dirname(dir);
24-
if (parent === dir) break;
25-
dir = parent;
26-
}
27-
return "0.0.0";
28-
}
29-
30-
const VERSION = readCliVersion();
7+
const VERSION = cliVersion();
318

329
const HELP_TEXT = `meta — MetaObjects CLI (v${VERSION})
3310
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import { AGENT_CONTEXT_MANIFEST_PATH, agentContextStaleness, type Manifest } from "@metaobjectsdev/sdk";
4+
import { cliVersion } from "./version.js";
5+
import { log } from "./log.js";
6+
7+
/**
8+
* Advisory: if a scaffolded MetaObjects agent context predates this CLI version,
9+
* print a one-line nudge to re-scaffold. Never throws, never blocks — an absent or
10+
* corrupt manifest is silently ignored (this is a reminder, not a gate).
11+
*/
12+
export function warnIfAgentContextStale(cwd: string): void {
13+
const p = join(cwd, AGENT_CONTEXT_MANIFEST_PATH);
14+
let manifest: Manifest | undefined;
15+
if (existsSync(p)) {
16+
try {
17+
manifest = JSON.parse(readFileSync(p, "utf8")) as Manifest;
18+
} catch {
19+
return; // unreadable/corrupt — say nothing
20+
}
21+
}
22+
const msg = agentContextStaleness({ manifest, currentVersion: cliVersion() });
23+
if (msg !== null) log.warn(msg);
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { dirname, join } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
/**
6+
* The installed `@metaobjectsdev/cli` version, read from its own package.json so it
7+
* never goes stale. Walks up from this module to the cli package root; returns
8+
* "0.0.0" if not found.
9+
*/
10+
export function cliVersion(): string {
11+
let dir = dirname(fileURLToPath(import.meta.url));
12+
for (let i = 0; i < 6; i++) {
13+
const candidate = join(dir, "package.json");
14+
if (existsSync(candidate)) {
15+
try {
16+
const pkg = JSON.parse(readFileSync(candidate, "utf8")) as { name?: string; version?: string };
17+
if (pkg.name === "@metaobjectsdev/cli" && pkg.version) return pkg.version;
18+
} catch {
19+
// not our manifest / unreadable — keep walking up
20+
}
21+
}
22+
const parent = dirname(dir);
23+
if (parent === dir) break;
24+
dir = parent;
25+
}
26+
return "0.0.0";
27+
}

server/typescript/packages/sdk/src/agent-context/scaffold.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ export const AGENT_CONTEXT_MANIFEST_PATH = ".metaobjects/.agent-context.json";
66

77
export interface Manifest {
88
version: 1;
9+
/**
10+
* The MetaObjects version that last scaffolded this agent context. Used to nudge
11+
* a re-scaffold when the installed version moves ahead (the skills/docs ship with
12+
* the package, so an upgrade can leave the copied-in context stale). Optional for
13+
* back-compat with manifests written before version tracking existed.
14+
*/
15+
generatedBy?: string;
916
servers: string[];
1017
clients: string[];
1118
/** consumer-relative path → sha256 of the contents as last scaffolded. */
@@ -38,8 +45,10 @@ export function planScaffold(opts: {
3845
assembled: AssembledFile[];
3946
prior: Manifest | undefined;
4047
readCurrent: (path: string) => string | undefined;
48+
/** The MetaObjects version doing the scaffold — stamped into the manifest. */
49+
generatedBy: string;
4150
}): ScaffoldDecision {
42-
const { stack, assembled, prior, readCurrent } = opts;
51+
const { stack, assembled, prior, readCurrent, generatedBy } = opts;
4352
const writes: ScaffoldDecision["writes"] = [];
4453
const conflicts: ScaffoldDecision["conflicts"] = [];
4554
const files: Record<string, string> = {};
@@ -65,7 +74,30 @@ export function planScaffold(opts: {
6574
return {
6675
writes,
6776
conflicts,
68-
manifest: { version: 1, servers: stack.servers, clients: stack.clients, files },
77+
manifest: { version: 1, generatedBy, servers: stack.servers, clients: stack.clients, files },
6978
removed,
7079
};
7180
}
81+
82+
/**
83+
* A one-line nudge if the scaffolded agent context predates the installed MetaObjects
84+
* (so `gen`/`verify` can remind the user to refresh the skills after an upgrade), or
85+
* `null` when there is nothing to say — no agent context scaffolded, or it is in sync.
86+
* Advisory only: never throws, never blocks, never writes.
87+
*/
88+
export function agentContextStaleness(opts: {
89+
manifest: Manifest | undefined;
90+
currentVersion: string;
91+
}): string | null {
92+
const { manifest, currentVersion } = opts;
93+
if (manifest === undefined) return null; // no agent context here → nothing to nudge
94+
// Exact-equality on purpose: ANY drift nudges (a re-scaffold is cheap + idempotent).
95+
// Don't "fix" this into a semver compare — a prerelease/build-metadata difference is
96+
// still a reason to refresh, and the nudge is advisory, never a gate.
97+
if (manifest.generatedBy === currentVersion) return null; // in sync
98+
const from = manifest.generatedBy ?? "an older MetaObjects";
99+
return (
100+
`MetaObjects agent context was generated by ${from}; you're on ${currentVersion}. ` +
101+
`Re-run 'meta init --docs-only --refresh-docs' to refresh the .claude/skills docs.`
102+
);
103+
}

server/typescript/packages/sdk/test/agent-context/scaffold.test.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect, describe } from "bun:test";
2-
import { hashContents, planScaffold, AGENT_CONTEXT_MANIFEST_PATH, type Manifest } from "../../src/agent-context/scaffold.js";
2+
import { hashContents, planScaffold, agentContextStaleness, AGENT_CONTEXT_MANIFEST_PATH, type Manifest } from "../../src/agent-context/scaffold.js";
33
import { makeStack } from "../../src/agent-context/resolve.js";
44
import type { AssembledFile } from "../../src/agent-context/types.js";
55

@@ -15,7 +15,7 @@ describe("planScaffold", () => {
1515
});
1616

1717
test("all-new project: every file is a write; manifest records hashes; no conflicts", () => {
18-
const d = planScaffold({ stack, assembled: files, prior: undefined, readCurrent: () => undefined });
18+
const d = planScaffold({ stack, assembled: files, prior: undefined, readCurrent: () => undefined, generatedBy: "0.9.0" });
1919
expect(d.writes.map((w) => w.path).sort()).toEqual(files.map((f) => f.path).sort());
2020
expect(d.conflicts).toEqual([]);
2121
expect(d.manifest.files[".metaobjects/AGENTS.md"]).toBe(hashContents("always-on v2"));
@@ -28,6 +28,7 @@ describe("planScaffold", () => {
2828
const d = planScaffold({
2929
stack, assembled: [files[0]!], prior,
3030
readCurrent: (p) => (p === ".metaobjects/AGENTS.md" ? "always-on v1" : undefined),
31+
generatedBy: "0.9.0",
3132
});
3233
expect(d.writes.map((w) => w.path)).toEqual([".metaobjects/AGENTS.md"]);
3334
expect(d.conflicts).toEqual([]);
@@ -38,19 +39,49 @@ describe("planScaffold", () => {
3839
const d = planScaffold({
3940
stack, assembled: [files[0]!], prior,
4041
readCurrent: () => "I HAND EDITED THIS",
42+
generatedBy: "0.9.0",
4143
});
4244
expect(d.writes).toEqual([]);
4345
expect(d.conflicts).toEqual([{ path: ".metaobjects/AGENTS.md", newPath: ".metaobjects/AGENTS.md.new", contents: "always-on v2" }]);
4446
});
4547

4648
test("file present but no prior manifest record → treat as hand-edited (.new), never clobber", () => {
47-
const d = planScaffold({ stack, assembled: [files[0]!], prior: undefined, readCurrent: () => "pre-existing unknown" });
49+
const d = planScaffold({ stack, assembled: [files[0]!], prior: undefined, readCurrent: () => "pre-existing unknown", generatedBy: "0.9.0" });
4850
expect(d.conflicts.map((c) => c.path)).toEqual([".metaobjects/AGENTS.md"]);
4951
});
5052

5153
test("a file in the prior manifest no longer assembled (stack shrank) is reported as removed", () => {
5254
const prior: Manifest = { version: 1, servers: ["typescript", "java"], clients: ["react"], files: { ".claude/skills/metaobjects-codegen/references/java.md": "abc" } };
53-
const d = planScaffold({ stack, assembled: files, prior, readCurrent: () => undefined });
55+
const d = planScaffold({ stack, assembled: files, prior, readCurrent: () => undefined, generatedBy: "0.9.0" });
5456
expect(d.removed).toEqual([".claude/skills/metaobjects-codegen/references/java.md"]);
5557
});
5658
});
59+
60+
describe("planScaffold generatedBy stamp", () => {
61+
test("manifest records the MetaObjects version that scaffolded it", () => {
62+
const d = planScaffold({ stack, assembled: files, prior: undefined, readCurrent: () => undefined, generatedBy: "0.9.0" });
63+
expect(d.manifest.generatedBy).toBe("0.9.0");
64+
});
65+
});
66+
67+
describe("agentContextStaleness", () => {
68+
const m = (generatedBy?: string): Manifest => ({ version: 1, generatedBy, servers: ["typescript"], clients: ["react"], files: {} });
69+
70+
test("no agent-context manifest → silent (null)", () => {
71+
expect(agentContextStaleness({ manifest: undefined, currentVersion: "0.9.0" })).toBeNull();
72+
});
73+
test("generatedBy equals installed version → in sync (null)", () => {
74+
expect(agentContextStaleness({ manifest: m("0.9.0"), currentVersion: "0.9.0" })).toBeNull();
75+
});
76+
test("generatedBy differs from installed → warning naming both versions", () => {
77+
const w = agentContextStaleness({ manifest: m("0.8.0"), currentVersion: "0.9.0" });
78+
expect(w).toContain("0.8.0");
79+
expect(w).toContain("0.9.0");
80+
expect(w).toContain("refresh-docs");
81+
});
82+
test("legacy manifest without generatedBy → warning (treated as older)", () => {
83+
const w = agentContextStaleness({ manifest: m(undefined), currentVersion: "0.9.0" });
84+
expect(w).toContain("0.9.0");
85+
expect(w).toContain("refresh-docs");
86+
});
87+
});

0 commit comments

Comments
 (0)