@witchpot/gmc 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  All notable changes to the Game Market Copilot CLI will be recorded here.
4
4
 
5
+ ## 0.10.0 - 2026-09-22
6
+
7
+ - New `gmc roblox` command family (NAK-715, Provisional): `charts` (one
8
+ recorded ranking cohort as of a cutoff), `experiences` (the tracked
9
+ catalog with filters, sorts and signed-cursor pagination), `experience
10
+ <universeId>` (one experience with per-metric observation times),
11
+ `history <universeId> --granularity hourly|daily` (retained observations
12
+ with explicit gaps), and `coverage` (the configured cohort matrix with
13
+ per-dimension freshness). Universe ids are decimal strings end to end —
14
+ they are never Steam appids, and the commands reject one. The Roblox
15
+ surface went live in production on 2026-09-22 (NAK-725); the commands
16
+ stay Provisional until the surface has been through a broader real-data
17
+ smoke period.
18
+
19
+ - The Roblox read endpoints reject impossible calendar dates in every
20
+ offset spelling (`as_of=2026-02-31T12:00:00+09:00` is now a 400 naming
21
+ the nonexistent date instead of silently answering for March 3), and
22
+ history gap counts always reconcile with the expected point count.
23
+
24
+ - Not yet mapped: the saved-experiences surface (`/api/v1/roblox/saved`,
25
+ NAK-723) is live over REST and MCP; a CLI mapping will follow.
26
+
27
+ ## 0.9.0 - 2026-09-16
28
+
29
+ - New `--release-status <released|upcoming|tba>` filter on the games
30
+ search/count/aggregate commands (NAK-803), forwarded verbatim and
31
+ case-sensitively as the `releaseStatus` query param. The parameter was
32
+ already in the REST contract; the CLI simply had no mapping for it, which
33
+ forced CLI recipes for "released games only" to lean on the release window
34
+ alone. The filter is an exact match layered on top of the guard that
35
+ already drops any title still marked `upcoming` past its provisional
36
+ release date.
37
+
38
+ - New command `gmc games ai-disclosure <appid> --json` (NAK-804): one title's
39
+ developer-authored AI-use disclosure text, the store page it was read from,
40
+ and its observed history of change. It takes the appid alone — no `--source`
41
+ selector and no query parameters, matching the route — and rejects any other
42
+ flag as a usage error rather than ignoring it. `absent` means no disclosure
43
+ block was observed on the store page, never that the title uses no
44
+ generative AI; `unconfirmed` includes titles never checked, and is also what
45
+ an appid never observed returns, since the route has no existence check.
46
+ Listed as Provisional in the beta command set.
47
+ - Bundled `gmc-analysis` skill bumped to 0.16.0 with a "Disclosure text and
48
+ change history" subsection covering that command and how to report its text
49
+ and events honestly.
50
+ - An inapplicable `--preset` is now rejected as a usage error BEFORE the
51
+ metered request, for every command. Previously the request executed (and
52
+ charged a credit) and the preset was only rejected while shaping the output.
53
+ - Packaging: `scripts/` (release tooling) no longer ships in the npm tarball,
54
+ and the release-readiness gate now scans every file the tarball actually
55
+ contains instead of a hardcoded source list.
56
+
5
57
  ## 0.8.0 - 2026-09-14
6
58
 
7
59
  For npm users this release lands together with 0.7.0's content, because
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@witchpot/gmc",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Command line tools for Game Market Copilot market research.",
6
6
  "license": "UNLICENSED",
@@ -33,7 +33,6 @@
33
33
  "files": [
34
34
  "bin",
35
35
  "src",
36
- "scripts",
37
36
  "skill",
38
37
  "README.md",
39
38
  "CHANGELOG.md"
@@ -1,5 +1,30 @@
1
1
  # gmc-analysis skill changelog
2
2
 
3
+ ## 0.16.0 - 2026-09-16
4
+
5
+ - New "Disclosure text and change history" subsection under "AI disclosure
6
+ state" (NAK-804): the surface behind the tri-state flag — the developer's
7
+ disclosure text, the store page it was read from, the first and latest
8
+ observation dates, and the observed change history. Reached with
9
+ `gmc games ai-disclosure <appid>` (appid only, no `--source` and no query
10
+ parameters) or `game_profile` with `ai_disclosure` in `sections` (opt-in, so
11
+ a call that does not name it performs no extra read, and raw-read weight
12
+ class, so asking for it beside `detail` leaves the per-appid cost at 1).
13
+ - Adds the interpretation guardrails as honesty rules: `disclosureText` is the
14
+ developer's own wording, normalized only for whitespace and never classified
15
+ or scored by Game Market Copilot, so it is quoted and attributed to them
16
+ rather than paraphrased into a verdict; each event is an OBSERVED change
17
+ dated as the window between `previousObservedAt` and `observedAt`, never as
18
+ a moment; an empty `events` array means no change was observed, never that
19
+ nothing changed, because a title's first observation is a baseline that
20
+ never appears as an event; `events` can be non-empty while `state` is
21
+ `unconfirmed`; `unconfirmed` still covers titles never checked, since this
22
+ surface has no existence check; a `null` payload carries
23
+ `ai_disclosure_detail_unavailable` and means the read could not be reached,
24
+ never "not disclosed".
25
+ - MCP-only mapping updated: per-title AI-disclosure text and change history ->
26
+ `game_profile` with `ai_disclosure` in `sections`.
27
+
3
28
  ## 0.15.0 - 2026-09-14
4
29
 
5
30
  - New "AI disclosure state" section in SKILL.md (NAK-773): the tri-state
@@ -8,7 +8,7 @@ description: >-
8
8
  or campaigns that GMC can answer — including turning results into
9
9
  publish-ready branded chart cards.
10
10
  metadata:
11
- version: 0.15.0
11
+ version: 0.16.0
12
12
  ---
13
13
 
14
14
  # GMC Analysis
@@ -57,7 +57,9 @@ applies identically. Mechanics map as follows:
57
57
  -> `more_like_this`; review topics by review language (`games
58
58
  language-topics`) -> `game_language_topics`; review-count history with
59
59
  its lifecycle/announcement/discount context (`games review-history`) ->
60
- `game_review_history`.
60
+ `game_review_history`; per-title AI-disclosure text and change history
61
+ (`games ai-disclosure`) -> `game_profile` with `ai_disclosure` in
62
+ `sections`.
61
63
  - Per-family collection status ("has GMC actually collected X for this
62
64
  game?") has no CLI command yet and is MCP-only: `game_collection_status`
63
65
  (see the dedicated section below).
@@ -483,7 +485,8 @@ Every title carries a tri-state AI-disclosure read from its Steam store page:
483
485
  search/count and `market_aggregate`; group with
484
486
  `--group-by ai_disclosure` (it cross-tabs with `release_month`); read one
485
487
  title's state from `detail.aiDisclosure` (`state`, `observedAt`,
486
- `rawCategoryValue`).
488
+ `rawCategoryValue`). The text and history behind that flag are a separate
489
+ surface — see the subsection below.
487
490
 
488
491
  Interpretation guardrails (honesty rules, not suggestions):
489
492
 
@@ -505,6 +508,42 @@ Interpretation guardrails (honesty rules, not suggestions):
505
508
  (the underlying rollups have no disclosure predicate). Size disclosure
506
509
  cohorts with `market_aggregate` instead.
507
510
 
511
+ ### Disclosure text and change history
512
+
513
+ `gmc games ai-disclosure <appid> --json` (MCP: `game_profile` with
514
+ `ai_disclosure` in `sections`) returns what sits behind that flag: the
515
+ developer's disclosure text, the store page it was read from, the first and
516
+ latest observation dates, and the observed change history. The CLI command
517
+ takes the appid and nothing else — no `--source`, no query parameters. Over
518
+ MCP the section is opt-in, so a call that does not name it performs no extra
519
+ read, and it stays in the raw-read weight class: asking for it beside `detail`
520
+ leaves the per-appid cost at 1.
521
+
522
+ Interpretation guardrails (honesty rules, not suggestions):
523
+
524
+ - **The text is the developer's, never ours.** `disclosureText` is their own
525
+ wording as read from the store page, normalized only for whitespace. Game
526
+ Market Copilot never classifies it, scores it, or infers from it how much AI
527
+ was used. Quote it and attribute it to the developer; never paraphrase it
528
+ into a verdict of your own. It is `null` whenever `state` is not
529
+ `disclosed`.
530
+ - **Each event is an observed change, so date it as a window.** The store page
531
+ is read periodically, not continuously, so a change happened SOMEWHERE
532
+ between `previousObservedAt` and `observedAt`. Say "between those two
533
+ reads"; never say it happened on either date.
534
+ - **An empty `events` array is not "nothing ever changed".** A title's FIRST
535
+ observation is a baseline and never appears as an event, so an empty array
536
+ means only that no change has been observed between two reads. `events` can
537
+ also be non-empty while `state` is `unconfirmed` — history outlives a later
538
+ read that failed.
539
+ - **`unconfirmed` still includes never checked.** This surface has no
540
+ existence check, so an appid never observed comes back `unconfirmed` with no
541
+ events rather than an error. That is a statement about what has been
542
+ observed, never about the title.
543
+ - **A `null` payload is unavailability.** It carries
544
+ `ai_disclosure_detail_unavailable` and means the read could not be reached.
545
+ Never report it as "not disclosed", and never as not collected.
546
+
508
547
  ## Hard rules
509
548
 
510
549
  - **Missing data is not absent sentiment.** Report `cohort.usableGames`,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.15.0",
3
- "contentHash": "74cc6c0184ced1c74a624bd193f531bfad70dae3507f52b1a8090b02d81427b9",
2
+ "version": "0.16.0",
3
+ "contentHash": "a751df9637a24adb16604c91f77618ba1af16ce191843d85d3d8f2e6ae4df363",
4
4
  "files": 7
5
5
  }
package/src/args.js CHANGED
@@ -90,6 +90,11 @@ function isCommandToken(command, token) {
90
90
  // unknown command `steam` with a stray positional rather than reaching
91
91
  // the tag-trends branch.
92
92
  "steam",
93
+ // NAK-715. Same trap as `steam` above: a token missing from this list
94
+ // falls through to positional rather than erroring, so `gmc roblox
95
+ // charts` would parse as the unknown command `roblox` with a stray
96
+ // positional instead of reaching the charts branch.
97
+ "roblox",
93
98
  "auth",
94
99
  "login",
95
100
  "logout",
@@ -109,7 +114,7 @@ function isCommandToken(command, token) {
109
114
  return ["install", "print", "status"].includes(token);
110
115
  }
111
116
  if (command[0] === "games") {
112
- return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "language-topics", "review-history", "success-report", "more-like-this"].includes(token);
117
+ return ["search", "count", "aggregate", "resolve", "benchmark", "detail", "analysis", "compare", "topics", "language-topics", "review-history", "success-report", "more-like-this", "ai-disclosure"].includes(token);
113
118
  }
114
119
  if (command[0] === "reviews") {
115
120
  return token === "patterns";
@@ -138,6 +143,9 @@ function isCommandToken(command, token) {
138
143
  if (command[0] === "steam") {
139
144
  return ["tag-trends", "trend-stories"].includes(token);
140
145
  }
146
+ if (command[0] === "roblox") {
147
+ return ["charts", "experiences", "experience", "history", "coverage"].includes(token);
148
+ }
141
149
  return false;
142
150
  }
143
151
 
package/src/constants.js CHANGED
@@ -7,7 +7,7 @@ export const EXIT_CONFIG = 3;
7
7
  export const EXIT_API = 4;
8
8
 
9
9
  export const CLI_PACKAGE_NAME = "@witchpot/gmc";
10
- export const CLI_VERSION = "0.8.0";
10
+ export const CLI_VERSION = "0.10.0";
11
11
 
12
12
  export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
13
13
  export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
@@ -60,7 +60,18 @@ export const PROVISIONAL_BETA_COMMANDS = [
60
60
  // default-off, so claiming supported-beta status would promise a stability
61
61
  // the feature flag has not yet been asked to keep.
62
62
  "gmc steam trend-stories --locale en --json",
63
- "gmc steam tag-trends --view featured|board|group|table|tag --json"
63
+ "gmc steam tag-trends --view featured|board|group|table|tag --json",
64
+ // NAK-804. New command; starts Provisional per the closed-beta roadmap's
65
+ // default for anything not yet through broader real-data smoke testing.
66
+ "gmc games ai-disclosure <appid> --json",
67
+ // NAK-715/NAK-725. Enabled in production on 2026-09-22; stays Provisional
68
+ // per the closed-beta roadmap's default until the surface has been through
69
+ // a broader real-data smoke period (the NAK-804 rule), then graduates.
70
+ "gmc roblox charts --country all --sort top-playing-now --json",
71
+ "gmc roblox experiences --country all --limit 50 --json",
72
+ "gmc roblox experience <universeId> --json",
73
+ "gmc roblox history <universeId> --granularity daily|hourly --json",
74
+ "gmc roblox coverage --json"
64
75
  ];
65
76
 
66
77
  export const COMPARE_FOCUSES = ["overview", "market", "reviews", "marketing", "audience", "showcases"];
package/src/help.js CHANGED
@@ -51,6 +51,7 @@ Usage:
51
51
  gmc games language-topics --source steam <externalId> --min-reviews <number> --include-insufficient --json
52
52
  gmc games review-history --source steam <externalId> --json
53
53
  gmc games more-like-this --source steam <externalId> --country <country> --json
54
+ gmc games ai-disclosure <appid> --json
54
55
  gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
55
56
  gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size <number> --preset summary --json
56
57
  gmc marketing patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
@@ -69,6 +70,11 @@ Usage:
69
70
  gmc steam tag-trends --view featured --json
70
71
  gmc steam tag-trends --view table --sort release_share_delta --order desc --json
71
72
  gmc steam tag-trends --view tag --tag <tag_slug> --json
73
+ gmc roblox charts --country all --sort top-playing-now --json
74
+ gmc roblox experiences --country all --limit 50 --json
75
+ gmc roblox experience <universeId> --json
76
+ gmc roblox history <universeId> --granularity daily --json
77
+ gmc roblox coverage --json
72
78
 
73
79
  Tag taxonomy:
74
80
  gmc tags --json List the full tag dictionary (slug, name).
@@ -151,6 +157,31 @@ Review-count history:
151
157
  Examples:
152
158
  gmc games review-history --source steam 730 --json
153
159
 
160
+ AI-disclosure text and history:
161
+ gmc games ai-disclosure <appid> --json
162
+ One game's developer-authored AI-use
163
+ disclosure text, the store page it was
164
+ read from, and its observed history of
165
+ change. There are no parameters beyond
166
+ the appid.
167
+
168
+ data.state is disclosed, absent, or unconfirmed. absent means no
169
+ disclosure block was observed on the store page -- never that the title
170
+ uses no generative AI. unconfirmed means Game Market Copilot has not yet
171
+ successfully observed the store page either way, including a title never
172
+ attempted at all.
173
+
174
+ data.disclosureText is the developer's own wording, normalized only for
175
+ whitespace and never classified or summarized by Game Market Copilot. It
176
+ is null unless state is disclosed. data.sourceUrl is the store page the
177
+ observation was read from.
178
+
179
+ data.events lists only OBSERVED changes to the state, oldest first; a
180
+ title's first observation is a baseline and never appears as an event.
181
+
182
+ Example:
183
+ gmc games ai-disclosure 730 --json
184
+
154
185
  Steam tag trends:
155
186
  gmc steam tag-trends --view <view> --json
156
187
  Tag supply trends over one frozen monthly
@@ -206,6 +237,70 @@ Steam tag trends:
206
237
  gmc steam tag-trends --view table --sort current_share --limit 50 --json
207
238
  gmc steam tag-trends --view tag --tag cozy --json
208
239
 
240
+ Roblox:
241
+ gmc roblox charts --json One ranking cohort's stored chart
242
+ snapshot as of a cutoff.
243
+ gmc roblox experiences --json Browse the tracked cohort with coverage.
244
+ gmc roblox experience <universeId> --json
245
+ One experience: metadata, metrics,
246
+ artwork.
247
+ gmc roblox history <universeId> --json
248
+ A bounded window of stored metric
249
+ observations.
250
+ gmc roblox coverage --json What the surface has, per dimension.
251
+ --country <country> all | US | JP. Selects which CHART was
252
+ read, never a regional metric scope.
253
+ --device <device> computer.
254
+ --sort <sort> top-playing-now | top-trending |
255
+ top-revisited | fun-with-friends.
256
+ --as-of <instant> ISO 8601 cutoff; defaults to now. No
257
+ observation after it is considered.
258
+ --eligibility-days <number> experiences only, 1-30 (default 7). How
259
+ far back a chart appearance still counts.
260
+ --min-player-count <number> experiences only, on the resolved row.
261
+ --max-rank <number> experiences only, on the resolved row.
262
+ --genre <genre> experiences only, exact match.
263
+ --creator-type <type> experiences only, e.g. Group or User.
264
+ --detail <enriched|chart_only> experiences only. Omitted returns both.
265
+ --order-by <key> rank | player_count | last_chart_seen |
266
+ universe_id (experiences only).
267
+ --order <asc|desc> experiences only.
268
+ --granularity <hourly|daily> history only. Defaults to daily.
269
+ --from <instant> --to <instant> history only. Hourly windows span at most
270
+ 90 days, daily at most 365.
271
+ --limit <number> charts 1-250, experiences 1-200.
272
+ --offset <number> charts only.
273
+ --cursor <cursor> experiences only. Continuation token.
274
+
275
+ A Roblox universe id is NOT a Steam appid. Ids travel as decimal strings
276
+ because they exceed the range a JSON number represents exactly, and nothing
277
+ here merges a Roblox experience with a Steam game.
278
+
279
+ Unknown is never zero. A null player count was not measured. price.known
280
+ false means the price is UNKNOWN, not that the experience is free. Zero
281
+ recorded votes gives no positive percentage at all, rather than 0%.
282
+
283
+ Nothing is fabricated to fill a gap. An absent cohort answers availability
284
+ "never_observed" with an empty entries list — a measurement about
285
+ collection, not an error and never another sort's ranking. History reports
286
+ unsampled periods in data.gaps and interpolates nothing, so a gap is not a
287
+ zero. Daily sampledPlayingMin/Max are the extremes of the SAMPLES, not the
288
+ day's peak; compare CCU only across points whose comparable is true.
289
+ Observed counter decreases are reported as-is and are not resets.
290
+
291
+ This is a curated tracked cohort, never all of Roblox. Quote
292
+ coverage.trackedUniverses as the denominator for any share.
293
+
294
+ While the surface is switched off the commands answer 404
295
+ ROBLOX_UNAVAILABLE. That is the documented not-available state, not an
296
+ outage and not worth retrying on a schedule.
297
+
298
+ Examples:
299
+ gmc roblox coverage --json
300
+ gmc roblox charts --country JP --sort top-trending --json
301
+ gmc roblox experiences --min-player-count 1000 --order-by player_count --json
302
+ gmc roblox history <universeId> --granularity hourly --json
303
+
209
304
  Binary aliases:
210
305
  gmcp and game-market-copilot run the same CLI. Use them when a shell
211
306
  alias shadows gmc (Oh My Zsh's git plugin defines gmc as
@@ -306,6 +401,10 @@ Filters:
306
401
  result carries catalog_as_of_filter_unavailable
307
402
  instead: there is no availability history behind
308
403
  fixtures, so the filter was not applied at all.
404
+ --release-status <value> released, upcoming, or tba. An exact match on
405
+ top of the guard that already drops any title
406
+ still marked upcoming after its provisional
407
+ release date has passed, on every query.
309
408
  --ai-disclosure <value> disclosed, absent, or unconfirmed. absent means
310
409
  no AI-use disclosure was observed in collected
311
410
  data -- not confirmed non-use. unconfirmed means
package/src/main.js CHANGED
@@ -10,7 +10,7 @@ import { matches, parseArgs } from "./args.js";
10
10
  import { handleLocalCommand, loadConfig, parseBaseUrl } from "./config.js";
11
11
  import { printGamesSearchHelp, printHelp } from "./help.js";
12
12
  import { writeJsonError } from "./output.js";
13
- import { applyOutputProjection } from "./projection.js";
13
+ import { applyOutputProjection, assertPresetApplicable } from "./projection.js";
14
14
  import { buildRequest } from "./requests.js";
15
15
  import { trackCliCommandExecuted } from "./telemetry.js";
16
16
  import { executeRequestPlan } from "./workflows.js";
@@ -71,6 +71,12 @@ export async function main(argv = process.argv.slice(2)) {
71
71
  process.exit(EXIT_USAGE);
72
72
  }
73
73
 
74
+ // NAK-804 cross-model review: validated BEFORE the metered request below,
75
+ // not only afterward inside applyOutputProjection — an inapplicable
76
+ // --preset must never pay for the HTTP round trip (or its credit charge)
77
+ // only to be rejected once the response is already back.
78
+ assertPresetApplicable({ fields: options.fields, preset: options.preset, request });
79
+
74
80
  if (request.requiresApiKey && !apiKey) {
75
81
  writeJsonError({
76
82
  code: "CLI_MISSING_CONFIG",
@@ -0,0 +1,130 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { main } from "./main.js";
5
+ import { assertPresetApplicable } from "./projection.js";
6
+
7
+ /**
8
+ * Async counterpart to the `captureUsageFailure` helper in requests.test.js:
9
+ * `main()` is async, so `process.exit` must be mocked to throw INTO the
10
+ * awaited call rather than a synchronous one.
11
+ */
12
+ async function captureCliExit(run) {
13
+ const originalWrite = process.stdout.write;
14
+ const originalExit = process.exit;
15
+ const chunks = [];
16
+ let exitCode;
17
+ process.stdout.write = (chunk) => {
18
+ chunks.push(String(chunk));
19
+ return true;
20
+ };
21
+ process.exit = (code) => {
22
+ exitCode = code;
23
+ throw new Error("__cli_exit__");
24
+ };
25
+ try {
26
+ await run();
27
+ throw new Error("expected the CLI to exit");
28
+ } catch (error) {
29
+ if (error.message !== "__cli_exit__") throw error;
30
+ } finally {
31
+ process.stdout.write = originalWrite;
32
+ process.exit = originalExit;
33
+ }
34
+
35
+ return { output: JSON.parse(chunks.join("")), exitCode };
36
+ }
37
+
38
+ /**
39
+ * NAK-804 cross-model review (Codex Sol): `gmc games ai-disclosure 730
40
+ * --preset summary --json` used to build the request, send it, and only
41
+ * THEN discover in applyOutputProjection that ai-disclosure supports no
42
+ * preset — charging a credit for a call whose answer was always going to be
43
+ * a usage error. The counting fetch stub below is the direct evidence that
44
+ * the metered request never fires: if the ordering regresses, this test
45
+ * fails on `fetchCalls === 0` even though the exit code/message stay right.
46
+ */
47
+ test("(NAK-804) an unsupported --preset is rejected before the metered request executes", async () => {
48
+ const originalFetch = globalThis.fetch;
49
+ let fetchCalls = 0;
50
+ globalThis.fetch = async () => {
51
+ fetchCalls += 1;
52
+ throw new Error("fetch must not be called once --preset is rejected");
53
+ };
54
+
55
+ try {
56
+ const { output, exitCode } = await captureCliExit(() =>
57
+ main(["games", "ai-disclosure", "730", "--preset", "summary", "--json"])
58
+ );
59
+
60
+ assert.equal(fetchCalls, 0);
61
+ assert.equal(exitCode, 2);
62
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
63
+ assert.match(
64
+ output.error.message,
65
+ /--preset summary is supported for gmc reviews patterns, gmc games benchmark, and gmc cohort\/pattern workflows/
66
+ );
67
+ } finally {
68
+ globalThis.fetch = originalFetch;
69
+ }
70
+ });
71
+
72
+ /**
73
+ * The same early check is generic (it reuses resolvePresetFields exactly),
74
+ * not an ai-disclosure special case: an unknown preset NAME is rejected
75
+ * before the request too, for any command.
76
+ */
77
+ test("(NAK-804) an unknown --preset value is rejected before the metered request executes", async () => {
78
+ const originalFetch = globalThis.fetch;
79
+ let fetchCalls = 0;
80
+ globalThis.fetch = async () => {
81
+ fetchCalls += 1;
82
+ throw new Error("fetch must not be called once --preset is rejected");
83
+ };
84
+
85
+ try {
86
+ const { output, exitCode } = await captureCliExit(() =>
87
+ main(["games", "ai-disclosure", "730", "--preset", "not-a-real-preset", "--json"])
88
+ );
89
+
90
+ assert.equal(fetchCalls, 0);
91
+ assert.equal(exitCode, 2);
92
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
93
+ assert.match(output.error.message, /--preset must be one of: summary/);
94
+ } finally {
95
+ globalThis.fetch = originalFetch;
96
+ }
97
+ });
98
+
99
+ /**
100
+ * Negative control on the pure check itself (no CLI/network plumbing
101
+ * needed): a request `kind` that IS in the preset allowlist must not be
102
+ * rejected — the early check only intercepts INAPPLICABLE presets, not
103
+ * every --preset call.
104
+ */
105
+ test("(NAK-804) assertPresetApplicable does not reject a preset the request kind supports", () => {
106
+ assert.doesNotThrow(() =>
107
+ assertPresetApplicable({ preset: "summary", request: { kind: "reviewPatterns" } })
108
+ );
109
+ assert.doesNotThrow(() =>
110
+ assertPresetApplicable({ preset: "summary", request: { kind: "benchmark" } })
111
+ );
112
+ assert.doesNotThrow(() =>
113
+ assertPresetApplicable({ preset: "summary", request: { kind: "cohortEvidence" } })
114
+ );
115
+ });
116
+
117
+ /**
118
+ * Same precedence rule applyOutputProjection enforces: --fields silences an
119
+ * otherwise-inapplicable --preset entirely, so passing both together must
120
+ * not trip the early check either.
121
+ */
122
+ test("(NAK-804) assertPresetApplicable defers to --fields precedence, like applyOutputProjection", () => {
123
+ assert.doesNotThrow(() =>
124
+ assertPresetApplicable({
125
+ fields: "state",
126
+ preset: "summary",
127
+ request: { kind: undefined }
128
+ })
129
+ );
130
+ });
package/src/projection.js CHANGED
@@ -47,6 +47,27 @@ export function applyOutputProjection(payload, { fields, preset, request } = {})
47
47
  return applyFieldsProjection(payload, presetFields, `preset ${preset}`);
48
48
  }
49
49
 
50
+ /**
51
+ * NAK-804 cross-model review (Codex Sol): every metered command built a
52
+ * usage-error-only exit on an inapplicable --preset, but the check ran
53
+ * INSIDE applyOutputProjection, which main() calls only AFTER the HTTP
54
+ * request already executed (and, for a metered endpoint, already charged a
55
+ * credit). This is the same validation, called right after buildRequest()
56
+ * and before that request is ever sent, so the usage error lands before any
57
+ * network call or charge. General fix, not ai-disclosure-specific: it reuses
58
+ * resolvePresetFields exactly, so any command missing from its
59
+ * kind-allowlist gets the early rejection for free.
60
+ *
61
+ * Mirrors applyOutputProjection's own precedence: --fields silences --preset
62
+ * entirely (never even inspected) both here and there, so the two agree on
63
+ * every input rather than only on the paths that reach a real request.
64
+ */
65
+ export function assertPresetApplicable({ fields, preset, request } = {}) {
66
+ if (fields != null && fields !== "") return;
67
+ if (preset == null || preset === "") return;
68
+ resolvePresetFields(preset, request);
69
+ }
70
+
50
71
  function resolvePresetFields(preset, request) {
51
72
  if (preset !== "summary") {
52
73
  writeJsonError({