@witchpot/gmc 0.4.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,6 +2,21 @@
2
2
 
3
3
  All notable changes to the Game Market Copilot CLI will be recorded here.
4
4
 
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
+
5
20
  ## 0.4.0 - 2026-07-09
6
21
 
7
22
  - gmc-analysis skill v0.4.0: new `references/validity.md` with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@witchpot/gmc",
3
- "version": "0.4.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",
@@ -122,6 +122,25 @@ for (const file of sourceFiles) {
122
122
  }
123
123
  }
124
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
+
125
144
  // The public distribution repo (github.com/witchpot-studio/gmc-agent-skills)
126
145
  // mirrors skill/gmc-analysis. When it is checked out as a workspace sibling,
127
146
  // a drift between the two is a release blocker — MCP-only users install the
@@ -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
+ }
@@ -1,5 +1,43 @@
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
+
3
41
  ## 0.4.0 - 2026-07-09
4
42
 
5
43
  - Added `references/validity.md` (NAK-82): statistical-validity checklist
@@ -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.4.0
11
+ version: 0.5.0
12
12
  ---
13
13
 
14
14
  # GMC Analysis
@@ -96,6 +96,50 @@ Search commands are optimized for row retrieval. Do not expect
96
96
  unless you explicitly pass `--include-total`; for count-only questions use
97
97
  `gmc games count` instead.
98
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
+
99
143
  ## Hard rules
100
144
 
101
145
  - **Missing data is not absent sentiment.** Report `cohort.usableGames`,
@@ -34,25 +34,68 @@ violates the non-negotiables below must not carry GMC attribution.
34
34
  entirely. If one must appear, add a second, visually separate footnote
35
35
  line: `External estimate: <source> — not GMC data`.
36
36
  7. **Opaque background** (`#fafaf7` page, `#ffffff` card). Never export
37
- transparent PNGs. Recommended export: X 1200x675, LinkedIn 1200x627,
38
- or the natural 720px card width at 2x.
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.
39
40
 
40
41
  ## Picking the chart type from gmc output shapes
41
42
 
42
43
  | Output | Shape | Chart |
43
44
  |---|---|---|
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) |
45
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 |
46
51
  | same, time buckets (release windows, months) | series over time | Line (single/few series) or column |
47
52
  | MCP `market_aggregate` with `segment_filter` | segment vs base | Two big numbers + ratio, or grouped bar |
48
53
  | `gmc reviews patterns` / MCP `cohort_evidence` / clustered `negativeThemes` | theme -> game counts | Horizontal bar; caption carries `x of N usable games` |
49
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 |
50
57
  | single KPI (one count, one median, one share) | one number | Hero number card (no chart canvas) |
51
58
 
52
59
  Rules of thumb: when unsure, use a horizontal bar. One message per card.
53
60
  Never: 3D, dual axes, cropped bar baselines, pie with 5+ slices, line
54
61
  charts for non-time data.
55
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
+
56
99
  ## Color
57
100
 
58
101
  Neutrals (fixed): page `#fafaf7`, card `#ffffff`, ink `#1a1d20`, muted
@@ -79,7 +122,7 @@ network access for fonts, Chart.js CDN, and the brand lockup.
79
122
  <head>
80
123
  <meta charset="utf-8">
81
124
  <meta name="viewport" content="width=device-width, initial-scale=1">
82
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Instrument+Serif&display=swap" rel="stylesheet">
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">
83
126
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
84
127
  <style>
85
128
  :root {
@@ -88,7 +131,7 @@ network access for fonts, Chart.js CDN, and the brand lockup.
88
131
  --rule: #e8e5de; --accent: #ec7336;
89
132
  --sans: 'Inter', system-ui, sans-serif;
90
133
  --mono: 'JetBrains Mono', ui-monospace, monospace;
91
- --serif: 'Instrument Serif', Georgia, serif;
134
+ --title: 'Quicksand', 'Inter', system-ui, sans-serif;
92
135
  }
93
136
  * { margin: 0; padding: 0; box-sizing: border-box; }
94
137
  body { background: var(--bg); font-family: var(--sans); color: var(--ink);
@@ -101,8 +144,8 @@ network access for fonts, Chart.js CDN, and the brand lockup.
101
144
  color: var(--accent); max-width: 76%; }
102
145
  .card-cat { font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
103
146
  text-transform: uppercase; color: var(--ink-faint); white-space: nowrap; }
104
- .card-title { padding: 26px 20px 8px; font-family: var(--serif);
105
- font-size: 34px; line-height: 1.12; font-weight: 400; }
147
+ .card-title { padding: 26px 20px 8px; font-family: var(--title);
148
+ font-size: 34px; line-height: 1.12; font-weight: 700; }
106
149
  .chart-wrap { position: relative; margin: 18px 20px 8px;
107
150
  min-height: 470px; height: 470px; }
108
151
  .card-foot { display: flex; justify-content: space-between; align-items: center;
@@ -208,7 +251,8 @@ Variants:
208
251
 
209
252
  Japanese edition: add `&family=Noto+Sans+JP:wght@400;500;600` to the fonts
210
253
  URL, append `'Noto Sans JP'` to `--sans`, set `<html lang="ja">`, and use
211
- `GMCデータベース` as the source label. Steam tags in JA charts:
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:
212
256
  `English Tag(日本語タグ名)` on first appearance.
213
257
 
214
258
  ## Capture
@@ -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,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.4.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
+ });