@lovrozagar/grunt 0.6.2 → 0.8.0

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.
@@ -11,6 +11,11 @@ import {
11
11
  findSkillContentConflicts,
12
12
  formatSkillConflictWarn,
13
13
  } from "./skill-conflicts.mjs";
14
+ import {
15
+ PACKAGE_MANAGERS,
16
+ detectPackageManager,
17
+ runScriptLine,
18
+ } from "./package-manager.mjs";
14
19
 
15
20
  export const CHROMIUM_BINS = [
16
21
  "chromium",
@@ -28,8 +33,7 @@ const RTK_CURL =
28
33
  "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh";
29
34
  const RTK_DOCS = "https://www.rtk-ai.app/docs/getting-started/installation/";
30
35
  const LP_INSTALL_SH = "curl -fsSL https://pkg.lightpanda.io/install.sh | bash";
31
- const RULESYNC_SCHEMA = "rulesync schema doctor: npm run rulesync:doctor";
32
- const REQUIRED = ["node", "npm", "git", "rtk", "rulesync", "lightpanda", "chromium"];
36
+ const REQUIRED = ["node", "git", "rtk", "rulesync", "lightpanda", "chromium"];
33
37
  export const REQUIRED_MAP_FILES = [
34
38
  { name: "INDEX.md", rel: ".rulesync/reference/INDEX.md" },
35
39
  { name: "skills-map.md", rel: ".rulesync/reference/skills-map.md" },
@@ -127,7 +131,7 @@ function lookupChromium(pathEnv, platform) {
127
131
 
128
132
  export function installHints(id, platform = process.platform) {
129
133
  if (id === "all") {
130
- const keys = ["node", "npm", "git", "rtk", "rulesync", "lightpanda", "chromium"];
134
+ const keys = ["node", "package-manager", "git", "rtk", "rulesync", "lightpanda", "chromium"];
131
135
  return keys.flatMap((k) => installHints(k, platform));
132
136
  }
133
137
  if (id === "node") {
@@ -139,6 +143,22 @@ export function installHints(id, platform = process.platform) {
139
143
  if (id === "npm") {
140
144
  return ["npm ships with Node ≥22", NODE_URL];
141
145
  }
146
+ if (id === "yarn") {
147
+ return ["corepack enable", "npm i -g yarn", "https://yarnpkg.com/getting-started/install"];
148
+ }
149
+ if (id === "pnpm") {
150
+ return ["corepack enable", "npm i -g pnpm", "https://pnpm.io/installation"];
151
+ }
152
+ if (id === "bun") {
153
+ return ["curl -fsSL https://bun.sh/install | bash", "https://bun.sh"];
154
+ }
155
+ if (id === "package-manager") {
156
+ return [
157
+ "npm ships with Node ≥22",
158
+ "corepack enable (yarn / pnpm)",
159
+ "curl -fsSL https://bun.sh/install | bash",
160
+ ];
161
+ }
142
162
  if (id === "git") {
143
163
  if (platform === "win32") return ["winget install Git.Git"];
144
164
  if (platform === "darwin") return ["brew install git"];
@@ -151,7 +171,13 @@ export function installHints(id, platform = process.platform) {
151
171
  return [RTK_CURL, "brew install rtk"];
152
172
  }
153
173
  if (id === "rulesync") {
154
- return ["npm i -D rulesync", "npx"];
174
+ return [
175
+ "npm i -D rulesync",
176
+ "yarn add -D rulesync",
177
+ "pnpm add -D rulesync",
178
+ "bun add -D rulesync",
179
+ "npx",
180
+ ];
155
181
  }
156
182
  if (id === "lightpanda") {
157
183
  if (platform === "win32") {
@@ -296,7 +322,17 @@ export function runDoctor({
296
322
  const major = nodeMajor(ver);
297
323
  const nodeOk = Boolean(nodeBin) && major >= 22;
298
324
 
299
- const npm = whichBin("npm", pathEnv, platform);
325
+ const pmBins = Object.fromEntries(
326
+ PACKAGE_MANAGERS.map((name) => [name, whichBin(name, pathEnv, platform)]),
327
+ );
328
+ const detectedPm = detectPackageManager({ cwd, env });
329
+ const wantedPm = detectedPm.manager
330
+ ? [detectedPm.manager]
331
+ : detectedPm.candidates?.length
332
+ ? detectedPm.candidates
333
+ : PACKAGE_MANAGERS;
334
+ const presentPm = wantedPm.filter((name) => pmBins[name]);
335
+ const pmOk = presentPm.length > 0;
300
336
  const git = whichBin("git", pathEnv, platform);
301
337
  const rtk = whichBin("rtk", nmPath, platform);
302
338
  const rulesync = whichBin("rulesync", nmPath, platform);
@@ -310,7 +346,6 @@ export function runDoctor({
310
346
 
311
347
  const found = {
312
348
  node: nodeOk ? nodeBin : "",
313
- npm,
314
349
  git,
315
350
  rtk,
316
351
  rulesync,
@@ -326,7 +361,17 @@ export function runDoctor({
326
361
  } else {
327
362
  lines.push(row("node", "missing"));
328
363
  }
329
- lines.push(npm ? row("npm", "ok", npm) : row("npm", "missing"));
364
+ if (pmOk) {
365
+ lines.push(
366
+ row(
367
+ "package-manager",
368
+ "ok",
369
+ presentPm.map((name) => `${name} (${pmBins[name]})`).join(", "),
370
+ ),
371
+ );
372
+ } else {
373
+ lines.push(row("package-manager", "missing", wantedPm.join(" | ")));
374
+ }
330
375
  lines.push(git ? row("git", "ok", git) : row("git", "missing"));
331
376
  lines.push(rtk ? row("rtk", "ok", rtk) : row("rtk", "missing"));
332
377
  lines.push(rulesync ? row("rulesync", "ok", rulesync) : row("rulesync", "missing"));
@@ -348,15 +393,20 @@ export function runDoctor({
348
393
  lines.push(speak.ok ? row("speak", "ok", speak.extra) : row("speak", "missing (optional)"));
349
394
 
350
395
  const missingRequired = REQUIRED.filter((k) => (k === "node" ? !nodeOk : !found[k]));
396
+ if (!pmOk) missingRequired.push("package-manager");
351
397
  if (missingRequired.length) {
352
398
  lines.push("");
353
399
  lines.push("install (print-only; not run):");
354
400
  const seen = new Set();
355
401
  for (const k of missingRequired) {
356
- for (const h of installHints(k, platform)) {
357
- if (seen.has(h)) continue;
358
- seen.add(h);
359
- lines.push(h);
402
+ const hintIds =
403
+ k === "package-manager" && wantedPm.length === 1 ? wantedPm : [k];
404
+ for (const id of hintIds) {
405
+ for (const h of installHints(id, platform)) {
406
+ if (seen.has(h)) continue;
407
+ seen.add(h);
408
+ lines.push(h);
409
+ }
360
410
  }
361
411
  }
362
412
  }
@@ -389,7 +439,7 @@ export function runDoctor({
389
439
  }
390
440
 
391
441
  lines.push("");
392
- lines.push(RULESYNC_SCHEMA);
442
+ lines.push(`rulesync schema doctor: ${runScriptLine(detectedPm.manager, "rulesync:doctor")}`);
393
443
  const stdout = `${lines.join("\n")}\n`;
394
444
  return { code: missingRequired.length || missingMaps.length ? 1 : 0, stdout, stderr: "" };
395
445
  }
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Code-only folder map for agents. Folders, never files.
4
+ *
5
+ * Source: git ls-files (tracked + untracked, gitignore applied). A folder is
6
+ * shown when it or a descendant holds a code file and no segment is skipped.
7
+ * Package roots (package.json, go.mod, Cargo.toml, pyproject.toml) carry
8
+ * `# name path`. Single-child chains collapse; plain leaf dirs fold at foldAt.
9
+ *
10
+ * node scripts/folder-map.mjs [dir]
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import { stripJsonc } from "./emit-mcp-policy.mjs";
17
+
18
+ export const DEFAULTS = Object.freeze({
19
+ budget: 5000,
20
+ depthCap: 6,
21
+ foldAt: 12,
22
+ codeExt: ["ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "sql", "vue", "svelte", "astro"],
23
+ skip: [
24
+ "_gen", "gen", "generated", "dist", "build", "assets", "static", "public",
25
+ "fixtures", "__fixtures__", "__snapshots__", "__mocks__", "testdata",
26
+ "vendor", "migrations", "locales", "i18n",
27
+ ],
28
+ include: [],
29
+ exclude: [],
30
+ });
31
+
32
+ export const CONFIG_REL = ".rulesync/grunt.map.jsonc";
33
+ const MANIFESTS = ["package.json", "go.mod", "Cargo.toml", "pyproject.toml"];
34
+
35
+ /** DEFAULTS merged with known keys from CONFIG_REL; bad file → defaults + warn. */
36
+ export function loadMapConfig(root, warn = (m) => process.stderr.write(`${m}\n`)) {
37
+ const abs = path.join(root, CONFIG_REL);
38
+ if (!fs.existsSync(abs)) return { ...DEFAULTS };
39
+ let raw;
40
+ try {
41
+ raw = JSON.parse(stripJsonc(fs.readFileSync(abs, "utf8")));
42
+ } catch (err) {
43
+ warn(`grunt map: ignoring ${CONFIG_REL}: ${err.message}`);
44
+ return { ...DEFAULTS };
45
+ }
46
+ const out = { ...DEFAULTS };
47
+ for (const [k, v] of Object.entries(raw || {})) {
48
+ if (!(k in DEFAULTS)) continue;
49
+ const ok = Array.isArray(DEFAULTS[k])
50
+ ? Array.isArray(v) && v.every((x) => typeof x === "string")
51
+ : Number.isFinite(v) && v > 0;
52
+ if (ok) out[k] = v;
53
+ else warn(`grunt map: ignoring ${CONFIG_REL} key ${k}`);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function git(args, cwd) {
59
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
60
+ return r.status === 0 ? r.stdout : null;
61
+ }
62
+
63
+ /** Repo top and cwd-relative prefix, or null outside git. */
64
+ export function repoRoot(cwd) {
65
+ const top = git(["rev-parse", "--show-toplevel"], cwd);
66
+ return top ? top.trim() : null;
67
+ }
68
+
69
+ /** Tracked + untracked-unignored files, repo-relative, posix. */
70
+ export function listFiles(root) {
71
+ const out = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root);
72
+ if (out == null) return null;
73
+ return [...new Set(out.split("\0").filter(Boolean))];
74
+ }
75
+
76
+ /** `*` = one segment, `**` = any depth; matches the dir and everything under it. */
77
+ function globRe(glob) {
78
+ const body = String(glob)
79
+ .replace(/\/+$/, "")
80
+ .split("**")
81
+ .map((part) => part.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"))
82
+ .join(".*");
83
+ return new RegExp(`^${body}(/|$)`);
84
+ }
85
+
86
+ function packageName(root, dir, manifest) {
87
+ try {
88
+ const text = fs.readFileSync(path.join(root, dir, manifest), "utf8");
89
+ if (manifest === "package.json") return JSON.parse(text).name || "";
90
+ if (manifest === "go.mod") return (text.match(/^module\s+(\S+)/m) || [])[1] || "";
91
+ return (text.match(/^name\s*=\s*"([^"]+)"/m) || [])[1] || "";
92
+ } catch {
93
+ return "";
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Raw tree from file paths. Node: { name, path, kids: Map, own, pkg }.
99
+ * `own` = holds a kept file directly. `pkg` is the package name ("" when
100
+ * unnamed) on package roots, else null.
101
+ */
102
+ export function buildTree(root, files, opts = {}) {
103
+ const o = { ...DEFAULTS, ...opts };
104
+ const code = new RegExp(`\\.(${o.codeExt.join("|")})$`);
105
+ const skip = new Set(o.skip);
106
+ const include = o.include.map(globRe);
107
+ const exclude = o.exclude.map(globRe);
108
+ const manifests = new Map();
109
+ for (const f of files) {
110
+ const base = path.posix.basename(f);
111
+ if (MANIFESTS.includes(base) && !manifests.has(path.posix.dirname(f))) {
112
+ manifests.set(path.posix.dirname(f), base);
113
+ }
114
+ }
115
+ const top = { name: "", path: "", kids: new Map(), own: false, pkg: null };
116
+ for (const f of files) {
117
+ const dir = path.posix.dirname(f);
118
+ if (dir === ".") continue;
119
+ if (exclude.some((re) => re.test(dir))) continue;
120
+ const forced = include.some((re) => re.test(dir));
121
+ const segs = dir.split("/");
122
+ if (!forced) {
123
+ if (!code.test(f)) continue;
124
+ if (segs.some((s) => s.startsWith(".") || skip.has(s))) continue;
125
+ }
126
+ let node = top;
127
+ for (const s of segs) {
128
+ let next = node.kids.get(s);
129
+ if (!next) {
130
+ const p = node.path ? `${node.path}/${s}` : s;
131
+ const m = manifests.get(p);
132
+ next = { name: s, path: p, kids: new Map(), own: false, pkg: m ? packageName(root, p, m) : null };
133
+ node.kids.set(s, next);
134
+ }
135
+ node = next;
136
+ }
137
+ node.own = true;
138
+ }
139
+ return top;
140
+ }
141
+
142
+ /**
143
+ * Display tree: chains collapsed, leaf dirs folded.
144
+ * Row: { kind: "dir"|"fold", label, path, pkg, kids: Row[] }.
145
+ */
146
+ export function displayRows(node, opts = {}) {
147
+ const o = { ...DEFAULTS, ...opts };
148
+ const names = [...node.kids.keys()].sort();
149
+ const leaves = names.filter((n) => {
150
+ const k = node.kids.get(n);
151
+ return k.kids.size === 0 && k.pkg == null;
152
+ });
153
+ const folded = leaves.length >= o.foldAt ? new Set(leaves) : new Set();
154
+ const rows = [];
155
+ if (folded.size) {
156
+ rows.push({ kind: "fold", label: leaves, path: node.path, pkg: null, kids: [] });
157
+ }
158
+ for (const n of names) {
159
+ if (folded.has(n)) continue;
160
+ let k = node.kids.get(n);
161
+ let label = n;
162
+ // Collapse pass-through dirs only; a dir with its own code stays a row.
163
+ while (k.kids.size === 1 && k.pkg == null && !k.own) {
164
+ const [only] = k.kids.values();
165
+ label = `${label}/${only.name}`;
166
+ k = only;
167
+ }
168
+ rows.push({ kind: "dir", label, path: k.path, pkg: k.pkg, kids: displayRows(k, o) });
169
+ }
170
+ return rows;
171
+ }
172
+
173
+ export function rowText(row, depth, { more = false, base = "" } = {}) {
174
+ const pad = " ".repeat(depth);
175
+ if (row.kind === "fold") {
176
+ const shown = row.label.slice(0, 6).join(", ");
177
+ return `${pad}(${row.label.length} leaf dirs: ${shown}${row.label.length > 6 ? ", …" : ""})`;
178
+ }
179
+ const full = base ? `${base}/${row.path}` : row.path;
180
+ let line = `${pad}${row.label}/`;
181
+ if (row.pkg != null) line += row.pkg ? ` # ${row.pkg} ${full}` : ` # ${full}`;
182
+ if (more) line += " …";
183
+ return line;
184
+ }
185
+
186
+ export function tokens(text) {
187
+ return Math.ceil(text.length / 4);
188
+ }
189
+
190
+ /**
191
+ * Pick rows under the budget, breadth-first by display level.
192
+ *
193
+ * Level 0 always shows. Each deeper level is one unit per top-level subtree:
194
+ * a subtree's rows at that level show together or not at all, so a parent's
195
+ * kids are never half-listed. Cost is exact in chars: a row with kids is
196
+ * charged its `…` marker until its kids land. When a level does
197
+ * not fit, cheapest subtrees take an equal share first; a subtree that misses
198
+ * its share stops expanding. Rows past depthCap (from the nearest package
199
+ * root) never expand.
200
+ */
201
+ function selectRows(rows, o, base) {
202
+ const levels = [];
203
+ const visit = (list, depth, top, pkgDepth) => {
204
+ for (const row of list) {
205
+ const rel = row.pkg != null ? 0 : pkgDepth;
206
+ row.top = top ?? row;
207
+ row.capped = row.kids.length > 0 && rel >= o.depthCap;
208
+ row.shown = false;
209
+ (levels[depth] ||= []).push(row);
210
+ if (!row.capped) visit(row.kids, depth + 1, row.top, rel + 1);
211
+ }
212
+ };
213
+ visit(rows, 0, null, 1);
214
+ const cost = (row, depth, more) => rowText(row, depth, { more, base }).length + 1;
215
+ const pointer = (row, depth) => cost(row, depth, true) - cost(row, depth, false);
216
+ const charge = (row, depth) => cost(row, depth, row.kids.length > 0);
217
+
218
+ let used = 0;
219
+ for (const row of levels[0] || []) {
220
+ row.shown = true;
221
+ used += charge(row, 0);
222
+ }
223
+ const limit = o.budget * 4;
224
+ let active = new Set(levels[0] || []);
225
+ for (let d = 1; d < levels.length && active.size; d++) {
226
+ const units = new Map();
227
+ for (const row of levels[d]) {
228
+ if (!active.has(row.top)) continue;
229
+ const u = units.get(row.top) || { rows: [], parents: new Set(), cost: 0 };
230
+ u.rows.push(row);
231
+ u.cost += charge(row, d);
232
+ units.set(row.top, u);
233
+ }
234
+ for (const row of levels[d - 1]) {
235
+ const u = units.get(row.top);
236
+ if (u && row.shown && row.kids.length && !row.capped) u.cost -= pointer(row, d - 1);
237
+ }
238
+ const next = new Set();
239
+ let left = limit - used;
240
+ const order = [...units.entries()].sort((a, b) => a[1].cost - b[1].cost);
241
+ order.forEach(([top, u], i) => {
242
+ const share = left / (order.length - i);
243
+ if (u.cost > share) return;
244
+ for (const row of u.rows) row.shown = true;
245
+ left -= u.cost;
246
+ used += u.cost;
247
+ next.add(top);
248
+ });
249
+ active = next;
250
+ }
251
+ const lines = [];
252
+ const walk = (list, depth) => {
253
+ for (const row of list) {
254
+ if (!row.shown) continue;
255
+ const open = row.kids.length > 0 && row.kids.some((k) => k.shown);
256
+ lines.push(rowText(row, depth, { more: row.kids.length > 0 && !open, base }));
257
+ if (open) walk(row.kids, depth + 1);
258
+ }
259
+ };
260
+ walk(rows, 0);
261
+ return lines;
262
+ }
263
+
264
+ /**
265
+ * Map text for `root` (any dir inside a git repo), scoped to `dir` when set.
266
+ * Returns { text, rows, tokens } or null outside git.
267
+ */
268
+ export function folderMap({ root, dir = "", ...opts } = {}) {
269
+ const top = repoRoot(root);
270
+ if (!top) return null;
271
+ const files = listFiles(top);
272
+ if (!files) return null;
273
+ const o = { ...loadMapConfig(top), ...opts };
274
+ const scope = dir ? path.relative(top, path.resolve(root, dir)).split(path.sep).join("/") : "";
275
+ const scoped = scope
276
+ ? files.filter((f) => f.startsWith(`${scope}/`)).map((f) => f.slice(scope.length + 1))
277
+ : files;
278
+ const tree = buildTree(scope ? path.join(top, scope) : top, scoped, o);
279
+ const lines = selectRows(displayRows(tree, o), o, scope);
280
+ const text = lines.length ? `${lines.join("\n")}\n` : "";
281
+ return { text, rows: lines.length, tokens: tokens(text) };
282
+ }
283
+
284
+ /** Text for `grunt map [dir]` / `node scripts/folder-map.mjs [dir]` run from `cwd`. */
285
+ export function mapCommand(cwd, dir = "") {
286
+ const out = folderMap({ root: cwd, dir });
287
+ if (!out) return "grunt map: not a git repository\n";
288
+ return out.text || "grunt map: no code folders\n";
289
+ }
290
+
291
+ function main() {
292
+ process.stdout.write(mapCommand(process.cwd(), process.argv[2] || ""));
293
+ return 0;
294
+ }
295
+
296
+ const thisFile = fileURLToPath(import.meta.url);
297
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
298
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
299
+ process.exit(main());
300
+ }
@@ -190,9 +190,10 @@ export function pathIsDenied(p) {
190
190
  return n.split("/").some((seg) => DENY_PATH_SEGMENTS.has(seg));
191
191
  }
192
192
 
193
+ /** Top-level "block" is the only legacy deny Claude/Codex validate; allow = empty stdout. */
193
194
  export function denyResponse(reason) {
194
195
  return {
195
- decision: "deny",
196
+ decision: "block",
196
197
  reason,
197
198
  hookSpecificOutput: {
198
199
  hookEventName: "PreToolUse",
@@ -341,8 +342,9 @@ export function rewriteScratchWrite(filePath, workspaceRoot) {
341
342
  return null;
342
343
  }
343
344
 
344
- function quoteQuery(q) {
345
- return `"${String(q).replace(/"/g, '\\"')}"`;
345
+ /** POSIX single-quote so `, $, and \ stay literal. */
346
+ export function quoteQuery(q) {
347
+ return `'${String(q).replace(/'/g, "'\\''")}'`;
346
348
  }
347
349
 
348
350
  export function gruntJobCommand(workspaceRoot, args) {
@@ -702,13 +704,15 @@ export function processFatTools(data) {
702
704
  }
703
705
  return { type: "deny", reason: REASON_DENYLIST };
704
706
  }
705
- if (BASH_DUMP_CMDS.test(cmd) && !alreadyRtk(cmd)) {
707
+ // grunt-job and rtk take one plain command; compound shell passes through.
708
+ const compound = SHELL_META.test(cmd);
709
+ if (BASH_DUMP_CMDS.test(cmd) && !alreadyRtk(cmd) && !compound) {
706
710
  const job = BASH_SEARCH.test(cmd) ? "search" : "exec";
707
711
  const q = cmd.replace(/^\s*(rtk\s+)?/, "").trim();
708
712
  next.command = gruntJobCommand(ws, `--job ${job} --query ${quoteQuery(q)}`);
709
713
  return { type: "rewrite", updatedInput: next };
710
714
  }
711
- if (BASH_TREE.test(cmd) && !alreadyRtk(cmd)) {
715
+ if (BASH_TREE.test(cmd) && !alreadyRtk(cmd) && !compound) {
712
716
  next.command = `rtk ${cmd.trim()}`;
713
717
  return { type: "rewrite", updatedInput: next };
714
718
  }
@@ -6,6 +6,8 @@
6
6
  * Claude/Codex native event is UserPromptSubmit (mapped from SoT beforeSubmitPrompt).
7
7
  * Antigravity has neither beforeSubmitPrompt nor subagentStop.
8
8
  * Grok SessionStart stays empty on orchestrate-parent.json.
9
+ * Grunt SessionStart: session-map.mjs (folder map) when shipped, first in the group;
10
+ * Claude/Codex only (rulesync emits no Antigravity sessionStart).
9
11
  *
10
12
  * Consumer extras (if those scripts exist): SessionStart, check-behind,
11
13
  * validate+sim. Path deny from consumer `.rulesync/permissions.json`.
@@ -30,6 +32,9 @@ export const SCRUB =
30
32
  'node "${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/scrub-spawn-prompt.mjs"';
31
33
  export const GATE_FAT =
32
34
  'node "${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/gate-fat-tools.mjs"';
35
+ export const MAP =
36
+ 'node "${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/session-map.mjs"';
37
+ const MAP_NEEDLE = "session-map.mjs";
33
38
 
34
39
  export const AUTORUN_NEEDLES = [
35
40
  "daily-pull-check.mjs",
@@ -173,8 +178,14 @@ function desiredPre(root) {
173
178
  return pre;
174
179
  }
175
180
 
176
- function desiredSession(root) {
181
+ function mapNeedles(root) {
182
+ return hasScript(root, MAP_NEEDLE) ? [MAP_NEEDLE] : [];
183
+ }
184
+
185
+ /** `map: false` for hosts rulesync emits no sessionStart for (Antigravity). */
186
+ function desiredSession(root, { map = true } = {}) {
177
187
  const hooks = [];
188
+ if (map && hasScript(root, MAP_NEEDLE)) hooks.push(hookCmd(MAP, 5));
178
189
  if (hasScript(root, "daily-pull-check.mjs")) hooks.push(hookCmd(PULL));
179
190
  if (hasScript(root, "rtk-check.mjs")) hooks.push(hookCmd(RTK));
180
191
  if (hasScript(root, "daily-fleet-check.mjs")) hooks.push(hookCmd(FLEET));
@@ -264,7 +275,7 @@ function applyAgents(root) {
264
275
  const live = loadJson(abs);
265
276
  const rulesync = isPlainObject(live.rulesync) ? { ...live.rulesync } : {};
266
277
  rulesync.PreToolUse = upsertGroups(rulesync.PreToolUse, desiredPre(root));
267
- const session = desiredSession(root);
278
+ const session = desiredSession(root, { map: false });
268
279
  if (session) {
269
280
  rulesync.SessionStart = [{ matcher: ".*", hooks: session[0].hooks }];
270
281
  }
@@ -320,12 +331,12 @@ export function checkUnion(opts = {}) {
320
331
  {
321
332
  id: "claude",
322
333
  path: ".claude/settings.json",
323
- needles: [...GRUNT_NEEDLES, ...autorun, ...loadConsumerDeny(root)],
334
+ needles: [...GRUNT_NEEDLES, ...mapNeedles(root), ...autorun, ...loadConsumerDeny(root)],
324
335
  },
325
336
  {
326
337
  id: "codex",
327
338
  path: ".codex/hooks.json",
328
- needles: [...GRUNT_NEEDLES, ...autorun],
339
+ needles: [...GRUNT_NEEDLES, ...mapNeedles(root), ...autorun],
329
340
  },
330
341
  {
331
342
  id: "agents",