Skip to content

Commit ca7827c

Browse files
dmealingclaude
andcommitted
fix(init): one silently dropped flag value made three statements false
F54 — `meta init --server node`, the value the CLI's own `--help` gives as an example, exited 0 with no warning and recorded `"servers": []`. The four missing reference fragments were the small half. F56 is the large half, and it is the SAME defect: a non-empty override array suppresses both the prior manifest's stack and detection, so `["node"]` filtered to `[]` left the scaffolded context declaring "Stack: no server, no client" and telling the agent that this project has no `metaobjects.config.ts` — about a project with a Fastify server, a React client, and the very config file the same CLI had just read to run `meta gen`. One dropped value made three statements false in the artifact whose entire job is to orient an agent correctly. An unknown `--server` / `--client` value is now REFUSED before anything is written, naming the vocabulary; and `node` is ACCEPTED, as an alias for `typescript`, because the help text gives it and it is the more natural word for a Fastify backend run by the Node CLI. Either accept it or say so — never neither. Being accepted is not the same as being resolved, so the test asserts `node` lands as `typescript` and not merely that it stopped throwing. The help text is corrected too: it now names the vocabulary instead of examples, one of which (`--client vue`) was a value this CLI has never accepted. F53 — the scaffold manifest recorded the hash of content it DECLINED to write. `hashContents(f.contents)` was stamped for every assembled file before the write/decline decision, so a path whose fresh content went to `<path>.new` — "tell, don't merge" working exactly as designed — was recorded with the hash of the file that was not written: a value matching neither the disk nor anything this tool had ever written, in the one record that exists to tell "still what we scaffolded" from "hand-edited", under a `generatedBy` asserting the context was current while the file on disk was the predecessor's text. A declined path now keeps the PRIOR hash — what we last actually wrote, so reverting the edit lets the next refresh see an unmodified file — and a declined path we have never written gets no entry, because there is no true value to record. F55 — `meta init` reports "(11 files)" while eight land under `.claude/`, which many projects git-ignore under a comment about credentials. Ignored, they exist only on the machine that ran the command — not committed, absent from CI and a fresh clone, invisible to a teammate — while `.agent-context.json`, which IS committed, records every one. That silently voids the whole downstream agent-context design, and the answer was available from the outputs the command had just written. Now an advisory naming the count and the directories, never an error: ignoring `.claude/` may be exactly what the project wants. cli 852 pass, sdk 278 pass, workspace typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent 70cc227 commit ca7827c

7 files changed

Lines changed: 273 additions & 10 deletions

File tree

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
assemble, resolveAgentContextRoot, planScaffold,
99
AGENT_CONTEXT_MANIFEST_PATH, type Manifest, type Stack,
1010
} from "@metaobjectsdev/sdk/agent-context";
11-
import { resolveStack } from "../lib/detect-stack.js";
11+
import { assertKnownStackValues, resolveStack } from "../lib/detect-stack.js";
12+
import { reportIgnoredScaffold } from "../lib/ignored-scaffold-check.js";
1213
import { parseInitArgs } from "../lib/args.js";
1314
import { log } from "../lib/log.js";
1415
import { cliVersion } from "../lib/version.js";
@@ -1135,6 +1136,11 @@ export async function initCommand(args: string[], cwd: string): Promise<number>
11351136
let flags;
11361137
try {
11371138
flags = parseInitArgs(args);
1139+
// Refused HERE, before anything is written: a value that names nothing used to be
1140+
// dropped silently, and a non-empty override array then suppressed both the prior
1141+
// manifest's stack and detection — so the scaffolded context asserted an empty stack
1142+
// and a missing config about a project that had both. See `assertKnownStackValues`.
1143+
assertKnownStackValues({ servers: flags.servers ?? [], clients: flags.clients ?? [] });
11381144
} catch (err) {
11391145
log.error((err as Error).message);
11401146
return 2;
@@ -1163,6 +1169,14 @@ export async function initCommand(args: string[], cwd: string): Promise<number>
11631169
}
11641170

11651171
if (!flags.quiet) {
1172+
// A count is not a guarantee that the files reached the repository. Many projects
1173+
// git-ignore `.claude/` under a comment about credentials — a common and defensible
1174+
// convention — and eight of the eleven files land there. Ignored, they exist only on
1175+
// the machine that ran this: not committed, absent from CI and from a fresh clone,
1176+
// invisible to a teammate, while `.agent-context.json` (which IS committed) tracks
1177+
// them. That silently voids the whole downstream agent-context design, and the answer
1178+
// was available from the outputs this command had just written.
1179+
reportIgnoredScaffold(cwd, result.created);
11661180
if (flags.docsOnly) {
11671181
log.info(`Scaffolded the MetaObjects agent context (${result.created.length} files): .metaobjects/AGENTS.md + .claude/skills/metaobjects-*.`);
11681182
for (const w of result.warnings) log.info(` ${w}`);

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,12 @@ FLAGS:
300300
--docs-only Write only the agent-context files — no metadata sources, no
301301
codegen scaffold, no config. Pair with --refresh-docs to update
302302
an existing project after upgrading.
303-
--server <lang> Declare a server language for the agent context (repeatable;
304-
e.g. csharp, kotlin, python, node)
305-
--client <fw> Declare a client framework for the agent context (repeatable;
306-
e.g. react, vue)
303+
--server <lang> Declare a server language for the agent context (repeatable):
304+
typescript | java | kotlin | csharp | python
305+
('node' is accepted as an alias for typescript; an unknown
306+
value is refused, never dropped)
307+
--client <fw> Declare a client framework for the agent context (repeatable):
308+
react | tanstack | angular
307309
--no-skills Skip the .claude/skills/ scaffold
308310
--force Overwrite existing files
309311
--quiet Suppress output
@@ -320,8 +322,8 @@ USAGE:
320322
meta agent-docs [--server <lang>]... [--client <fw>]... [--out <dir>] [flags]
321323
322324
FLAGS:
323-
--server <lang> Server language (repeatable; e.g. csharp, kotlin, python, node)
324-
--client <fw> Client framework (repeatable; e.g. react, vue)
325+
--server <lang> Server language (repeatable): typescript|java|kotlin|csharp|python
326+
--client <fw> Client framework (repeatable): react|tanstack|angular
325327
--out <dir> Output directory (default: current directory)
326328
--no-skills Skip .claude/skills/ scaffold
327329
--no-wire-root Skip wiring root CLAUDE.md @import

server/typescript/packages/cli/src/lib/detect-stack.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,85 @@ async function probe(cwd: string): Promise<ProjectProbe> {
4848
};
4949
}
5050

51+
/**
52+
* Spellings accepted for a stack value that is not the canonical one.
53+
*
54+
* `node` is here because the CLI's OWN `--help` gives it as an example of `--server`, and it
55+
* is the more natural word for a Fastify backend run by the Node CLI. It used to be filtered
56+
* out silently — see `assertKnownStackValues` for what that silence cost.
57+
*/
58+
const SERVER_ALIASES: Readonly<Record<string, ServerLang>> = {
59+
node: "typescript",
60+
nodejs: "typescript",
61+
ts: "typescript",
62+
"c#": "csharp",
63+
dotnet: "csharp",
64+
py: "python",
65+
};
66+
67+
const CLIENT_ALIASES: Readonly<Record<string, ClientFramework>> = {
68+
reactjs: "react",
69+
"react-query": "tanstack",
70+
};
71+
72+
/** Canonicalize one value, or `undefined` when it names nothing we know. */
73+
function canonical<T extends string>(
74+
raw: string,
75+
valid: readonly string[],
76+
aliases: Readonly<Record<string, T>>,
77+
): T | undefined {
78+
const v = raw.trim().toLowerCase();
79+
if (valid.includes(v)) return v as T;
80+
return aliases[v];
81+
}
82+
83+
/**
84+
* Refuse an unknown `--server` / `--client` value instead of dropping it.
85+
*
86+
* `--server node` — the CLI's own help example — used to exit 0 with no warning and record
87+
* `"servers": []`. Worse than the missing four reference fragments: a non-empty override
88+
* array suppresses BOTH the prior manifest's stack and detection, so the emitted context then
89+
* declared "Stack: no server, no client" and told the agent that this project has no
90+
* `metaobjects.config.ts` — about a project with a Fastify server, a React client, and the
91+
* very config file the same CLI had just read. One silently-dropped flag value made three
92+
* statements false in the artifact whose whole job is to orient an agent correctly.
93+
*
94+
* Either accept the value or say so; never neither.
95+
*/
96+
export function assertKnownStackValues(overrides: { servers: string[]; clients: string[] }): void {
97+
const bad: string[] = [];
98+
for (const s of overrides.servers) {
99+
if (canonical(s, SERVER_LANGS as readonly string[], SERVER_ALIASES) === undefined) {
100+
bad.push(`--server ${s} (known: ${SERVER_LANGS.join(", ")})`);
101+
}
102+
}
103+
for (const c of overrides.clients) {
104+
if (canonical(c, CLIENT_FRAMEWORKS as readonly string[], CLIENT_ALIASES) === undefined) {
105+
bad.push(`--client ${c} (known: ${CLIENT_FRAMEWORKS.join(", ")})`);
106+
}
107+
}
108+
if (bad.length > 0) {
109+
throw new Error(
110+
`unknown stack value(s): ${bad.join("; ")}. A value that names nothing is refused rather ` +
111+
"than dropped: an empty stack suppresses both the prior manifest and detection, and the " +
112+
"generated agent context then states the project has no server, no client and no " +
113+
"metaobjects.config.ts.",
114+
);
115+
}
116+
}
117+
51118
/** Resolve the stack: explicit --server/--client overrides take precedence; otherwise detect.
52119
* Concern tokens (e.g. requirements) are always OBSERVED from project state, independent of
53120
* any --server/--client override — a concern is not a stack axis. */
54121
export async function resolveStack(cwd: string, overrides: { servers: string[]; clients: string[] }): Promise<Stack> {
55122
const validServers = SERVER_LANGS as readonly string[];
56123
const validClients = CLIENT_FRAMEWORKS as readonly string[];
57-
const oServers = overrides.servers.filter((s): s is ServerLang => validServers.includes(s));
58-
const oClients = overrides.clients.filter((c): c is ClientFramework => validClients.includes(c));
124+
const oServers = overrides.servers
125+
.map((s) => canonical<ServerLang>(s, validServers, SERVER_ALIASES))
126+
.filter((s): s is ServerLang => s !== undefined);
127+
const oClients = overrides.clients
128+
.map((c) => canonical<ClientFramework>(c, validClients, CLIENT_ALIASES))
129+
.filter((c): c is ClientFramework => c !== undefined);
59130
const p = await probe(cwd);
60131
const concerns = detectConcerns(p);
61132
if (oServers.length > 0 || oClients.length > 0) return makeStack(oServers, oClients, concerns);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Advisory: did the scaffold land in a directory this repository ignores?
2+
//
3+
// `meta init` reports "(11 files)". Eight of them go under `.claude/skills/`, and many
4+
// projects git-ignore `.claude/` — commonly under a comment about credentials, which is a
5+
// defensible convention, not a mistake. Ignored, those files exist only on the machine that
6+
// ran the command: never committed, absent from CI and from a fresh clone, invisible to a
7+
// teammate — while `.metaobjects/.agent-context.json`, which IS committed, records every one
8+
// of them. The count says eleven; the repository gets three.
9+
//
10+
// The failure is INVISIBLE, which is why it is worth a check rather than a doc: nothing
11+
// errors, nothing looks wrong, and the whole downstream agent-context design is simply void
12+
// for that repo.
13+
//
14+
// Deliberately ADVISORY. Ignoring `.claude/` may be exactly what the project wants — it is a
15+
// per-repo policy call, and a scaffolder has no standing to fail a build over it.
16+
17+
import { relative } from "node:path";
18+
import { log } from "./log.js";
19+
import { gitIgnored } from "./git-ignore.js";
20+
21+
/**
22+
* Warn when some of the scaffolded paths are git-ignored, naming how many and where.
23+
*
24+
* Silent when: nothing is ignored, the project is not a git repository, or git cannot be
25+
* consulted — "cannot say" must never be reported as "there is a problem".
26+
*
27+
* `honourGlobalExcludes: false` for the same reason the docs-drift gate uses it: this is a
28+
* property of the REPOSITORY, and a personal `~/.gitignore` is not something a teammate
29+
* shares. A warning that fires for one developer and not another is worse than none.
30+
*/
31+
export function reportIgnoredScaffold(cwd: string, created: readonly string[]): void {
32+
const rels = created
33+
.map((p) => relative(cwd, p) || p)
34+
.map((p) => p.split("\\").join("/"))
35+
.filter((p) => p.length > 0 && !p.startsWith(".."));
36+
if (rels.length === 0) return;
37+
38+
const res = gitIgnored(cwd, rels, { honourGlobalExcludes: false });
39+
if ("unavailable" in res) return; // not a git repo, or git could not answer
40+
const ignored = rels.filter((p) => res.ignored.has(p));
41+
if (ignored.length === 0) return;
42+
43+
// Report the DIRECTORIES rather than every path: an adopter fixes this by editing one
44+
// `.gitignore` line, and a wall of forty filenames buries that.
45+
const dirs = [...new Set(ignored.map((p) => {
46+
const i = p.indexOf("/");
47+
return i < 0 ? p : p.slice(0, i) + "/";
48+
}))].sort();
49+
50+
log.warn(
51+
`${ignored.length} of ${rels.length} scaffolded file(s) are git-ignored (${dirs.join(", ")}), ` +
52+
"so they exist only on this machine — not committed, absent from CI and from a fresh " +
53+
"clone, invisible to a teammate — while .metaobjects/.agent-context.json is committed " +
54+
"and records them. If the agent context is meant to be shared, un-ignore those paths; " +
55+
"if it is deliberately local, nothing to do.",
56+
);
57+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// A stack value that names nothing is REFUSED, not dropped.
2+
//
3+
// `--server node` — the CLI's own help example — exited 0 with no warning and recorded
4+
// `"servers": []`. The missing four reference fragments were the small half. The large half is
5+
// that a NON-EMPTY override array suppresses both the prior manifest's stack AND detection, so
6+
// the scaffolded agent context then declared "Stack: no server, no client" and told the agent
7+
// this project has no `metaobjects.config.ts` — about a project with a Fastify server, a React
8+
// client, and the very config file the same CLI had just read to run `meta gen`. One silently
9+
// dropped flag value made three statements false in the artifact whose whole job is to orient
10+
// an agent correctly.
11+
import { describe, test, expect } from "bun:test";
12+
import { assertKnownStackValues, resolveStack } from "../../src/lib/detect-stack.js";
13+
14+
describe("stack value validation", () => {
15+
test("an unknown --server is refused, and the message names the vocabulary", () => {
16+
expect(() => assertKnownStackValues({ servers: ["klingon"], clients: [] }))
17+
.toThrow(/unknown stack value.*--server klingon.*typescript, java, kotlin, csharp, python/s);
18+
});
19+
20+
test("an unknown --client is refused too", () => {
21+
expect(() => assertKnownStackValues({ servers: [], clients: ["vue"] }))
22+
.toThrow(/--client vue.*react, tanstack, angular/s);
23+
});
24+
25+
test("`node` is ACCEPTED as an alias for typescript — the help text's own example", () => {
26+
expect(() => assertKnownStackValues({ servers: ["node"], clients: [] })).not.toThrow();
27+
});
28+
29+
test("canonical values pass unchanged", () => {
30+
expect(() => assertKnownStackValues({
31+
servers: ["typescript", "java", "kotlin", "csharp", "python"],
32+
clients: ["react", "tanstack", "angular"],
33+
})).not.toThrow();
34+
});
35+
36+
test("case and surrounding space do not decide whether a value is known", () => {
37+
expect(() => assertKnownStackValues({ servers: [" Node ", "TypeScript"], clients: [] }))
38+
.not.toThrow();
39+
});
40+
41+
test("...and the alias resolves to the canonical language, not to nothing", async () => {
42+
// The half that actually cost the estate its four reference fragments: being accepted is
43+
// not the same as being RESOLVED. `node` must land as `typescript`.
44+
const stack = await resolveStack(process.cwd(), { servers: ["node"], clients: [] });
45+
expect(stack.servers).toContain("typescript");
46+
});
47+
});

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,32 @@ export function planScaffold(opts: {
6868
const conflicts: ScaffoldDecision["conflicts"] = [];
6969
const files: Record<string, string> = {};
7070

71+
// The manifest records what we WROTE, never what we merely produced. It used to be
72+
// stamped with `hashContents(f.contents)` for every assembled file, before the write /
73+
// decline decision — so a DECLINED path (contents parked in `<path>.new`, original left
74+
// alone by design) was recorded with the hash of the file that was not written. That hash
75+
// matched neither the disk nor anything this tool had ever written: pure fiction in the
76+
// one record that exists to tell "still exactly what we scaffolded" from "hand-edited",
77+
// sitting under a `generatedBy` asserting the context was current at this version while
78+
// the file on disk was the predecessor's text.
79+
//
80+
// A declined path keeps the PRIOR hash — what we last actually wrote — so the comparison
81+
// stays meaningful: revert the hand edit and the next refresh sees an unmodified file and
82+
// refreshes it. A declined path we have never written (a file that was already there,
83+
// unmanaged) gets no entry at all, because there is no true value to record.
7184
for (const f of assembled) {
72-
files[f.path] = hashContents(f.contents);
7385
const current = readCurrent(f.path);
7486
if (current === undefined) {
87+
files[f.path] = hashContents(f.contents);
7588
writes.push({ path: f.path, contents: f.contents });
7689
continue;
7790
}
7891
const priorHash = prior?.files[f.path];
7992
if (priorHash !== undefined && hashContents(current) === priorHash) {
93+
files[f.path] = hashContents(f.contents);
8094
writes.push({ path: f.path, contents: f.contents }); // unmodified → refresh to latest
8195
} else {
96+
if (priorHash !== undefined) files[f.path] = priorHash;
8297
conflicts.push({ path: f.path, newPath: `${f.path}.new`, contents: f.contents });
8398
}
8499
}

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,60 @@ describe("agentContextStaleness", () => {
168168
expect(agentContextStaleness({ manifest: m("dev"), currentVersion: "0.24.4" })).not.toBeNull();
169169
});
170170
});
171+
172+
// F53 — the manifest must record what was WRITTEN, never what was merely produced.
173+
//
174+
// It used to stamp `hashContents(f.contents)` for every assembled file, before the
175+
// write/decline decision. So a DECLINED path — contents parked in `<path>.new`, original
176+
// left alone, which is the "tell, don't merge" design working — was recorded with the hash
177+
// of the file that was NOT written: a value matching neither the disk nor anything this
178+
// tool had ever written, in the one record that exists to tell "still exactly what we
179+
// scaffolded" from "hand-edited", under a `generatedBy` asserting the context was current
180+
// at this version while the file on disk was the predecessor's text.
181+
describe("planScaffold — the manifest never records content it declined to write", () => {
182+
const hand: AssembledFile[] = [{ path: ".metaobjects/AGENTS.md", contents: "fresh v3" }];
183+
184+
test("a declined path keeps the PRIOR hash, not the hash of the unwritten file", () => {
185+
const prior: Manifest = {
186+
version: 1,
187+
servers: ["typescript"],
188+
clients: ["react"],
189+
files: { ".metaobjects/AGENTS.md": hashContents("what we wrote last time") },
190+
};
191+
const d = planScaffold({
192+
stack,
193+
assembled: hand,
194+
prior,
195+
readCurrent: () => "hand-edited by the adopter",
196+
generatedBy: "1.0.0",
197+
});
198+
expect(d.conflicts.map((c) => c.newPath)).toEqual([".metaobjects/AGENTS.md.new"]);
199+
expect(d.writes).toEqual([]);
200+
// The recorded value is what we last WROTE — so reverting the hand edit makes the next
201+
// refresh see an unmodified file and refresh it cleanly.
202+
expect(d.manifest.files[".metaobjects/AGENTS.md"]).toBe(hashContents("what we wrote last time"));
203+
expect(d.manifest.files[".metaobjects/AGENTS.md"]).not.toBe(hashContents("fresh v3"));
204+
});
205+
206+
test("a declined path we have NEVER written gets no entry at all", () => {
207+
// A file that was simply already there, unmanaged (the estate's `forge init`-era
208+
// context). There is no true value to record, so nothing is recorded.
209+
const d = planScaffold({
210+
stack,
211+
assembled: hand,
212+
prior: undefined,
213+
readCurrent: () => "a file this tool never wrote",
214+
generatedBy: "1.0.0",
215+
});
216+
expect(d.conflicts).toHaveLength(1);
217+
expect(Object.keys(d.manifest.files)).not.toContain(".metaobjects/AGENTS.md");
218+
});
219+
220+
test("...and a path that WAS written still records the new hash", () => {
221+
const d = planScaffold({
222+
stack, assembled: hand, prior: undefined, readCurrent: () => undefined, generatedBy: "1.0.0",
223+
});
224+
expect(d.writes).toHaveLength(1);
225+
expect(d.manifest.files[".metaobjects/AGENTS.md"]).toBe(hashContents("fresh v3"));
226+
});
227+
});

0 commit comments

Comments
 (0)