@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.
- package/.agents/skills/su/SKILL.md +11 -0
- package/.claude/settings.json +11 -0
- package/.claude/skills/su/SKILL.md +11 -0
- package/.codex/hooks.json +11 -0
- package/.grok/hooks/orchestrate-parent.js +3 -8
- package/.grok/skills/su/SKILL.md +11 -0
- package/.rulesync/hooks.jsonc +8 -0
- package/.rulesync/reference/INDEX.md +3 -2
- package/.rulesync/reference/hooks.md +1 -1
- package/.rulesync/reference/law.md +1 -1
- package/.rulesync/reference/refs-map.md +1 -1
- package/.rulesync/reference/skills-map.md +1 -0
- package/.rulesync/rules/overview.md +2 -0
- package/.rulesync/skills/su/SKILL.md +11 -0
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +15 -0
- package/README.md +25 -1
- package/cli/grunt.mjs +6 -0
- package/cli/grunt.test.ts +19 -0
- package/cli/init.mjs +9 -1
- package/cli/init.test.ts +30 -0
- package/package.json +3 -1
- package/scripts/folder-map.mjs +300 -0
- package/scripts/gate-fat-tools.mjs +9 -5
- package/scripts/hooks-union.mjs +15 -4
- package/scripts/scrub-spawn-prompt.mjs +1 -1
- package/scripts/session-map.mjs +72 -0
|
@@ -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/.claude/settings.json
CHANGED
|
@@ -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
|
@@ -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(
|
|
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(
|
|
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.
|
package/.rulesync/hooks.jsonc
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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 = [
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
}
|
package/scripts/hooks-union.mjs
CHANGED
|
@@ -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
|
|
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 === "
|
|
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
|
+
}
|