mover-os 4.7.4 → 4.7.5

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 (2) hide show
  1. package/install.js +397 -3
  2. package/package.json +1 -1
package/install.js CHANGED
@@ -1923,13 +1923,13 @@ const AGENT_REGISTRY = {
1923
1923
  // ── Enhanced Tier ──────────────────────────────────────────────────────────
1924
1924
  "codex": {
1925
1925
  name: "Codex",
1926
- tier: "enhanced",
1927
- tierDesc: "AGENTS.md, skills (skills = commands)",
1926
+ tier: "full",
1927
+ tierDesc: "AGENTS.md, skills (skills = commands), 5 hooks",
1928
1928
  detect: () => cmdExists("codex") || fs.existsSync(path.join(H, ".codex")),
1929
1929
  rules: { type: "agents-md", dest: () => path.join(H, ".codex", "AGENTS.md") },
1930
1930
  skills: { dest: () => path.join(H, ".codex", "skills") },
1931
1931
  commands: null,
1932
- hooks: null,
1932
+ hooks: { type: "codex-hooks-json", dest: () => path.join(H, ".codex", "hooks.json") },
1933
1933
  },
1934
1934
  "antigravity": {
1935
1935
  name: "Antigravity",
@@ -2240,6 +2240,167 @@ Stuck: /debug-resistance
2240
2240
  }
2241
2241
 
2242
2242
  // ─── Claude Code hooks (settings.json) ──────────────────────────────────────
2243
+ // ─── Codex hook config generator ────────────────────────────────────────────
2244
+ // v4.7.5: Codex hooks invoked through mover-hook-adapter.js for schema
2245
+ // translation. MVP scope: session-start, engine-protection, git-safety,
2246
+ // plan-sync-reminder, dirty-tree-guard (no session-log-reminder under Codex
2247
+ // — that script is Claude-transcript-specific).
2248
+ //
2249
+ // IMPORTANT: Codex runs hook commands through cmd.exe on Windows, which does
2250
+ // NOT expand $HOME. We resolve absolute paths at install time so the same
2251
+ // hooks.json works on macOS/Linux/Windows.
2252
+ function generateCodexHooks() {
2253
+ const home = os.homedir();
2254
+ // Forward slashes work on all three OSes when invoking node directly.
2255
+ const fwd = (p) => p.split(path.sep).join("/");
2256
+ const hooksRoot = fwd(path.join(home, ".codex", "hooks"));
2257
+ const adapter = `"${hooksRoot}/mover-hook-adapter.js"`;
2258
+ const hookDir = `"${hooksRoot}`;
2259
+ return JSON.stringify(
2260
+ {
2261
+ hooks: {
2262
+ SessionStart: [
2263
+ {
2264
+ matcher: "startup|resume|clear",
2265
+ hooks: [
2266
+ {
2267
+ type: "command",
2268
+ command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh" full`,
2269
+ timeout: 5,
2270
+ },
2271
+ ],
2272
+ },
2273
+ ],
2274
+ PreToolUse: [
2275
+ {
2276
+ matcher: "Bash",
2277
+ hooks: [
2278
+ {
2279
+ type: "command",
2280
+ command: `node ${adapter} codex PreToolUse ${hookDir}/git-safety.sh"`,
2281
+ timeout: 5,
2282
+ },
2283
+ ],
2284
+ },
2285
+ {
2286
+ matcher: "Edit|Write|apply_patch",
2287
+ hooks: [
2288
+ {
2289
+ type: "command",
2290
+ command: `node ${adapter} codex PreToolUse ${hookDir}/engine-protection.sh"`,
2291
+ timeout: 5,
2292
+ },
2293
+ ],
2294
+ },
2295
+ ],
2296
+ PostToolUse: [
2297
+ {
2298
+ matcher: "Edit|Write|apply_patch",
2299
+ hooks: [
2300
+ {
2301
+ type: "command",
2302
+ command: `node ${adapter} codex PostToolUse ${hookDir}/plan-sync-reminder.sh"`,
2303
+ timeout: 5,
2304
+ },
2305
+ ],
2306
+ },
2307
+ ],
2308
+ Stop: [
2309
+ {
2310
+ hooks: [
2311
+ {
2312
+ type: "command",
2313
+ command: `node ${adapter} codex Stop ${hookDir}/dirty-tree-guard.sh"`,
2314
+ timeout: 10,
2315
+ },
2316
+ ],
2317
+ },
2318
+ ],
2319
+ },
2320
+ },
2321
+ null,
2322
+ 2
2323
+ );
2324
+ }
2325
+
2326
+ // ─── Gemini hook config generator ───────────────────────────────────────────
2327
+ // v4.7.5: Gemini events differ from Claude/Codex — UserPromptSubmit→BeforeAgent,
2328
+ // PreToolUse→BeforeTool, PostToolUse→AfterTool, Stop→AfterAgent. Adapter
2329
+ // translates schema; we map event names here.
2330
+ // Timeouts are in milliseconds per Gemini hook spec (default 60000).
2331
+ // Absolute paths used so cmd.exe on Windows can resolve correctly.
2332
+ function generateGeminiHooks() {
2333
+ const home = os.homedir();
2334
+ const fwd = (p) => p.split(path.sep).join("/");
2335
+ const hooksRoot = fwd(path.join(home, ".gemini", "hooks"));
2336
+ const adapter = `"${hooksRoot}/mover-hook-adapter.js"`;
2337
+ const hookDir = `"${hooksRoot}`;
2338
+ return {
2339
+ SessionStart: [
2340
+ {
2341
+ matcher: "startup",
2342
+ hooks: [
2343
+ {
2344
+ name: "mover-session-start",
2345
+ type: "command",
2346
+ command: `node ${adapter} gemini SessionStart ${hookDir}/session-start.sh" full`,
2347
+ timeout: 5000,
2348
+ },
2349
+ ],
2350
+ },
2351
+ ],
2352
+ BeforeTool: [
2353
+ {
2354
+ matcher: "write_file|replace",
2355
+ hooks: [
2356
+ {
2357
+ name: "mover-engine-protection",
2358
+ type: "command",
2359
+ command: `node ${adapter} gemini BeforeTool ${hookDir}/engine-protection.sh"`,
2360
+ timeout: 5000,
2361
+ },
2362
+ ],
2363
+ },
2364
+ {
2365
+ matcher: "run_shell_command",
2366
+ hooks: [
2367
+ {
2368
+ name: "mover-git-safety",
2369
+ type: "command",
2370
+ command: `node ${adapter} gemini BeforeTool ${hookDir}/git-safety.sh"`,
2371
+ timeout: 5000,
2372
+ },
2373
+ ],
2374
+ },
2375
+ ],
2376
+ AfterTool: [
2377
+ {
2378
+ matcher: "write_file|replace",
2379
+ hooks: [
2380
+ {
2381
+ name: "mover-plan-sync",
2382
+ type: "command",
2383
+ command: `node ${adapter} gemini AfterTool ${hookDir}/plan-sync-reminder.sh"`,
2384
+ timeout: 5000,
2385
+ },
2386
+ ],
2387
+ },
2388
+ ],
2389
+ AfterAgent: [
2390
+ {
2391
+ hooks: [
2392
+ {
2393
+ name: "mover-dirty-tree-guard",
2394
+ type: "command",
2395
+ command: `node ${adapter} gemini AfterAgent ${hookDir}/dirty-tree-guard.sh"`,
2396
+ timeout: 10000,
2397
+ },
2398
+ ],
2399
+ },
2400
+ ],
2401
+ };
2402
+ }
2403
+
2243
2404
  function generateClaudeSettings() {
2244
2405
  return JSON.stringify(
2245
2406
  {
@@ -2969,6 +3130,227 @@ function installHooksForClaude(bundleDir, vaultPath) {
2969
3130
  return count;
2970
3131
  }
2971
3132
 
3133
+ // ─── Multi-agent hook installer (v4.7.5) ────────────────────────────────────
3134
+ // Copies the MVP hook set (5 enforcement hooks + adapter + shared lib) to the
3135
+ // agent's hook directory and writes its hook config. Used by Codex and Gemini.
3136
+ const MVP_HOOK_SCRIPTS = [
3137
+ "session-start.sh",
3138
+ "engine-protection.sh",
3139
+ "git-safety.sh",
3140
+ "plan-sync-reminder.sh",
3141
+ "dirty-tree-guard.sh",
3142
+ "mover-lib.sh", // sourced by the others
3143
+ ];
3144
+
3145
+ // Section-aware TOML upsert. Sets [section] key = value, preserving comments,
3146
+ // existing keys, and other sections. If section exists with the key set to a
3147
+ // different value, the value is REPLACED (not duplicated). If the section
3148
+ // header has whitespace variants like `[ features ]`, treat as the same section.
3149
+ function upsertTomlKey(filePath, section, key, value) {
3150
+ let content = "";
3151
+ if (fs.existsSync(filePath)) {
3152
+ content = fs.readFileSync(filePath, "utf8");
3153
+ }
3154
+
3155
+ // Match table header in any whitespace variant: [section], [ section ], etc.
3156
+ // Also tolerate trailing comment after header: [section] # ...
3157
+ const headerRe = (name) =>
3158
+ new RegExp(
3159
+ `^\\s*\\[\\s*${name.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*\\](?:[^\\n]*)$`,
3160
+ "m"
3161
+ );
3162
+ // Any section header (used to find section boundaries)
3163
+ const anyHeaderRe = /^\s*\[\s*[^\]]+\s*\](?:[^\n]*)$/m;
3164
+
3165
+ const lines = content.split("\n");
3166
+ let inSection = false;
3167
+ let sectionStart = -1;
3168
+ let sectionEnd = lines.length;
3169
+ for (let i = 0; i < lines.length; i++) {
3170
+ if (headerRe(section).test(lines[i])) {
3171
+ inSection = true;
3172
+ sectionStart = i;
3173
+ // Find next header (or EOF) — that's section end
3174
+ for (let j = i + 1; j < lines.length; j++) {
3175
+ if (anyHeaderRe.test(lines[j])) {
3176
+ sectionEnd = j;
3177
+ break;
3178
+ }
3179
+ }
3180
+ break;
3181
+ }
3182
+ }
3183
+
3184
+ const newKv = `${key} = ${value}`;
3185
+ // Match existing key in this section: tolerates whitespace + comment
3186
+ const keyRe = new RegExp(
3187
+ `^\\s*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*=.*$`
3188
+ );
3189
+
3190
+ if (inSection) {
3191
+ let replaced = false;
3192
+ for (let i = sectionStart + 1; i < sectionEnd; i++) {
3193
+ if (keyRe.test(lines[i])) {
3194
+ lines[i] = newKv;
3195
+ replaced = true;
3196
+ break;
3197
+ }
3198
+ }
3199
+ if (!replaced) {
3200
+ // Insert key right after section header
3201
+ lines.splice(sectionStart + 1, 0, newKv);
3202
+ }
3203
+ } else {
3204
+ // Section absent — append fresh section at EOF
3205
+ if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
3206
+ lines.push("");
3207
+ }
3208
+ lines.push(`[${section}]`);
3209
+ lines.push(newKv);
3210
+ }
3211
+
3212
+ fs.writeFileSync(filePath, lines.join("\n"), "utf8");
3213
+ }
3214
+
3215
+ function copyMvpHooks(bundleDir, destDir) {
3216
+ const hooksSrc = path.join(bundleDir, "src", "hooks");
3217
+ if (!fs.existsSync(hooksSrc)) return 0;
3218
+ fs.mkdirSync(destDir, { recursive: true });
3219
+ let count = 0;
3220
+ for (const file of MVP_HOOK_SCRIPTS) {
3221
+ const src = path.join(hooksSrc, file);
3222
+ if (!fs.existsSync(src)) continue;
3223
+ const dst = path.join(destDir, file);
3224
+ const content = fs
3225
+ .readFileSync(src, "utf8")
3226
+ .replace(/\r\n/g, "\n")
3227
+ .replace(/\r/g, "\n");
3228
+ fs.writeFileSync(dst, content, { mode: 0o755 });
3229
+ count++;
3230
+ }
3231
+ // Adapter (Node script — copy preserving binary mode)
3232
+ const adapterSrc = path.join(hooksSrc, "mover-hook-adapter.js");
3233
+ if (fs.existsSync(adapterSrc)) {
3234
+ const dst = path.join(destDir, "mover-hook-adapter.js");
3235
+ const content = fs
3236
+ .readFileSync(adapterSrc, "utf8")
3237
+ .replace(/\r\n/g, "\n")
3238
+ .replace(/\r/g, "\n");
3239
+ fs.writeFileSync(dst, content, { mode: 0o755 });
3240
+ count++;
3241
+ }
3242
+ return count;
3243
+ }
3244
+
3245
+ function installHooksForCodex(bundleDir) {
3246
+ const home = os.homedir();
3247
+ const codexDir = path.join(home, ".codex");
3248
+ const hooksDst = path.join(codexDir, "hooks");
3249
+
3250
+ // Detect Codex install
3251
+ if (!fs.existsSync(codexDir) && !cmdExists("codex")) return 0;
3252
+
3253
+ fs.mkdirSync(codexDir, { recursive: true });
3254
+ const count = copyMvpHooks(bundleDir, hooksDst);
3255
+ if (count === 0) return 0;
3256
+
3257
+ // Write hooks.json (deep-merge if existing).
3258
+ // v4.7.5 fix: per-entry idempotency, not per-event. Previous coarse check
3259
+ // skipped sibling entries when one Mover hook was already registered.
3260
+ const hooksJsonPath = path.join(codexDir, "hooks.json");
3261
+ const newConfig = JSON.parse(generateCodexHooks());
3262
+ if (fs.existsSync(hooksJsonPath)) {
3263
+ try {
3264
+ const existing = JSON.parse(fs.readFileSync(hooksJsonPath, "utf8"));
3265
+ mergeHooksConfig(existing, newConfig);
3266
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(existing, null, 2), "utf8");
3267
+ } catch {
3268
+ try {
3269
+ fs.copyFileSync(hooksJsonPath, hooksJsonPath + ".bak");
3270
+ } catch {}
3271
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(newConfig, null, 2), "utf8");
3272
+ }
3273
+ } else {
3274
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(newConfig, null, 2), "utf8");
3275
+ }
3276
+
3277
+ // Enable codex_hooks feature in config.toml (section-aware upsert).
3278
+ // Handles edge cases the prior regex approach missed:
3279
+ // [features] # comment — replacement misses the table header
3280
+ // codex_hooks = false — would create duplicate key
3281
+ // [ features ] — bare-line regex misses whitespace variant
3282
+ // Strategy: parse file into sections, locate or create [features],
3283
+ // upsert codex_hooks=true within that section, reassemble.
3284
+ const configToml = path.join(codexDir, "config.toml");
3285
+ upsertTomlKey(configToml, "features", "codex_hooks", "true");
3286
+
3287
+ return count;
3288
+ }
3289
+
3290
+ function installHooksForGemini(bundleDir) {
3291
+ const home = os.homedir();
3292
+ const geminiDir = path.join(home, ".gemini");
3293
+ const hooksDst = path.join(geminiDir, "hooks");
3294
+
3295
+ // Detect Gemini install
3296
+ if (
3297
+ !fs.existsSync(geminiDir) &&
3298
+ !cmdExists("gemini") &&
3299
+ !fs.existsSync(path.join(geminiDir, "settings.json"))
3300
+ )
3301
+ return 0;
3302
+
3303
+ fs.mkdirSync(geminiDir, { recursive: true });
3304
+ const count = copyMvpHooks(bundleDir, hooksDst);
3305
+ if (count === 0) return 0;
3306
+
3307
+ // Deep-merge into settings.json
3308
+ const settingsPath = path.join(geminiDir, "settings.json");
3309
+ const newHooks = generateGeminiHooks();
3310
+ let existing = {};
3311
+ if (fs.existsSync(settingsPath)) {
3312
+ try {
3313
+ existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
3314
+ } catch {
3315
+ try {
3316
+ fs.copyFileSync(settingsPath, settingsPath + ".bak");
3317
+ } catch {}
3318
+ existing = {};
3319
+ }
3320
+ }
3321
+ if (!existing.hooks) existing.hooks = {};
3322
+ mergeHooksConfig(existing, { hooks: newHooks });
3323
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2), "utf8");
3324
+
3325
+ return count;
3326
+ }
3327
+
3328
+ // Per-entry hook merge. For each new entry, check if an entry with the SAME
3329
+ // matcher AND a Mover adapter command already exists. Skip only that exact
3330
+ // duplicate. Sibling entries (different matchers, or non-Mover entries) are
3331
+ // left intact.
3332
+ function mergeHooksConfig(existing, newConfig) {
3333
+ if (!existing.hooks) existing.hooks = {};
3334
+ for (const [event, newEntries] of Object.entries(newConfig.hooks || {})) {
3335
+ if (!existing.hooks[event]) {
3336
+ existing.hooks[event] = newEntries;
3337
+ continue;
3338
+ }
3339
+ for (const newEntry of newEntries) {
3340
+ const matcher = newEntry.matcher; // may be undefined
3341
+ const newCmds = (newEntry.hooks || []).map((h) => h.command || "");
3342
+ const isDup = existing.hooks[event].some((existingEntry) => {
3343
+ if ((existingEntry.matcher || "") !== (matcher || "")) return false;
3344
+ const existingCmds = (existingEntry.hooks || []).map(
3345
+ (h) => h.command || ""
3346
+ );
3347
+ return newCmds.every((c) => existingCmds.includes(c));
3348
+ });
3349
+ if (!isDup) existing.hooks[event].push(newEntry);
3350
+ }
3351
+ }
3352
+ }
3353
+
2972
3354
  // ─── Per-agent install orchestrators ────────────────────────────────────────
2973
3355
  function installClaudeCode(bundleDir, vaultPath, skillOpts) {
2974
3356
  const home = os.homedir();
@@ -3122,6 +3504,12 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
3122
3504
  if (skCount > 0) steps.push(`${skCount} skills`);
3123
3505
  }
3124
3506
 
3507
+ // v4.7.5: Native Codex hook support via mover-hook-adapter.js
3508
+ if (!skillOpts?.skipHooks) {
3509
+ const hkCount = installHooksForCodex(bundleDir);
3510
+ if (hkCount > 0) steps.push(`${hkCount} hooks`);
3511
+ }
3512
+
3125
3513
  return steps;
3126
3514
  }
3127
3515
 
@@ -3183,6 +3571,12 @@ function installGeminiCli(bundleDir, vaultPath, skillOpts, writtenFiles) {
3183
3571
  if (skCount > 0) steps.push(`${skCount} skills`);
3184
3572
  }
3185
3573
 
3574
+ // v4.7.5: Native Gemini hook support via mover-hook-adapter.js
3575
+ if (!skillOpts?.skipHooks) {
3576
+ const hkCount = installHooksForGemini(bundleDir);
3577
+ if (hkCount > 0) steps.push(`${hkCount} hooks`);
3578
+ }
3579
+
3186
3580
  return steps;
3187
3581
  }
3188
3582
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mover-os",
3
- "version": "4.7.4",
3
+ "version": "4.7.5",
4
4
  "description": "Your AI co-founder. Remembers your goals, pushes back when you drift. Works with 15 AI agents.",
5
5
  "bin": {
6
6
  "moveros": "install.js"