@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.
- package/.rulesync/reference/hooks.md +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/scripts/folder-map.mjs +44 -71
- package/scripts/session-map.mjs +13 -5
|
@@ -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` (
|
|
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
|
-
-
|
|
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
package/scripts/folder-map.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
|
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
|
-
|
|
255
|
-
const
|
|
256
|
-
lines.push(rowText(row, depth, { more:
|
|
257
|
-
if (
|
|
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
|
-
*
|
|
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 ?
|
|
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
|
|
279
|
-
const
|
|
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`. */
|
package/scripts/session-map.mjs
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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: `${
|
|
66
|
+
additionalContext: `${header(/ …$/m.test(out.text) ? out.depth : null)}\n${out.text}`,
|
|
59
67
|
},
|
|
60
68
|
}),
|
|
61
69
|
);
|