@graphit/cli 0.2.380 → 0.2.384

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 (41) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/bin/graphit +1 -1
  5. package/bin/graphit.ps1 +1 -1
  6. package/dist/commands/ds/sql-history.d.ts +29 -0
  7. package/dist/commands/ds/sql-history.js +69 -0
  8. package/dist/commands/ds/sql-history.js.map +1 -0
  9. package/dist/commands/ds.js +3 -0
  10. package/dist/commands/ds.js.map +1 -1
  11. package/dist/commands/kb-batch.js +1 -1
  12. package/dist/commands/kb-batch.js.map +1 -1
  13. package/dist/commands/kb.js +2 -21
  14. package/dist/commands/kb.js.map +1 -1
  15. package/dist/skill-guard.js +0 -2
  16. package/dist/skill-guard.js.map +1 -1
  17. package/hooks/hooks.json +12 -1
  18. package/package.json +1 -1
  19. package/scripts/fixtures/kb-cutover-project293.json +13 -0
  20. package/scripts/plugin-status/claude-install.mjs +215 -0
  21. package/scripts/plugin-status/cloud-refresh.mjs +97 -0
  22. package/scripts/plugin-status/common.mjs +107 -0
  23. package/scripts/plugin-status/hook-io.mjs +200 -0
  24. package/scripts/plugin-status.mjs +68 -505
  25. package/scripts/sync-plugin-marketplace.sh +18 -0
  26. package/scripts/verb-policy-source.json +49 -30
  27. package/scripts/verify-kb-cutover.mjs +134 -0
  28. package/skills/graphit/SKILL.md +7 -7
  29. package/skills/graphit/VERSION.json +1 -1
  30. package/skills/graphit/references/governance.md +2 -2
  31. package/skills/graphit/references/kb-actions.md +5 -5
  32. package/skills/graphit/references/kb-discovery.md +1 -1
  33. package/skills/graphit/references/metric-families.md +1 -1
  34. package/skills/graphit/references/onboarding.md +1 -1
  35. package/skills/graphit/references/page-load.md +38 -0
  36. package/skills/graphit/references/runtime.md +5 -5
  37. package/skills/graphit/references/semantic-authoring.md +5 -5
  38. package/skills/graphit/references/share.md +4 -4
  39. package/skills/graphit-build/SKILL.md +1 -1
  40. package/skills/graphit-explore/SKILL.md +1 -1
  41. package/skills/graphit-share/SKILL.md +5 -5
@@ -1,31 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import {
4
- chmodSync,
5
- cpSync,
6
- existsSync,
7
- mkdirSync,
8
- readdirSync,
9
- readFileSync,
10
- realpathSync,
11
- renameSync,
12
- rmSync,
13
- statSync,
14
- unlinkSync,
15
- writeFileSync,
16
- } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
17
4
  import { homedir } from "node:os";
18
- import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { dirname, join } from "node:path";
19
6
  import { fileURLToPath, pathToFileURL } from "node:url";
20
7
 
8
+ import {
9
+ CACHE_SCHEMA_VERSION,
10
+ CLAUDE_PLUGIN_NAME,
11
+ PACKAGE_NAME,
12
+ atomicWriteJson,
13
+ compareVersions,
14
+ isStrictSemver,
15
+ readFrontmatterVersion,
16
+ tryParseJson,
17
+ tryReadJson,
18
+ } from "./plugin-status/common.mjs";
19
+ import { inspectClaudeInstalledPlugin } from "./plugin-status/claude-install.mjs";
20
+ import { cloudRefreshApplies, refreshPluginInCloud } from "./plugin-status/cloud-refresh.mjs";
21
+ import { armCutoff, buildHookNudges, shouldRunForPrompt, stampSessionMarker } from "./plugin-status/hook-io.mjs";
22
+
21
23
  // Project #246: GRAPHIT_REGISTRY_URL lets tests point at a local server and
22
24
  // supports private/enterprise registries; defaults to the public npm registry.
23
25
  const REGISTRY_URL =
24
26
  process.env.GRAPHIT_REGISTRY_URL ?? "https://registry.npmjs.org/@graphit/cli/latest";
25
- const PACKAGE_NAME = "@graphit/cli";
26
- const PLUGIN_ID = "graphit@graphit-plugin";
27
- const CLAUDE_MARKETPLACE_NAME = "graphit-plugin";
28
- const CLAUDE_PLUGIN_NAME = "graphit";
27
+ // Feature #1042: this list stays in the entry file - plugin-commands.test.mjs reads
28
+ // it from here to prove `--repair` keeps `commands`.
29
29
  const REPAIRABLE_BUNDLE_ENTRIES = [
30
30
  ".claude-plugin",
31
31
  ".codex-plugin",
@@ -40,11 +40,8 @@ const REPAIRABLE_BUNDLE_ENTRIES = [
40
40
  // Project #246: ~4h TTL (was 24h) so a single-source bump reaches returning
41
41
  // sessions within one window (ARCH-2, PERF-2).
42
42
  const TTL_MS = 4 * 60 * 60 * 1000;
43
- // Project #246: version-cache schema. Bump when the cache shape changes; older
44
- // caches are then treated as a miss and refetched (self-healing).
45
- const CACHE_SCHEMA_VERSION = 1;
46
- // Strict semver. The wrapper enforces the same shape (SEC-6); keep them in sync.
47
- const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
43
+ // Feature #1042: the budget every hook run had before hooks.json gave startup 120 s.
44
+ const LOCAL_HOOK_CUTOFF_MS = 5000;
48
45
  const args = new Set(process.argv.slice(2));
49
46
  const shouldRepair = args.has("--repair") || args.has("--auto-repair");
50
47
  const isPreflight = args.has("--preflight");
@@ -72,28 +69,6 @@ const PLUGIN_UPDATE_STEPS =
72
69
  // owned dir guarantees both sides agree. GRAPHIT_PLUGIN_DATA overrides.
73
70
  const cacheRoot = process.env.GRAPHIT_PLUGIN_DATA ?? join(homedir(), ".graphit");
74
71
 
75
- function readJson(path) {
76
- return JSON.parse(readFileSync(path, "utf-8"));
77
- }
78
-
79
- function tryReadJson(path) {
80
- try {
81
- if (!existsSync(path)) return null;
82
- return readJson(path);
83
- } catch {
84
- return null;
85
- }
86
- }
87
-
88
- function readFrontmatterVersion(path) {
89
- try {
90
- const content = readFileSync(path, "utf-8");
91
- return content.match(/^skill_version:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1] ?? null;
92
- } catch {
93
- return null;
94
- }
95
- }
96
-
97
72
  function readBundleInfo(root) {
98
73
  const packageJson = tryReadJson(join(root, "package.json"));
99
74
  const versionJson = tryReadJson(join(root, "skills", "graphit", "VERSION.json"));
@@ -116,37 +91,6 @@ function readBundleInfo(root) {
116
91
  };
117
92
  }
118
93
 
119
- function compareVersions(left, right) {
120
- const leftParts = left.split(".").map(Number);
121
- const rightParts = right.split(".").map(Number);
122
-
123
- for (let index = 0; index < 3; index += 1) {
124
- const leftPart = leftParts[index] ?? 0;
125
- const rightPart = rightParts[index] ?? 0;
126
- if (leftPart > rightPart) return 1;
127
- if (leftPart < rightPart) return -1;
128
- }
129
-
130
- return 0;
131
- }
132
-
133
- function versionIsBehind(version, currentVersion) {
134
- if (!isStrictSemver(currentVersion)) return false;
135
- if (!version || !isStrictSemver(version)) return true;
136
- return compareVersions(version, currentVersion) < 0;
137
- }
138
-
139
- function sanitizePathPart(value) {
140
- return value.replace(/[^a-zA-Z0-9\-_.]/g, "-");
141
- }
142
-
143
- function isWithinDirectory(child, parent) {
144
- const parentResolved = resolve(parent);
145
- const childResolved = resolve(child);
146
- const rel = relative(parentResolved, childResolved);
147
- return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
148
- }
149
-
150
94
  function copiedSkillTargets(home = homedir(), cwd = process.cwd()) {
151
95
  return [
152
96
  { kind: "claude-code", label: "Claude Code global", scope: "global", path: join(home, ".claude", "skills", "graphit", "SKILL.md") },
@@ -170,19 +114,6 @@ function readCopiedSkillVersion(path) {
170
114
  return versionJson?.version ?? readFrontmatterVersion(path);
171
115
  }
172
116
 
173
- function readCache(path) {
174
- try {
175
- if (!existsSync(path)) return null;
176
- return readJson(path);
177
- } catch {
178
- return null;
179
- }
180
- }
181
-
182
- function isStrictSemver(value) {
183
- return typeof value === "string" && SEMVER_RE.test(value);
184
- }
185
-
186
117
  // SEC-6: the cache file is user-writable. Treat it as untrusted - require the
187
118
  // expected schema version, package name, a strict-semver version, and a numeric
188
119
  // timestamp. Anything else is treated as a miss (refetch / fall back).
@@ -196,219 +127,6 @@ function isValidVersionCache(cache) {
196
127
  );
197
128
  }
198
129
 
199
- // SEC-4: atomic write (tmp + rename) so concurrent SessionStart writers can never
200
- // leave a torn/partial file for the wrapper or a later read.
201
- function atomicWriteJson(path, obj) {
202
- const tmpPath = `${path}.${process.pid}.tmp`;
203
- try {
204
- mkdirSync(dirname(path), { recursive: true });
205
- writeFileSync(tmpPath, JSON.stringify(obj), "utf-8");
206
- renameSync(tmpPath, path);
207
- } catch {
208
- // Writes here must never break startup hooks; clean up any temp file.
209
- try {
210
- rmSync(tmpPath, { force: true });
211
- } catch {
212
- // ignore
213
- }
214
- }
215
- }
216
-
217
- function claudePluginsRoot() {
218
- return process.env.CLAUDE_PLUGINS_ROOT ?? join(homedir(), ".claude", "plugins");
219
- }
220
-
221
- function claudeInstalledPluginsPath(root = claudePluginsRoot()) {
222
- return join(root, "installed_plugins.json");
223
- }
224
-
225
- function claudeVersionedCachePath(version, root = claudePluginsRoot()) {
226
- return join(
227
- root,
228
- "cache",
229
- CLAUDE_MARKETPLACE_NAME,
230
- CLAUDE_PLUGIN_NAME,
231
- sanitizePathPart(version),
232
- );
233
- }
234
-
235
- function readClaudeInstalledFile(root = claudePluginsRoot()) {
236
- const path = claudeInstalledPluginsPath(root);
237
- const data = tryReadJson(path);
238
- if (!data || typeof data !== "object") return null;
239
- return { path, data };
240
- }
241
-
242
- function graphitInstallEntries(installedData) {
243
- const plugins = installedData.plugins && typeof installedData.plugins === "object"
244
- ? installedData.plugins
245
- : installedData;
246
- const raw = plugins?.[PLUGIN_ID];
247
- if (!raw) return [];
248
- if (Array.isArray(raw)) {
249
- return raw
250
- .filter((entry) => entry && typeof entry === "object")
251
- .map((entry) => ({ entry }));
252
- }
253
- if (typeof raw === "object") {
254
- return [{ entry: raw }];
255
- }
256
- return [];
257
- }
258
-
259
- function pluginEntryVersion(entry) {
260
- if (!entry || typeof entry !== "object") return null;
261
- const installPath = typeof entry.installPath === "string" ? entry.installPath : null;
262
- const skillVersion = installPath
263
- ? readFrontmatterVersion(join(installPath, "skills", "graphit", "SKILL.md"))
264
- : null;
265
- const versionJson = installPath
266
- ? tryReadJson(join(installPath, "skills", "graphit", "VERSION.json"))?.version
267
- : null;
268
- return {
269
- installPath,
270
- entryVersion: typeof entry.version === "string" ? entry.version : null,
271
- skillVersion,
272
- versionJson: versionJson ?? null,
273
- effectiveVersion: skillVersion ?? versionJson ?? (typeof entry.version === "string" ? entry.version : null),
274
- };
275
- }
276
-
277
- function repairSourceLooksCurrent(sourceRoot, currentVersion) {
278
- if (!isStrictSemver(currentVersion)) return false;
279
- const skillVersion = readFrontmatterVersion(join(sourceRoot, "skills", "graphit", "SKILL.md"));
280
- const versionJson = tryReadJson(join(sourceRoot, "skills", "graphit", "VERSION.json"))?.version;
281
- const claudePlugin = tryReadJson(join(sourceRoot, ".claude-plugin", "plugin.json"))?.version;
282
- return skillVersion === currentVersion && versionJson === currentVersion && claudePlugin === currentVersion;
283
- }
284
-
285
- function copyRepairableBundle(sourceRoot, targetRoot) {
286
- if (!isWithinDirectory(targetRoot, claudePluginsRoot())) {
287
- throw new Error(`Refusing to repair outside Claude plugins directory: ${targetRoot}`);
288
- }
289
-
290
- const tmpRoot = `${targetRoot}.tmp-${process.pid}-${Date.now()}`;
291
- rmSync(tmpRoot, { recursive: true, force: true });
292
- mkdirSync(tmpRoot, { recursive: true });
293
- try {
294
- for (const entry of REPAIRABLE_BUNDLE_ENTRIES) {
295
- const source = join(sourceRoot, entry);
296
- if (!existsSync(source)) continue;
297
- cpSync(source, join(tmpRoot, entry), {
298
- recursive: true,
299
- force: true,
300
- verbatimSymlinks: true,
301
- });
302
- }
303
- try {
304
- chmodSync(join(tmpRoot, "bin", "graphit"), 0o755);
305
- } catch {
306
- // Windows or missing wrapper; integrity checks catch real packaging drift.
307
- }
308
- mkdirSync(dirname(targetRoot), { recursive: true });
309
- rmSync(targetRoot, { recursive: true, force: true });
310
- renameSync(tmpRoot, targetRoot);
311
- } catch (error) {
312
- rmSync(tmpRoot, { recursive: true, force: true });
313
- throw error;
314
- }
315
- }
316
-
317
- function updateClaudeInstalledEntriesOnDisk(installedFile, staleRecords, targetPath, currentVersion) {
318
- const now = new Date().toISOString();
319
- for (const record of staleRecords) {
320
- record.entry.installPath = targetPath;
321
- record.entry.version = currentVersion;
322
- record.entry.lastUpdated = now;
323
- if (typeof record.entry.installedAt !== "string") {
324
- record.entry.installedAt = now;
325
- }
326
- }
327
- atomicWriteJson(installedFile.path, installedFile.data);
328
- }
329
-
330
- function inspectClaudeInstalledPlugin(currentVersion, repair) {
331
- const installedFile = readClaudeInstalledFile();
332
- if (!installedFile) return { findings: [], metadata: [] };
333
-
334
- const metadata = [];
335
- const entries = graphitInstallEntries(installedFile.data);
336
- if (entries.length === 0) return { findings: [], metadata };
337
-
338
- const staleRecords = [];
339
- for (const record of entries) {
340
- const versionInfo = pluginEntryVersion(record.entry);
341
- metadata.push({
342
- label: `Claude Code installed ${PLUGIN_ID}`,
343
- version: versionInfo.effectiveVersion,
344
- installPath: versionInfo.installPath,
345
- entryVersion: versionInfo.entryVersion,
346
- skillVersion: versionInfo.skillVersion,
347
- scope: record.entry.scope ?? "user",
348
- projectPath: record.entry.projectPath ?? null,
349
- });
350
-
351
- if (
352
- versionIsBehind(versionInfo.effectiveVersion, currentVersion) ||
353
- versionIsBehind(versionInfo.entryVersion, currentVersion)
354
- ) {
355
- staleRecords.push({ ...record, versionInfo });
356
- }
357
- }
358
-
359
- if (staleRecords.length === 0) return { findings: [], metadata };
360
-
361
- const staleVersions = [...new Set(staleRecords.map((record) => record.versionInfo.effectiveVersion ?? "unknown"))].join(", ");
362
- const targetPath = claudeVersionedCachePath(currentVersion);
363
-
364
- if (!repair) {
365
- return {
366
- metadata,
367
- findings: [
368
- {
369
- type: "claude-installed-plugin-stale",
370
- message: `Claude Code installed Graphit plugin is stale (${staleVersions}), expected ${currentVersion}`,
371
- remediation: "Run `graphit plugin status --repair`, then restart/reload Claude Code. If you prefer the native plugin manager, run `/plugin marketplace update graphit-plugin`, then `/plugin update graphit@graphit-plugin`, then restart/reload.",
372
- },
373
- ],
374
- };
375
- }
376
-
377
- try {
378
- if (!repairSourceLooksCurrent(pluginRoot, currentVersion)) {
379
- throw new Error(`current npm/plugin bundle at ${pluginRoot} is missing synced ${currentVersion} plugin files`);
380
- }
381
- const targetSkillVersion = readFrontmatterVersion(join(targetPath, "skills", "graphit", "SKILL.md"));
382
- if (targetSkillVersion !== currentVersion) {
383
- copyRepairableBundle(pluginRoot, targetPath);
384
- }
385
- updateClaudeInstalledEntriesOnDisk(installedFile, staleRecords, targetPath, currentVersion);
386
- return {
387
- metadata,
388
- findings: [
389
- {
390
- type: "claude-installed-plugin-repaired",
391
- message: `Claude Code installed Graphit plugin was stale (${staleVersions}); repaired disk cache to ${currentVersion}`,
392
- remediation: `Restart/reload Claude Code so it loads the repaired plugin cache at ${targetPath}. The already-running session may still have the old SKILL.md in memory until then.`,
393
- repaired: true,
394
- installPath: targetPath,
395
- },
396
- ],
397
- };
398
- } catch (error) {
399
- return {
400
- metadata,
401
- findings: [
402
- {
403
- type: "claude-installed-plugin-stale",
404
- message: `Claude Code installed Graphit plugin is stale (${staleVersions}), expected ${currentVersion}; auto-repair failed: ${error instanceof Error ? error.message : String(error)}`,
405
- remediation: "Run `/plugin marketplace update graphit-plugin`, then `/plugin update graphit@graphit-plugin`, then restart/reload Claude Code. `graphit plugin status --repair` can repair from the npm bundle once the local files are writable.",
406
- },
407
- ],
408
- };
409
- }
410
- }
411
-
412
130
  function writeCache(path, latestVersion) {
413
131
  atomicWriteJson(path, {
414
132
  schemaVersion: CACHE_SCHEMA_VERSION,
@@ -418,71 +136,15 @@ function writeCache(path, latestVersion) {
418
136
  });
419
137
  }
420
138
 
421
- function realpathSafe(path) {
422
- try {
423
- return realpathSync(path);
424
- } catch {
425
- return path;
426
- }
427
- }
428
-
429
- // 4.1 (SEC-5): find a `graphit` on PATH that is NOT this plugin's bundled wrapper -
430
- // i.e. a legacy global @graphit/cli install. Because plugin executables are
431
- // appended to PATH (Phase 2 finding), any global install shadows the wrapper, so
432
- // its mere presence is the migration trigger. Returns the first such path or null.
433
- function detectForeignGraphit() {
434
- const pathValue = process.env.PATH ?? "";
435
- if (!pathValue) return null;
436
- const names = process.platform === "win32" ? ["graphit.cmd", "graphit.ps1", "graphit"] : ["graphit"];
437
- // On Windows the plugin ships graphit.cmd / graphit.ps1 alongside the POSIX
438
- // `graphit`, so excluding only `bin/graphit` would leave our own .cmd/.ps1
439
- // wrappers looking like a foreign global. Exclude every bundled wrapper name.
440
- const ownWrapperReals = new Set(
441
- names.map((name) => realpathSafe(join(pluginRoot, "bin", name))),
442
- );
443
- for (const dir of pathValue.split(delimiter).filter(Boolean)) {
444
- for (const name of names) {
445
- const candidate = join(dir, name);
446
- if (!existsSync(candidate)) continue;
447
- const real = realpathSafe(candidate);
448
- if (ownWrapperReals.has(real)) continue; // our own bundled wrapper
449
- if (/[/\\]plugins[/\\]/.test(real)) continue; // a plugin-cache wrapper
450
- return candidate;
451
- }
452
- }
453
- return null;
454
- }
455
-
456
- // Nudge throttle (US-3.2 / US-4.1: ~once/day). State is graphit-owned and atomic.
457
- const NUDGE_TTL_MS = 24 * 60 * 60 * 1000;
458
- const nudgeStatePath = join(cacheRoot, "nudge-state.json");
459
-
460
- function readNudgeState() {
461
- const state = tryReadJson(nudgeStatePath);
462
- if (state && state.schemaVersion === CACHE_SCHEMA_VERSION && state.nudges && typeof state.nudges === "object") {
463
- return state;
464
- }
465
- return { schemaVersion: CACHE_SCHEMA_VERSION, nudges: {} };
466
- }
467
-
468
- function nudgeAllowed(state, key) {
469
- const last = state.nudges[key];
470
- return typeof last !== "number" || Date.now() - last >= NUDGE_TTL_MS;
471
- }
472
-
473
- function persistNudges(state, keys) {
474
- if (keys.length === 0) return;
475
- for (const key of keys) state.nudges[key] = Date.now();
476
- atomicWriteJson(nudgeStatePath, state);
477
- }
478
-
479
- async function getLatestVersion(currentVersion) {
139
+ // Feature #1042: `fresh` skips a cache hit - a cloud startup decides whether to update
140
+ // from it, and a snapshot can carry a cache written before the latest release.
141
+ async function getLatestVersion(currentVersion, { fresh = false } = {}) {
480
142
  if (args.has("--skip-network")) return null;
481
143
 
482
144
  const cachePath = join(cacheRoot, "plugin-status.json");
483
- const cache = readCache(cachePath);
145
+ const cache = tryReadJson(cachePath);
484
146
  const cachedVersion = isValidVersionCache(cache) ? cache.latestVersion : null;
485
- if (cachedVersion && Date.now() - cache.checkedAt < TTL_MS) {
147
+ if (!fresh && cachedVersion && Date.now() - cache.checkedAt < TTL_MS) {
486
148
  return cachedVersion;
487
149
  }
488
150
 
@@ -501,91 +163,18 @@ async function getLatestVersion(currentVersion) {
501
163
  return currentVersion;
502
164
  }
503
165
 
504
- function shouldRunForPrompt() {
505
- if (hookEvent !== "UserPromptSubmit") return true;
506
-
507
- let input = "";
508
- try {
509
- input = readFileSync(0, "utf-8");
510
- } catch {
511
- return false;
512
- }
513
-
514
- const parsed = tryParseJson(input);
515
- const prompt = String(parsed?.prompt ?? parsed?.user_prompt ?? input).toLowerCase();
516
- return (
517
- prompt.includes("graphit") &&
518
- /\b(update|version|status|doctor|plugin|stale|staleness)\b/.test(prompt)
519
- );
520
- }
521
-
522
- function tryParseJson(value) {
523
- try {
524
- return JSON.parse(value);
525
- } catch {
526
- return null;
527
- }
528
- }
529
-
530
- // Feature #743: stamp a per-session marker so the CLI can tell whether the plugin
531
- // actually loaded THIS session. This SessionStart hook only runs when Claude Code
532
- // loaded the plugin at session start - a mid-session install never runs it, so the
533
- // marker is absent and the CLI can warn. The SessionStart payload carries session_id
534
- // (the same value as the Bash tool's CLAUDE_CODE_SESSION_ID - both from getSessionId()),
535
- // and the marker is keyed by it so concurrent sessions never clobber each other.
536
- function stampSessionMarker() {
537
- if (hookEvent !== "SessionStart") return;
538
- let sessionId = "";
539
- try {
540
- sessionId = String(tryParseJson(readFileSync(0, "utf-8"))?.session_id ?? "").trim();
541
- } catch {
542
- return;
166
+ // Feature #1042: the hook's stdin can be read only once, so it is read here for
167
+ // both hook events that use it, and only in hook mode - `graphit plugin status`
168
+ // in a terminal must never wait on a TTY. raw is null when the read fails.
169
+ function readHookInput() {
170
+ if (hookEvent !== "SessionStart" && hookEvent !== "UserPromptSubmit") {
171
+ return { raw: "", parsed: null };
543
172
  }
544
- // The id becomes a filename, so accept only an id-shaped token (path-safety).
545
- if (!/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) return;
546
- const dir = join(cacheRoot, "sessions");
547
173
  try {
548
- mkdirSync(dir, { recursive: true });
549
- // Feature #765: marker CONTENT is a capability signal. The CLI arms the
550
- // skill-invocation tripwire only when the marker declares skill-ack; legacy
551
- // markers (bare timestamp) keep it dormant, so a new npm CLI over an old
552
- // bundle can never block a session that cannot stamp.
553
- //
554
- // This is an implication, NOT an equivalence: within the plugin-bundle install
555
- // path the hook, SKILL.md, and CLI bin ship together, so caps => the loaded
556
- // SKILL.md teaches the attestation. It does NOT hold for legacy copied
557
- // snapshots (`graphit setup --legacy-copy`, see src/skill-version.ts), where a
558
- // stale copied SKILL.md can be loaded alongside a current plugin hook. Those
559
- // sessions are why every block message carries the "already invoked?" escape.
560
- writeFileSync(
561
- join(dir, sessionId),
562
- JSON.stringify({ caps: ["skill-ack"], ts: Date.now() }),
563
- "utf-8",
564
- );
565
- pruneOldSessionMarkers(dir);
566
- } catch {
567
- // Best-effort: a missing marker only costs an extra (harmless) CLI warning.
568
- }
569
- }
570
-
571
- // Feature #765: the `--skill-ack` session attestation is stamped in src/skill-guard.ts
572
- // (`stampSkillAck`), called from the `plugin status` command BEFORE it delegates here.
573
- // It deliberately does NOT live in this script: stamping must not depend on a second
574
- // artifact, or a package missing this file could leave the gate armed with no way to
575
- // clear it. A failed write is reported there rather than swallowed.
576
-
577
- function pruneOldSessionMarkers(dir) {
578
- const cutoff = Date.now() - 48 * 60 * 60 * 1000;
579
- try {
580
- for (const name of readdirSync(dir)) {
581
- try {
582
- if (statSync(join(dir, name)).mtimeMs < cutoff) unlinkSync(join(dir, name));
583
- } catch {
584
- /* skip an unreadable entry */
585
- }
586
- }
174
+ const raw = readFileSync(0, "utf-8");
175
+ return { raw, parsed: tryParseJson(raw) };
587
176
  } catch {
588
- /* dir missing or unreadable - nothing to prune */
177
+ return { raw: null, parsed: null };
589
178
  }
590
179
  }
591
180
 
@@ -712,7 +301,10 @@ async function collectStatus() {
712
301
  );
713
302
  }
714
303
 
715
- const claudeInstall = inspectClaudeInstalledPlugin(currentVersion, shouldRepair);
304
+ const claudeInstall = inspectClaudeInstalledPlugin(currentVersion, shouldRepair, {
305
+ pluginRoot,
306
+ repairableEntries: REPAIRABLE_BUNDLE_ENTRIES,
307
+ });
716
308
  metadata.push(...claudeInstall.metadata);
717
309
  findings.push(...claudeInstall.findings);
718
310
 
@@ -760,56 +352,6 @@ function formatPlain(status) {
760
352
  ].join("\n");
761
353
  }
762
354
 
763
- // Build the SessionStart agent nudges. Unlike --json (full diagnostics), the hook
764
- // surfaces only user-actionable, throttled items: the migration nudge (4.1), the
765
- // thin-layer /plugin update nudge (4.3), and legacy copied-skill cleanup. It does
766
- // NOT emit founder-only publish drift, nor the npm-install-g binary message - under
767
- // the plugin model the binary auto-updates via npx, so the bundle nudge is correct.
768
- function buildHookNudges(status) {
769
- const state = readNudgeState();
770
- const lines = [];
771
- const shown = [];
772
-
773
- // 4.1 Migration: a global @graphit/cli shadows the plugin's npx-backed wrapper.
774
- const foreignGraphit = detectForeignGraphit();
775
- if (foreignGraphit && nudgeAllowed(state, "legacy-global")) {
776
- lines.push(
777
- `- A legacy global \`graphit\` (@graphit/cli) at ${foreignGraphit} shadows the plugin's bundled CLI ` +
778
- "(plugin executables are appended to PATH, so a global install wins). The plugin now delivers the CLI " +
779
- "automatically via npx - tell the user once and offer to run `npm uninstall -g @graphit/cli` so it takes over.",
780
- );
781
- shown.push("legacy-global");
782
- }
783
-
784
- // 4.3 Thin-layer: the plugin bundle is behind npm latest (the binary itself
785
- // auto-updates via npx; only the skill/hook/wrapper bundle needs a manual update).
786
- if (
787
- status.latestVersion &&
788
- compareVersions(status.latestVersion, status.currentVersion) > 0 &&
789
- nudgeAllowed(state, "plugin-update")
790
- ) {
791
- lines.push(
792
- `- A newer Graphit plugin is available (${status.currentVersion} -> ${status.latestVersion}). ` +
793
- "Tell the user once to update it with `/plugin marketplace update graphit-plugin`, then " +
794
- "`/plugin update graphit@graphit-plugin`, then restart Claude Code; the CLI itself already runs the latest release.",
795
- );
796
- shown.push("plugin-update");
797
- }
798
-
799
- // Legacy copied skill snapshots are real user-side issues - surface until removed.
800
- for (const finding of status.findings) {
801
- if (finding.type === "legacy-copied-skill-present" || finding.type === "copied-skill-stale") {
802
- lines.push(`- ${finding.message}. ${finding.remediation}`);
803
- }
804
- }
805
-
806
- if (lines.length === 0) return null;
807
- return {
808
- context: ["Graphit plugin status check found actionable update/version information.", ...lines].join("\n"),
809
- persist: () => persistNudges(state, shown),
810
- };
811
- }
812
-
813
355
  // Feature #675: run the CLI/hook flow only when executed directly (the `plugin
814
356
  // status` command and the hooks both invoke `node plugin-status.mjs ...`), not
815
357
  // when a test imports this module for the exported pure helpers - otherwise the
@@ -823,29 +365,50 @@ function isMainModule() {
823
365
  }
824
366
 
825
367
  if (isMainModule()) {
826
- if (hookEvent && !shouldRunForPrompt()) {
368
+ const hookInput = readHookInput();
369
+ if (hookEvent && !shouldRunForPrompt(hookEvent, hookInput.raw, hookInput.parsed)) {
827
370
  process.exit(0);
828
371
  }
829
372
 
830
- // Feature #743: record that the plugin loaded this session (SessionStart only;
831
- // reads stdin before anything else would consume it).
832
- stampSessionMarker();
373
+ const cloudStartup = cloudRefreshApplies({ hookEvent, source: hookInput.parsed?.source, env: process.env });
374
+ if (hookEvent && !cloudStartup) armCutoff(LOCAL_HOOK_CUTOFF_MS);
833
375
 
376
+ // Feature #743: record that the plugin loaded this session (SessionStart only).
377
+ stampSessionMarker(hookEvent, hookInput.parsed, cacheRoot);
378
+
379
+ // Feature #1042: update before the status read, so a cloud startup reports the
380
+ // version it leaves on disk rather than asking the agent for /plugin update.
381
+ let refreshLine = null;
382
+ if (cloudStartup) {
383
+ const installed = readBundleInfo(pluginRoot).currentVersion;
384
+ try {
385
+ refreshLine = await refreshPluginInCloud({
386
+ pluginRoot,
387
+ installed,
388
+ fetchLatest: () => getLatestVersion(installed, { fresh: true }),
389
+ env: process.env,
390
+ });
391
+ } catch (error) {
392
+ // A startup hook must never fail the session; the installed version keeps running.
393
+ refreshLine = `Graphit plugin: update failed, running ${installed}: ${error instanceof Error ? error.message : String(error)}`;
394
+ }
395
+ }
834
396
 
835
397
  const status = await collectStatus();
836
398
 
837
399
  if (args.has("--json")) {
838
400
  console.log(JSON.stringify(status, null, 2));
839
401
  } else if (hookEvent) {
840
- const nudge = buildHookNudges(status);
841
- if (nudge) {
402
+ const nudge = buildHookNudges(status, { cacheRoot, pluginRoot, suppressPluginUpdate: cloudStartup });
403
+ const context = [refreshLine, nudge?.context].filter(Boolean).join("\n");
404
+ if (context) {
842
405
  console.log(JSON.stringify({
843
406
  hookSpecificOutput: {
844
407
  hookEventName: hookEvent,
845
- additionalContext: nudge.context,
408
+ additionalContext: context,
846
409
  },
847
410
  }));
848
- nudge.persist();
411
+ nudge?.persist();
849
412
  }
850
413
  } else if (!args.has("--quiet") || status.findings.length > 0) {
851
414
  console.log(formatPlain(status));