@graphit/cli 0.1.51 → 0.1.107

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.
Files changed (61) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -2
  4. package/README.md +99 -0
  5. package/bin/graphit +45 -0
  6. package/bin/graphit.cmd +8 -0
  7. package/bin/graphit.ps1 +38 -0
  8. package/dist/api/client.js +11 -0
  9. package/dist/api/client.js.map +1 -1
  10. package/dist/commands/auth.js +14 -6
  11. package/dist/commands/auth.js.map +1 -1
  12. package/dist/commands/ds-poll.d.ts +8 -0
  13. package/dist/commands/ds-poll.js +53 -0
  14. package/dist/commands/ds-poll.js.map +1 -0
  15. package/dist/commands/ds.js +113 -57
  16. package/dist/commands/ds.js.map +1 -1
  17. package/dist/commands/governance.js +15 -11
  18. package/dist/commands/governance.js.map +1 -1
  19. package/dist/commands/kb.js +14 -4
  20. package/dist/commands/kb.js.map +1 -1
  21. package/dist/commands/query.js +9 -7
  22. package/dist/commands/query.js.map +1 -1
  23. package/dist/index.js +2 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/output/format.d.ts +1 -0
  26. package/dist/output/format.js +6 -0
  27. package/dist/output/format.js.map +1 -1
  28. package/dist/output/table.d.ts +1 -0
  29. package/dist/output/table.js +20 -3
  30. package/dist/output/table.js.map +1 -1
  31. package/dist/skill-version.d.ts +12 -0
  32. package/dist/skill-version.js +21 -0
  33. package/dist/skill-version.js.map +1 -1
  34. package/dist/update-check.d.ts +19 -0
  35. package/dist/update-check.js +105 -12
  36. package/dist/update-check.js.map +1 -1
  37. package/hooks/hooks.json +13 -0
  38. package/package.json +11 -3
  39. package/scripts/block-legacy-setup.mjs +90 -0
  40. package/scripts/generate-commands-doc.mjs +182 -0
  41. package/scripts/plugin-status.mjs +179 -29
  42. package/scripts/sync-plugin-version.mjs +26 -4
  43. package/skills/graphit/SKILL.md +173 -433
  44. package/skills/graphit/VERSION.json +1 -1
  45. package/skills/graphit/cursor/README.md +11 -0
  46. package/skills/graphit/graphit.mdc +3 -1
  47. package/skills/graphit/references/dashboard-planning.md +1 -7
  48. package/skills/graphit/references/data-sources.md +30 -1
  49. package/skills/graphit/references/filters-advanced.md +61 -0
  50. package/skills/graphit/references/filters.md +27 -93
  51. package/skills/graphit/references/governance.md +49 -66
  52. package/skills/graphit/references/graphit-style.md +6 -18
  53. package/skills/graphit/references/kb-actions.md +44 -52
  54. package/skills/graphit/references/kb-discovery.md +10 -13
  55. package/skills/graphit/references/kb-structure.md +10 -8
  56. package/skills/graphit/references/kb-traversal.md +16 -16
  57. package/skills/graphit/references/operations.md +115 -0
  58. package/skills/graphit/references/parameterized-metrics.md +32 -44
  59. package/skills/graphit/references/runtime.md +172 -0
  60. package/skills/graphit/references/sql-reference.md +7 -40
  61. package/skills/graphit/references/domain-lenses.md +0 -116
package/hooks/hooks.json CHANGED
@@ -26,6 +26,19 @@
26
26
  ]
27
27
  }
28
28
  ],
29
+ "PreToolUse": [
30
+ {
31
+ "matcher": "Bash",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node",
36
+ "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/block-legacy-setup.mjs", "--hook", "PreToolUse"],
37
+ "timeout": 5
38
+ }
39
+ ]
40
+ }
41
+ ],
29
42
  "PostToolUse": [
30
43
  {
31
44
  "matcher": "Bash",
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@graphit/cli",
3
- "version": "0.1.51",
3
+ "version": "0.1.107",
4
4
  "description": "Graphit CLI - Build custom dashboards from any AI coding assistant",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/omry-graphit/graphit.git",
8
+ "directory": "cli"
9
+ },
5
10
  "type": "module",
6
11
  "bin": {
7
12
  "graphit": "./dist/index.js"
@@ -10,15 +15,18 @@
10
15
  "build": "npm run sync:version && tsc",
11
16
  "dev": "tsx src/index.ts",
12
17
  "lint": "npm run check:version && tsc --noEmit",
13
- "test": "npm run build && node --test test/*.test.mjs",
18
+ "test": "npm run build && node scripts/generate-commands-doc.mjs --check && node --test test/*.test.mjs",
14
19
  "sync:version": "node scripts/sync-plugin-version.mjs",
15
- "check:version": "node scripts/sync-plugin-version.mjs --check"
20
+ "check:version": "node scripts/sync-plugin-version.mjs --check",
21
+ "gen:commands": "node scripts/generate-commands-doc.mjs",
22
+ "check:commands": "node scripts/generate-commands-doc.mjs --check"
16
23
  },
17
24
  "engines": {
18
25
  "node": ">=18"
19
26
  },
20
27
  "files": [
21
28
  "dist",
29
+ "bin",
22
30
  "skills",
23
31
  "hooks",
24
32
  "scripts",
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse(Bash) hook: block re-introducing a legacy copied install of the
3
+ // Graphit skill (`graphit setup ... --legacy-copy`) for Claude Code or Codex
4
+ // while the plugin bundle is the active source. The plugin bundle is the single
5
+ // source of truth on those two platforms; a copied snapshot drifts and shadows
6
+ // it, which is exactly the failure mode `graphit setup --remove-legacy-copies`
7
+ // exists to undo. When the about-to-run Bash command matches, this prints a
8
+ // remediation message and exits 2 (block). Every other command exits 0 (allow).
9
+ //
10
+ // Claude-Code-only progressive enhancement: Codex has no hook system, so this
11
+ // invariant is restated as prose in the skill rather than enforced there. The
12
+ // hook is purely additive - on the platforms that lack it, nothing changes.
13
+ //
14
+ // Defensive by contract: malformed input, a missing field, or any thrown error
15
+ // resolves to exit 0 (allow). The hook must never block an unrelated command or
16
+ // crash the agent's Bash flow on a parse error.
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ // Mirrors the plugin-root resolution used by plugin-status.mjs so the script is
23
+ // portable across the Claude Code / Codex / standalone env layouts. Unused for
24
+ // the block decision today, but kept consistent so future remediation can point
25
+ // at bundle-relative paths without re-deriving the root.
26
+ const pluginRoot =
27
+ process.env.CLAUDE_PLUGIN_ROOT ??
28
+ process.env.CODEX_PLUGIN_ROOT ??
29
+ process.env.GRAPHIT_PLUGIN_ROOT ??
30
+ dirname(dirname(fileURLToPath(import.meta.url)));
31
+
32
+ function readStdin() {
33
+ try {
34
+ return readFileSync(0, "utf-8");
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ function tryParseJson(value) {
41
+ try {
42
+ return JSON.parse(value);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // Match `graphit setup` (or local dev `node dist/index.js setup`) combined with
49
+ // the `--legacy-copy` flag, in either order, tolerating env prefixes
50
+ // (GRAPHIT_API_URL=... graphit setup ...). Anchored on both the setup command
51
+ // and the legacy-copy flag so plain `graphit setup` and unrelated commands that
52
+ // merely mention "legacy" are never blocked.
53
+ function isLegacyCopySetup(command) {
54
+ if (!command) return false;
55
+ const isSetup = /(?:graphit|index\.js)\s+setup\b/.test(command);
56
+ const hasLegacyCopyFlag = /--legacy-copy\b/.test(command);
57
+ return isSetup && hasLegacyCopyFlag;
58
+ }
59
+
60
+ const REMEDIATION =
61
+ "[Graphit plugin] Blocked `graphit setup --legacy-copy`. On Claude Code and " +
62
+ "Codex the Graphit plugin bundle is the active source of truth for the skill; " +
63
+ "a legacy copied snapshot drifts and shadows it. Do not re-introduce a copied " +
64
+ "install. If a stale copy is already present, remove it with " +
65
+ "`graphit setup --remove-legacy-copies` after confirming the plugin is " +
66
+ "installed. Plugin root: " +
67
+ pluginRoot;
68
+
69
+ function main() {
70
+ const raw = readStdin();
71
+ if (!raw) return 0;
72
+
73
+ const payload = tryParseJson(raw);
74
+ if (!payload || payload.tool_name !== "Bash") return 0;
75
+
76
+ const command = payload.tool_input?.command ?? "";
77
+ if (!isLegacyCopySetup(command)) return 0;
78
+
79
+ process.stderr.write(`${REMEDIATION}\n`);
80
+ return 2;
81
+ }
82
+
83
+ let exitCode = 0;
84
+ try {
85
+ exitCode = main();
86
+ } catch {
87
+ // Never block an unrelated command or crash the agent's Bash flow on error.
88
+ exitCode = 0;
89
+ }
90
+ process.exit(exitCode);
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ // Generates the command reference injected into cli/skills/graphit/SKILL.md
3
+ // between the COMMANDS:START / COMMANDS:END markers, from Commander metadata.
4
+ // The CLI is the single source of truth - this never hand-maintains command facts.
5
+ //
6
+ // node scripts/generate-commands-doc.mjs # write the table into SKILL.md
7
+ // node scripts/generate-commands-doc.mjs --check # exit 1 if SKILL.md is stale (CI gate)
8
+ // node scripts/generate-commands-doc.mjs --stdout # print the block only (debug)
9
+ //
10
+ // Requires a prior `npm run build` (it imports the compiled registrars from dist/).
11
+
12
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { Command } from "commander";
16
+
17
+ const checkOnly = process.argv.includes("--check");
18
+ const toStdout = process.argv.includes("--stdout");
19
+
20
+ const cliRoot =
21
+ process.env.GRAPHIT_CLI_ROOT ??
22
+ join(fileURLToPath(new URL(".", import.meta.url)), "..");
23
+ const distCommandsDir = join(cliRoot, "dist", "commands");
24
+ const skillPath = join(cliRoot, "skills", "graphit", "SKILL.md");
25
+
26
+ const START = "<!-- COMMANDS:START -->";
27
+ const END = "<!-- COMMANDS:END -->";
28
+
29
+ // Mirrors index.ts registration order; groups not listed are appended alphabetically.
30
+ const GROUP_ORDER = [
31
+ "auth", "kb", "query", "metadata", "ds", "dashboard",
32
+ "connector", "governance", "team", "plugin", "setup",
33
+ ];
34
+
35
+ // Assemble a throwaway program by importing only the dist/ registrars (never
36
+ // dist/index.js - that fires the update banner + program.parse() at load).
37
+ async function buildProgram() {
38
+ if (!existsSync(distCommandsDir)) {
39
+ throw new Error(
40
+ `dist/commands not found at ${distCommandsDir} - run "npm run build" first`,
41
+ );
42
+ }
43
+ const program = new Command();
44
+ program.name("graphit");
45
+ const files = readdirSync(distCommandsDir)
46
+ .filter((f) => f.endsWith(".js"))
47
+ .sort(); // deterministic import order
48
+ for (const file of files) {
49
+ const mod = await import(pathToFileURL(join(distCommandsDir, file)).href);
50
+ for (const [name, value] of Object.entries(mod)) {
51
+ if (typeof value === "function" && /^register/.test(name)) {
52
+ value(program);
53
+ }
54
+ }
55
+ }
56
+ return program;
57
+ }
58
+
59
+ function esc(text) {
60
+ return String(text ?? "").replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
61
+ }
62
+
63
+ function formatArgs(cmd) {
64
+ return (cmd.registeredArguments ?? [])
65
+ .map((arg) => {
66
+ const name = arg.name() + (arg.variadic ? "..." : "");
67
+ return arg.required ? `<${name}>` : `[${name}]`;
68
+ })
69
+ .join(" ");
70
+ }
71
+
72
+ function formatFlags(cmd) {
73
+ return (cmd.options ?? [])
74
+ .map((opt) => opt.long ?? opt.flags)
75
+ .filter(Boolean)
76
+ .join(" ");
77
+ }
78
+
79
+ // A command earns its own row if it is a leaf OR it has its own args/options
80
+ // (e.g. `query <sql>` both takes an arg and has the `metadata` sibling group).
81
+ function hasOwnInvocation(cmd) {
82
+ return (
83
+ (cmd.registeredArguments ?? []).length > 0 ||
84
+ (cmd.options ?? []).length > 0
85
+ );
86
+ }
87
+
88
+ function collectRows(cmd, path, rows) {
89
+ const subs = cmd.commands ?? [];
90
+ if (subs.length === 0 || hasOwnInvocation(cmd)) {
91
+ rows.push({
92
+ command: `${path}${formatArgs(cmd) ? ` ${formatArgs(cmd)}` : ""}`,
93
+ description: cmd.description(),
94
+ flags: formatFlags(cmd),
95
+ });
96
+ }
97
+ for (const sub of subs) {
98
+ collectRows(sub, `${path} ${sub.name()}`.trim(), rows);
99
+ }
100
+ }
101
+
102
+ function orderGroups(names) {
103
+ return [...names].sort((a, b) => {
104
+ const ia = GROUP_ORDER.indexOf(a);
105
+ const ib = GROUP_ORDER.indexOf(b);
106
+ if (ia === -1 && ib === -1) return a.localeCompare(b);
107
+ if (ia === -1) return 1;
108
+ if (ib === -1) return -1;
109
+ return ia - ib;
110
+ });
111
+ }
112
+
113
+ function renderBlock(program) {
114
+ const groups = new Map();
115
+ for (const top of program.commands) {
116
+ const rows = [];
117
+ collectRows(top, top.name(), rows);
118
+ groups.set(top.name(), { description: top.description(), rows });
119
+ }
120
+ const lines = [
121
+ "",
122
+ "_Generated from the CLI by `npm run gen:commands` - do not hand-edit between the markers. Run `graphit <cmd> --help` for exact flag values and descriptions._",
123
+ "",
124
+ ];
125
+ for (const name of orderGroups(groups.keys())) {
126
+ const { description, rows } = groups.get(name);
127
+ lines.push(`**${name}**${description ? ` - ${esc(description)}` : ""}`);
128
+ for (const row of rows) {
129
+ const parts = [`- \`${row.command}\``];
130
+ if (row.description) parts.push(esc(row.description));
131
+ if (row.flags) parts.push(`\`${row.flags}\``);
132
+ lines.push(parts.join(" - "));
133
+ }
134
+ lines.push("");
135
+ }
136
+ return `${lines.join("\n").trimEnd()}\n`;
137
+ }
138
+
139
+ function injectBetweenMarkers(content, block) {
140
+ const start = content.indexOf(START);
141
+ const end = content.indexOf(END);
142
+ if (start === -1 || end === -1) {
143
+ throw new Error(
144
+ `Missing ${START} / ${END} markers in ${skillPath} - add them before generating.`,
145
+ );
146
+ }
147
+ if (end < start) {
148
+ throw new Error(`${END} appears before ${START} in ${skillPath}`);
149
+ }
150
+ const before = content.slice(0, start + START.length);
151
+ const after = content.slice(end);
152
+ return `${before}\n${block}\n${after}`;
153
+ }
154
+
155
+ const program = await buildProgram();
156
+ const block = renderBlock(program);
157
+
158
+ if (toStdout) {
159
+ process.stdout.write(block);
160
+ process.exit(0);
161
+ }
162
+
163
+ // Normalize CRLF before compare so Windows checkouts don't report perpetual drift.
164
+ const current = existsSync(skillPath)
165
+ ? readFileSync(skillPath, "utf-8").replace(/\r\n/g, "\n")
166
+ : "";
167
+ const next = injectBetweenMarkers(current, block);
168
+
169
+ if (current === next) {
170
+ console.error("Command table in SKILL.md is in sync.");
171
+ process.exit(0);
172
+ }
173
+
174
+ if (checkOnly) {
175
+ console.error(
176
+ "SKILL.md command table is stale - run: npm run gen:commands",
177
+ );
178
+ process.exit(1);
179
+ }
180
+
181
+ writeFileSync(skillPath, next, "utf-8");
182
+ console.error(`Updated command table in ${skillPath}`);
@@ -1,12 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
5
+ import { delimiter, dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
- const REGISTRY_URL = "https://registry.npmjs.org/@graphit/cli/latest";
9
- const TTL_MS = 24 * 60 * 60 * 1000;
8
+ // Project #246: GRAPHIT_REGISTRY_URL lets tests point at a local server and
9
+ // supports private/enterprise registries; defaults to the public npm registry.
10
+ const REGISTRY_URL =
11
+ process.env.GRAPHIT_REGISTRY_URL ?? "https://registry.npmjs.org/@graphit/cli/latest";
12
+ const PACKAGE_NAME = "@graphit/cli";
13
+ // Project #246: ~4h TTL (was 24h) so a single-source bump reaches returning
14
+ // sessions within one window (ARCH-2, PERF-2).
15
+ const TTL_MS = 4 * 60 * 60 * 1000;
16
+ // Project #246: version-cache schema. Bump when the cache shape changes; older
17
+ // caches are then treated as a miss and refetched (self-healing).
18
+ const CACHE_SCHEMA_VERSION = 1;
19
+ // Strict semver. The wrapper enforces the same shape (SEC-6); keep them in sync.
20
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
10
21
  const args = new Set(process.argv.slice(2));
11
22
  const hookIndex = process.argv.indexOf("--hook");
12
23
  const hookEvent = hookIndex >= 0 ? process.argv[hookIndex + 1] : null;
@@ -15,11 +26,12 @@ const pluginRoot =
15
26
  process.env.CODEX_PLUGIN_ROOT ??
16
27
  process.env.GRAPHIT_PLUGIN_ROOT ??
17
28
  dirname(dirname(fileURLToPath(import.meta.url)));
18
- const cacheRoot =
19
- process.env.CLAUDE_PLUGIN_DATA ??
20
- process.env.CODEX_PLUGIN_DATA ??
21
- process.env.GRAPHIT_PLUGIN_DATA ??
22
- join(homedir(), ".graphit");
29
+ // Project #246: the version cache must be shared between this SessionStart hook
30
+ // and the bin/graphit wrapper (agent Bash tool). CLAUDE_PLUGIN_DATA is set
31
+ // per-plugin/per-context by Claude Code and is NOT guaranteed identical across
32
+ // those two contexts, so it is intentionally excluded here - a fixed, graphit-
33
+ // owned dir guarantees both sides agree. GRAPHIT_PLUGIN_DATA overrides.
34
+ const cacheRoot = process.env.GRAPHIT_PLUGIN_DATA ?? join(homedir(), ".graphit");
23
35
 
24
36
  function readJson(path) {
25
37
  return JSON.parse(readFileSync(path, "utf-8"));
@@ -89,34 +101,128 @@ function readCache(path) {
89
101
  }
90
102
  }
91
103
 
92
- function writeCache(path, entry) {
104
+ function isStrictSemver(value) {
105
+ return typeof value === "string" && SEMVER_RE.test(value);
106
+ }
107
+
108
+ // SEC-6: the cache file is user-writable. Treat it as untrusted - require the
109
+ // expected schema version, package name, a strict-semver version, and a numeric
110
+ // timestamp. Anything else is treated as a miss (refetch / fall back).
111
+ function isValidVersionCache(cache) {
112
+ return (
113
+ !!cache &&
114
+ cache.schemaVersion === CACHE_SCHEMA_VERSION &&
115
+ cache.packageName === PACKAGE_NAME &&
116
+ isStrictSemver(cache.latestVersion) &&
117
+ typeof cache.checkedAt === "number"
118
+ );
119
+ }
120
+
121
+ // SEC-4: atomic write (tmp + rename) so concurrent SessionStart writers can never
122
+ // leave a torn/partial file for the wrapper or a later read.
123
+ function atomicWriteJson(path, obj) {
124
+ const tmpPath = `${path}.${process.pid}.tmp`;
93
125
  try {
94
126
  mkdirSync(dirname(path), { recursive: true });
95
- writeFileSync(path, JSON.stringify(entry), "utf-8");
127
+ writeFileSync(tmpPath, JSON.stringify(obj), "utf-8");
128
+ renameSync(tmpPath, path);
129
+ } catch {
130
+ // Writes here must never break startup hooks; clean up any temp file.
131
+ try {
132
+ rmSync(tmpPath, { force: true });
133
+ } catch {
134
+ // ignore
135
+ }
136
+ }
137
+ }
138
+
139
+ function writeCache(path, latestVersion) {
140
+ atomicWriteJson(path, {
141
+ schemaVersion: CACHE_SCHEMA_VERSION,
142
+ packageName: PACKAGE_NAME,
143
+ latestVersion,
144
+ checkedAt: Date.now(),
145
+ });
146
+ }
147
+
148
+ function realpathSafe(path) {
149
+ try {
150
+ return realpathSync(path);
96
151
  } catch {
97
- // Cache writes should never break startup hooks.
152
+ return path;
98
153
  }
99
154
  }
100
155
 
156
+ // 4.1 (SEC-5): find a `graphit` on PATH that is NOT this plugin's bundled wrapper -
157
+ // i.e. a legacy global @graphit/cli install. Because plugin executables are
158
+ // appended to PATH (Phase 2 finding), any global install shadows the wrapper, so
159
+ // its mere presence is the migration trigger. Returns the first such path or null.
160
+ function detectForeignGraphit() {
161
+ const pathValue = process.env.PATH ?? "";
162
+ if (!pathValue) return null;
163
+ const names = process.platform === "win32" ? ["graphit.cmd", "graphit.ps1", "graphit"] : ["graphit"];
164
+ // On Windows the plugin ships graphit.cmd / graphit.ps1 alongside the POSIX
165
+ // `graphit`, so excluding only `bin/graphit` would leave our own .cmd/.ps1
166
+ // wrappers looking like a foreign global. Exclude every bundled wrapper name.
167
+ const ownWrapperReals = new Set(
168
+ names.map((name) => realpathSafe(join(pluginRoot, "bin", name))),
169
+ );
170
+ for (const dir of pathValue.split(delimiter).filter(Boolean)) {
171
+ for (const name of names) {
172
+ const candidate = join(dir, name);
173
+ if (!existsSync(candidate)) continue;
174
+ const real = realpathSafe(candidate);
175
+ if (ownWrapperReals.has(real)) continue; // our own bundled wrapper
176
+ if (/[/\\]plugins[/\\]/.test(real)) continue; // a plugin-cache wrapper
177
+ return candidate;
178
+ }
179
+ }
180
+ return null;
181
+ }
182
+
183
+ // Nudge throttle (US-3.2 / US-4.1: ~once/day). State is graphit-owned and atomic.
184
+ const NUDGE_TTL_MS = 24 * 60 * 60 * 1000;
185
+ const nudgeStatePath = join(cacheRoot, "nudge-state.json");
186
+
187
+ function readNudgeState() {
188
+ const state = tryReadJson(nudgeStatePath);
189
+ if (state && state.schemaVersion === CACHE_SCHEMA_VERSION && state.nudges && typeof state.nudges === "object") {
190
+ return state;
191
+ }
192
+ return { schemaVersion: CACHE_SCHEMA_VERSION, nudges: {} };
193
+ }
194
+
195
+ function nudgeAllowed(state, key) {
196
+ const last = state.nudges[key];
197
+ return typeof last !== "number" || Date.now() - last >= NUDGE_TTL_MS;
198
+ }
199
+
200
+ function persistNudges(state, keys) {
201
+ if (keys.length === 0) return;
202
+ for (const key of keys) state.nudges[key] = Date.now();
203
+ atomicWriteJson(nudgeStatePath, state);
204
+ }
205
+
101
206
  async function getLatestVersion(currentVersion) {
102
207
  if (args.has("--skip-network")) return null;
103
208
 
104
209
  const cachePath = join(cacheRoot, "plugin-status.json");
105
210
  const cache = readCache(cachePath);
106
- if (cache?.latestVersion && Date.now() - cache.checkedAt < TTL_MS) {
107
- return cache.latestVersion;
211
+ const cachedVersion = isValidVersionCache(cache) ? cache.latestVersion : null;
212
+ if (cachedVersion && Date.now() - cache.checkedAt < TTL_MS) {
213
+ return cachedVersion;
108
214
  }
109
215
 
110
216
  try {
111
217
  const response = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(1500) });
112
- if (!response.ok) return cache?.latestVersion ?? null;
218
+ if (!response.ok) return cachedVersion;
113
219
  const data = await response.json();
114
- if (data && typeof data.version === "string") {
115
- writeCache(cachePath, { latestVersion: data.version, checkedAt: Date.now() });
220
+ if (data && isStrictSemver(data.version)) {
221
+ writeCache(cachePath, data.version);
116
222
  return data.version;
117
223
  }
118
224
  } catch {
119
- return cache?.latestVersion ?? null;
225
+ return cachedVersion;
120
226
  }
121
227
 
122
228
  return currentVersion;
@@ -165,7 +271,9 @@ async function collectStatus() {
165
271
  [".claude-plugin/marketplace.json plugin", marketplacePlugin?.version],
166
272
  ["skills/graphit/VERSION.json", tryReadJson(join(pluginRoot, "skills", "graphit", "VERSION.json"))?.version],
167
273
  ["skills/graphit/SKILL.md", readFrontmatterVersion(join(pluginRoot, "skills", "graphit", "SKILL.md"))],
168
- ["skills/graphit/graphit.mdc", readFrontmatterVersion(join(pluginRoot, "skills", "graphit", "graphit.mdc"))],
274
+ // The Cursor source file (skills/graphit/graphit.mdc) is frozen and no longer
275
+ // version-stamped, so it is intentionally excluded here. The copied-Cursor-home
276
+ // staleness checks below still run for existing Cursor users.
169
277
  ];
170
278
 
171
279
  for (const [label, version] of versionChecks) {
@@ -204,7 +312,7 @@ async function collectStatus() {
204
312
  findings.push({
205
313
  type: "package-update",
206
314
  message: `@graphit/cli update available: ${currentVersion} -> ${latestVersion}`,
207
- remediation: "Update Graphit through your assistant's plugin manager. Claude Code users can run `claude plugin update graphit`; Codex plugin users should use the Codex plugin update command. Standalone npm users can run `npm update -g @graphit/cli`.",
315
+ remediation: "This update is for the npm CLI binary (@graphit/cli), a separate artifact from the skill bundle. Update the binary with `npm install -g @graphit/cli@latest` (not `npm update -g`, which respects the original semver range and can miss the latest). If `graphit` resolves to a custom npm prefix (compare `command -v graphit` with `npm prefix -g`), reinstall to that prefix with `npm install -g @graphit/cli@latest --prefix <dir>` (<dir> = the parent of the bin dir holding graphit). The Claude Code/Codex skill bundle updates separately via `claude plugin update graphit@graphit-plugin` and does NOT update the binary.",
208
316
  });
209
317
  }
210
318
 
@@ -251,13 +359,54 @@ function formatPlain(status) {
251
359
  ].join("\n");
252
360
  }
253
361
 
254
- function formatHookContext(status) {
255
- if (status.findings.length === 0) return null;
362
+ // Build the SessionStart agent nudges. Unlike --json (full diagnostics), the hook
363
+ // surfaces only user-actionable, throttled items: the migration nudge (4.1), the
364
+ // thin-layer /plugin update nudge (4.3), and legacy copied-skill cleanup. It does
365
+ // NOT emit founder-only publish drift, nor the npm-install-g binary message - under
366
+ // the plugin model the binary auto-updates via npx, so the bundle nudge is correct.
367
+ function buildHookNudges(status) {
368
+ const state = readNudgeState();
369
+ const lines = [];
370
+ const shown = [];
371
+
372
+ // 4.1 Migration: a global @graphit/cli shadows the plugin's npx-backed wrapper.
373
+ const foreignGraphit = detectForeignGraphit();
374
+ if (foreignGraphit && nudgeAllowed(state, "legacy-global")) {
375
+ lines.push(
376
+ `- A legacy global \`graphit\` (@graphit/cli) at ${foreignGraphit} shadows the plugin's bundled CLI ` +
377
+ "(plugin executables are appended to PATH, so a global install wins). The plugin now delivers the CLI " +
378
+ "automatically via npx - tell the user once and offer to run `npm uninstall -g @graphit/cli` so it takes over.",
379
+ );
380
+ shown.push("legacy-global");
381
+ }
256
382
 
257
- return [
258
- "Graphit plugin status check found actionable update/version information.",
259
- ...status.findings.map((finding) => `- ${finding.message}. ${finding.remediation}`),
260
- ].join("\n");
383
+ // 4.3 Thin-layer: the plugin bundle is behind npm latest (the binary itself
384
+ // auto-updates via npx; only the skill/hook/wrapper bundle needs a manual update).
385
+ if (
386
+ status.latestVersion &&
387
+ compareVersions(status.latestVersion, status.currentVersion) > 0 &&
388
+ nudgeAllowed(state, "plugin-update")
389
+ ) {
390
+ lines.push(
391
+ `- A newer Graphit plugin is available (${status.currentVersion} -> ${status.latestVersion}). ` +
392
+ "Tell the user once to update the bundle with `/plugin update graphit@graphit-plugin`; " +
393
+ "the CLI binary itself updates automatically.",
394
+ );
395
+ shown.push("plugin-update");
396
+ }
397
+
398
+ // Legacy copied skill snapshots are real user-side issues - surface until removed.
399
+ for (const finding of status.findings) {
400
+ if (finding.type === "legacy-copied-skill-present" || finding.type === "copied-skill-stale") {
401
+ lines.push(`- ${finding.message}. ${finding.remediation}`);
402
+ }
403
+ }
404
+
405
+ if (lines.length === 0) return null;
406
+ return {
407
+ context: ["Graphit plugin status check found actionable update/version information.", ...lines].join("\n"),
408
+ persist: () => persistNudges(state, shown),
409
+ };
261
410
  }
262
411
 
263
412
  if (hookEvent && !shouldRunForPrompt()) {
@@ -269,14 +418,15 @@ const status = await collectStatus();
269
418
  if (args.has("--json")) {
270
419
  console.log(JSON.stringify(status, null, 2));
271
420
  } else if (hookEvent) {
272
- const additionalContext = formatHookContext(status);
273
- if (additionalContext) {
421
+ const nudge = buildHookNudges(status);
422
+ if (nudge) {
274
423
  console.log(JSON.stringify({
275
424
  hookSpecificOutput: {
276
425
  hookEventName: hookEvent,
277
- additionalContext,
426
+ additionalContext: nudge.context,
278
427
  },
279
428
  }));
429
+ nudge.persist();
280
430
  }
281
431
  } else if (!args.has("--quiet") || status.findings.length > 0) {
282
432
  console.log(formatPlain(status));
@@ -88,9 +88,29 @@ function updateVersionJson(path, packageName, version, changes) {
88
88
  );
89
89
  }
90
90
 
91
- function updateFrontmatterVersion(path, version, changes) {
91
+ // Project #246: stamp BOTH the frontmatter skill_version AND the Codex-facing npx
92
+ // invocation version (the concrete `npx -y @graphit/cli@<v>` form in the body) so
93
+ // check:version fails if either drifts from package.json. Non-CC surfaces (Codex,
94
+ // Cursor, terminal, CI) have no resolver hook, so the skill hands them a stamped
95
+ // concrete version, not a placeholder; `@<exact>` placeholders are left untouched.
96
+ function updateSkillFile(path, version, packageName, changes) {
92
97
  const content = readFileSync(path, "utf-8");
93
- writeOrReport(path, upsertFrontmatterField(content, "skill_version", version), changes);
98
+ const stamped = upsertFrontmatterField(content, "skill_version", version);
99
+ const escapedPkg = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
+ const npxVersionRe = new RegExp(`${escapedPkg}@\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?`, "g");
101
+ writeOrReport(path, stamped.replace(npxVersionRe, `${packageName}@${version}`), changes);
102
+ }
103
+
104
+ // Project #246: stamp the baked floor version into the bin/graphit wrapper(s) so
105
+ // first-run/offline falls back to a real published version. Matches the bash
106
+ // `FLOOR_VERSION="x"` and PowerShell `$FloorVersion = "x"` markers.
107
+ function updateWrapperFloor(path, version, changes) {
108
+ if (!existsSync(path)) return;
109
+ const content = readFileSync(path, "utf-8");
110
+ const next = content
111
+ .replace(/^FLOOR_VERSION=".*"$/m, () => `FLOOR_VERSION="${version}"`)
112
+ .replace(/^\$FloorVersion = ".*"$/m, () => `$FloorVersion = "${version}"`);
113
+ writeOrReport(path, next, changes);
94
114
  }
95
115
 
96
116
  const packageJson = readJson(join(cliRoot, "package.json"));
@@ -102,8 +122,10 @@ updatePluginManifest(join(cliRoot, ".claude-plugin", "plugin.json"), version, ch
102
122
  updatePluginManifest(join(cliRoot, ".codex-plugin", "plugin.json"), version, changes);
103
123
  updateMarketplace(join(cliRoot, ".claude-plugin", "marketplace.json"), version, packageName, changes);
104
124
  updateVersionJson(join(cliRoot, "skills", "graphit", "VERSION.json"), packageName, version, changes);
105
- updateFrontmatterVersion(join(cliRoot, "skills", "graphit", "SKILL.md"), version, changes);
106
- updateFrontmatterVersion(join(cliRoot, "skills", "graphit", "graphit.mdc"), version, changes);
125
+ updateSkillFile(join(cliRoot, "skills", "graphit", "SKILL.md"), version, packageName, changes);
126
+ // graphit.mdc is the frozen Cursor mirror and is no longer stamped (Cursor unmaintained for Claude Code + Codex).
127
+ updateWrapperFloor(join(cliRoot, "bin", "graphit"), version, changes);
128
+ updateWrapperFloor(join(cliRoot, "bin", "graphit.ps1"), version, changes);
107
129
 
108
130
  if (changes.length > 0) {
109
131
  const rel = (path) => path.startsWith(`${cliRoot}/`) ? path.slice(cliRoot.length + 1) : path;