@michelj/context-guard 0.4.4 → 0.6.2

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 (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -12,8 +12,15 @@ const sourceHooksPath = path.join(packageRoot, "hooks.json");
12
12
  const pythonScript = path.join(sourceSkillDir, "scripts", "context_guard.py");
13
13
  const skillInstallEntries = [
14
14
  "SKILL.md",
15
+ "roles.md",
16
+ "Coordinator.md",
17
+ "Executor.md",
18
+ "Tester.md",
15
19
  "README.md",
16
20
  "README.zh-CN.md",
21
+ "THIRD_PARTY_NOTICES.md",
22
+ "licenses",
23
+ "bin",
17
24
  "agents",
18
25
  "prototype",
19
26
  "references",
@@ -26,7 +33,10 @@ function usage() {
26
33
  Usage:
27
34
  context-guard install [--platform auto|all|codex|cursor|claude] [--no-hooks]
28
35
  [--target <dir>] [--hooks-target <file>] [--config-target <file>]
36
+ context-guard doctor [--platform auto|all|codex|cursor|claude] [--root <project>]
37
+ [--target <dir>] [--hooks-target <file>] [--config-target <file>] [--json]
29
38
  context-guard path
39
+ context-guard sync ensure|status|pull|prepare|checkpoint|finish [args...]
30
40
  context-guard <context_guard.py command> [args...]
31
41
 
32
42
  Examples:
@@ -51,12 +61,11 @@ function expandHome(inputPath) {
51
61
  const PLATFORM_SPECS = {
52
62
  codex: { env: "CODEX_HOME", folder: ".codex", hooksFile: "hooks.json", configFile: "config.toml" },
53
63
  cursor: { env: "CURSOR_HOME", folder: ".cursor", hooksFile: "hooks.json" },
54
- claude: { env: "CLAUDE_HOME", folder: ".claude", hooksFile: "settings.json" }
64
+ claude: { env: "CLAUDE_CONFIG_DIR", legacyEnv: "CLAUDE_HOME", folder: ".claude", hooksFile: "settings.json" }
55
65
  };
56
66
 
57
67
  function platformHome(platform) {
58
- const spec = PLATFORM_SPECS[platform];
59
- return path.resolve(expandHome(process.env[spec.env] || path.join(os.homedir(), spec.folder)));
68
+ return platformHomeBySpec(PLATFORM_SPECS[platform]);
60
69
  }
61
70
 
62
71
  function platformTargets(platform) {
@@ -70,17 +79,32 @@ function platformTargets(platform) {
70
79
  };
71
80
  }
72
81
 
82
+ let detectedPython;
83
+ function pythonCommand() {
84
+ if (detectedPython !== undefined) return detectedPython;
85
+ const candidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
86
+ detectedPython = null;
87
+ for (const command of candidates) {
88
+ const probe = spawnSync(command, ["--version"], { encoding: "utf8", windowsHide: true, timeout: 5000 });
89
+ if (!probe.error && probe.status === 0 && /^Python 3\./m.test(`${probe.stdout || ""}\n${probe.stderr || ""}`)) {
90
+ detectedPython = command;
91
+ break;
92
+ }
93
+ }
94
+ return detectedPython;
95
+ }
96
+
73
97
  function selectedPlatforms(requested) {
74
98
  if (requested === "all") return Object.keys(PLATFORM_SPECS);
75
99
  if (requested !== "auto") return [requested];
76
100
  const detected = Object.entries(PLATFORM_SPECS)
77
- .filter(([, spec]) => Boolean(process.env[spec.env]) || fs.existsSync(platformHomeBySpec(spec)))
101
+ .filter(([, spec]) => Boolean(process.env[spec.env] || process.env[spec.legacyEnv]) || fs.existsSync(platformHomeBySpec(spec)))
78
102
  .map(([name]) => name);
79
103
  return detected.length ? detected : ["codex"];
80
104
  }
81
105
 
82
106
  function platformHomeBySpec(spec) {
83
- return path.resolve(expandHome(process.env[spec.env] || path.join(os.homedir(), spec.folder)));
107
+ return path.resolve(expandHome(process.env[spec.env] || process.env[spec.legacyEnv] || path.join(os.homedir(), spec.folder)));
84
108
  }
85
109
 
86
110
  function parseInstallArgs(args) {
@@ -147,7 +171,7 @@ function parseInstallArgs(args) {
147
171
  return options;
148
172
  }
149
173
 
150
- function migrateHooksFeatureConfig(configTarget, dryRun) {
174
+ function migratedHooksFeatureConfig(configTarget) {
151
175
  const original = fs.existsSync(configTarget) ? fs.readFileSync(configTarget, "utf8") : "";
152
176
  const lines = original ? original.replace(/\r\n/g, "\n").split("\n") : [];
153
177
  const sectionStart = lines.findIndex((line) => /^\s*\[features\]\s*(?:#.*)?$/.test(line));
@@ -186,46 +210,48 @@ function migrateHooksFeatureConfig(configTarget, dryRun) {
186
210
  }
187
211
 
188
212
  const next = `${nextLines.join("\n").replace(/\n*$/, "")}\n`;
189
- if (next === original.replace(/\r\n/g, "\n")) return;
190
- if (dryRun) {
191
- console.log(`[context-guard-skill] would enable hooks in ${configTarget}`);
192
- return;
193
- }
194
- fs.mkdirSync(path.dirname(configTarget), { recursive: true });
195
- if (fs.existsSync(configTarget)) {
196
- const backupPath = `${configTarget}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
197
- fs.copyFileSync(configTarget, backupPath);
198
- console.log(`[context-guard-skill] backed up config: ${backupPath}`);
199
- }
200
- fs.writeFileSync(configTarget, next);
201
- console.log(`[context-guard-skill] enabled hooks: ${configTarget}`);
213
+ return next === original.replace(/\r\n/g, "\n") ? null : next;
202
214
  }
203
215
 
204
- function copySkill(target, dryRun) {
216
+ function copySkill(target) {
205
217
  if (!fs.existsSync(path.join(sourceSkillDir, "SKILL.md"))) {
206
- fail(`source skill folder is missing: ${sourceSkillDir}`);
218
+ throw new Error(`source skill folder is missing: ${sourceSkillDir}`);
207
219
  }
208
- if (dryRun) {
209
- console.log(`[context-guard-skill] would install skill to ${target}`);
210
- return;
211
- }
212
- fs.rmSync(target, { recursive: true, force: true });
213
- fs.mkdirSync(path.dirname(target), { recursive: true });
214
220
  fs.mkdirSync(target, { recursive: true });
215
221
  for (const entry of skillInstallEntries) {
216
222
  const from = path.join(sourceSkillDir, entry);
217
223
  if (!fs.existsSync(from)) continue;
218
224
  const to = path.join(target, entry);
219
- fs.cpSync(from, to, { recursive: true });
225
+ const options = entry === "scripts"
226
+ ? {
227
+ recursive: true,
228
+ filter(source) {
229
+ // Node 18 may pass a namespaced Windows path to this callback.
230
+ const relative = path.relative(path.toNamespacedPath(from), path.toNamespacedPath(source));
231
+ if (relative === "") return true;
232
+ const parts = relative.split(path.sep);
233
+ return parts[0] !== "cloud"
234
+ && relative !== "branch_guard.py"
235
+ && !parts.includes("__pycache__")
236
+ && !/\.py[co]$/i.test(parts.at(-1));
237
+ }
238
+ }
239
+ : entry === "bin"
240
+ ? { recursive: true, filter(source) {
241
+ const relative = path.relative(path.toNamespacedPath(from), path.toNamespacedPath(source));
242
+ return relative === "" || relative === "context-guard-skill.js";
243
+ } }
244
+ : { recursive: true };
245
+ fs.cpSync(from, to, options);
220
246
  }
221
- console.log(`[context-guard-skill] installed skill: ${target}`);
222
247
  }
223
248
 
224
249
  function hookCommand(skillTarget, event, platform) {
225
250
  const hookScript = path.join(skillTarget, "scripts", "context_guard_hook.py");
226
251
  const encodedHookScript = JSON.stringify(hookScript);
227
- const pythonCommand = process.platform === "win32" ? "python" : "python3";
228
- return `${pythonCommand} ${encodedHookScript} ${event} --platform ${platform}`;
252
+ const python = pythonCommand();
253
+ if (!python) throw new Error("Python 3 is required before lifecycle hooks can be installed");
254
+ return `${python} ${encodedHookScript} ${event} --platform ${platform}`;
229
255
  }
230
256
 
231
257
  function rewriteGroupedHookCommands(hooksConfig, skillTarget, platform) {
@@ -257,7 +283,7 @@ function cursorHooks(skillTarget) {
257
283
  hooks[cursorEvent] = [{
258
284
  type: "command",
259
285
  command: hookCommand(skillTarget, normalizedEvent, "cursor"),
260
- timeout: 10
286
+ timeout: normalizedEvent === "session-start" ? 20 : 10
261
287
  }];
262
288
  }
263
289
  return { version: 1, hooks };
@@ -268,9 +294,10 @@ function mergeHooks(existing, incoming) {
268
294
  merged.hooks = merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {};
269
295
  for (const [event, groups] of Object.entries(incoming.hooks || {})) {
270
296
  const current = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : [];
271
- const withoutOldContextGuard = current.filter((group) => {
272
- const hooks = Array.isArray(group && group.hooks) ? group.hooks : [];
273
- return !hooks.some((hook) => String(hook.command || "").includes("context_guard_hook.py"));
297
+ const withoutOldContextGuard = current.flatMap((group) => {
298
+ const hooks = group.hooks.filter((hook) => !String(hook.command || "").includes("context_guard_hook.py"));
299
+ if (hooks.length === group.hooks.length) return [group];
300
+ return hooks.length ? [{ ...group, hooks }] : [];
274
301
  });
275
302
  merged.hooks[event] = withoutOldContextGuard.concat(groups);
276
303
  }
@@ -290,40 +317,132 @@ function mergeCursorHooks(existing, incoming) {
290
317
  return merged;
291
318
  }
292
319
 
293
- function writeConfigFile(target, value, label, dryRun) {
294
- if (dryRun) {
295
- console.log(`[context-guard-skill] would install ${label} to ${target}`);
296
- return;
320
+ function readObject(target, platform) {
321
+ if (!fs.existsSync(target)) return {};
322
+ const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
323
+ let value;
324
+ try {
325
+ value = JSON.parse(fs.readFileSync(target, "utf8").replace(/^\uFEFF/, ""));
326
+ } catch (error) {
327
+ throw new Error(`Cannot read JSON config ${target}: ${error.message}`);
297
328
  }
298
- fs.mkdirSync(path.dirname(target), { recursive: true });
299
- if (fs.existsSync(target)) {
300
- const backupPath = `${target}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
301
- fs.copyFileSync(target, backupPath);
302
- console.log(`[context-guard-skill] backed up ${label}: ${backupPath}`);
329
+ if (!object(value) || (value.hooks !== undefined && !object(value.hooks))) {
330
+ throw new Error(`Invalid config object or hooks in ${target}; original file was not changed.`);
331
+ }
332
+ for (const [event, entries] of Object.entries(value.hooks || {})) {
333
+ if (!Array.isArray(entries) || entries.some(entry => !object(entry) ||
334
+ (platform !== "cursor" && (!Array.isArray(entry.hooks) || entry.hooks.some(hook => !object(hook)))))) {
335
+ throw new Error(`Invalid hook event ${event} in ${target}; original file was not changed.`);
336
+ }
303
337
  }
304
- fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`);
305
- console.log(`[context-guard-skill] installed ${label}: ${target}`);
338
+ return value;
306
339
  }
307
340
 
308
- function readObject(target) {
309
- if (!fs.existsSync(target)) return {};
310
- const value = JSON.parse(fs.readFileSync(target, "utf8"));
311
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
312
- }
341
+ const CLAUDE_EVENTS = ["SessionStart", "SubagentStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse", "PostToolUseFailure", "PreCompact", "PostCompact", "SubagentStop", "Stop", "StopFailure", "SessionEnd"];
313
342
 
314
- function installHooks(platform, skillTarget, hooksTarget, dryRun) {
343
+ function plannedHooks(platform, skillTarget, hooksTarget) {
315
344
  if (!fs.existsSync(sourceHooksPath)) {
316
- fail(`source hooks file is missing: ${sourceHooksPath}`);
345
+ throw new Error(`source hooks file is missing: ${sourceHooksPath}`);
317
346
  }
318
- const existing = readObject(hooksTarget);
347
+ const existing = readObject(hooksTarget, platform);
319
348
  if (platform === "cursor") {
320
- writeConfigFile(hooksTarget, mergeCursorHooks(existing, cursorHooks(skillTarget)), "hooks", dryRun);
321
- return;
349
+ return mergeCursorHooks(existing, cursorHooks(skillTarget));
322
350
  }
323
351
  const rawIncoming = JSON.parse(fs.readFileSync(sourceHooksPath, "utf8"));
352
+ if (platform === "claude") {
353
+ for (const [name, source, from, to] of [["PostToolUseFailure", "PostToolUse", "post-tool-use", "post-tool-use-failure"], ["SessionEnd", "Stop", "stop", "session-end"], ["StopFailure", "Interrupt", "interrupt", "stop-failure"]]) {
354
+ rawIncoming.hooks[name] = JSON.parse(JSON.stringify(rawIncoming.hooks[source]));
355
+ for (const group of rawIncoming.hooks[name]) for (const hook of group.hooks) hook.command = hook.command.replace(`context_guard_hook.py ${from} `, `context_guard_hook.py ${to} `);
356
+ }
357
+ const supported = new Set(CLAUDE_EVENTS);
358
+ rawIncoming.hooks = Object.fromEntries(Object.entries(rawIncoming.hooks || {}).filter(([event]) => supported.has(event)));
359
+ }
324
360
  const incoming = rewriteGroupedHookCommands(rawIncoming, skillTarget, platform);
325
- const merged = mergeHooks(existing, incoming);
326
- writeConfigFile(hooksTarget, merged, platform === "claude" ? "settings hooks" : "hooks", dryRun);
361
+ return mergeHooks(existing, incoming);
362
+ }
363
+
364
+ function containsPath(parent, child) {
365
+ const relative = path.relative(parent, child);
366
+ return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
367
+ }
368
+
369
+ function applyInstallPlan(plan, dryRun) {
370
+ for (const entry of plan) {
371
+ if (containsPath(entry.target, os.homedir())) {
372
+ throw new Error(`Install destination must not replace the user home or its parent: ${entry.target}`);
373
+ }
374
+ if (containsPath(entry.target, sourceSkillDir) || containsPath(sourceSkillDir, entry.target)) {
375
+ throw new Error(`Install destination overlaps its source package: ${entry.target}`);
376
+ }
377
+ const stat = fs.lstatSync(entry.target, { throwIfNoEntry: false });
378
+ if (stat && (stat.isSymbolicLink() || (entry.kind === "skill" ? !stat.isDirectory() : !stat.isFile()))) {
379
+ throw new Error(`Invalid install destination: ${entry.target}`);
380
+ }
381
+ entry.mode = stat ? stat.mode & 0o777 : 0o600;
382
+ for (const other of plan) if (entry !== other && containsPath(entry.target, other.target)) {
383
+ throw new Error(`Overlapping install destinations: ${entry.target} and ${other.target}`);
384
+ }
385
+ }
386
+ if (dryRun) {
387
+ for (const entry of plan) console.log(`[context-guard-skill] would install ${entry.kind}: ${entry.target}`);
388
+ return;
389
+ }
390
+
391
+ const staged = [];
392
+ try {
393
+ // Prepare every replacement before touching existing files. Temporary
394
+ // siblings allow renames on the same filesystem, including on Windows.
395
+ for (const entry of plan) {
396
+ fs.mkdirSync(path.dirname(entry.target), { recursive: true });
397
+ entry.temp = fs.mkdtempSync(path.join(path.dirname(entry.target), ".context-guard-install-"));
398
+ entry.next = path.join(entry.temp, "next");
399
+ entry.previous = path.join(entry.temp, "previous");
400
+ staged.push(entry);
401
+ if (entry.kind === "skill") {
402
+ copySkill(entry.next);
403
+ // Existing Claude receivers may still reference this absolute prompt path.
404
+ if (fs.existsSync(path.join(entry.target, "Developer.md"))) {
405
+ fs.copyFileSync(path.join(entry.next, "Executor.md"), path.join(entry.next, "Developer.md"));
406
+ }
407
+ } else fs.writeFileSync(entry.next, entry.content, { mode: entry.mode });
408
+ }
409
+ for (const entry of staged) {
410
+ if (fs.existsSync(entry.target)) {
411
+ fs.renameSync(entry.target, entry.previous);
412
+ entry.oldMoved = true;
413
+ }
414
+ fs.renameSync(entry.next, entry.target);
415
+ entry.newMoved = true;
416
+ }
417
+ } catch (error) {
418
+ for (const entry of staged.slice().reverse()) {
419
+ try {
420
+ if (entry.newMoved) fs.rmSync(entry.target, { recursive: entry.kind === "skill", force: true });
421
+ if (entry.oldMoved) fs.renameSync(entry.previous, entry.target);
422
+ } catch (rollbackError) {
423
+ entry.preserve = true;
424
+ console.error(`[context-guard-skill] Recovery needed; kept ${entry.temp}: ${rollbackError.message}`);
425
+ }
426
+ }
427
+ throw error;
428
+ } finally {
429
+ for (const entry of staged) {
430
+ if (entry.preserve) continue;
431
+ try {
432
+ // Keep original client configuration as a uniquely named backup only
433
+ // after a successful replacement. Failed installs restore it instead.
434
+ if (entry.kind !== "skill" && fs.existsSync(entry.previous)) {
435
+ const backup = `${entry.target}.bak-${path.basename(entry.temp)}`;
436
+ fs.renameSync(entry.previous, backup);
437
+ console.log(`[context-guard-skill] backed up config: ${backup}`);
438
+ }
439
+ fs.rmSync(entry.temp, { recursive: true, force: true });
440
+ } catch (error) {
441
+ console.warn(`[context-guard-skill] Kept recovery files at ${entry.temp}: ${error.message}`);
442
+ }
443
+ }
444
+ }
445
+ for (const entry of plan) console.log(`[context-guard-skill] installed ${entry.kind}: ${entry.target}`);
327
446
  }
328
447
 
329
448
  function install(args) {
@@ -332,32 +451,126 @@ function install(args) {
332
451
  if ((options.target || options.hooksTarget || options.configTarget) && options.platform === "auto") {
333
452
  platforms = ["codex"];
334
453
  }
454
+ const plan = [];
335
455
  for (const platform of platforms) {
336
456
  const defaults = platformTargets(platform);
337
457
  const skillTarget = options.target || defaults.target;
338
458
  const hooksTarget = options.hooksTarget || defaults.hooksTarget;
339
459
  const configTarget = options.configTarget || defaults.configTarget;
340
- copySkill(skillTarget, options.dryRun);
460
+ plan.push({ target: skillTarget, kind: "skill" });
341
461
  if (options.withHooks) {
462
+ const hooks = plannedHooks(platform, skillTarget, hooksTarget);
342
463
  if (platform === "codex" && configTarget) {
343
- migrateHooksFeatureConfig(configTarget, options.dryRun);
464
+ const content = migratedHooksFeatureConfig(configTarget);
465
+ if (content !== null) plan.push({ target: configTarget, kind: "config", content });
344
466
  }
345
- installHooks(platform, skillTarget, hooksTarget, options.dryRun);
467
+ plan.push({ target: hooksTarget, kind: "hooks", content: `${JSON.stringify(hooks, null, 2)}\n` });
468
+ }
469
+ }
470
+ applyInstallPlan(plan, options.dryRun);
471
+ }
472
+
473
+ function parseDoctorArgs(args) {
474
+ const options = { platform: "auto", root: process.cwd(), target: null, hooksTarget: null, configTarget: null, json: false };
475
+ for (let i = 0; i < args.length; i += 1) {
476
+ const arg = args[i];
477
+ if (["--platform", "--root", "--target", "--hooks-target", "--config-target"].includes(arg)) {
478
+ const value = args[++i];
479
+ if (!value) fail(`${arg} requires a value`);
480
+ if (arg === "--platform") {
481
+ const platform = String(value).toLowerCase();
482
+ if (!["auto", "all", ...Object.keys(PLATFORM_SPECS)].includes(platform)) fail("--platform must be auto, all, codex, cursor, or claude");
483
+ options.platform = platform;
484
+ } else options[{ "--root": "root", "--target": "target", "--hooks-target": "hooksTarget", "--config-target": "configTarget" }[arg]] = path.resolve(expandHome(value));
485
+ } else if (arg === "--json") options.json = true;
486
+ else fail(`unknown doctor option: ${arg}`);
487
+ }
488
+ if ((options.target || options.hooksTarget || options.configTarget) && options.platform === "all") fail("custom doctor targets can only be used with one platform");
489
+ return options;
490
+ }
491
+
492
+ function hookCommands(config, platform, event) {
493
+ const entries = config?.hooks?.[event];
494
+ if (!Array.isArray(entries)) return [];
495
+ if (platform === "cursor") return entries.map(item => item?.command).filter(Boolean);
496
+ return entries.flatMap(group => (group?.hooks || []).map(item => item?.command)).filter(Boolean);
497
+ }
498
+
499
+ function doctor(args) {
500
+ const options = parseDoctorArgs(args);
501
+ const results = [];
502
+ const check = (name, ok, detail, required = true) => results.push({ name, ok: Boolean(ok), detail, required });
503
+ const python = pythonCommand();
504
+ check("python", python, python ? `${python} is Python 3` : "no working Python 3 interpreter");
505
+ let platforms = selectedPlatforms(options.platform);
506
+ if ((options.target || options.hooksTarget || options.configTarget) && options.platform === "auto") platforms = ["codex"];
507
+ const eventNames = {
508
+ codex: ["SessionStart", "SubagentStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact", "SubagentStop", "Stop", "Interrupt"],
509
+ claude: CLAUDE_EVENTS,
510
+ cursor: ["sessionStart", "subagentStart", "beforeSubmitPrompt", "subagentStop", "stop"]
511
+ };
512
+ for (const platform of platforms) {
513
+ const defaults = platformTargets(platform);
514
+ const target = options.target || defaults.target;
515
+ const hooksTarget = options.hooksTarget || defaults.hooksTarget;
516
+ const configTarget = options.configTarget || defaults.configTarget;
517
+ const installedLauncher = path.join(target, "bin", "context-guard-skill.js");
518
+ check(`${platform}.skill`, fs.existsSync(path.join(target, "SKILL.md")) && fs.existsSync(path.join(target, "scripts", "context_guard_hook.py")) && fs.existsSync(installedLauncher), target);
519
+ check(`${platform}.cli`, fs.existsSync(installedLauncher), installedLauncher);
520
+ let config = null;
521
+ try { config = readObject(hooksTarget, platform); } catch (error) { check(`${platform}.hooks`, false, error.message); }
522
+ if (config) {
523
+ const missing = eventNames[platform].filter(event => !hookCommands(config, platform, event).some(command => command.includes("context_guard_hook.py") && command.includes(`--platform ${platform}`)));
524
+ check(`${platform}.hooks`, missing.length === 0, missing.length ? `missing ${missing.join(", ")}` : hooksTarget);
346
525
  }
526
+ if (platform === "codex" && configTarget) {
527
+ const text = fs.existsSync(configTarget) ? fs.readFileSync(configTarget, "utf8") : "";
528
+ check("codex.hooks-feature", /^\s*hooks\s*=\s*true\s*$/m.test(text), configTarget);
529
+ const probe = spawnSync(process.execPath, [path.join(sourceSkillDir, 'scripts/workbench/hook-status.mjs'), options.root, target], { encoding: 'utf8', windowsHide: true, timeout: 7000 });
530
+ let native = {}; try { native = JSON.parse(probe.stdout); } catch {}
531
+ check('codex.hooks-trust', native.trusted, native.unavailable ? 'native trust status unavailable; not ready' : JSON.stringify(native));
532
+ const crypto = require('crypto');
533
+ let expected = '', installed = '';
534
+ try { expected = crypto.createHash('sha256').update(fs.readFileSync(path.join(sourceSkillDir, 'scripts/context_guard_hook.py'))).digest('hex'); installed = crypto.createHash('sha256').update(fs.readFileSync(path.join(target, 'scripts/context_guard_hook.py'))).digest('hex'); } catch {}
535
+ check('codex.hooks-version', expected && expected === installed, 'installed Hook must match this release');
536
+ let events = [];
537
+ try { events = fs.readFileSync(path.join(options.root, '.codex/context/sessions.jsonl'), 'utf8').split('\n').filter(Boolean).map(JSON.parse); } catch {}
538
+ const current = events.filter(event => event.hook_sha256 === installed && (!process.env.CODEX_THREAD_ID || event.session_id === process.env.CODEX_THREAD_ID));
539
+ check('codex.hooks-executed', current.length > 0, 'execution evidence for the current installed script');
540
+ check('codex.context-emitted', current.some(event => event.context_emitted), 'context output emitted; delivery to a model is not observable by doctor');
541
+ }
542
+ }
543
+ const ctx = path.join(options.root, ".codex", "context");
544
+ let map = null;
545
+ try { map = JSON.parse(fs.readFileSync(path.join(ctx, "map.json"), "utf8")); } catch {}
546
+ check("project.map", map?.root?.id || map?.root === null, path.join(ctx, "map.json"));
547
+ check("project.sessions", fs.existsSync(path.join(ctx, "sessions.jsonl")), path.join(ctx, "sessions.jsonl"));
548
+ const diagnosticLauncher = platforms.length === 1 ? path.join((options.target || platformTargets(platforms[0]).target), "bin", "context-guard-skill.js") : null;
549
+ let diagnostic = null;
550
+ if (diagnosticLauncher && fs.existsSync(diagnosticLauncher)) {
551
+ const probe = spawnSync(process.execPath, [diagnosticLauncher, "workbench", "--diagnose", "--root", options.root, ...(process.env.CODEX_THREAD_ID ? ["--session", process.env.CODEX_THREAD_ID] : [])], { encoding: "utf8", windowsHide: true, timeout: 7000 });
552
+ try { diagnostic = JSON.parse(probe.stdout); } catch {}
347
553
  }
554
+ const runtimeStatus = diagnostic?.runtime?.status || "unknown";
555
+ check("project.workbench", ["ready", "stopped"].includes(runtimeStatus), diagnostic ? JSON.stringify({ status: runtimeStatus, named: diagnostic.runtime.named, services: diagnostic.runtime.services }) : "diagnosis unavailable", false);
556
+ const ok = results.every(item => !item.required || item.ok);
557
+ if (options.json) console.log(JSON.stringify({ ok, results }, null, 2));
558
+ else {
559
+ for (const item of results) console.log(`[${item.ok ? "ok" : item.required ? "fail" : "warn"}] ${item.name}: ${item.detail}`);
560
+ console.log(`[context-guard-skill] doctor: ${ok ? "ready" : "not ready"}`);
561
+ }
562
+ if (!ok) process.exitCode = 1;
348
563
  }
349
564
 
350
565
  function runPython(args) {
351
566
  if (!fs.existsSync(pythonScript)) {
352
567
  fail(`context_guard.py is missing: ${pythonScript}`);
353
568
  }
354
- for (const command of ["python3", "python"]) {
355
- const result = spawnSync(command, [pythonScript, ...args], { stdio: "inherit" });
356
- if (result.error && result.error.code === "ENOENT") continue;
357
- if (result.error) fail(result.error.message);
358
- process.exit(result.status === null ? 1 : result.status);
359
- }
360
- fail("Python 3 is required; neither `python3` nor `python` was found.");
569
+ const command = pythonCommand();
570
+ if (!command) fail("Python 3 is required; no working Python 3 interpreter was found (`python`, `python3`).");
571
+ const result = spawnSync(command, [pythonScript, ...args], { stdio: "inherit", windowsHide: true });
572
+ if (result.error) fail(result.error.message);
573
+ process.exit(result.status === null ? 1 : result.status);
361
574
  }
362
575
 
363
576
  const [command, ...rest] = process.argv.slice(2);
@@ -365,9 +578,14 @@ const [command, ...rest] = process.argv.slice(2);
365
578
  if (!command || command === "-h" || command === "--help" || command === "help") {
366
579
  usage();
367
580
  } else if (command === "install") {
368
- install(rest);
581
+ try { install(rest); } catch (error) { fail(error.message); }
369
582
  } else if (command === "path") {
370
583
  console.log(sourceSkillDir);
584
+ } else if (command === "doctor") {
585
+ doctor(rest);
586
+ } else if (["map", "workbench", "memory", "preferences", "sync"].includes(command)) {
587
+ const result = spawnSync(process.execPath, [path.join(sourceSkillDir, "scripts", "workbench", "cli.mjs"), command, ...rest], { stdio: "inherit", windowsHide: true });
588
+ process.exit(result.status ?? 1);
371
589
  } else {
372
590
  runPython([command, ...rest]);
373
591
  }
@@ -18,7 +18,7 @@ if (!isGlobalInstall && !autoInstall) {
18
18
  }
19
19
 
20
20
  const cli = path.join(__dirname, "context-guard-skill.js");
21
- const result = spawnSync(process.execPath, [cli, "install"], { stdio: "inherit" });
21
+ const result = spawnSync(process.execPath, [cli, "install"], { stdio: "inherit", windowsHide: true });
22
22
 
23
23
  if (result.error) {
24
24
  console.warn(`[context-guard-skill] auto install skipped: ${result.error.message}`);
package/hooks.json CHANGED
@@ -7,8 +7,8 @@
7
7
  {
8
8
  "type": "command",
9
9
  "command": "python3 ./scripts/context_guard_hook.py session-start --platform codex",
10
- "timeout": 10,
11
- "statusMessage": "Initializing context folder"
10
+ "timeout": 20,
11
+ "statusMessage": "Checking Session context"
12
12
  }
13
13
  ]
14
14
  }
@@ -37,6 +37,70 @@
37
37
  ]
38
38
  }
39
39
  ],
40
+ "PreToolUse": [
41
+ {
42
+ "matcher": "Bash|exec_command|shell|run_shell_command|apply_patch|Edit|Write|mcp__.*(write|edit|delete|move).*",
43
+ "hooks": [
44
+ {
45
+ "type": "command",
46
+ "command": "python3 ./scripts/context_guard_hook.py pre-tool-use --platform codex",
47
+ "timeout": 20,
48
+ "statusMessage": "Checking tool scope"
49
+ }
50
+ ]
51
+ }
52
+ ],
53
+ "PermissionRequest": [
54
+ {
55
+ "hooks": [
56
+ {
57
+ "type": "command",
58
+ "command": "python3 ./scripts/context_guard_hook.py permission-request --platform codex",
59
+ "timeout": 10,
60
+ "statusMessage": "Checking Context Guard node scope"
61
+ }
62
+ ]
63
+ }
64
+ ],
65
+ "PostToolUse": [
66
+ {
67
+ "matcher": "Bash|exec_command|shell|run_shell_command|apply_patch|Edit|Write|mcp__.*(write|edit|delete|move).*",
68
+ "hooks": [
69
+ {
70
+ "type": "command",
71
+ "command": "python3 ./scripts/context_guard_hook.py post-tool-use --platform codex",
72
+ "timeout": 20,
73
+ "statusMessage": "Recording tool result"
74
+ }
75
+ ]
76
+ }
77
+ ],
78
+ "PreCompact": [
79
+ {
80
+ "matcher": "manual|auto",
81
+ "hooks": [
82
+ {
83
+ "type": "command",
84
+ "command": "python3 ./scripts/context_guard_hook.py pre-compact --platform codex",
85
+ "timeout": 10,
86
+ "statusMessage": "Saving Context Guard plan state"
87
+ }
88
+ ]
89
+ }
90
+ ],
91
+ "PostCompact": [
92
+ {
93
+ "matcher": "manual|auto",
94
+ "hooks": [
95
+ {
96
+ "type": "command",
97
+ "command": "python3 ./scripts/context_guard_hook.py post-compact --platform codex",
98
+ "timeout": 10,
99
+ "statusMessage": "Restoring Context Guard map state"
100
+ }
101
+ ]
102
+ }
103
+ ],
40
104
  "SubagentStop": [
41
105
  {
42
106
  "hooks": [
@@ -44,7 +108,7 @@
44
108
  "type": "command",
45
109
  "command": "python3 ./scripts/context_guard_hook.py subagent-stop --platform codex",
46
110
  "timeout": 10,
47
- "statusMessage": "Reminder: write sessions/bugs/tasks if this turn mattered"
111
+ "statusMessage": "Recording subagent stop"
48
112
  }
49
113
  ]
50
114
  }
@@ -56,7 +120,19 @@
56
120
  "type": "command",
57
121
  "command": "python3 ./scripts/context_guard_hook.py stop --platform codex",
58
122
  "timeout": 10,
59
- "statusMessage": "Reminder: write sessions/bugs/tasks if this turn mattered"
123
+ "statusMessage": "Checking Map archive and plan completion"
124
+ }
125
+ ]
126
+ }
127
+ ],
128
+ "Interrupt": [
129
+ {
130
+ "hooks": [
131
+ {
132
+ "type": "command",
133
+ "command": "python3 ./scripts/context_guard_hook.py interrupt --platform codex",
134
+ "timeout": 3,
135
+ "statusMessage": "Saving interrupted Context Guard state"
60
136
  }
61
137
  ]
62
138
  }
@@ -0,0 +1,24 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2012 Tim Caswell
4
+
5
+ Permission is hereby granted, free of charge,
6
+ to any person obtaining a copy of this software and
7
+ associated documentation files (the "Software"), to
8
+ deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify,
10
+ merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom
12
+ the Software is furnished to do so,
13
+ subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice
16
+ shall be included in all copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
21
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
22
+ ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
23
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
24
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.