@michelj/context-guard 0.4.3 → 0.6.1

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 (161) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +89 -224
  4. package/README.zh-CN.md +89 -224
  5. package/SKILL.md +26 -684
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/agents/openai.yaml +2 -2
  9. package/bin/build-runtime.mjs +96 -0
  10. package/bin/context-guard-skill.js +399 -78
  11. package/bin/postinstall.js +2 -2
  12. package/hooks.json +89 -13
  13. package/licenses/JSONParse-MIT.txt +24 -0
  14. package/licenses/Marked-MIT.txt +44 -0
  15. package/licenses/Portless-Apache-2.0.txt +201 -0
  16. package/package.json +35 -6
  17. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  18. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  19. package/prototype/attachments.mjs +75 -0
  20. package/prototype/coordinator-markdown.mjs +283 -0
  21. package/prototype/coordinator-working-blot.mjs +124 -0
  22. package/prototype/vendor/marked.mjs +2189 -0
  23. package/prototype/workbench-app.js +5197 -0
  24. package/prototype/workbench-data.js +33 -0
  25. package/prototype/workbench-sync.mjs +898 -0
  26. package/prototype/workbench.css +1050 -0
  27. package/prototype/workbench.html +211 -0
  28. package/prototype/working-blot-atlas.png +0 -0
  29. package/references/agent-handoff.md +40 -0
  30. package/references/claude-runtime.md +120 -0
  31. package/references/cloud-sync-interface.md +66 -0
  32. package/references/design-current.md +14 -0
  33. package/references/map-mount.md +41 -0
  34. package/references/map-read.md +50 -0
  35. package/references/memory-definition.md +120 -0
  36. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  37. package/references/memory-filesystem-v2/Bug.md +162 -0
  38. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  40. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  41. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  42. package/references/memory-filesystem-v2/Idea.md +36 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  44. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  45. package/references/memory-filesystem-v2/README.md +60 -0
  46. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  47. package/references/memory-filesystem-v2/Todo.md +137 -0
  48. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  50. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  51. package/references/named-workbench.md +124 -0
  52. package/references/plan-review.md +12 -0
  53. package/references/server-memory.md +276 -0
  54. package/references/test-check.md +7 -0
  55. package/references/user-reply.md +38 -0
  56. package/references/workbench-interface.md +531 -0
  57. package/roles.md +13 -0
  58. package/scripts/context_guard.py +1366 -7602
  59. package/scripts/context_guard_hook.py +1960 -711
  60. package/scripts/map_owns.py +699 -0
  61. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  62. package/scripts/shared/filesystem-v2.mjs +430 -0
  63. package/scripts/shared/io.mjs +117 -0
  64. package/scripts/shared/map-model.mjs +506 -0
  65. package/scripts/shared/memory-schema.mjs +13 -0
  66. package/scripts/shared/protocol-blobs.mjs +112 -0
  67. package/scripts/shared/protocol-map.mjs +146 -0
  68. package/scripts/shared/protocol-snapshots.mjs +84 -0
  69. package/scripts/shared/protocol-store.mjs +624 -0
  70. package/scripts/shared/protocol-workflow.mjs +226 -0
  71. package/scripts/shared/protocol.mjs +125 -0
  72. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  73. package/scripts/workbench/access.mjs +496 -0
  74. package/scripts/workbench/attachments.mjs +92 -0
  75. package/scripts/workbench/browser-login.mjs +78 -0
  76. package/scripts/workbench/claude-runtime.mjs +372 -0
  77. package/scripts/workbench/cli.mjs +980 -0
  78. package/scripts/workbench/device-heartbeat.mjs +72 -0
  79. package/scripts/workbench/hook-status.mjs +38 -0
  80. package/scripts/workbench/inbox.mjs +155 -0
  81. package/scripts/workbench/journal.mjs +56 -0
  82. package/scripts/workbench/memory-merge.mjs +65 -0
  83. package/scripts/workbench/memory.mjs +252 -0
  84. package/scripts/workbench/named-proxy.mjs +108 -0
  85. package/scripts/workbench/named.mjs +152 -0
  86. package/scripts/workbench/portless-routes.mjs +51 -0
  87. package/scripts/workbench/project.mjs +327 -0
  88. package/scripts/workbench/projections.mjs +68 -0
  89. package/scripts/workbench/protocol-client.mjs +165 -0
  90. package/scripts/workbench/protocol-delivery.mjs +133 -0
  91. package/scripts/workbench/protocol-device.mjs +316 -0
  92. package/scripts/workbench/protocol-events.mjs +53 -0
  93. package/scripts/workbench/protocol-repository.mjs +58 -0
  94. package/scripts/workbench/reconcile.mjs +244 -0
  95. package/scripts/workbench/registry.mjs +111 -0
  96. package/scripts/workbench/runtime.mjs +54 -0
  97. package/scripts/workbench/server.mjs +1171 -0
  98. package/scripts/workbench/store.mjs +243 -0
  99. package/scripts/workbench/sync-coordinator.mjs +518 -0
  100. package/scripts/workbench/sync.mjs +86 -0
  101. package/references/context-template.md +0 -341
  102. package/references/feature-chain-methodology.md +0 -228
  103. package/references/register-template.md +0 -85
  104. package/references/task-case-template.md +0 -63
  105. package/tests/BC-20260618-063.sh +0 -116
  106. package/tests/BC-20260618-065.sh +0 -66
  107. package/tests/BC-20260626-080.sh +0 -48
  108. package/tests/BC-20260626-081.sh +0 -40
  109. package/tests/BC-20260626-082.sh +0 -32
  110. package/tests/BC-20260626-083.sh +0 -66
  111. package/tests/BC-20260627-084.sh +0 -74
  112. package/tests/BC-20260630-086.sh +0 -50
  113. package/tests/BC-20260630-087.sh +0 -103
  114. package/tests/BC-20260630-088.sh +0 -32
  115. package/tests/BC-20260630-089.sh +0 -63
  116. package/tests/BC-20260701-090.sh +0 -84
  117. package/tests/BC-20260702-096.sh +0 -48
  118. package/tests/BC-20260706-098.sh +0 -66
  119. package/tests/BC-20260707-099.sh +0 -47
  120. package/tests/BC-20260707-100.sh +0 -46
  121. package/tests/BC-20260707-101.sh +0 -47
  122. package/tests/BC-20260707-102.sh +0 -68
  123. package/tests/BC-20260707-103.sh +0 -59
  124. package/tests/BC-20260707-104.sh +0 -103
  125. package/tests/BC-20260707-105.sh +0 -109
  126. package/tests/BC-20260707-106.sh +0 -80
  127. package/tests/BC-20260707-107.sh +0 -74
  128. package/tests/BC-20260707-108.sh +0 -48
  129. package/tests/BC-20260707-109.sh +0 -56
  130. package/tests/BC-20260707-110.sh +0 -71
  131. package/tests/BC-20260707-111.sh +0 -70
  132. package/tests/BC-20260707-112.sh +0 -45
  133. package/tests/BC-20260707-113.sh +0 -73
  134. package/tests/BC-20260707-115.sh +0 -77
  135. package/tests/BC-20260707-116.sh +0 -77
  136. package/tests/BC-20260707-118.sh +0 -115
  137. package/tests/BC-20260707-119.sh +0 -47
  138. package/tests/BC-20260707-120.sh +0 -60
  139. package/tests/BC-20260707-121.sh +0 -66
  140. package/tests/BC-20260707-122.sh +0 -48
  141. package/tests/BC-20260707-123.sh +0 -43
  142. package/tests/BC-20260707-124.sh +0 -56
  143. package/tests/BC-20260707-125.sh +0 -64
  144. package/tests/BC-20260707-126.sh +0 -80
  145. package/tests/BC-20260707-127.sh +0 -88
  146. package/tests/BC-20260707-129.sh +0 -59
  147. package/tests/BC-20260707-130.sh +0 -69
  148. package/tests/BC-20260707-131.sh +0 -140
  149. package/tests/BC-20260707-132.sh +0 -150
  150. package/tests/BC-20260707-133.sh +0 -70
  151. package/tests/BC-20260708-136.sh +0 -210
  152. package/tests/BC-20260708-137.sh +0 -106
  153. package/tests/BC-20260708-138.sh +0 -168
  154. package/tests/BC-20260708-139.sh +0 -79
  155. package/tests/BC-20260709-002.sh +0 -63
  156. package/tests/BC-20260709-003.sh +0 -239
  157. package/tests/BC-20260709-006.sh +0 -76
  158. package/tests/BC-20260709-008.sh +0 -168
  159. package/tests/BC-20260710-001.sh +0 -61
  160. package/tests/BC-20260710-002.sh +0 -111
  161. package/tests/npm-install-smoke.sh +0 -53
@@ -12,26 +12,37 @@ 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",
25
+ "prototype",
18
26
  "references",
19
- "scripts",
20
- "tests"
27
+ "scripts"
21
28
  ];
22
29
 
23
30
  function usage() {
24
31
  console.log(`Context Guard Skill
25
32
 
26
33
  Usage:
27
- context-guard install [--target <dir>] [--with-hooks] [--hooks-target <file>] [--config-target <file>]
34
+ context-guard install [--platform auto|all|codex|cursor|claude] [--no-hooks]
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]
28
38
  context-guard path
39
+ context-guard sync ensure|status|pull|prepare|checkpoint|finish [args...]
29
40
  context-guard <context_guard.py command> [args...]
30
41
 
31
42
  Examples:
32
43
  npx @michelj/context-guard install
33
- npx @michelj/context-guard install --with-hooks
34
- npx @michelj/context-guard show-roadmap --root /path/to/project
44
+ npx @michelj/context-guard install --platform all
45
+ npx @michelj/context-guard init --root /path/to/project
35
46
  `);
36
47
  }
37
48
 
@@ -47,34 +58,68 @@ function expandHome(inputPath) {
47
58
  return inputPath;
48
59
  }
49
60
 
50
- function defaultSkillTarget() {
51
- if (process.env.CONTEXT_GUARD_SKILL_TARGET) {
52
- return path.resolve(expandHome(process.env.CONTEXT_GUARD_SKILL_TARGET));
53
- }
54
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
55
- return path.join(codexHome, "skills", "context-guard");
61
+ const PLATFORM_SPECS = {
62
+ codex: { env: "CODEX_HOME", folder: ".codex", hooksFile: "hooks.json", configFile: "config.toml" },
63
+ cursor: { env: "CURSOR_HOME", folder: ".cursor", hooksFile: "hooks.json" },
64
+ claude: { env: "CLAUDE_CONFIG_DIR", legacyEnv: "CLAUDE_HOME", folder: ".claude", hooksFile: "settings.json" }
65
+ };
66
+
67
+ function platformHome(platform) {
68
+ return platformHomeBySpec(PLATFORM_SPECS[platform]);
69
+ }
70
+
71
+ function platformTargets(platform) {
72
+ const home = platformHome(platform);
73
+ return {
74
+ target: path.join(home, "skills", "context-guard"),
75
+ hooksTarget: path.join(home, PLATFORM_SPECS[platform].hooksFile),
76
+ configTarget: PLATFORM_SPECS[platform].configFile
77
+ ? path.join(home, PLATFORM_SPECS[platform].configFile)
78
+ : null
79
+ };
56
80
  }
57
81
 
58
- function defaultHooksTarget() {
59
- if (process.env.CONTEXT_GUARD_HOOKS_TARGET) {
60
- return path.resolve(expandHome(process.env.CONTEXT_GUARD_HOOKS_TARGET));
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
+ }
61
93
  }
62
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
63
- return path.join(codexHome, "hooks.json");
94
+ return detectedPython;
64
95
  }
65
96
 
66
- function defaultConfigTarget() {
67
- const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
68
- return path.join(codexHome, "config.toml");
97
+ function selectedPlatforms(requested) {
98
+ if (requested === "all") return Object.keys(PLATFORM_SPECS);
99
+ if (requested !== "auto") return [requested];
100
+ const detected = Object.entries(PLATFORM_SPECS)
101
+ .filter(([, spec]) => Boolean(process.env[spec.env] || process.env[spec.legacyEnv]) || fs.existsSync(platformHomeBySpec(spec)))
102
+ .map(([name]) => name);
103
+ return detected.length ? detected : ["codex"];
104
+ }
105
+
106
+ function platformHomeBySpec(spec) {
107
+ return path.resolve(expandHome(process.env[spec.env] || process.env[spec.legacyEnv] || path.join(os.homedir(), spec.folder)));
69
108
  }
70
109
 
71
110
  function parseInstallArgs(args) {
72
111
  const options = {
73
- target: defaultSkillTarget(),
74
- withHooks: false,
75
- hooksTarget: defaultHooksTarget(),
76
- configTarget: defaultConfigTarget(),
77
- configTargetExplicit: false,
112
+ platform: "auto",
113
+ target: process.env.CONTEXT_GUARD_SKILL_TARGET
114
+ ? path.resolve(expandHome(process.env.CONTEXT_GUARD_SKILL_TARGET))
115
+ : null,
116
+ withHooks: true,
117
+ hooksTarget: process.env.CONTEXT_GUARD_HOOKS_TARGET
118
+ ? path.resolve(expandHome(process.env.CONTEXT_GUARD_HOOKS_TARGET))
119
+ : null,
120
+ configTarget: null,
121
+ targetExplicit: Boolean(process.env.CONTEXT_GUARD_SKILL_TARGET),
122
+ hooksTargetExplicit: Boolean(process.env.CONTEXT_GUARD_HOOKS_TARGET),
78
123
  dryRun: false
79
124
  };
80
125
 
@@ -84,20 +129,26 @@ function parseInstallArgs(args) {
84
129
  const value = args[++i];
85
130
  if (!value) fail("--target requires a directory");
86
131
  options.target = path.resolve(expandHome(value));
132
+ options.targetExplicit = true;
133
+ } else if (arg === "--platform") {
134
+ const value = String(args[++i] || "").toLowerCase();
135
+ if (!["auto", "all", ...Object.keys(PLATFORM_SPECS)].includes(value)) {
136
+ fail("--platform must be auto, all, codex, cursor, or claude");
137
+ }
138
+ options.platform = value;
87
139
  } else if (arg === "--with-hooks" || arg === "--hooks") {
88
140
  options.withHooks = true;
141
+ } else if (arg === "--no-hooks") {
142
+ options.withHooks = false;
89
143
  } else if (arg === "--hooks-target") {
90
144
  const value = args[++i];
91
145
  if (!value) fail("--hooks-target requires a file path");
92
146
  options.hooksTarget = path.resolve(expandHome(value));
93
- if (!options.configTargetExplicit) {
94
- options.configTarget = path.join(path.dirname(options.hooksTarget), "config.toml");
95
- }
147
+ options.hooksTargetExplicit = true;
96
148
  } else if (arg === "--config-target") {
97
149
  const value = args[++i];
98
150
  if (!value) fail("--config-target requires a file path");
99
151
  options.configTarget = path.resolve(expandHome(value));
100
- options.configTargetExplicit = true;
101
152
  } else if (arg === "--dry-run") {
102
153
  options.dryRun = true;
103
154
  } else if (arg === "-h" || arg === "--help") {
@@ -107,10 +158,20 @@ function parseInstallArgs(args) {
107
158
  fail(`unknown install option: ${arg}`);
108
159
  }
109
160
  }
161
+ if ((options.target || options.hooksTarget || options.configTarget) && options.platform === "all") {
162
+ fail("custom targets can only be used with one platform");
163
+ }
164
+ if (options.targetExplicit && !options.hooksTargetExplicit) {
165
+ const platform = options.platform === "auto" ? "codex" : options.platform;
166
+ options.hooksTarget = path.join(path.dirname(options.target), PLATFORM_SPECS[platform].hooksFile);
167
+ }
168
+ if (!options.configTarget && options.hooksTarget && (options.platform === "auto" || options.platform === "codex")) {
169
+ options.configTarget = path.join(path.dirname(options.hooksTarget), "config.toml");
170
+ }
110
171
  return options;
111
172
  }
112
173
 
113
- function migrateHooksFeatureConfig(configTarget, dryRun) {
174
+ function migratedHooksFeatureConfig(configTarget) {
114
175
  const original = fs.existsSync(configTarget) ? fs.readFileSync(configTarget, "utf8") : "";
115
176
  const lines = original ? original.replace(/\r\n/g, "\n").split("\n") : [];
116
177
  const sectionStart = lines.findIndex((line) => /^\s*\[features\]\s*(?:#.*)?$/.test(line));
@@ -149,50 +210,59 @@ function migrateHooksFeatureConfig(configTarget, dryRun) {
149
210
  }
150
211
 
151
212
  const next = `${nextLines.join("\n").replace(/\n*$/, "")}\n`;
152
- if (next === original.replace(/\r\n/g, "\n")) return;
153
- if (dryRun) {
154
- console.log(`[context-guard-skill] would enable hooks in ${configTarget}`);
155
- return;
156
- }
157
- fs.mkdirSync(path.dirname(configTarget), { recursive: true });
158
- if (fs.existsSync(configTarget)) {
159
- const backupPath = `${configTarget}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
160
- fs.copyFileSync(configTarget, backupPath);
161
- console.log(`[context-guard-skill] backed up config: ${backupPath}`);
162
- }
163
- fs.writeFileSync(configTarget, next);
164
- console.log(`[context-guard-skill] enabled hooks: ${configTarget}`);
213
+ return next === original.replace(/\r\n/g, "\n") ? null : next;
165
214
  }
166
215
 
167
- function copySkill(target, dryRun) {
216
+ function copySkill(target) {
168
217
  if (!fs.existsSync(path.join(sourceSkillDir, "SKILL.md"))) {
169
- fail(`source skill folder is missing: ${sourceSkillDir}`);
218
+ throw new Error(`source skill folder is missing: ${sourceSkillDir}`);
170
219
  }
171
- if (dryRun) {
172
- console.log(`[context-guard-skill] would install skill to ${target}`);
173
- return;
174
- }
175
- fs.rmSync(target, { recursive: true, force: true });
176
- fs.mkdirSync(path.dirname(target), { recursive: true });
177
220
  fs.mkdirSync(target, { recursive: true });
178
221
  for (const entry of skillInstallEntries) {
179
222
  const from = path.join(sourceSkillDir, entry);
180
223
  if (!fs.existsSync(from)) continue;
181
224
  const to = path.join(target, entry);
182
- 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);
183
246
  }
184
- console.log(`[context-guard-skill] installed skill: ${target}`);
185
247
  }
186
248
 
187
- function rewriteHookCommands(hooksConfig, skillTarget) {
249
+ function hookCommand(skillTarget, event, platform) {
188
250
  const hookScript = path.join(skillTarget, "scripts", "context_guard_hook.py");
189
251
  const encodedHookScript = JSON.stringify(hookScript);
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}`;
255
+ }
256
+
257
+ function rewriteGroupedHookCommands(hooksConfig, skillTarget, platform) {
190
258
  const next = JSON.parse(JSON.stringify(hooksConfig));
191
259
  for (const groups of Object.values(next.hooks || {})) {
192
260
  for (const group of groups || []) {
193
261
  for (const hook of group.hooks || []) {
194
262
  if (hook.type === "command" && typeof hook.command === "string" && hook.command.includes("context_guard_hook.py")) {
195
- hook.command = `python3 ${encodedHookScript} ${hook.command.split(" ").pop()}`;
263
+ const match = hook.command.match(/context_guard_hook\.py["']?\s+([a-z-]+)/);
264
+ const event = match ? match[1] : "session-start";
265
+ hook.command = hookCommand(skillTarget, event, platform);
196
266
  }
197
267
  }
198
268
  }
@@ -200,59 +270,305 @@ function rewriteHookCommands(hooksConfig, skillTarget) {
200
270
  return next;
201
271
  }
202
272
 
273
+ function cursorHooks(skillTarget) {
274
+ const events = {
275
+ sessionStart: "session-start",
276
+ subagentStart: "subagent-start",
277
+ beforeSubmitPrompt: "user-prompt-submit",
278
+ subagentStop: "subagent-stop",
279
+ stop: "stop"
280
+ };
281
+ const hooks = {};
282
+ for (const [cursorEvent, normalizedEvent] of Object.entries(events)) {
283
+ hooks[cursorEvent] = [{
284
+ type: "command",
285
+ command: hookCommand(skillTarget, normalizedEvent, "cursor"),
286
+ timeout: normalizedEvent === "session-start" ? 20 : 10
287
+ }];
288
+ }
289
+ return { version: 1, hooks };
290
+ }
291
+
203
292
  function mergeHooks(existing, incoming) {
204
293
  const merged = existing && typeof existing === "object" ? existing : {};
205
294
  merged.hooks = merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {};
206
295
  for (const [event, groups] of Object.entries(incoming.hooks || {})) {
207
296
  const current = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : [];
208
- const withoutOldContextGuard = current.filter((group) => {
209
- const hooks = Array.isArray(group && group.hooks) ? group.hooks : [];
210
- 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 }] : [];
211
301
  });
212
302
  merged.hooks[event] = withoutOldContextGuard.concat(groups);
213
303
  }
214
304
  return merged;
215
305
  }
216
306
 
217
- function installHooks(skillTarget, hooksTarget, dryRun) {
307
+ function mergeCursorHooks(existing, incoming) {
308
+ const merged = existing && typeof existing === "object" ? existing : {};
309
+ merged.version = merged.version || 1;
310
+ merged.hooks = merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {};
311
+ for (const [event, hooks] of Object.entries(incoming.hooks || {})) {
312
+ const current = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : [];
313
+ merged.hooks[event] = current
314
+ .filter((hook) => !String(hook && hook.command || "").includes("context_guard_hook.py"))
315
+ .concat(hooks);
316
+ }
317
+ return merged;
318
+ }
319
+
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}`);
328
+ }
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
+ }
337
+ }
338
+ return value;
339
+ }
340
+
341
+ const CLAUDE_EVENTS = ["SessionStart", "SubagentStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse", "PostToolUseFailure", "PreCompact", "PostCompact", "SubagentStop", "Stop", "StopFailure", "SessionEnd"];
342
+
343
+ function plannedHooks(platform, skillTarget, hooksTarget) {
218
344
  if (!fs.existsSync(sourceHooksPath)) {
219
- fail(`source hooks file is missing: ${sourceHooksPath}`);
345
+ throw new Error(`source hooks file is missing: ${sourceHooksPath}`);
346
+ }
347
+ const existing = readObject(hooksTarget, platform);
348
+ if (platform === "cursor") {
349
+ return mergeCursorHooks(existing, cursorHooks(skillTarget));
220
350
  }
221
351
  const rawIncoming = JSON.parse(fs.readFileSync(sourceHooksPath, "utf8"));
222
- const incoming = rewriteHookCommands(rawIncoming, skillTarget);
223
- let existing = {};
224
- if (fs.existsSync(hooksTarget)) {
225
- existing = JSON.parse(fs.readFileSync(hooksTarget, "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
+ }
360
+ const incoming = rewriteGroupedHookCommands(rawIncoming, skillTarget, platform);
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
+ }
226
385
  }
227
- const merged = mergeHooks(existing, incoming);
228
386
  if (dryRun) {
229
- console.log(`[context-guard-skill] would install hooks to ${hooksTarget}`);
387
+ for (const entry of plan) console.log(`[context-guard-skill] would install ${entry.kind}: ${entry.target}`);
230
388
  return;
231
389
  }
232
- fs.mkdirSync(path.dirname(hooksTarget), { recursive: true });
233
- if (fs.existsSync(hooksTarget)) {
234
- const backupPath = `${hooksTarget}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
235
- fs.copyFileSync(hooksTarget, backupPath);
236
- console.log(`[context-guard-skill] backed up hooks: ${backupPath}`);
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
+ }
237
444
  }
238
- fs.writeFileSync(hooksTarget, `${JSON.stringify(merged, null, 2)}\n`);
239
- console.log(`[context-guard-skill] installed hooks: ${hooksTarget}`);
445
+ for (const entry of plan) console.log(`[context-guard-skill] installed ${entry.kind}: ${entry.target}`);
240
446
  }
241
447
 
242
448
  function install(args) {
243
449
  const options = parseInstallArgs(args);
244
- copySkill(options.target, options.dryRun);
245
- if (options.withHooks) {
246
- migrateHooksFeatureConfig(options.configTarget, options.dryRun);
247
- installHooks(options.target, options.hooksTarget, options.dryRun);
450
+ let platforms = selectedPlatforms(options.platform);
451
+ if ((options.target || options.hooksTarget || options.configTarget) && options.platform === "auto") {
452
+ platforms = ["codex"];
453
+ }
454
+ const plan = [];
455
+ for (const platform of platforms) {
456
+ const defaults = platformTargets(platform);
457
+ const skillTarget = options.target || defaults.target;
458
+ const hooksTarget = options.hooksTarget || defaults.hooksTarget;
459
+ const configTarget = options.configTarget || defaults.configTarget;
460
+ plan.push({ target: skillTarget, kind: "skill" });
461
+ if (options.withHooks) {
462
+ const hooks = plannedHooks(platform, skillTarget, hooksTarget);
463
+ if (platform === "codex" && configTarget) {
464
+ const content = migratedHooksFeatureConfig(configTarget);
465
+ if (content !== null) plan.push({ target: configTarget, kind: "config", content });
466
+ }
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);
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 {}
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"}`);
248
561
  }
562
+ if (!ok) process.exitCode = 1;
249
563
  }
250
564
 
251
565
  function runPython(args) {
252
566
  if (!fs.existsSync(pythonScript)) {
253
567
  fail(`context_guard.py is missing: ${pythonScript}`);
254
568
  }
255
- const result = spawnSync("python3", [pythonScript, ...args], { stdio: "inherit" });
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 });
256
572
  if (result.error) fail(result.error.message);
257
573
  process.exit(result.status === null ? 1 : result.status);
258
574
  }
@@ -262,9 +578,14 @@ const [command, ...rest] = process.argv.slice(2);
262
578
  if (!command || command === "-h" || command === "--help" || command === "help") {
263
579
  usage();
264
580
  } else if (command === "install") {
265
- install(rest);
581
+ try { install(rest); } catch (error) { fail(error.message); }
266
582
  } else if (command === "path") {
267
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);
268
589
  } else {
269
590
  runPython([command, ...rest]);
270
591
  }
@@ -13,12 +13,12 @@ if (skipInstall) {
13
13
  }
14
14
 
15
15
  if (!isGlobalInstall && !autoInstall) {
16
- console.log("[context-guard-skill] package installed. Run `npx @michelj/context-guard install` to install the Codex skill.");
16
+ console.log("[context-guard-skill] package installed. Run `npx @michelj/context-guard install` to install the skill and hooks for detected clients.");
17
17
  process.exit(0);
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}`);