@lovrozagar/grunt 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ tags: [hooks]
4
4
 
5
5
  # Hooks
6
6
 
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`.
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` (capped under Claude Code's 10,000-char per-hook `additionalContext` limit, about 2.4k tokens, below the 5k `grunt map` budget; 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
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.2] - 2026-10-03
11
+
12
+ ### Fixed
13
+
14
+ - SessionStart folder map exceeded Claude Code's 10,000-character per-hook `additionalContext` limit on large repos, so only a 2KB preview reached the model. The injected map now stays under that limit
15
+ - Folder map depth is uniform: the deepest level (up to `depthCap`) that fits the size limit, applied to every package, instead of uneven fair-share cuts. Folders leading to a nested package always expand. The injected header names the depth used, or `complete`; the per-session `map.json` logs `depth`
16
+
17
+ ## [0.8.1] - 2026-10-03
18
+
19
+ ### Fixed
20
+
21
+ - `grunt map <dir>` printed nothing on Windows: the scope dir is now resolved with `git rev-parse --show-prefix`, so 8.3 short paths and symlinked roots match git's paths. A missing scope dir prints an empty map
22
+
10
23
  ## [0.8.0] - 2026-10-03
11
24
 
12
25
  ### Added
package/README.md CHANGED
@@ -179,13 +179,13 @@ Emit writes other-CLI trees from `.rulesync` for the **next** process of that CL
179
179
 
180
180
  ### Folder map
181
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.
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. The injected map stays under Claude Code's 10,000-character per-hook `additionalContext` limit (about 2.4k tokens); past it, Claude keeps only a 2KB preview inline. `grunt map` itself uses the full `budget`.
183
183
 
184
184
  - Folders only, never files. Source: `git ls-files --cached --others --exclude-standard`, so gitignored paths never show
185
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
186
  - Package roots (`package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`) show `# name repo/relative/path`
187
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
188
+ - Depth is uniform: the map uses the deepest level (up to `depthCap`, counted from each package root) that fits the size limit, so every package shows the same number of levels. Deeper folders end in `…`; folders that lead to a nested package always expand. The size limit is `budget` × 4 chars, lowered to fit Claude Code's per-hook limit at SessionStart. The injected header names the depth used, or says `complete`
189
189
  - Size per session: `.tmp/grunt/sessions/<sid>/map.json`
190
190
 
191
191
  Optional `.rulesync/grunt.map.jsonc` (missing or invalid → defaults):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
@@ -6,6 +6,8 @@
6
6
  * shown when it or a descendant holds a code file and no segment is skipped.
7
7
  * Package roots (package.json, go.mod, Cargo.toml, pyproject.toml) carry
8
8
  * `# name path`. Single-child chains collapse; plain leaf dirs fold at foldAt.
9
+ * Depth is uniform per package: the deepest level that fits the size limit;
10
+ * deeper folders end in `…`.
9
11
  *
10
12
  * node scripts/folder-map.mjs [dir]
11
13
  */
@@ -60,12 +62,22 @@ function git(args, cwd) {
60
62
  return r.status === 0 ? r.stdout : null;
61
63
  }
62
64
 
63
- /** Repo top and cwd-relative prefix, or null outside git. */
65
+ /** Repo top, or null outside git. */
64
66
  export function repoRoot(cwd) {
65
67
  const top = git(["rev-parse", "--show-toplevel"], cwd);
66
68
  return top ? top.trim() : null;
67
69
  }
68
70
 
71
+ /**
72
+ * Repo-relative posix path of `abs` ("" at the top), or null when it is not a
73
+ * dir in a repo. Asks git so Windows short (8.3) vs long paths cannot diverge.
74
+ */
75
+ export function repoPrefix(abs) {
76
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return null;
77
+ const prefix = git(["rev-parse", "--show-prefix"], abs);
78
+ return prefix == null ? null : prefix.trim().replace(/\/$/, "");
79
+ }
80
+
69
81
  /** Tracked + untracked-unignored files, repo-relative, posix. */
70
82
  export function listFiles(root) {
71
83
  const out = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root);
@@ -187,83 +199,36 @@ export function tokens(text) {
187
199
  return Math.ceil(text.length / 4);
188
200
  }
189
201
 
202
+ /** True when a package root sits anywhere below `row`. */
203
+ function holdsPackage(row) {
204
+ return row.kids.some((k) => k.pkg != null || holdsPackage(k));
205
+ }
206
+
190
207
  /**
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.
208
+ * Render rows with every package expanded to `cap` levels below its root
209
+ * (top-level non-package rows count from 1). A row past the cap hides its
210
+ * kids and ends in `…`, unless a package root sits below it: package rows
211
+ * always stay reachable, and their own depth restarts at 0.
200
212
  */
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
- }
213
+ function renderAtDepth(rows, cap, base) {
251
214
  const lines = [];
252
- const walk = (list, depth) => {
215
+ const walk = (list, depth, pkgDepth) => {
253
216
  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);
217
+ const rel = row.pkg != null ? 0 : pkgDepth;
218
+ const hidden = row.kids.length > 0 && rel >= cap && !holdsPackage(row);
219
+ lines.push(rowText(row, depth, { more: hidden, base }));
220
+ if (!hidden) walk(row.kids, depth + 1, rel + 1);
258
221
  }
259
222
  };
260
- walk(rows, 0);
223
+ walk(rows, 0, 1);
261
224
  return lines;
262
225
  }
263
226
 
264
227
  /**
265
228
  * Map text for `root` (any dir inside a git repo), scoped to `dir` when set.
266
- * Returns { text, rows, tokens } or null outside git.
229
+ * Depth is uniform: the deepest cap (≤ depthCap) whose text fits the limit,
230
+ * where limit = budget × 4 chars, lowered by `opts.maxChars` (hook caps).
231
+ * Returns { text, rows, tokens, depth } or null outside git.
267
232
  */
268
233
  export function folderMap({ root, dir = "", ...opts } = {}) {
269
234
  const top = repoRoot(root);
@@ -271,14 +236,22 @@ export function folderMap({ root, dir = "", ...opts } = {}) {
271
236
  const files = listFiles(top);
272
237
  if (!files) return null;
273
238
  const o = { ...loadMapConfig(top), ...opts };
274
- const scope = dir ? path.relative(top, path.resolve(root, dir)).split(path.sep).join("/") : "";
239
+ const scope = dir ? repoPrefix(path.resolve(root, dir)) : "";
240
+ if (scope == null) return { text: "", rows: 0, tokens: 0, depth: 0 };
275
241
  const scoped = scope
276
242
  ? files.filter((f) => f.startsWith(`${scope}/`)).map((f) => f.slice(scope.length + 1))
277
243
  : files;
278
- const tree = buildTree(scope ? path.join(top, scope) : top, scoped, o);
279
- const lines = selectRows(displayRows(tree, o), o, scope);
244
+ const rows = displayRows(buildTree(scope ? path.join(top, scope) : top, scoped, o), o);
245
+ const limit = Math.min(o.budget * 4, o.maxChars ?? Infinity);
246
+ let lines = [];
247
+ let depth = o.depthCap;
248
+ for (; depth >= 1; depth--) {
249
+ lines = renderAtDepth(rows, depth, scope);
250
+ if (lines.join("\n").length + 1 <= limit) break;
251
+ }
252
+ depth = Math.max(depth, 1);
280
253
  const text = lines.length ? `${lines.join("\n")}\n` : "";
281
- return { text, rows: lines.length, tokens: tokens(text) };
254
+ return { text, rows: lines.length, tokens: tokens(text), depth };
282
255
  }
283
256
 
284
257
  /** Text for `grunt map [dir]` / `node scripts/folder-map.mjs [dir]` run from `cwd`. */
@@ -9,8 +9,15 @@ import path from "node:path";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  import { folderMap } from "./folder-map.mjs";
11
11
 
12
- export const HEADER =
13
- "Folder map (code folders only; `…` = more inside, run `node scripts/folder-map.mjs <dir>`):";
12
+ /** First line of the injected map; `depth` = levels per package, null when nothing was cut. */
13
+ export function header(depth) {
14
+ const scope = depth == null ? "complete" : `${depth} levels per package`;
15
+ return `Folder map (code folders, ${scope}; \`…\` = deeper, run \`node scripts/folder-map.mjs <dir>\`):`;
16
+ }
17
+ /** Claude Code caps each hook's additionalContext at 10,000 chars; above it, only a 2KB preview stays inline. */
18
+ export const HOOK_CONTEXT_CHARS = 10000;
19
+ // Covers the header (depth is one or two digits) plus slack.
20
+ const RESERVE = header(99).length + 1 + 100;
14
21
 
15
22
  function readStdin() {
16
23
  try {
@@ -37,14 +44,15 @@ function logSize(root, data, out) {
37
44
  if (!sid) return;
38
45
  const dir = path.join(root, ".tmp", "grunt", "sessions", sid);
39
46
  fs.mkdirSync(dir, { recursive: true });
40
- fs.writeFileSync(path.join(dir, "map.json"), `${JSON.stringify({ rows: out.rows, tokens: out.tokens })}\n`);
47
+ const size = { rows: out.rows, tokens: out.tokens, depth: out.depth };
48
+ fs.writeFileSync(path.join(dir, "map.json"), `${JSON.stringify(size)}\n`);
41
49
  }
42
50
 
43
51
  function main() {
44
52
  try {
45
53
  const data = readStdin();
46
54
  const root = workspaceRootOf(data);
47
- const out = folderMap({ root });
55
+ const out = folderMap({ root, maxChars: HOOK_CONTEXT_CHARS - RESERVE });
48
56
  if (!out || !out.text) return 0;
49
57
  try {
50
58
  logSize(root, data, out);
@@ -55,7 +63,7 @@ function main() {
55
63
  JSON.stringify({
56
64
  hookSpecificOutput: {
57
65
  hookEventName: "SessionStart",
58
- additionalContext: `${HEADER}\n${out.text}`,
66
+ additionalContext: `${header(/ …$/m.test(out.text) ? out.depth : null)}\n${out.text}`,
59
67
  },
60
68
  }),
61
69
  );