@lovrozagar/grunt 0.7.0 → 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.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: su
3
+ description: 'Short status update: done, next, and blockers or on-track ETA.'
4
+ ---
5
+ This reply only: a status update in two to four short lines. No process narration.
6
+
7
+ - Done: what is finished. Flag anything unverified.
8
+ - Next: the immediate next step.
9
+ - Health: if stuck, blocked, broken, or slipping, say so plainly. Otherwise "On track" with a rough ETA (minutes, ~1h, end of session).
10
+
11
+ Never soften bad news or claim progress not made.
@@ -73,6 +73,17 @@
73
73
  }
74
74
  ]
75
75
  }
76
+ ],
77
+ "SessionStart": [
78
+ {
79
+ "hooks": [
80
+ {
81
+ "type": "command",
82
+ "command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/session-map.mjs\"",
83
+ "timeout": 5
84
+ }
85
+ ]
86
+ }
76
87
  ]
77
88
  },
78
89
  "enableAllProjectMcpServers": false,
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: su
3
+ description: 'Short status update: done, next, and blockers or on-track ETA.'
4
+ ---
5
+ This reply only: a status update in two to four short lines. No process narration.
6
+
7
+ - Done: what is finished. Flag anything unverified.
8
+ - Next: the immediate next step.
9
+ - Health: if stuck, blocked, broken, or slipping, say so plainly. Otherwise "On track" with a rough ETA (minutes, ~1h, end of session).
10
+
11
+ Never soften bad news or claim progress not made.
package/.codex/hooks.json CHANGED
@@ -64,6 +64,17 @@
64
64
  }
65
65
  ]
66
66
  }
67
+ ],
68
+ "SessionStart": [
69
+ {
70
+ "hooks": [
71
+ {
72
+ "type": "command",
73
+ "command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/session-map.mjs\"",
74
+ "timeout": 5
75
+ }
76
+ ]
77
+ }
67
78
  ]
68
79
  }
69
80
  }
@@ -87,7 +87,6 @@ function preToolUse(data) {
87
87
  });
88
88
  return 0;
89
89
  }
90
- emit({ decision: "allow" });
91
90
  return 0;
92
91
  }
93
92
  if (WRITE_TOOLS.has(toolKey)) {
@@ -96,7 +95,6 @@ function preToolUse(data) {
96
95
  }
97
96
  const fatCode = emitFat(data);
98
97
  if (fatCode !== null) return fatCode;
99
- emit({ decision: "allow" });
100
98
  return 0;
101
99
  }
102
100
 
@@ -162,7 +160,7 @@ export function isUnderTmp(filePath, workspaceRoot) {
162
160
 
163
161
  function parentWrite(data, toolInput) {
164
162
  if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
165
- emit({ decision: "deny", reason: DENY_REASON });
163
+ emit(denyResponse(DENY_REASON));
166
164
  return 0;
167
165
  }
168
166
  const ws = workspaceRootOf(data);
@@ -200,7 +198,7 @@ function parentWrite(data, toolInput) {
200
198
  if (!isFullImplementationBody(content)) return null;
201
199
  const errors = validateImplementation(path.basename(abs), content);
202
200
  if (errors.length) {
203
- emit({ decision: "deny", reason: errors.join("; ") || invalid });
201
+ emit(denyResponse(errors.join("; ") || invalid));
204
202
  return 0;
205
203
  }
206
204
  const next = Object.assign({}, toolInput, {
@@ -224,10 +222,7 @@ function parentWrite(data, toolInput) {
224
222
  const content = typeof toolInput.content === "string" ? toolInput.content : "";
225
223
  const result = persist({ workspaceRoot: ws, content });
226
224
  if (!result.ok) {
227
- emit({
228
- decision: "deny",
229
- reason: result.error || invalid,
230
- });
225
+ emit(denyResponse(result.error || invalid));
231
226
  return 0;
232
227
  }
233
228
  const next = Object.assign({}, toolInput, {
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: su
3
+ description: 'Short status update: done, next, and blockers or on-track ETA.'
4
+ ---
5
+ This reply only: a status update in two to four short lines. No process narration.
6
+
7
+ - Done: what is finished. Flag anything unverified.
8
+ - Next: the immediate next step.
9
+ - Health: if stuck, blocked, broken, or slipping, say so plainly. Otherwise "On track" with a rough ETA (minutes, ~1h, end of session).
10
+
11
+ Never soften bad news or claim progress not made.
@@ -25,6 +25,14 @@
25
25
  "timeout": 5
26
26
  }
27
27
  ],
28
+ "sessionStart": [
29
+ {
30
+ // Code-only folder map as additionalContext (hooks-union keeps it first).
31
+ "type": "command",
32
+ "command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/session-map.mjs\"",
33
+ "timeout": 5
34
+ }
35
+ ],
28
36
  "beforeSubmitPrompt": [
29
37
  {
30
38
  "type": "command",
@@ -6,7 +6,7 @@ Protocol stays overview; domain fills this.
6
6
 
7
7
  ## Skills naming
8
8
 
9
- Reserved (do not reuse in consumer custom skills): `ask` `auto` `browser` `clasp` `commit` `commit-and-push` `commit-push` `commit-push-deploy` `commit-push-release` `explain` `google-workspace` `handoff` `implement-plan` `listen` `pickup` `speak` `tmp` `write-plan`.
9
+ Reserved (do not reuse in consumer custom skills): `ask` `auto` `browser` `clasp` `commit` `commit-and-push` `commit-push` `commit-push-deploy` `commit-push-release` `explain` `google-workspace` `handoff` `implement-plan` `listen` `pickup` `speak` `su` `tmp` `write-plan`.
10
10
 
11
11
  Override: same name → one SSOT path (`.rulesync/skills/<name>/`). Re-init / `grunt upgrade` force-refresh overwrites grunt-owned names; consumer extras kept. Maps `origin` badge ≠ content picker.
12
12
 
@@ -32,6 +32,7 @@ Upgrade copies the current package trees and product scripts, then deletes cumul
32
32
  | listen | local | Speech-to-text via node scripts/listen.mjs (ffmpeg mic + local whisper.cpp, OpenAI fallback). Input only. | | | |
33
33
  | pickup | local | Continue a handoff under .tmp/grunt/handoffs/. | | | |
34
34
  | speak | local | Text-to-speech via node scripts/speak.mjs (ElevenLabs or OpenAI). Output only. | | | |
35
+ | su | local | Short status update: done, next, and blockers or on-track ETA. | | | |
35
36
  | tmp | local | Dump a one-off convo artifact under .tmp/grunt/. | | | |
36
37
  | write-plan | local | Write a local checklist under .tmp/grunt/plans/. Then /implement-plan {n}. | | | |
37
38
 
@@ -43,7 +44,7 @@ Upgrade copies the current package trees and product scripts, then deletes cumul
43
44
  | .rulesync/reference/cascade.md | Protocol | cascade | The session agent has tools. Fat Read/Grep/Bash dumps are rewritten to `scripts/grunt-job.mjs` (squeez + stash). Isolat… |
44
45
  | .rulesync/reference/clasp.md | Clasp | clasp | Low-level Apps Script CLI. Optional. Doctor reports it. |
45
46
  | .rulesync/reference/google-workspace.md | google-workspace | google-workspace | Create and edit Google Sheets, Docs, Slides, Calendar events, and Gmail from the session. |
46
- | .rulesync/reference/hooks.md | Hooks | hooks | SessionStart: keep empty (token baseline). Do not inject context. Do not register a SessionStart hook on `../../.grok/h… |
47
+ | .rulesync/reference/hooks.md | Hooks | hooks | SessionStart carries only the folder map: Claude / Codex run `../../scripts/session-map.mjs`, which injects the code-on… |
47
48
  | .rulesync/reference/implementation-format.md | Implementation format (SSOT) | | Local session journal for `/implement-plan`. Not a plan, handoff, or git commit. |
48
49
  | .rulesync/reference/law.md | Law | | Protocol stays overview; domain fills this. |
49
50
  | .rulesync/reference/listen.md | Listen | listen | Speech-to-text from the session. Input only. Not TTS. Not a live voice agent. Not MCP. |
@@ -4,7 +4,7 @@ tags: [hooks]
4
4
 
5
5
  # Hooks
6
6
 
7
- SessionStart: keep empty (token baseline). Do not inject context. Do not register a SessionStart hook on `../../.grok/hooks/orchestrate-parent.json`.
7
+ SessionStart carries only the folder map: Claude / Codex run `../../scripts/session-map.mjs`, which injects the code-only map from `../../scripts/folder-map.mjs` as `additionalContext` (budget 5k tokens, cache-stable, fail-open) and logs `{rows, tokens}` to `.tmp/grunt/sessions/<sid>/map.json`. Inject nothing else at SessionStart. Antigravity has no rulesync sessionStart. Do not register a SessionStart hook on `../../.grok/hooks/orchestrate-parent.json`.
8
8
 
9
9
  Grok PostToolUse / SessionStart are observe-only — do not attempt output scrub; compress via RTK / spawn / isolation facts only. Do not add PostToolUse scrub hooks. Grok UserPromptSubmit may emit a one-line `sessionGate=auto|ask` plus session receipt (`hookSpecificOutput.additionalContext`). Skip host Stop banners. `/ask` exact-match slash stamps `session-gate-{sid}` to `ask`. `/auto` unlinks. Sid-less: no stamp. Default: `auto`.
10
10
 
@@ -4,7 +4,7 @@ Protocol stays overview; domain fills this.
4
4
 
5
5
  ## Skills naming
6
6
 
7
- Reserved (do not reuse in consumer custom skills): `ask` `auto` `browser` `clasp` `commit` `commit-and-push` `commit-push` `commit-push-deploy` `commit-push-release` `explain` `google-workspace` `handoff` `implement-plan` `listen` `pickup` `speak` `tmp` `write-plan`.
7
+ Reserved (do not reuse in consumer custom skills): `ask` `auto` `browser` `clasp` `commit` `commit-and-push` `commit-push` `commit-push-deploy` `commit-push-release` `explain` `google-workspace` `handoff` `implement-plan` `listen` `pickup` `speak` `su` `tmp` `write-plan`.
8
8
 
9
9
  Override: same name → one SSOT path (`.rulesync/skills/<name>/`). Re-init / `grunt upgrade` force-refresh overwrites grunt-owned names; consumer extras kept. Maps `origin` badge ≠ content picker.
10
10
 
@@ -8,7 +8,7 @@
8
8
  | .rulesync/reference/cascade.md | Protocol | cascade | The session agent has tools. Fat Read/Grep/Bash dumps are rewritten to `scripts/grunt-job.mjs` (squeez + stash). Isolat… |
9
9
  | .rulesync/reference/clasp.md | Clasp | clasp | Low-level Apps Script CLI. Optional. Doctor reports it. |
10
10
  | .rulesync/reference/google-workspace.md | google-workspace | google-workspace | Create and edit Google Sheets, Docs, Slides, Calendar events, and Gmail from the session. |
11
- | .rulesync/reference/hooks.md | Hooks | hooks | SessionStart: keep empty (token baseline). Do not inject context. Do not register a SessionStart hook on `../../.grok/h… |
11
+ | .rulesync/reference/hooks.md | Hooks | hooks | SessionStart carries only the folder map: Claude / Codex run `../../scripts/session-map.mjs`, which injects the code-on… |
12
12
  | .rulesync/reference/implementation-format.md | Implementation format (SSOT) | | Local session journal for `/implement-plan`. Not a plan, handoff, or git commit. |
13
13
  | .rulesync/reference/law.md | Law | | Protocol stays overview; domain fills this. |
14
14
  | .rulesync/reference/listen.md | Listen | listen | Speech-to-text from the session. Input only. Not TTS. Not a live voice agent. Not MCP. |
@@ -20,5 +20,6 @@
20
20
  | listen | local | Speech-to-text via node scripts/listen.mjs (ffmpeg mic + local whisper.cpp, OpenAI fallback). Input only. | | | |
21
21
  | pickup | local | Continue a handoff under .tmp/grunt/handoffs/. | | | |
22
22
  | speak | local | Text-to-speech via node scripts/speak.mjs (ElevenLabs or OpenAI). Output only. | | | |
23
+ | su | local | Short status update: done, next, and blockers or on-track ETA. | | | |
23
24
  | tmp | local | Dump a one-off convo artifact under .tmp/grunt/. | | | |
24
25
  | write-plan | local | Write a local checklist under .tmp/grunt/plans/. Then /implement-plan {n}. | | | |
@@ -12,8 +12,10 @@ You are the session agent. Do the work. Use en-US unless asked. Write concise co
12
12
 
13
13
  Size first. Straight shot: do it. Else read `.rulesync/reference/scope.md`.
14
14
  Default `/auto`: keep going; ask on blockers. `/ask`: one step, then ask.
15
+ Ask before destructive git (stash, reset, checkout, restore, clean, rebase, push -f); others share the tree. In a loop, skip it and edit code instead.
15
16
 
16
17
  Read `.rulesync/reference/INDEX.md` once. When a row matches the work, read that reference in full.
18
+ Use the folder map in session context to find code. Run `node scripts/folder-map.mjs <dir>` for depth before `ls` or `find`.
17
19
 
18
20
  Large dumps are compressed (search, test logs, snaps). Scratch and dumps live in `.tmp/grunt/` (gitignored). After a write, recap the path; edit with an offset slice when you need lines.
19
21
  Fat dumps: `node scripts/grunt-job.mjs --job search|exec|slice|fetch|test` first; spawn grunt only when the dump needs judgment.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: su
3
+ description: "Short status update: done, next, and blockers or on-track ETA."
4
+ ---
5
+ This reply only: a status update in two to four short lines. No process narration.
6
+
7
+ - Done: what is finished. Flag anything unverified.
8
+ - Next: the immediate next step.
9
+ - Health: if stuck, blocked, broken, or slipping, say so plainly. Otherwise "On track" with a rough ETA (minutes, ~1h, end of session).
10
+
11
+ Never soften bad news or claim progress not made.
package/AGENTS.md CHANGED
@@ -3,8 +3,10 @@ You are the session agent. Do the work. Use en-US unless asked. Write concise co
3
3
 
4
4
  Size first. Straight shot: do it. Else read `.rulesync/reference/scope.md`.
5
5
  Default `/auto`: keep going; ask on blockers. `/ask`: one step, then ask.
6
+ Ask before destructive git (stash, reset, checkout, restore, clean, rebase, push -f); others share the tree. In a loop, skip it and edit code instead.
6
7
 
7
8
  Read `.rulesync/reference/INDEX.md` once. When a row matches the work, read that reference in full.
9
+ Use the folder map in session context to find code. Run `node scripts/folder-map.mjs <dir>` for depth before `ls` or `find`.
8
10
 
9
11
  Large dumps are compressed (search, test logs, snaps). Scratch and dumps live in `.tmp/grunt/` (gitignored). After a write, recap the path; edit with an offset slice when you need lines.
10
12
  Fat dumps: `node scripts/grunt-job.mjs --job search|exec|slice|fetch|test` first; spawn grunt only when the dump needs judgment.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-10-03
11
+
12
+ ### Added
13
+
14
+ - `grunt map [dir]` / `node scripts/folder-map.mjs [dir]`: code-only folder map (folders only, git-tracked, gitignore applied). Package rows show name and full path; pass-through chains collapse; 12+ leaf dirs fold; 5k-token breadth-first budget with fair share; depth cap counts from package roots. Optional `.rulesync/grunt.map.jsonc`
15
+ - Claude / Codex SessionStart hook `scripts/session-map.mjs` injects the folder map and logs `{rows, tokens}` per session
16
+ - `/su` skill: short status update (done, next, blockers or on-track ETA)
17
+ - Root rule: ask before destructive git; in a loop, skip it and edit code instead
18
+
19
+ ### Fixed
20
+
21
+ - PreToolUse hooks no longer print top-level `decision: "allow"` / `"deny"`, which Claude rejected on every tool call; allow is empty stdout and deny is `block` plus `permissionDecision: "deny"`, so denies are enforced
22
+ - Fat-tool gate no longer rewrites compound shell (`|`, `;`, `&&`, heredocs) into grunt-job, which never ran them; rewritten queries are single-quoted so backticks and `$` stay literal
23
+ - Re-init replaces a grunt-owned SessionStart group instead of duplicating it
24
+
10
25
  ## [0.7.0] - 2026-09-30
11
26
 
12
27
  ### Added
package/README.md CHANGED
@@ -85,6 +85,7 @@ Rulesync schema doctor is separate: `grunt:rulesync:doctor` (or SoT `rulesync:do
85
85
  - `doctor` → run `grunt:doctor` (`grunt doctor` / `node scripts/doctor.mjs` stay). Rulesync schema: `grunt:rulesync:doctor`
86
86
  - `setup` → run `grunt:setup` (`node scripts/setup.mjs`) — handheld speak / listen / google-workspace / browser. TTY walks each; flags for non-TTY keys/`--creds`
87
87
  - `upgrade` → same merge as init for an already-inited repo: copy owned trees/scripts, prune retired grunt-owned names, print reserved skill names
88
+ - `map [dir]` → code-only folder map (`scripts/folder-map.mjs`); no package manager needed. See [Folder map](#folder-map)
88
89
  - `help`
89
90
  - `version`
90
91
 
@@ -144,7 +145,7 @@ Lightpanda-first session CLI: `node scripts/browser.mjs`. Zero MCP. Zero env kno
144
145
 
145
146
  Present under `.claude` / `.rulesync` / `.agents` / `.grok` (`rulesync -f skills` mirrors SSOT):
146
147
 
147
- - `ask` `auto` `browser` `clasp` `commit` `commit-and-push` (1-release alias → `commit-push`) `commit-push` `commit-push-deploy` `commit-push-release` `explain` `google-workspace` `handoff` `implement-plan` `listen` `pickup` `speak` `tmp` `write-plan`
148
+ - `ask` `auto` `browser` `clasp` `commit` `commit-and-push` (1-release alias → `commit-push`) `commit-push` `commit-push-deploy` `commit-push-release` `explain` `google-workspace` `handoff` `implement-plan` `listen` `pickup` `speak` `su` `tmp` `write-plan`
148
149
 
149
150
  `/auto` (default) keeps going and asks on blockers. `/ask` finishes one step, recaps, then asks. `/auto` returns the session to auto.
150
151
 
@@ -176,6 +177,29 @@ Emit writes other-CLI trees from `.rulesync` for the **next** process of that CL
176
177
  - `sync-globals` / `purge-mcps`: dry-run default; `--apply` writes
177
178
  - Hosts: grok claude codex gemini antigravity
178
179
 
180
+ ### Folder map
181
+
182
+ Claude and Codex get a folder map at SessionStart (`scripts/session-map.mjs`), so agents skip `ls`/`find` loops. Agents run `node scripts/folder-map.mjs <dir>` (same as `grunt map <dir>`) for depth. Grok and Antigravity get the `AGENTS.md` line only.
183
+
184
+ - Folders only, never files. Source: `git ls-files --cached --others --exclude-standard`, so gitignored paths never show
185
+ - A folder shows when it or a descendant holds a code file (`codeExt`) and no path segment is a dot dir or in `skip`
186
+ - Package roots (`package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`) show `# name repo/relative/path`
187
+ - Pass-through single-child chains collapse (`a/b/c/`). 12+ plain leaf dirs under one parent fold into one row
188
+ - Budget (chars ÷ 4): breadth-first per top-level subtree; on overflow, cheapest subtrees first with an equal share; cut dirs end in `…` (the header names the command). Depth cap counts from the nearest package root
189
+ - Size per session: `.tmp/grunt/sessions/<sid>/map.json`
190
+
191
+ Optional `.rulesync/grunt.map.jsonc` (missing or invalid → defaults):
192
+
193
+ | key | default | |
194
+ |---|---|---|
195
+ | `budget` | `5000` | tokens |
196
+ | `depthCap` | `6` | levels below a package root |
197
+ | `foldAt` | `12` | leaf dirs before folding |
198
+ | `codeExt` | `ts tsx js jsx mjs cjs py go rs sql vue svelte astro` | |
199
+ | `skip` | `_gen gen generated dist build assets static public fixtures __fixtures__ __snapshots__ __mocks__ testdata vendor migrations locales i18n` | segment names |
200
+ | `include` | `[]` | globs always shown (`*` one segment, `**` any) |
201
+ | `exclude` | `[]` | globs never shown |
202
+
179
203
  ### Secrets (machine)
180
204
 
181
205
  Not git. Not `sync-globals`.
package/cli/grunt.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  isPackageManager,
13
13
  runScriptArgs,
14
14
  } from "../scripts/package-manager.mjs"
15
+ import { mapCommand } from "../scripts/folder-map.mjs"
15
16
 
16
17
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
17
18
 
@@ -28,6 +29,7 @@ Commands:
28
29
  doctor run grunt:doctor
29
30
  setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
30
31
  upgrade Re-init: copy owned files, prune retired grunt-owned names, print reserved skills
32
+ map [dir] Code-only folder map (git-tracked, no files); dir for depth
31
33
  help Show this help
32
34
  version Print package version
33
35
 
@@ -231,6 +233,10 @@ async function dispatch(cmd, flags, interactive, pm) {
231
233
  runScript(pm, toGruntScriptName("setup"), flags.args)
232
234
  return
233
235
  }
236
+ if (cmd === "map") {
237
+ process.stdout.write(mapCommand(process.cwd(), flags.args[0] || ""))
238
+ return
239
+ }
234
240
  if (cmd === "upgrade") {
235
241
  await runInit(process.cwd(), {
236
242
  skipGlobals: flags.skipGlobals,
package/cli/grunt.test.ts CHANGED
@@ -19,6 +19,7 @@ const spinner = vi.hoisted(() =>
19
19
  vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
20
20
  );
21
21
  const bailIfCancel = vi.hoisted(() => vi.fn((v) => v));
22
+ const mapCommand = vi.hoisted(() => vi.fn((_cwd: string, dir: string) => `map:${dir}\n`));
22
23
 
23
24
  vi.mock("./init.mjs", () => ({
24
25
  init,
@@ -29,6 +30,7 @@ vi.mock("./init.mjs", () => ({
29
30
  GRUNT_NPM_PREFIX: "grunt:",
30
31
  }));
31
32
  vi.mock("node:child_process", () => ({ execFileSync }));
33
+ vi.mock("../scripts/folder-map.mjs", () => ({ mapCommand }));
32
34
  vi.mock("../scripts/package-manager.mjs", async (importOriginal) => {
33
35
  const actual = await importOriginal<typeof import("../scripts/package-manager.mjs")>();
34
36
  actualDetect.fn = actual.detectPackageManager;
@@ -66,6 +68,7 @@ Commands:
66
68
  doctor run grunt:doctor
67
69
  setup run grunt:setup — handheld keys/OAuth (speak, listen, google-workspace, browser)
68
70
  upgrade Re-init: copy owned files, prune retired grunt-owned names, print reserved skills
71
+ map [dir] Code-only folder map (git-tracked, no files); dir for depth
69
72
  help Show this help
70
73
  version Print package version
71
74
 
@@ -321,6 +324,22 @@ describe("start", () => {
321
324
  expect(select).not.toHaveBeenCalled();
322
325
  });
323
326
 
327
+ it("map prints the folder map without a package manager", async () => {
328
+ process.argv = ["node", "grunt", "map"];
329
+ await start();
330
+ expect(mapCommand).toHaveBeenLastCalledWith(process.cwd(), "");
331
+ expect(chunks.join("")).toBe("map:\n");
332
+ expect(detectPackageManager).not.toHaveBeenCalled();
333
+ expect(execFileSync).not.toHaveBeenCalled();
334
+ });
335
+
336
+ it("map <dir> scopes the folder map", async () => {
337
+ process.argv = ["node", "grunt", "map", "packages/web"];
338
+ await start();
339
+ expect(mapCommand).toHaveBeenLastCalledWith(process.cwd(), "packages/web");
340
+ expect(chunks.join("")).toBe("map:packages/web\n");
341
+ });
342
+
324
343
  it("unknown writes usage and exitCode 1", async () => {
325
344
  process.argv = ["node", "grunt", "nope"];
326
345
  await start();
package/cli/init.mjs CHANGED
@@ -53,6 +53,7 @@ export const PRODUCT_SCRIPTS = [
53
53
  "guarded-md.mjs",
54
54
  "guarded-roots.mjs",
55
55
  "emit-mcp-policy.mjs",
56
+ "folder-map.mjs",
56
57
  "gate-fat-tools.mjs",
57
58
  "hooks-union.mjs",
58
59
  "pipeline.mjs",
@@ -66,6 +67,7 @@ export const PRODUCT_SCRIPTS = [
66
67
  "purge-global-mcps.mjs",
67
68
  "scrub-spawn-prompt.mjs",
68
69
  "scrub-text-lib.mjs",
70
+ "session-map.mjs",
69
71
  "sync-global-settings.mjs",
70
72
  "browser.mjs",
71
73
  "speak.mjs",
@@ -112,6 +114,7 @@ export const RESERVED_SKILLS = [
112
114
  "listen",
113
115
  "pickup",
114
116
  "speak",
117
+ "su",
115
118
  "tmp",
116
119
  "write-plan",
117
120
  ]
@@ -143,7 +146,12 @@ export const LAUNCH_SCRIPTS = {
143
146
  gemini: "gemini --yolo",
144
147
  grok: "grok --yolo",
145
148
  }
146
- const OWNED_HOOK_FILES = ["scrub-spawn-prompt.mjs", "gate-fat-tools.mjs", "orchestrate-parent.js"]
149
+ const OWNED_HOOK_FILES = [
150
+ "scrub-spawn-prompt.mjs",
151
+ "gate-fat-tools.mjs",
152
+ "orchestrate-parent.js",
153
+ "session-map.mjs",
154
+ ]
147
155
 
148
156
  function sortKeys(obj) {
149
157
  return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]))
package/cli/init.test.ts CHANGED
@@ -82,6 +82,17 @@ const SRC_CLAUDE_SETTINGS = {
82
82
  deny: ["Agent(Explore)", "Agent(orchestrator)", "mcp__*"],
83
83
  },
84
84
  hooks: {
85
+ SessionStart: [
86
+ {
87
+ hooks: [
88
+ {
89
+ type: "command",
90
+ command: 'node "${ROOT}/scripts/session-map.mjs"',
91
+ timeout: 5,
92
+ },
93
+ ],
94
+ },
95
+ ],
85
96
  PreToolUse: [
86
97
  {
87
98
  matcher: "spawn",
@@ -1728,6 +1739,24 @@ describe("init", () => {
1728
1739
  expect(settings.permissions.deny).toContain("mcp__*");
1729
1740
  });
1730
1741
 
1742
+ it("re-init replaces a prior grunt session-map SessionStart group instead of duplicating it", () => {
1743
+ const pkgRoot = stubPkgRoot();
1744
+ const dest = tmp("grunt-rerun-map-");
1745
+ fs.mkdirSync(path.join(dest, ".claude"), { recursive: true });
1746
+ const mapGroup = {
1747
+ hooks: [{ type: "command", command: 'node "$X/scripts/session-map.mjs"', timeout: 5 }],
1748
+ };
1749
+ fs.writeFileSync(
1750
+ path.join(dest, ".claude", "settings.json"),
1751
+ JSON.stringify({ permissions: {}, hooks: { SessionStart: [mapGroup] } }),
1752
+ );
1753
+ init(dest, { pkgRoot, execFileSync: vi.fn() });
1754
+ const settings = JSON.parse(
1755
+ fs.readFileSync(path.join(dest, ".claude", "settings.json"), "utf8"),
1756
+ );
1757
+ expect(settings.hooks.SessionStart).toEqual(SRC_CLAUDE_SETTINGS.hooks.SessionStart);
1758
+ });
1759
+
1731
1760
  it("omitted execFileSync uses default; self-skip never invokes it", () => {
1732
1761
  const pkgRoot = stubPkgRoot();
1733
1762
  init(pkgRoot, { pkgRoot });
@@ -1772,6 +1801,7 @@ describe("init", () => {
1772
1801
  "listen",
1773
1802
  "pickup",
1774
1803
  "speak",
1804
+ "su",
1775
1805
  "tmp",
1776
1806
  "write-plan",
1777
1807
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Multi-agent orchestrator SoT — deep-merge rulesync trees, product scripts, and generate pipeline into a consumer repo",
5
5
  "keywords": [
6
6
  "agents",
@@ -31,6 +31,7 @@
31
31
  "scripts/guarded-md.mjs",
32
32
  "scripts/guarded-roots.mjs",
33
33
  "scripts/emit-mcp-policy.mjs",
34
+ "scripts/folder-map.mjs",
34
35
  "scripts/gate-fat-tools.mjs",
35
36
  "scripts/hooks-union.mjs",
36
37
  "scripts/pipeline.mjs",
@@ -44,6 +45,7 @@
44
45
  "scripts/purge-global-mcps.mjs",
45
46
  "scripts/scrub-spawn-prompt.mjs",
46
47
  "scripts/scrub-text-lib.mjs",
48
+ "scripts/session-map.mjs",
47
49
  "scripts/sync-global-settings.mjs",
48
50
  "scripts/browser.mjs",
49
51
  "scripts/speak.mjs",
@@ -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",
@@ -170,7 +170,7 @@ function main() {
170
170
  const data = readJsonValue();
171
171
  const updated = processHookPayload(data);
172
172
  if (!updated) return 0;
173
- if (updated.decision === "deny") {
173
+ if (updated.decision === "block") {
174
174
  process.stdout.write(JSON.stringify(updated));
175
175
  return 0;
176
176
  }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart hook: inject the code-only folder map as additionalContext.
4
+ * Logs { rows, tokens } to .tmp/grunt/sessions/<sid>/map.json for tuning.
5
+ * Fail-open: any error, no git, or an empty map → empty stdout, exit 0.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { folderMap } from "./folder-map.mjs";
11
+
12
+ export const HEADER =
13
+ "Folder map (code folders only; `…` = more inside, run `node scripts/folder-map.mjs <dir>`):";
14
+
15
+ function readStdin() {
16
+ try {
17
+ const v = JSON.parse(fs.readFileSync(0, "utf8"));
18
+ return v && typeof v === "object" ? v : {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ function workspaceRootOf(data) {
25
+ return (
26
+ process.env.GROK_WORKSPACE_ROOT ||
27
+ data.workspaceRoot ||
28
+ data.workspace_root ||
29
+ data.cwd ||
30
+ process.env.CLAUDE_PROJECT_DIR ||
31
+ process.cwd()
32
+ );
33
+ }
34
+
35
+ function logSize(root, data, out) {
36
+ const sid = String(data.session_id || data.sessionId || "").replace(/[^\w-]/g, "");
37
+ if (!sid) return;
38
+ const dir = path.join(root, ".tmp", "grunt", "sessions", sid);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ fs.writeFileSync(path.join(dir, "map.json"), `${JSON.stringify({ rows: out.rows, tokens: out.tokens })}\n`);
41
+ }
42
+
43
+ function main() {
44
+ try {
45
+ const data = readStdin();
46
+ const root = workspaceRootOf(data);
47
+ const out = folderMap({ root });
48
+ if (!out || !out.text) return 0;
49
+ try {
50
+ logSize(root, data, out);
51
+ } catch {
52
+ // Logging is best-effort; the map still ships.
53
+ }
54
+ process.stdout.write(
55
+ JSON.stringify({
56
+ hookSpecificOutput: {
57
+ hookEventName: "SessionStart",
58
+ additionalContext: `${HEADER}\n${out.text}`,
59
+ },
60
+ }),
61
+ );
62
+ } catch {
63
+ // Fail open.
64
+ }
65
+ return 0;
66
+ }
67
+
68
+ const thisFile = fileURLToPath(import.meta.url);
69
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
70
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
71
+ process.exit(main());
72
+ }