@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/bin/graphit +1 -1
- package/bin/graphit.ps1 +1 -1
- package/dist/commands/ds/sql-history.d.ts +29 -0
- package/dist/commands/ds/sql-history.js +69 -0
- package/dist/commands/ds/sql-history.js.map +1 -0
- package/dist/commands/ds.js +3 -0
- package/dist/commands/ds.js.map +1 -1
- package/dist/commands/kb-batch.js +1 -1
- package/dist/commands/kb-batch.js.map +1 -1
- package/dist/commands/kb.js +2 -21
- package/dist/commands/kb.js.map +1 -1
- package/dist/skill-guard.js +0 -2
- package/dist/skill-guard.js.map +1 -1
- package/hooks/hooks.json +12 -1
- package/package.json +1 -1
- package/scripts/fixtures/kb-cutover-project293.json +13 -0
- package/scripts/plugin-status/claude-install.mjs +215 -0
- package/scripts/plugin-status/cloud-refresh.mjs +97 -0
- package/scripts/plugin-status/common.mjs +107 -0
- package/scripts/plugin-status/hook-io.mjs +200 -0
- package/scripts/plugin-status.mjs +68 -505
- package/scripts/sync-plugin-marketplace.sh +18 -0
- package/scripts/verb-policy-source.json +49 -30
- package/scripts/verify-kb-cutover.mjs +134 -0
- package/skills/graphit/SKILL.md +7 -7
- package/skills/graphit/VERSION.json +1 -1
- package/skills/graphit/references/governance.md +2 -2
- package/skills/graphit/references/kb-actions.md +5 -5
- package/skills/graphit/references/kb-discovery.md +1 -1
- package/skills/graphit/references/metric-families.md +1 -1
- package/skills/graphit/references/onboarding.md +1 -1
- package/skills/graphit/references/page-load.md +38 -0
- package/skills/graphit/references/runtime.md +5 -5
- package/skills/graphit/references/semantic-authoring.md +5 -5
- package/skills/graphit/references/share.md +4 -4
- package/skills/graphit-build/SKILL.md +1 -1
- package/skills/graphit-explore/SKILL.md +1 -1
- 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 {
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 =
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
+
const hookInput = readHookInput();
|
|
369
|
+
if (hookEvent && !shouldRunForPrompt(hookEvent, hookInput.raw, hookInput.parsed)) {
|
|
827
370
|
process.exit(0);
|
|
828
371
|
}
|
|
829
372
|
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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:
|
|
408
|
+
additionalContext: context,
|
|
846
409
|
},
|
|
847
410
|
}));
|
|
848
|
-
nudge
|
|
411
|
+
nudge?.persist();
|
|
849
412
|
}
|
|
850
413
|
} else if (!args.has("--quiet") || status.findings.length > 0) {
|
|
851
414
|
console.log(formatPlain(status));
|