@lovrozagar/grunt 0.8.1 → 0.8.3

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
 
@@ -15,7 +15,7 @@ Default `/auto`: keep going; ask on blockers. `/ask`: one step, then ask.
15
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.
16
16
 
17
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`.
18
+ To learn the repo layout, use the folder map if it is in your context. If it is missing, or you need more depth, run `node scripts/folder-map.mjs [dir]` instead of exploring with `ls` or `find`.
19
19
 
20
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.
21
21
  Fat dumps: `node scripts/grunt-job.mjs --job search|exec|slice|fetch|test` first; spawn grunt only when the dump needs judgment.
package/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ Default `/auto`: keep going; ask on blockers. `/ask`: one step, then ask.
6
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.
7
7
 
8
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`.
9
+ To learn the repo layout, use the folder map if it is in your context. If it is missing, or you need more depth, run `node scripts/folder-map.mjs [dir]` instead of exploring with `ls` or `find`.
10
10
 
11
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.
12
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,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.3] - 2026-10-03
11
+
12
+ ### Changed
13
+
14
+ - Root rule on the folder map now reads for every agent: use the map when it is in context, else run `node scripts/folder-map.mjs [dir]`, and only in place of `ls` / `find` for learning the repo layout
15
+
16
+ ## [0.8.2] - 2026-10-03
17
+
18
+ ### Fixed
19
+
20
+ - 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
21
+ - 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`
22
+
10
23
  ## [0.8.1] - 2026-10-03
11
24
 
12
25
  ### Fixed
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.1",
3
+ "version": "0.8.3",
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
  */
@@ -197,83 +199,36 @@ export function tokens(text) {
197
199
  return Math.ceil(text.length / 4);
198
200
  }
199
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
+
200
207
  /**
201
- * Pick rows under the budget, breadth-first by display level.
202
- *
203
- * Level 0 always shows. Each deeper level is one unit per top-level subtree:
204
- * a subtree's rows at that level show together or not at all, so a parent's
205
- * kids are never half-listed. Cost is exact in chars: a row with kids is
206
- * charged its `…` marker until its kids land. When a level does
207
- * not fit, cheapest subtrees take an equal share first; a subtree that misses
208
- * its share stops expanding. Rows past depthCap (from the nearest package
209
- * 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.
210
212
  */
211
- function selectRows(rows, o, base) {
212
- const levels = [];
213
- const visit = (list, depth, top, pkgDepth) => {
214
- for (const row of list) {
215
- const rel = row.pkg != null ? 0 : pkgDepth;
216
- row.top = top ?? row;
217
- row.capped = row.kids.length > 0 && rel >= o.depthCap;
218
- row.shown = false;
219
- (levels[depth] ||= []).push(row);
220
- if (!row.capped) visit(row.kids, depth + 1, row.top, rel + 1);
221
- }
222
- };
223
- visit(rows, 0, null, 1);
224
- const cost = (row, depth, more) => rowText(row, depth, { more, base }).length + 1;
225
- const pointer = (row, depth) => cost(row, depth, true) - cost(row, depth, false);
226
- const charge = (row, depth) => cost(row, depth, row.kids.length > 0);
227
-
228
- let used = 0;
229
- for (const row of levels[0] || []) {
230
- row.shown = true;
231
- used += charge(row, 0);
232
- }
233
- const limit = o.budget * 4;
234
- let active = new Set(levels[0] || []);
235
- for (let d = 1; d < levels.length && active.size; d++) {
236
- const units = new Map();
237
- for (const row of levels[d]) {
238
- if (!active.has(row.top)) continue;
239
- const u = units.get(row.top) || { rows: [], parents: new Set(), cost: 0 };
240
- u.rows.push(row);
241
- u.cost += charge(row, d);
242
- units.set(row.top, u);
243
- }
244
- for (const row of levels[d - 1]) {
245
- const u = units.get(row.top);
246
- if (u && row.shown && row.kids.length && !row.capped) u.cost -= pointer(row, d - 1);
247
- }
248
- const next = new Set();
249
- let left = limit - used;
250
- const order = [...units.entries()].sort((a, b) => a[1].cost - b[1].cost);
251
- order.forEach(([top, u], i) => {
252
- const share = left / (order.length - i);
253
- if (u.cost > share) return;
254
- for (const row of u.rows) row.shown = true;
255
- left -= u.cost;
256
- used += u.cost;
257
- next.add(top);
258
- });
259
- active = next;
260
- }
213
+ function renderAtDepth(rows, cap, base) {
261
214
  const lines = [];
262
- const walk = (list, depth) => {
215
+ const walk = (list, depth, pkgDepth) => {
263
216
  for (const row of list) {
264
- if (!row.shown) continue;
265
- const open = row.kids.length > 0 && row.kids.some((k) => k.shown);
266
- lines.push(rowText(row, depth, { more: row.kids.length > 0 && !open, base }));
267
- 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);
268
221
  }
269
222
  };
270
- walk(rows, 0);
223
+ walk(rows, 0, 1);
271
224
  return lines;
272
225
  }
273
226
 
274
227
  /**
275
228
  * Map text for `root` (any dir inside a git repo), scoped to `dir` when set.
276
- * 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.
277
232
  */
278
233
  export function folderMap({ root, dir = "", ...opts } = {}) {
279
234
  const top = repoRoot(root);
@@ -282,14 +237,21 @@ export function folderMap({ root, dir = "", ...opts } = {}) {
282
237
  if (!files) return null;
283
238
  const o = { ...loadMapConfig(top), ...opts };
284
239
  const scope = dir ? repoPrefix(path.resolve(root, dir)) : "";
285
- if (scope == null) return { text: "", rows: 0, tokens: 0 };
240
+ if (scope == null) return { text: "", rows: 0, tokens: 0, depth: 0 };
286
241
  const scoped = scope
287
242
  ? files.filter((f) => f.startsWith(`${scope}/`)).map((f) => f.slice(scope.length + 1))
288
243
  : files;
289
- const tree = buildTree(scope ? path.join(top, scope) : top, scoped, o);
290
- 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);
291
253
  const text = lines.length ? `${lines.join("\n")}\n` : "";
292
- return { text, rows: lines.length, tokens: tokens(text) };
254
+ return { text, rows: lines.length, tokens: tokens(text), depth };
293
255
  }
294
256
 
295
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
  );