@witchpot/gmc 0.3.0 → 0.5.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,7 +2,35 @@
2
2
 
3
3
  All notable changes to the Game Market Copilot CLI will be recorded here.
4
4
 
5
- ## Unreleased
5
+ ## 0.5.0 - 2026-07-13
6
+
7
+ - gmc-analysis skill v0.5.0 (NAK-190): new "Saved Game Lists" guidance in
8
+ SKILL.md and recipe R5 (reusable cohorts via `gmc lists ...` / the MCP
9
+ Game List tools / `game_list_id` and `--list <id>` inputs), plus a skill
10
+ changelog entry retroactively documenting the chart-card guidance that
11
+ shipped unversioned in CLI 0.4.0-era updates (new chart types, capsule
12
+ rows, export geometry, label placement, embedded brand typography).
13
+ - New skill content-hash manifest guard: `npm run skill:manifest`
14
+ regenerates `skill/gmc-analysis.manifest.json`;
15
+ `src/skill-manifest.test.js` (runs in `npm test` / repo `verify`) and
16
+ `release:check` both fail when skill content changes without a version
17
+ bump and manifest regeneration, so the bundled skill and the public
18
+ gmc-agent-skills distribution repo can no longer drift silently.
19
+
20
+ ## 0.4.0 - 2026-07-09
21
+
22
+ - gmc-analysis skill v0.4.0: new `references/validity.md` with the
23
+ statistical-validity checklist and claim-safety rules (NAK-82), and new
24
+ pitfall P9 documenting the single-assignment vs multi-assignment tag
25
+ counting bases (NAK-81). Both ship in `gmc skill install` and the
26
+ `gmc skill print --format agents-md` self-contained edition.
27
+ - Bundled chart-card generation into the gmc-analysis skill (v0.3.0,
28
+ NAK-89): new `references/charts.md` with attribution rules (fixed
29
+ `GMC database` source label, brand lockup, denominator + observation
30
+ window caption, no causal titles), a chart-type mapping from gmc output
31
+ shapes, and a self-contained HTML/Chart.js card template. The reference
32
+ is included in `gmc skill install` and in the
33
+ `gmc skill print --format agents-md` self-contained edition.
6
34
 
7
35
  ## 0.3.0 - 2026-07-05
8
36
 
package/README.md CHANGED
@@ -30,7 +30,9 @@ A fuller user guide lives in
30
30
 
31
31
  Closed-beta access is controlled by GMC API credentials, not npm package
32
32
  visibility. Installing the public package does not grant access to paid or beta
33
- data.
33
+ data. Authenticated access (login, API keys) requires a paid GMC plan
34
+ (Starter or Pro) — see https://gamemarketcopilot.com/plans. Without
35
+ credentials the CLI serves redacted public previews with a small quota.
34
36
 
35
37
  ## Auth
36
38
 
@@ -64,8 +66,10 @@ a non-secret prefix.
64
66
  ## Agent Skill
65
67
 
66
68
  The package bundles the `gmc-analysis` Agent Skill — analysis recipes, pivot
67
- heuristics, quota etiquette, and honesty rules for coding agents. The core
68
- analysis guidance is shared, but the registration step depends on the agent.
69
+ heuristics, quota etiquette, honesty rules, and branded chart-card generation
70
+ (publish-ready, correctly attributed charts from gmc output) for coding
71
+ agents. The core analysis guidance is shared, but the registration step
72
+ depends on the agent.
69
73
 
70
74
  For Claude Code, install the packaged skill directory:
71
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@witchpot/gmc",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Command line tools for Game Market Copilot market research.",
6
6
  "license": "UNLICENSED",
@@ -43,6 +43,7 @@
43
43
  },
44
44
  "scripts": {
45
45
  "test": "node --test src/*.test.js",
46
+ "skill:manifest": "node scripts/gen-skill-manifest.mjs",
46
47
  "release:check": "node scripts/check-release-readiness.mjs && node scripts/smoke-pack.mjs",
47
48
  "prepublishOnly": "npm run release:check",
48
49
  "smoke:beta": "node scripts/smoke-beta.mjs",
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawnSync } from "node:child_process";
3
4
  import { existsSync, readFileSync } from "node:fs";
4
5
  import path from "node:path";
5
6
 
@@ -16,7 +17,9 @@ const requiredFiles = [
16
17
  "skill/gmc-analysis/SKILL.md",
17
18
  "skill/gmc-analysis/references/recipes.md",
18
19
  "skill/gmc-analysis/references/pitfalls.md",
20
+ "skill/gmc-analysis/references/validity.md",
19
21
  "skill/gmc-analysis/references/quota.md",
22
+ "skill/gmc-analysis/references/charts.md",
20
23
  "skill/gmc-analysis/CHANGELOG.md",
21
24
  "README.md",
22
25
  "CHANGELOG.md"
@@ -119,6 +122,53 @@ for (const file of sourceFiles) {
119
122
  }
120
123
  }
121
124
 
125
+ // The skill content-hash manifest (NAK-190) is also enforced by
126
+ // src/skill-manifest.test.js, but release:check/prepublishOnly run WITHOUT
127
+ // the test suite — recheck here so a stale manifest can never reach npm.
128
+ const { buildSkillManifest, MANIFEST_PATH } = await import("./gen-skill-manifest.mjs");
129
+ if (!existsSync(MANIFEST_PATH)) {
130
+ failures.push("skill/gmc-analysis.manifest.json is missing — run npm run skill:manifest.");
131
+ } else {
132
+ const committedManifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
133
+ const currentManifest = buildSkillManifest();
134
+ if (
135
+ committedManifest.contentHash !== currentManifest.contentHash ||
136
+ committedManifest.version !== currentManifest.version
137
+ ) {
138
+ failures.push(
139
+ "skill/gmc-analysis.manifest.json is stale: bump metadata.version in SKILL.md, run npm run skill:manifest, and re-sync the gmc-agent-skills distribution repo."
140
+ );
141
+ }
142
+ }
143
+
144
+ // The public distribution repo (github.com/witchpot-studio/gmc-agent-skills)
145
+ // mirrors skill/gmc-analysis. When it is checked out as a workspace sibling,
146
+ // a drift between the two is a release blocker — MCP-only users install the
147
+ // skill from that repo and must get the same content the npm package ships.
148
+ const explicitAgentSkillsDir = process.env.GMC_AGENT_SKILLS_DIR;
149
+ const agentSkillsRepo =
150
+ explicitAgentSkillsDir ?? path.join(packageDir, "..", "..", "..", "gmc-agent-skills");
151
+ const agentSkillsSyncScript = path.join(agentSkillsRepo, "scripts", "sync-from-cli.mjs");
152
+ if (explicitAgentSkillsDir && !existsSync(agentSkillsSyncScript)) {
153
+ failures.push(
154
+ `GMC_AGENT_SKILLS_DIR is set but ${agentSkillsSyncScript} does not exist — fix the path or unset the variable.`
155
+ );
156
+ } else if (existsSync(agentSkillsSyncScript)) {
157
+ const drift = spawnSync(process.execPath, ["scripts/sync-from-cli.mjs", "--check"], {
158
+ cwd: agentSkillsRepo,
159
+ encoding: "utf8",
160
+ // Pin the canonical source to THIS checkout — the sync script's own
161
+ // default (../GMC relative to the skills repo) could silently compare
162
+ // against a different GMC checkout.
163
+ env: { ...process.env, GMC_REPO_DIR: path.join(packageDir, "..", "..") }
164
+ });
165
+ if (drift.status !== 0) {
166
+ failures.push(
167
+ `gmc-agent-skills repo has drifted from skill/gmc-analysis — run node scripts/sync-from-cli.mjs there.\n${(drift.stderr || "").trim()}`
168
+ );
169
+ }
170
+ }
171
+
122
172
  if (failures.length > 0) {
123
173
  process.stderr.write(`GMC CLI release readiness failed:\n${failures.map((failure) => `- ${failure}`).join("\n")}\n`);
124
174
  process.exit(1);
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Regenerates skill/gmc-analysis.manifest.json — a content-hash fingerprint
4
+ // of the packaged gmc-analysis skill (SKILL.md + references/ + CHANGELOG.md).
5
+ //
6
+ // Maintainer rule: any time skill/gmc-analysis/** content changes, bump
7
+ // metadata.version in SKILL.md, then run `npm run skill:manifest` (in
8
+ // packages/gmc-cli) to regenerate this manifest, and re-sync the
9
+ // gmc-agent-skills distribution repo (node scripts/sync-from-cli.mjs there).
10
+ // src/skill-manifest.test.js recomputes the same hash on every
11
+ // `npm run test:cli` / `npm run verify` and fails loudly if it no longer
12
+ // matches the committed manifest — this is the drift guard that would have
13
+ // caught the unversioned charts.md changes in PR #18 / PR #43 (NAK-190).
14
+ //
15
+ // The manifest is written OUTSIDE skill/gmc-analysis/ on purpose: that
16
+ // directory is mirrored byte-for-byte into the public gmc-agent-skills
17
+ // distribution repo, and the manifest must never be part of (or affect the
18
+ // hash of) the content it describes.
19
+
20
+ import { spawnSync } from "node:child_process";
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const packageDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
27
+ export const SKILL_DIR = path.join(packageDir, "skill", "gmc-analysis");
28
+ export const MANIFEST_PATH = path.join(packageDir, "skill", "gmc-analysis.manifest.json");
29
+
30
+ /** List every file under `dir`, as sorted relative POSIX paths. */
31
+ export function listSkillFiles(dir = SKILL_DIR) {
32
+ const results = [];
33
+ walk(dir, dir, results);
34
+ return results.sort();
35
+ }
36
+
37
+ function walk(root, current, results) {
38
+ const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
39
+ for (const entry of entries) {
40
+ const abs = path.join(current, entry.name);
41
+ if (entry.isDirectory()) {
42
+ walk(root, abs, results);
43
+ } else if (entry.isFile()) {
44
+ results.push(path.relative(root, abs).split(path.sep).join("/"));
45
+ }
46
+ }
47
+ }
48
+
49
+ function sha256Hex(buffer) {
50
+ return createHash("sha256").update(buffer).digest("hex");
51
+ }
52
+
53
+ /**
54
+ * Hash every file under `dir` and fold the per-file hashes into one
55
+ * top-level sha256 over `relpath\nfilehash\n` lines (sorted, deterministic).
56
+ */
57
+ export function computeSkillContentHash(dir = SKILL_DIR) {
58
+ const files = listSkillFiles(dir);
59
+ const digest = createHash("sha256");
60
+ for (const relPath of files) {
61
+ const fileHash = sha256Hex(readFileSync(path.join(dir, relPath)));
62
+ digest.update(`${relPath}\n${fileHash}\n`);
63
+ }
64
+ return { contentHash: digest.digest("hex"), files: files.length };
65
+ }
66
+
67
+ /** Parse metadata.version out of SKILL.md source text; undefined when absent. */
68
+ export function parseSkillVersion(source) {
69
+ const frontmatter = source.match(/^---\n([\s\S]*?)\n---/);
70
+ return frontmatter?.[1].match(/^\s*version:\s*"?([0-9][0-9A-Za-z.\-]*)"?\s*$/m)?.[1];
71
+ }
72
+
73
+ /** Read metadata.version from SKILL.md's YAML frontmatter. */
74
+ export function readSkillManifestVersion(dir = SKILL_DIR) {
75
+ const version = parseSkillVersion(readFileSync(path.join(dir, "SKILL.md"), "utf8"));
76
+ if (!version) {
77
+ throw new Error(`${path.join(dir, "SKILL.md")} must declare metadata.version in its frontmatter.`);
78
+ }
79
+ return version;
80
+ }
81
+
82
+ /** Build the manifest object (version + contentHash + files) for `dir`. */
83
+ export function buildSkillManifest(dir = SKILL_DIR) {
84
+ const { contentHash, files } = computeSkillContentHash(dir);
85
+ return { version: readSkillManifestVersion(dir), contentHash, files };
86
+ }
87
+
88
+ /**
89
+ * Baseline manifest for the version-bump rule. The authoritative baseline is
90
+ * the manifest COMMITTED ON origin/main (read via `git show`, so deleting or
91
+ * hand-editing the local file cannot dodge the rule); the local file is only
92
+ * a fallback for environments where the git baseline is unreadable.
93
+ */
94
+ function baselineSkillManifest() {
95
+ const result = spawnSync("git", ["show", "origin/main:packages/gmc-cli/skill/gmc-analysis.manifest.json"], {
96
+ cwd: packageDir,
97
+ encoding: "utf8"
98
+ });
99
+ if (result.status === 0 && typeof result.stdout === "string") {
100
+ try {
101
+ return JSON.parse(result.stdout);
102
+ } catch {
103
+ // fall through to the local file
104
+ }
105
+ }
106
+ if (existsSync(MANIFEST_PATH)) {
107
+ try {
108
+ return JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /**
117
+ * Pure regen-permission rule (unit-tested in src/skill-manifest.test.js).
118
+ * Content that differs from the baseline while keeping the baseline's
119
+ * version is refused: once a version's content is on origin/main, changing
120
+ * content under that version always requires a bump. A differing version
121
+ * (the in-flight bump case, iterated freely within one branch) and a
122
+ * missing baseline (true first generation) are allowed. There is
123
+ * deliberately NO force escape hatch: an override flag would let
124
+ * unversioned content changes reach npm and the public skills repo.
125
+ */
126
+ export function assertRegenAllowed({ baseline, next }) {
127
+ if (!baseline) return;
128
+ if (baseline.contentHash === next.contentHash) return;
129
+ if (baseline.version !== next.version) return;
130
+ throw new Error(
131
+ `skill content changed but metadata.version is still ${next.version}, whose content is already fixed by the baseline manifest (origin/main) — bump the version in SKILL.md, then rerun npm run skill:manifest.`
132
+ );
133
+ }
134
+
135
+ function main() {
136
+ const manifest = buildSkillManifest();
137
+ try {
138
+ assertRegenAllowed({ baseline: baselineSkillManifest(), next: manifest });
139
+ } catch (error) {
140
+ console.error(error.message);
141
+ process.exit(1);
142
+ }
143
+ writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
144
+ process.stdout.write(
145
+ `Wrote ${path.relative(packageDir, MANIFEST_PATH)}: version ${manifest.version}, ${manifest.files} files, contentHash ${manifest.contentHash}\n`
146
+ );
147
+ }
148
+
149
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
150
+ main();
151
+ }
@@ -81,6 +81,9 @@ try {
81
81
  if (!skillPrintResult.stdout.includes("GMC Analysis") || !skillPrintResult.stdout.includes("Pitfalls")) {
82
82
  throw new Error("Packed gmc skill print did not emit the inlined agents-md section");
83
83
  }
84
+ if (!skillPrintResult.stdout.includes("Chart cards") || !skillPrintResult.stdout.includes("gmc-lockup.svg")) {
85
+ throw new Error("Packed gmc skill print did not inline the chart cards reference");
86
+ }
84
87
 
85
88
  // gmc is shadowed by Oh My Zsh's `git merge --continue` alias, so the
86
89
  // package ships alias binaries; make sure npm actually installed them.
@@ -1,5 +1,85 @@
1
1
  # gmc-analysis skill changelog
2
2
 
3
+ ## 0.5.0 - 2026-07-13
4
+
5
+ - New "Saved Game Lists" section in SKILL.md, plus recipe R5 in
6
+ `references/recipes.md` (NAK-155/NAK-178/NAK-189): documents Game Lists as
7
+ reusable, workspace-scoped cohort inputs — `gmc lists ...` (CLI) or the 7
8
+ MCP tools (`list_game_lists`, `get_game_list`, `create_game_list`,
9
+ `update_game_list`, `add_game_list_items`, `remove_game_list_item`,
10
+ `delete_game_list`), all 0-credit; passing a filter-kind list's id as
11
+ `game_list_id`/`--list <id>` to `list_games`, `market_aggregate`,
12
+ `cohort_review_categories`, or `cohort_evidence` instead of re-sending an
13
+ inline filter; reading `game_list.definition_hash` to detect a changed
14
+ stored definition between calls; the composite-list tool restriction
15
+ (`COMPOSITE_LIST_UNSUPPORTED_FOR_TOOL` on `market_aggregate`/
16
+ `cohort_review_categories`), the manual-list restriction
17
+ (`GAME_LIST_KIND_UNSUPPORTED`), and `delete_game_list`'s MCP confirm-by-
18
+ exact-name two-call contract.
19
+ - `references/charts.md` retroactive documentation (NAK-190): this
20
+ changelog entry catches up two content changes that shipped without a
21
+ skill version bump because nothing enforced one at the time (see the new
22
+ content-hash manifest guard below, added in this same release, to prevent
23
+ a repeat):
24
+ - PR #18 (NAK-139..143): new chart types beyond horizontal bar (lollipop,
25
+ grouped column, stacked column, dumbbell, scatter, quadrant with
26
+ computed threshold lines, donut, hero-number card); Steam capsule rows
27
+ for per-game bar charts; wide (16:9, 1200x675) and square (1:1,
28
+ 1080x1080) export geometry replacing the earlier vertical (4:5) option.
29
+ - PR #43 (NAK-166/NAK-142): direct-label placement rules (values at bar/
30
+ line ends instead of legends, corner labels on quadrant charts) and the
31
+ embedded brand typography stack (Inter / JetBrains Mono / Quicksand)
32
+ used across card title, footer, and source-label text.
33
+ - Added `packages/gmc-cli/scripts/gen-skill-manifest.mjs` and
34
+ `packages/gmc-cli/src/skill-manifest.test.js` (NAK-190): a content-hash
35
+ manifest (`skill/gmc-analysis.manifest.json`, outside the synced skill
36
+ directory) that fails `npm run test:cli` whenever `skill/gmc-analysis/**`
37
+ content changes without a matching `metadata.version` bump and manifest
38
+ regeneration (`npm run skill:manifest`) — the gap that let the two chart
39
+ entries above ship unversioned.
40
+
41
+ ## 0.4.0 - 2026-07-09
42
+
43
+ - Added `references/validity.md` (NAK-82): statistical-validity checklist
44
+ (denominator + N on every share, observation window, counting basis,
45
+ N<30 warning, effect size over bare significance, confound
46
+ stratification for publisher axis / price / release year, multiple-
47
+ comparison disclosure) and claim-safety rules (no causal verbs for
48
+ observational associations, external-estimate labeling, `not_collected`
49
+ means not collected), with a compliant output example. Wired into the
50
+ SKILL.md hard rules and the AGENTS.md edition.
51
+ - New pitfall P9 (NAK-81): single-assignment (`primary_market_tag`) vs
52
+ multi-assignment (`tag` grouping / `tags` filter) tag counting bases
53
+ diverge sharply — pick one basis per question, name it next to the
54
+ denominator, never mix or compare across bases.
55
+
56
+ ## 0.3.1 - 2026-07-08
57
+
58
+ - quota.md: updated the paid allowances to the resized uniform per-credit
59
+ pricing (Starter 2,500 / Pro 7,000 credits per month, NAK-110); reset and
60
+ gate-time charging semantics unchanged.
61
+
62
+ ## 0.3.0 - 2026-07-07
63
+
64
+ - Added `references/charts.md`: publish-ready branded chart cards from gmc
65
+ CLI / MCP outputs (NAK-89). Bakes attribution into the artifact — fixed
66
+ `GMC database` source label, brand lockup (icon + wordmark), denominator
67
+ + observation window caption, no causal titles — and maps gmc output
68
+ shapes (market aggregates, cohort evidence, coverage checks, single
69
+ KPIs) to chart types with a self-contained HTML/Chart.js template.
70
+ - SKILL.md: new "Chart cards" section wiring the reference into the
71
+ workflow and into the AGENTS.md edition.
72
+ - SKILL.md: new "MCP-only setups" section mapping the CLI workflow onto
73
+ the GMC MCP tools (market_aggregate, coverage_check, cohort_evidence,
74
+ game_profile, ...) so agents with only the MCP connection do not attempt
75
+ gmc commands (NAK-98).
76
+ - quota.md: corrected to the monthly credit model (Starter 5,000 / Pro
77
+ 25,000 credits per month, reset on the 1st at 00:00 UTC, gate-time
78
+ charging) — the per-UTC-day request caps it described were outdated.
79
+ - Documented the paid-plan requirement (Starter/Pro) for authenticated
80
+ access, and the `PLAN_REQUIRED` vs `QUOTA_EXCEEDED` distinction for
81
+ MCP tool errors (upgrade vs wait-for-reset).
82
+
3
83
  ## 0.2.0 - 2026-06-12
4
84
 
5
85
  - Folded in the five learnings from the first field test (2025 city
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: gmc-analysis
3
3
  description: >-
4
- Use when analyzing Steam game market data with the gmc CLI (Game Market
5
- Copilot) — cross-game cohort analysis (common complaints, marketing
6
- patterns, pricing), single-title deep dives, market sizing, or any question
7
- about Steam games, reviews, creators, showcases, or campaigns that gmc can
8
- answer.
4
+ Use when analyzing Steam game market data with Game Market Copilot (the
5
+ gmc CLI or the GMC MCP tools) — cross-game cohort analysis (common
6
+ complaints, marketing patterns, pricing), single-title deep dives, market
7
+ sizing, or any question about Steam games, reviews, creators, showcases,
8
+ or campaigns that GMC can answer — including turning results into
9
+ publish-ready branded chart cards.
9
10
  metadata:
10
- version: 0.2.0
11
+ version: 0.5.0
11
12
  ---
12
13
 
13
14
  # GMC Analysis
@@ -26,12 +27,49 @@ gmc auth status --json # anonymous => redacted previews + tiny quota
26
27
  gmc schema --json # the authoritative command/endpoint reference
27
28
  ```
28
29
 
30
+ Authenticated access (CLI login, API keys, MCP) requires a paid GMC plan
31
+ (Starter or Pro); Free workspaces cannot mint credentials. Anonymous CLI
32
+ use works but returns redacted previews with a tiny quota.
33
+
29
34
  Never guess flags: `gmc schema --json` is the source of truth for commands
30
35
  and parameters. Endpoint query params are camelCase and the matching CLI
31
36
  flag is the kebab-case equivalent (`priceMin` -> `--price-min`,
32
37
  `reviewsGt` -> `--reviews-gt`). Per-command `--help` currently prints one
33
38
  generic usage list, so trust the schema. Always pass `--json`.
34
39
 
40
+ ## MCP-only setups (no CLI installed)
41
+
42
+ If you have the GMC MCP connection instead of the CLI, do NOT attempt gmc
43
+ commands — use the MCP tools; every workflow and honesty rule in this skill
44
+ applies identically. Mechanics map as follows:
45
+
46
+ - Sizing and denominators (`gmc games count` / `games aggregate`) ->
47
+ `market_aggregate` (population-true; prefer it over paginating
48
+ `list_games`).
49
+ - Coverage pre-check -> `coverage_check` before fanning out `game_profile`
50
+ on more than 3 titles.
51
+ - Cohort evidence (`reviews patterns`, `cohort ...`) -> `cohort_evidence`,
52
+ `cohort_review_categories`, `search_review_claims`.
53
+ - Single-title drill-down (`games detail` / `games analysis`) ->
54
+ `game_profile`; name resolution -> `resolve`; showcases ->
55
+ `showcase_fit`.
56
+ - The response envelope differs from the CLI: MCP tool responses carry
57
+ `meta.quota` = `{ used, limit, remaining, resets_at }` (credits) plus
58
+ `meta.credits_charged`, `meta.basis`, `meta.denominator`,
59
+ `meta.coverage`, and `meta.warnings` — there is no `meta.entitlements`
60
+ wrapper. The read-before-you-spend and honesty rules apply unchanged.
61
+ - CLI-only mechanics (pagination flags, `--fields` projection, map-reduce
62
+ recipes) may have no MCP equivalent — stay within the tool contracts
63
+ instead of simulating them.
64
+ - A `PLAN_REQUIRED` tool error means the workspace plan has no API access
65
+ (e.g. Free) — retrying or waiting will not help; tell the user an
66
+ upgrade at gamemarketcopilot.com/plans is required. Only
67
+ `QUOTA_EXCEEDED` is the wait-for-monthly-reset condition.
68
+
69
+ References in this skill use CLI syntax; translate to the matching MCP
70
+ tool. Chart-card rules (references/charts.md) apply to both paths
71
+ unchanged.
72
+
35
73
  ## Core workflow for cohort questions
36
74
 
37
75
  1. **Size the cohort and its research coverage first** (1-2 requests):
@@ -58,6 +96,50 @@ Search commands are optimized for row retrieval. Do not expect
58
96
  unless you explicitly pass `--include-total`; for count-only questions use
59
97
  `gmc games count` instead.
60
98
 
99
+ ## Saved Game Lists (reusable cohort definitions)
100
+
101
+ A Game List is a workspace-scoped, reusable cohort input — persist a filter
102
+ (or an explicit appid set) once instead of re-sending a large inline filter
103
+ on every call.
104
+
105
+ - Manage lists via `gmc lists ...` (`list`, `create`, `show`, `games`, `add`,
106
+ `remove`, `delete`) or the MCP tools `list_game_lists` / `get_game_list` /
107
+ `create_game_list` / `update_game_list` / `add_game_list_items` /
108
+ `remove_game_list_item` / `delete_game_list`. All are 0-credit and scoped
109
+ to the caller's own workspace.
110
+ - Two kinds: `manual` (explicit appid membership) and `filter` (a stored
111
+ `GameFilter`, re-evaluated live against current data on every read). The
112
+ MCP `game_list_id` input accepts `filter`-kind lists only; the CLI
113
+ `--list <id>` flag resolves membership server-side and accepts BOTH kinds.
114
+ - Pass a filter-kind list's `id` as `game_list_id` to `list_games`,
115
+ `market_aggregate`, `cohort_review_categories`, or `cohort_evidence`
116
+ instead of re-sending the inline filter — mutually exclusive with
117
+ `filter` (`INVALID_INPUT` if both or neither are given). CLI equivalent:
118
+ `--list <id>` on `gmc reviews patterns`, `gmc marketing patterns`, and
119
+ `gmc cohort ...` (cohort-evidence-shaped commands only — `gmc games
120
+ count`/`gmc games aggregate` have no `--list` flag; use `market_aggregate`
121
+ with `game_list_id` over MCP for that).
122
+ - The response's `game_list.definition_hash` (a SHA-256 over `{kind,
123
+ filter, sort}`) tells you whether the stored definition changed between
124
+ calls — diff hashes across calls instead of re-diffing the filter
125
+ yourself.
126
+
127
+ Sharp edges:
128
+
129
+ - **Composite (union) lists** work only with `list_games`/`cohort_evidence`;
130
+ `market_aggregate`/`cohort_review_categories` reject them with
131
+ `COMPOSITE_LIST_UNSUPPORTED_FOR_TOOL` (those two are single aggregate
132
+ calls that cannot be bound to an explicit appid population).
133
+ - **Manual lists cannot be used as `game_list_id`** (MCP only) — a
134
+ `kind:"manual"` list fails `GAME_LIST_KIND_UNSUPPORTED` there. Use the
135
+ CLI `--list <id>` flag (which accepts manual lists), or read membership
136
+ directly (`gmc lists games <id>` / `get_game_list`).
137
+ - `delete_game_list` is destructive and, over MCP, uses a confirm-by-
138
+ exact-name two-call contract: a call without `confirm` fails
139
+ `CONFIRMATION_REQUIRED`, naming the list; only pass `confirm` set to that
140
+ exact name after the user has explicitly asked to delete it — never
141
+ guess or pre-fill it speculatively.
142
+
61
143
  ## Hard rules
62
144
 
63
145
  - **Missing data is not absent sentiment.** Report `cohort.usableGames`,
@@ -69,6 +151,16 @@ unless you explicitly pass `--include-total`; for count-only questions use
69
151
  - **Bounded evidence, not market truth.** Carry the CLI's own caveats:
70
152
  evidence packs are sampled/returned-page evidence. Always state
71
153
  denominators ("87 of 107 analyzed titles") and preserve `warnings`.
154
+ - **Pass the validity checklist before handing off numbers.** Every
155
+ reported share carries its denominator, N, observation window, and tag
156
+ counting basis; small-N groups are labeled directional; group
157
+ comparisons are stratified for obvious confounds (publisher axis, price,
158
+ release year) or the unchecked confound is named; scanned-extreme
159
+ findings disclose the scan. Details in the validity reference.
160
+ - **Claim safety.** No causal verbs for observational associations; label
161
+ estimates not derived from gmc data as external with a source;
162
+ `not_collected` means not collected, never zero or absent. Details in
163
+ the validity reference.
72
164
  - **Separate API facts from your synthesis.** Quote theme labels and player
73
165
  counts as data; label your clustering and conclusions as your analysis.
74
166
  - **Pace your quota.** Read `meta.entitlements.quota` (`used`/`limit`)
@@ -76,12 +168,24 @@ unless you explicitly pass `--include-total`; for count-only questions use
76
168
  Details in the quota reference.
77
169
  - Preserve `meta.requestId` values for anything you may need to report.
78
170
 
171
+ ## Chart cards
172
+
173
+ When the user wants a shareable or publish-ready visual of gmc results,
174
+ follow the charts reference. Its rules are part of GMC attribution: a chart
175
+ under the GMC label must carry the fixed source label, the brand lockup,
176
+ and a denominator + observation window caption, and its title must not
177
+ make causal claims. Never emit a GMC-attributed chart that skips these.
178
+
79
179
  ## References
80
180
 
81
181
  - `references/recipes.md` — step-by-step playbooks (cohort complaints,
82
182
  full-cohort map-reduce, single-title deep dive, market sizing).
83
183
  - `references/pitfalls.md` — known failure modes and the correct pivots.
184
+ - `references/validity.md` — statistical-validity checklist and
185
+ claim-safety rules for any published number.
84
186
  - `references/quota.md` — quota model and cost estimation.
187
+ - `references/charts.md` — branded chart cards: attribution rules, chart
188
+ type mapping from gmc output shapes, self-contained HTML template.
85
189
 
86
190
  (In the AGENTS.md edition of this skill the references are inlined below.)
87
191
 
@@ -0,0 +1,280 @@
1
+ # Chart cards
2
+
3
+ Turn gmc CLI / MCP outputs into publish-ready, correctly attributed GMC
4
+ chart cards (self-contained HTML -> PNG). Follow this reference whenever a
5
+ chart, graph, or shareable visual is requested from GMC data. A chart that
6
+ violates the non-negotiables below must not carry GMC attribution.
7
+
8
+ ## Non-negotiables (every card)
9
+
10
+ 1. **Footer, left**: the fixed source label `GMC database` plus at most one
11
+ short factual note (collection method, cutoff, exclusions). Japanese
12
+ edition: `GMCデータベース`. Never change, translate loosely, or omit the
13
+ label.
14
+ 2. **Footer, right**: the brand lockup — icon + wordmark, one image:
15
+ `https://gamemarketcopilot.com/assets/gmc-lockup.svg`. Never the icon
16
+ alone, never a retyped wordmark, never someone else's logo.
17
+ 3. **Caption (top-left of the card)**: denominator + observation window,
18
+ e.g. `1,204 of 20,994 titles released 2025 / aggregated as of
19
+ 2026-07-06`. Take denominators from `gmc games count` (or the tool's own
20
+ `cohort.usableGames`), not from page sizes. The caption is the card's
21
+ denominator disclosure — exports must never crop it (or the footer) off.
22
+ 4. **Title**: state the observed fact — a level, difference, share, or
23
+ rank. No causal verbs (`drove`, `boosted`, `caused`, `led to`,
24
+ `because`); the data supports co-occurrence, not causation. No hype
25
+ metaphors. Correlation phrasing is fine.
26
+ 5. **Data honesty**: `not_collected` is never charted as zero. Locked or
27
+ `preview: true` payloads are never charted as full data. Small
28
+ denominators (n < 30) are presented as individual cases, not a trend
29
+ bar. `skippedGames` / `skippedReasons` go in the footnote when they
30
+ change the reading.
31
+ 6. **External estimates stay outside the label**: units multipliers,
32
+ wishlist folklore, or any non-GMC number never appear under the
33
+ `GMC database` label — prefer leaving them off a GMC-branded card
34
+ entirely. If one must appear, add a second, visually separate footnote
35
+ line: `External estimate: <source> — not GMC data`.
36
+ 7. **Opaque background** (`#fafaf7` page, `#ffffff` card). Never export
37
+ transparent PNGs. Export geometry: 16:9 1200x675 (X default) or 1:1
38
+ 1080x1080 (X-safe; donut/kpi/quadrant read best). Vertical (4:5) cards
39
+ are not produced. Default to 16:9 when unsure.
40
+
41
+ ## Picking the chart type from gmc output shapes
42
+
43
+ | Output | Shape | Chart |
44
+ |---|---|---|
45
+ | `gmc games aggregate --group-by <dim>` / MCP `market_aggregate` | 6+ groups, rank matters | Horizontal bar, sorted desc |
46
+ | same | 6+ groups, rank matters, compact point emphasis | Lollipop (bar_h variant) |
47
+ | same | 2-5 groups | Vertical column |
48
+ | same | 2-4 series × categories, e.g. release count by year × price band | Grouped column |
49
+ | same | 2-4 part-of-whole series across categories | Stacked column |
50
+ | same | before/after pairs per category, exactly 2 series | Dumbbell |
51
+ | same, time buckets (release windows, months) | series over time | Line (single/few series) or column |
52
+ | MCP `market_aggregate` with `segment_filter` | segment vs base | Two big numbers + ratio, or grouped bar |
53
+ | `gmc reviews patterns` / MCP `cohort_evidence` / clustered `negativeThemes` | theme -> game counts | Horizontal bar; caption carries `x of N usable games` |
54
+ | MCP `coverage_check` / `gmc games count --group-by coverage` | composition, 2-4 segments | Donut with absolute counts printed, or one 100% stacked bar |
55
+ | any tool output with x/y metric pairs per point | two metrics correlation | Scatter |
56
+ | any tool output with x/y metric pairs for positioning | scatter + median threshold lines + 4 descriptive corner labels; thresholds are computed from plotted values, never chosen | Quadrant |
57
+ | single KPI (one count, one median, one share) | one number | Hero number card (no chart canvas) |
58
+
59
+ Rules of thumb: when unsure, use a horizontal bar. One message per card.
60
+ Never: 3D, dual axes, cropped bar baselines, pie with 5+ slices, line
61
+ charts for non-time data.
62
+
63
+ ## Steam capsules for game rows
64
+
65
+ When bar rows are individual game titles, show the Steam capsule
66
+ (`https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{appid}/capsule_231x87.jpg`,
67
+ 231x87) left of the row label. Preload images before render; skip rows whose
68
+ image failed so the text-only fallback keeps the layout. Scatter/quadrant
69
+ points get the same treatment as a thumbnail overlay per the visual guide's
70
+ thumbnail-overlay pattern: a white, 1px-framed capsule anchored near the
71
+ point with a dashed leader line back to it, instead of a row-left capsule.
72
+
73
+ ```js
74
+ const rows = [{ label: '{{GAME TITLE}}', appid: 0 }];
75
+ const capsuleImages = await Promise.all(rows.map(({ appid }) => new Promise((resolve) => {
76
+ if (!appid) return resolve(null);
77
+ const img = new Image();
78
+ img.onload = () => resolve(img);
79
+ img.onerror = () => resolve(null);
80
+ img.src = `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${appid}/capsule_231x87.jpg`;
81
+ })));
82
+
83
+ const steamCapsules = {
84
+ id: 'steamCapsules',
85
+ afterDraw(chart) {
86
+ const yScale = chart.scales.y;
87
+ if (!yScale) return;
88
+ const { ctx } = chart;
89
+ yScale.ticks.forEach((_, i) => {
90
+ const img = capsuleImages[i];
91
+ if (!img) return;
92
+ const y = yScale.getPixelForTick(i) - 14;
93
+ ctx.drawImage(img, yScale.left - 66, y, 54, 20);
94
+ });
95
+ }
96
+ };
97
+ ```
98
+
99
+ ## Color
100
+
101
+ Neutrals (fixed): page `#fafaf7`, card `#ffffff`, ink `#1a1d20`, muted
102
+ `#3e454d`, faint `#818d9a`, grid `rgba(26,29,32,0.06)`.
103
+
104
+ - **Single series / ranking**: accent ladder only — Ember `#EC7336` at
105
+ alpha 0.85 (lead), 0.55, 0.30, rest `rgba(26,29,32,0.08)`.
106
+ - **2-3 categories**: Ember `#EC7336` + Seagrass `#1AA29D` (+ Inkwell
107
+ `#4057C7`). Never distinguish categories by alpha alone.
108
+ - **4-5 categories**: add Moss `#3E9C5F`, Mulberry `#9B4ABF`. Max 5 colors;
109
+ gray out non-focus series with `rgba(26,29,32,0.12)`. 6+ categories:
110
+ split the chart or bucket into "other".
111
+ - Direct labels beat legends: print values at bar ends / line ends.
112
+
113
+ ## Card template (self-contained)
114
+
115
+ Fill the `{{...}}` slots, adjust the dataset + type per the table above,
116
+ open in a browser, capture the `.card` element as PNG. The template needs
117
+ network access for fonts, Chart.js CDN, and the brand lockup.
118
+
119
+ ```html
120
+ <!DOCTYPE html>
121
+ <html lang="en">
122
+ <head>
123
+ <meta charset="utf-8">
124
+ <meta name="viewport" content="width=device-width, initial-scale=1">
125
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Quicksand:wght@700&display=swap" rel="stylesheet">
126
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
127
+ <style>
128
+ :root {
129
+ --bg: #fafaf7; --card: #ffffff;
130
+ --ink: #1a1d20; --ink-muted: #3e454d; --ink-faint: #818d9a;
131
+ --rule: #e8e5de; --accent: #ec7336;
132
+ --sans: 'Inter', system-ui, sans-serif;
133
+ --mono: 'JetBrains Mono', ui-monospace, monospace;
134
+ --title: 'Quicksand', 'Inter', system-ui, sans-serif;
135
+ }
136
+ * { margin: 0; padding: 0; box-sizing: border-box; }
137
+ body { background: var(--bg); font-family: var(--sans); color: var(--ink);
138
+ display: flex; justify-content: center; padding: 40px 16px; }
139
+ .card { width: 720px; max-width: 100%; background: var(--card);
140
+ border: 1px solid var(--rule); display: flex; flex-direction: column; }
141
+ .card-head { display: flex; justify-content: space-between; gap: 12px;
142
+ padding: 18px 20px 12px; border-bottom: 1px solid var(--rule); }
143
+ .card-num { font-family: var(--mono); font-size: 12.5px; font-weight: 500;
144
+ color: var(--accent); max-width: 76%; }
145
+ .card-cat { font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
146
+ text-transform: uppercase; color: var(--ink-faint); white-space: nowrap; }
147
+ .card-title { padding: 26px 20px 8px; font-family: var(--title);
148
+ font-size: 34px; line-height: 1.12; font-weight: 700; }
149
+ .chart-wrap { position: relative; margin: 18px 20px 8px;
150
+ min-height: 470px; height: 470px; }
151
+ .card-foot { display: flex; justify-content: space-between; align-items: center;
152
+ gap: 16px; border-top: 1px solid var(--rule);
153
+ padding: 14px 20px; margin-top: 12px; }
154
+ .foot-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
155
+ .foot-source { font-family: var(--mono); font-size: 13px; color: var(--ink-muted); }
156
+ .foot-note { font-size: 11.5px; line-height: 1.45; color: var(--ink-faint); }
157
+ .brand-lockup { flex: 0 0 auto; height: 24px; width: auto; display: block; }
158
+ </style>
159
+ </head>
160
+ <body>
161
+ <article class="card">
162
+ <div class="card-head">
163
+ <div class="card-num">{{DENOMINATOR AND WINDOW, e.g. 1,204 of 20,994 titles released 2025 / as of 2026-07-06}}</div>
164
+ <div class="card-cat">{{CHART TYPE, e.g. RANKED BAR}}</div>
165
+ </div>
166
+ <h2 class="card-title">{{FACTUAL TITLE — observed level/difference/share, no causal verbs}}</h2>
167
+ <div class="chart-wrap"><canvas id="chart"></canvas></div>
168
+ <div class="card-foot">
169
+ <div class="foot-meta">
170
+ <div class="foot-source">GMC database</div>
171
+ <div class="foot-note">{{ONE-LINE NOTE: method, cutoff, exclusions, skipped counts}}</div>
172
+ </div>
173
+ <img class="brand-lockup" src="https://gamemarketcopilot.com/assets/gmc-lockup.svg" alt="Game Market Copilot">
174
+ </div>
175
+ </article>
176
+ <script>
177
+ const ink = (a) => `rgba(26, 29, 32, ${a})`;
178
+ const accent = (a) => `rgba(236, 115, 54, ${a})`;
179
+ Chart.defaults.font.family = "'Inter', system-ui, sans-serif";
180
+ Chart.defaults.font.size = 13;
181
+ Chart.defaults.color = '#3e454d';
182
+
183
+ // Direct value labels at bar ends (skip for line charts; use point labels there).
184
+ const valueLabels = {
185
+ id: 'valueLabels',
186
+ afterDatasetsDraw(chart) {
187
+ const { ctx } = chart;
188
+ const horizontal = chart.options.indexAxis === 'y';
189
+ chart.data.datasets.forEach((ds, di) => {
190
+ chart.getDatasetMeta(di).data.forEach((el, i) => {
191
+ const v = ds.data[i];
192
+ if (v == null) return;
193
+ ctx.save();
194
+ ctx.font = "500 12px 'JetBrains Mono', monospace";
195
+ ctx.fillStyle = '#3e454d';
196
+ ctx.textBaseline = 'middle';
197
+ if (horizontal) { ctx.textAlign = 'left'; ctx.fillText(v.toLocaleString(), el.x + 8, el.y); }
198
+ else { ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(v.toLocaleString(), el.x, el.y - 6); }
199
+ ctx.restore();
200
+ });
201
+ });
202
+ }
203
+ };
204
+
205
+ const labels = ['{{GROUP A}}', '{{GROUP B}}', '{{GROUP C}}']; // sorted desc for rankings
206
+ const values = [0, 0, 0]; // real aggregates only
207
+ new Chart(document.getElementById('chart'), {
208
+ type: 'bar', // ranked bar. Column: drop indexAxis. Line: type 'line'.
209
+ data: { labels, datasets: [{
210
+ data: values,
211
+ // Ranking ladder — lead bar strongest, tail neutral:
212
+ backgroundColor: values.map((_, i) => [accent(0.85), accent(0.55), accent(0.30)][i] ?? ink(0.08)),
213
+ borderRadius: 2, barPercentage: 0.72
214
+ }]},
215
+ options: {
216
+ indexAxis: 'y', responsive: true, maintainAspectRatio: false,
217
+ layout: { padding: { right: 64 } }, // room for end-of-bar labels
218
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
219
+ scales: {
220
+ x: { beginAtZero: true, grid: { color: ink(0.06) }, border: { color: ink(0.18) } },
221
+ y: { grid: { display: false }, border: { color: ink(0.18) } }
222
+ },
223
+ animation: { onComplete: () => { window.__animComplete = true; } }
224
+ },
225
+ plugins: [valueLabels]
226
+ });
227
+ </script>
228
+ </body>
229
+ </html>
230
+ ```
231
+
232
+ Variants:
233
+
234
+ - **Column** (2-5 groups): remove `indexAxis`, move padding to `top`,
235
+ labels render above bars automatically via the plugin.
236
+ - **Line** (time buckets): `type: 'line'`, `borderColor: accent(0.85)`,
237
+ `pointRadius: 3`, `fill: false` (always false when more than one dataset
238
+ or when combined with bars), label only first/last/inflection points.
239
+ - **Donut** (coverage split, max 4 segments): `type: 'doughnut'`,
240
+ `cutout: '62%'`, colors Ember/Seagrass/Inkwell + gray; print
241
+ `label — count (share%)` per segment as direct labels or a compact list
242
+ under the chart; absolute counts are mandatory, share alone is not
243
+ enough.
244
+ - **Hero number** (single KPI): replace `.chart-wrap` with
245
+ `<div style="padding:56px 20px 40px; text-align:center;">
246
+ <div style="font-size:96px; font-weight:600; letter-spacing:-0.04em;
247
+ color:var(--accent);">{{VALUE}}</div>
248
+ <div style="font-size:15px; color:var(--ink-muted); margin-top:8px;">
249
+ {{ONE-LINE QUALIFIER}}</div></div>` and delete the chart script. The
250
+ caption and footer rules still apply unchanged.
251
+
252
+ Japanese edition: add `&family=Noto+Sans+JP:wght@400;500;600` to the fonts
253
+ URL, append `'Noto Sans JP'` to `--sans`, set `<html lang="ja">`, and use
254
+ `GMCデータベース` as the source label. Add `&family=Noto+Serif+JP:wght@400&`
255
+ to the fonts URL so JA titles render serif. Steam tags in JA charts:
256
+ `English Tag(日本語タグ名)` on first appearance.
257
+
258
+ ## Capture
259
+
260
+ Any browser screenshot of the `.card` element works. Headless example:
261
+
262
+ ```bash
263
+ npx playwright screenshot --wait-for-timeout 2500 --full-page card.html card.png
264
+ ```
265
+
266
+ then crop to the card, or capture the element via a driver that waits for
267
+ `window.__animComplete === true`. Verify before publishing: bars/labels not
268
+ clipped, text legible at mobile size (12px+ effective), background opaque.
269
+
270
+ ## Pre-publish checklist
271
+
272
+ - [ ] Title is factual, no causal verbs, no hype
273
+ - [ ] Caption has denominator AND observation window
274
+ - [ ] `GMC database` label + brand lockup both present, unmodified
275
+ - [ ] Every number comes from a real gmc/MCP response in this session
276
+ (no remembered or estimated values); re-run the query if stale
277
+ - [ ] `not_collected` / locked data not charted; skipped counts disclosed
278
+ when they change the reading
279
+ - [ ] External estimates (if any) credited separately in the footnote
280
+ - [ ] Colors follow the tier rules; max 5 colors; opaque background
@@ -72,3 +72,53 @@ single-request server aggregates. Reserve pagination loops for when you
72
72
  actually need the rows (recipe R2). `gmc games search` is optimized for rows
73
73
  and may omit `pagination.total`; pass `--include-total` only when a row page
74
74
  also needs an exact count.
75
+
76
+ ## P8. Wide aggregations can time out (2026-07)
77
+
78
+ Aggregate calls that request many metrics and/or two `group_by` dimensions
79
+ over the full catalog can exceed the server-side query timeout. Over MCP this
80
+ surfaces as an `UPSTREAM_TIMEOUT` tool error (older builds: a generic
81
+ `UPSTREAM_ERROR`); via the CLI as a failed request. A timeout is a load
82
+ limit, not a data problem — the cohort itself is fine, and retrying the same
83
+ call unchanged will usually time out again.
84
+
85
+ **Rule**: split the request instead of retrying as-is — fewer metrics per
86
+ call, one `group_by` dimension at a time, or a narrower filter (e.g. a
87
+ shorter release window) — then combine the results. Splits are safe: each
88
+ call returns population-true numbers for its slice, so recombined totals stay
89
+ consistent. Note the substitution in your analysis log so reviewers know the
90
+ numbers came from split calls.
91
+
92
+ ## P9. Two tag counting bases exist — never mix them (2026-07)
93
+
94
+ Tag shares can be computed on two different bases, and the numbers diverge
95
+ sharply (observed in a field comparison: the same cohort's genre shares
96
+ disagreed between two reports because each used a different basis):
97
+
98
+ - **Single-assignment** — `group_by primary_market_tag` (field
99
+ `primaryMarketTag`, derived from `tagRanks[0]`). Each game is counted at
100
+ most once, under its top-ranked tag. Rows are mutually exclusive and sum
101
+ to the TAGGED subset of the cohort — games without tags are omitted from
102
+ these rows, so compare the row total against the plain cohort count and
103
+ use "of tagged titles" as the denominator when the two differ.
104
+ - **Multi-assignment** — `group_by tag`, or separate per-tag filtered
105
+ counts compared or summed across tags (fields `tags` / `tagRanks` /
106
+ `facets.marketTags`). Each game is counted under every tag it carries, so
107
+ one game contributes to many rows and shares sum well past 100%. The
108
+ server marks `group_by tag` with `summary.methods.groupMembership:
109
+ "multi"` and a multi-membership caveat. (A single `tags`-filtered count
110
+ by itself still counts each matching title once — the double-counting
111
+ appears when per-tag numbers are set side by side.)
112
+
113
+ Neither basis is wrong — they answer different questions:
114
+
115
+ - "What is the market composed of?" / "which segment is a game in?" /
116
+ portfolio-style shares -> single-assignment (`primary_market_tag`).
117
+ - "How many games carry tag X?" / tag reach, co-occurrence, feature
118
+ adoption -> multi-assignment (`tag` grouping or `tags`-filtered counts).
119
+
120
+ **Rule**: pick one basis per question, name it next to the denominator
121
+ ("single-assignment by primary_market_tag, N=1,204"), and never compare or
122
+ combine numbers across bases — not within a report, and not against an
123
+ earlier report computed on the other basis. If a prior number's basis is
124
+ unknown, recompute it rather than citing it.
@@ -1,14 +1,19 @@
1
1
  # Quota etiquette
2
2
 
3
- Quotas are per workspace, per UTC day, enforced for bearer credentials
4
- (API keys / CLI credentials). As of 2026-06: starter 1,000/day, pro
5
- 10,000/day; anonymous public preview is far smaller. Treat these numbers as
6
- indicative — the response metadata is the source of truth.
3
+ Quotas are monthly, workspace-level credit allowances enforced for bearer
4
+ credentials (API keys / CLI credentials / MCP tokens). As of 2026-07:
5
+ Starter 2,500 credits/month, Pro 7,000; the counter resets at 00:00 UTC on
6
+ the first of each month. Anonymous public preview is far smaller. Credits
7
+ are charged when a request is accepted at the gate, so requests that later
8
+ fail validation or 404 still consume — validate appids before batch
9
+ fan-outs. Treat these numbers as indicative — the response metadata is the
10
+ source of truth.
7
11
 
8
12
  ## Rules
9
13
 
10
14
  1. **Read before you spend.** Every bearer response carries
11
- `meta.entitlements.quota` = `{ used, limit }`. Check it at the start of a
15
+ `meta.entitlements.quota` = `{ used, limit, remaining, exceeded,
16
+ resetsAt }` in credits. Check it at the start of a
12
17
  session and after each fan-out. Exception: CLI-composed evidence packs
13
18
  (`cohort ...`, `reviews patterns`, `games benchmark`, ...) expose quota
14
19
  per upstream call instead — read the last entry of
@@ -21,8 +26,10 @@ indicative — the response metadata is the source of truth.
21
26
  `--coverage full,partial` and tighter filters so requests are not spent on
22
27
  titles that would be skipped anyway.
23
28
  4. **On HTTP 429 (`QUOTA_EXCEEDED`)**: stop fanning out, respect the
24
- `Retry-After` header / `error.retryAfterSeconds` (the window resets at UTC
25
- midnight), and tell the user how much of the analysis completed.
29
+ `Retry-After` header / `error.retryAfterSeconds` (the allowance resets at
30
+ 00:00 UTC on the first of the next month; a short `Retry-After` means the
31
+ separate burst backstop), and tell the user how much of the analysis
32
+ completed.
26
33
  5. **Prefer server aggregates** (`count`, `aggregate`) over row pagination —
27
34
  they answer sizing questions in one request.
28
35
  6. **Keep totals explicit.** Search commands prioritize fast row pages and
@@ -95,6 +95,33 @@ gives the well-rated share. Divide by the unconditional bucket counts.
95
95
  Never read unconditional rating aggregates in low-traction buckets
96
96
  (pitfall P5).
97
97
 
98
+ ## R5. Repeatable cohort via a saved Game List
99
+
100
+ When a cohort filter will be reused across a session or a recurring
101
+ digest, persist it once instead of re-sending the inline filter on every
102
+ call.
103
+
104
+ ```bash
105
+ # 1. Create the filter-kind list once
106
+ gmc lists create --name "<label>" --kind filter \
107
+ --filter '{"tags":["<tag>"],"reviewsMin":<n>}' --json # -> data.id
108
+
109
+ # 2. Reference it on every follow-up call instead of the inline filter
110
+ gmc reviews patterns --source steam --list <id> --json
111
+ gmc cohort brief --source steam --list <id> --json
112
+ ```
113
+
114
+ Over MCP, `create_game_list` once, then pass its `id` as `game_list_id` to
115
+ `list_games` / `market_aggregate` / `cohort_review_categories` /
116
+ `cohort_evidence` on every follow-up call. Check `game_list.definition_hash`
117
+ on each response — a changed hash means the stored DEFINITION was edited;
118
+ re-state the cohort definition before comparing numbers. A stable hash does
119
+ not freeze the population: filter lists re-evaluate live, so denominators
120
+ can still move between calls. Composite (union) lists only work with `list_games`/
121
+ `cohort_evidence` (this recipe's map-reduce sibling, R2); manual lists are
122
+ not a `game_list_id` input over MCP (the CLI `--list <id>` flag does accept
123
+ them) — read their membership with `gmc lists games <id>` / `get_game_list`.
124
+
98
125
  ## Output contract for every analysis
99
126
 
100
127
  Always include in your final answer: the cohort definition and size, how many
@@ -0,0 +1,78 @@
1
+ # Statistical validity and claim safety
2
+
3
+ Every number you publish from gmc data — in an internal doc, a chat answer,
4
+ or a chart card — must pass this checklist. These rules exist because QA on
5
+ real analysis output repeatedly caught bad denominators, small-N
6
+ generalizations, confounded comparisons, and causal overclaims before they
7
+ reached readers. Run the checklist before handing off, not after.
8
+
9
+ ## Validity checklist (apply to every reported number)
10
+
11
+ 1. **Denominator and N on every share.** Never report a bare percentage.
12
+ State what the share is out of and how many that is: "34% (412 of 1,204
13
+ titles matching the filter)". If the denominator is a subset (analyzed
14
+ games, usable games), say so — `cohort.usableGames` vs the plain cohort
15
+ count are different denominators (see the pitfalls reference).
16
+ 2. **Counting basis named.** A tag/genre share must say whether it is
17
+ single-assignment (`primary_market_tag`) or multi-assignment (`tag`
18
+ grouping / `tags` filter). The two bases are not comparable — pitfall P9.
19
+ 3. **Observation window stated.** Every metric is a snapshot or a windowed
20
+ observation. State the window or snapshot date next to the number
21
+ ("releases 2024-01..2024-12, snapshot as of the response's
22
+ `meta.snapshot`/`generatedAt`"). Numbers without a window cannot be
23
+ reproduced or compared later.
24
+ 4. **Small-N warning.** If a group has N < 30, label the finding as
25
+ directional/anecdotal and do not generalize from it ("only 12 titles in
26
+ this bucket — treat as anecdote, not a segment truth"). Do not silently
27
+ drop small groups either; show them with the warning.
28
+ 5. **Effect size over bare significance.** Report the magnitude of a
29
+ difference, not just that one exists. On large N, tiny differences look
30
+ "real" but mean nothing for decisions; on small N, large-looking
31
+ differences may be noise. Ask: would this gap change anyone's decision?
32
+ 6. **Stratify obvious confounds before comparing groups.** Before claiming
33
+ "group A outperforms group B", check whether the gap survives splitting
34
+ by the usual suspects: publisher axis (`self_published`), price band,
35
+ and release year. If you cannot stratify (quota, missing dimension),
36
+ state the unchecked confound explicitly.
37
+ 7. **Disclose multiple comparisons.** If you scanned many tags, buckets, or
38
+ metrics and are reporting the extremes ("the top tag by rating"),
39
+ say that the winner came from a scan of K groups. Top-of-scan findings
40
+ are selection-biased; confirm them on an independent slice or label them
41
+ as hypotheses.
42
+
43
+ ## Claim-safety rules
44
+
45
+ - **No causal verbs without causal evidence.** gmc aggregates are
46
+ observational. "Drives", "boosts", "causes", "leads to", "because of" are
47
+ off-limits for correlations; write "is associated with", "co-occurs
48
+ with", "titles with X tend to Y". This matches the chart-card rule that
49
+ titles must not make causal claims (charts reference) — apply it to prose
50
+ too.
51
+ - **Label external estimates.** Any figure not computed from gmc responses
52
+ — units-per-review multipliers, wishlist conversion folklore, revenue
53
+ guesses, industry benchmarks — must be labeled as an external estimate
54
+ with its source, and must never be blended into gmc-derived numbers as if
55
+ measured.
56
+ - **`not_collected` means not collected.** Missing research coverage,
57
+ skipped games, and `not_collected` markers must never be reported as zero
58
+ events or absent sentiment ("no complaints"). Report them as "not
59
+ collected" with the skipped count — see the hard rules in SKILL.md and
60
+ pitfall P2.
61
+ - **Locked/preview data stays partial.** Never extrapolate what a locked or
62
+ preview payload "probably" contains (pitfall P3).
63
+
64
+ ## Compliant output example
65
+
66
+ > Of the 1,204 titles released 2024-01-01..2024-12-31 with >= 10 reviews
67
+ > (snapshot 2026-07-08), 412 (34%) carry the roguelike-deckbuilder tag
68
+ > (multi-assignment basis: the same title also counts toward its other
69
+ > tags' shares, so per-tag shares do not sum to 100%).
70
+ > Median review count is 86 for these vs 54 for the rest; the gap holds
71
+ > within the self-published stratum (78 vs 51, N=301) but not for
72
+ > publisher-backed titles (N=23 — too few to call, directional only).
73
+ > This is an observed association; nothing here shows the tag causes
74
+ > higher review counts.
75
+
76
+ Every share above carries a denominator, an N, a window/snapshot, and a
77
+ named counting basis; comparisons are stratified or flagged; language stays
78
+ associational. That is the bar for skill-produced output.
@@ -0,0 +1,5 @@
1
+ {
2
+ "version": "0.5.0",
3
+ "contentHash": "0a8b585ed7b76dab31fb468b7ff58e97a4a31a24fc00b595118aad31dadfeb60",
4
+ "files": 7
5
+ }
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.3.0";
10
+ export const CLI_VERSION = "0.5.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");
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import test from "node:test";
4
+
5
+ import {
6
+ MANIFEST_PATH,
7
+ assertRegenAllowed,
8
+ computeSkillContentHash,
9
+ readSkillManifestVersion
10
+ } from "../scripts/gen-skill-manifest.mjs";
11
+
12
+ const FAILURE_MESSAGE =
13
+ "skill content changed: bump metadata.version in SKILL.md, run npm run skill:manifest (in packages/gmc-cli), and re-sync the gmc-agent-skills distribution repo (node scripts/sync-from-cli.mjs there)";
14
+
15
+ function readCommittedManifest() {
16
+ return JSON.parse(readFileSync(MANIFEST_PATH, "utf8"));
17
+ }
18
+
19
+ test("committed skill manifest content hash matches the packaged skill content", () => {
20
+ const manifest = readCommittedManifest();
21
+ const { contentHash } = computeSkillContentHash();
22
+
23
+ assert.equal(contentHash, manifest.contentHash, FAILURE_MESSAGE);
24
+ });
25
+
26
+ test("committed skill manifest version matches SKILL.md metadata.version", () => {
27
+ const manifest = readCommittedManifest();
28
+ const skillVersion = readSkillManifestVersion();
29
+
30
+ assert.equal(manifest.version, skillVersion, FAILURE_MESSAGE);
31
+ });
32
+
33
+ test("regen rule: content change under the baseline's version is refused", () => {
34
+ assert.throws(
35
+ () =>
36
+ assertRegenAllowed({
37
+ baseline: { version: "0.5.0", contentHash: "aaa" },
38
+ next: { version: "0.5.0", contentHash: "bbb" }
39
+ }),
40
+ /bump the version in SKILL\.md/
41
+ );
42
+ });
43
+
44
+ test("regen rule: allowed on a bump, on unchanged content, and on first generation", () => {
45
+ // A bump (in-flight iteration keeps hitting this branch: baseline stays
46
+ // at origin/main's version until the bump merges).
47
+ assert.doesNotThrow(() =>
48
+ assertRegenAllowed({
49
+ baseline: { version: "0.4.0", contentHash: "aaa" },
50
+ next: { version: "0.5.0", contentHash: "bbb" }
51
+ })
52
+ );
53
+ // Idempotent rerun.
54
+ assert.doesNotThrow(() =>
55
+ assertRegenAllowed({
56
+ baseline: { version: "0.5.0", contentHash: "aaa" },
57
+ next: { version: "0.5.0", contentHash: "aaa" }
58
+ })
59
+ );
60
+ // True first generation (no committed baseline anywhere).
61
+ assert.doesNotThrow(() => assertRegenAllowed({ baseline: undefined, next: { version: "0.5.0", contentHash: "aaa" } }));
62
+ });
package/src/skill.js CHANGED
@@ -7,7 +7,7 @@ import { EXIT_USAGE } from "./constants.js";
7
7
  import { writeJsonError } from "./output.js";
8
8
 
9
9
  export const SKILL_NAME = "gmc-analysis";
10
- const SKILL_REFERENCE_FILES = ["recipes.md", "pitfalls.md", "quota.md"];
10
+ const SKILL_REFERENCE_FILES = ["recipes.md", "pitfalls.md", "validity.md", "quota.md", "charts.md"];
11
11
  const PACKAGED_SKILL_DIR = fileURLToPath(new URL(`../skill/${SKILL_NAME}/`, import.meta.url));
12
12
 
13
13
  export function getPackagedSkillDir() {
package/src/skill.test.js CHANGED
@@ -25,10 +25,19 @@ test("agents-md edition is self-contained and demotes headings", () => {
25
25
  // References must be inlined for AGENTS.md consumers without the skill dir.
26
26
  assert.match(section, /^### Recipes/m);
27
27
  assert.match(section, /^### Pitfalls and pivots/m);
28
+ assert.match(section, /^### Statistical validity and claim safety/m);
28
29
  assert.match(section, /^### Quota etiquette/m);
30
+ assert.match(section, /^### Chart cards/m);
29
31
  // Core field-test learnings must survive into the distributed edition.
30
32
  assert.match(section, /skippedReasons/);
31
33
  assert.match(section, /appearsInGames/);
34
+ // Validity guardrails must survive into the distributed edition.
35
+ assert.match(section, /Denominator and N on every share/);
36
+ assert.match(section, /No causal verbs without causal evidence/);
37
+ assert.match(section, /never mix them/);
38
+ // Chart attribution guardrails must survive into the distributed edition.
39
+ assert.match(section, /GMC database/);
40
+ assert.match(section, /gmc-lockup\.svg/);
32
41
  });
33
42
 
34
43
  test("skill install copies the packaged skill to a custom directory", () => {