@lovrozagar/grunt 0.3.0 → 0.3.1
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/agents/grunt.md +1 -1
- package/.agents/agents/orchestrator.md +2 -2
- package/.agents/hooks.json +1 -1
- package/.agents/skills/solo/SKILL.md +1 -1
- package/.claude/agents/grunt.md +1 -1
- package/.claude/agents/orchestrator.md +3 -3
- package/.claude/settings.json +1 -1
- package/.claude/skills/solo/SKILL.md +1 -1
- package/.codex/agents/grunt.toml +1 -1
- package/.codex/agents/orchestrator.toml +2 -2
- package/.codex/hooks.json +1 -1
- package/.grok/agents/grunt.md +1 -1
- package/.grok/agents/orchestrator.md +2 -2
- package/.grok/hooks/orchestrate-parent.js +177 -33
- package/.grok/skills/solo/SKILL.md +1 -1
- package/.rulesync/hooks.jsonc +3 -2
- package/.rulesync/reference/cascade.md +2 -2
- package/.rulesync/reference/hooks.md +1 -1
- package/.rulesync/rules/CLAUDE.md +1 -1
- package/.rulesync/rules/overview.md +1 -1
- package/.rulesync/skills/solo/SKILL.md +1 -1
- package/.rulesync/subagents/grunt.md +1 -1
- package/.rulesync/subagents/orchestrator.md +2 -2
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/gate-fat-tools.mjs +64 -0
- package/scripts/hooks-union.mjs +2 -2
- package/scripts/scrub-spawn-prompt.mjs +37 -17
package/.agents/agents/grunt.md
CHANGED
|
@@ -20,7 +20,7 @@ You CAN run npm/git/bash via `run_terminal_command`. Do NOT wait on MCP. Do NOT
|
|
|
20
20
|
Run tools. Return output. Never spawn. Never implement complex/feature solutions (implementer agent). Never plan (thinker agent).
|
|
21
21
|
May write simple structural folders/files (scaffold/mkdir/touch/boilerplate only). Not feature solutions.
|
|
22
22
|
Low-reason write: mechanical/repetitive/obvious; volume OK.
|
|
23
|
-
Scratch/tmp →
|
|
23
|
+
Scratch/tmp → `{workspaceRoot}/.tmp/grunt/` only (mkdir as needed). Never `.tmp/` root, `.tmp/plans/`, `.tmp/grunt/handoffs/`, or `.tmp/orchestrator-logs/`. Repo scaffold/mkdir/touch/boilerplate stays in the real tree.
|
|
24
24
|
|
|
25
25
|
`job: search|exec` → first action `node scripts/grunt-job.mjs --job search|exec --query …`; whole reply = stdout.
|
|
26
26
|
`job: test` → grunt-job; `FALLBACK` (exit 2) → LLM tools.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: orchestrator
|
|
3
|
-
description: 'Parent spawn supervisor. Always spawn+prompt. Echo [
|
|
3
|
+
description: 'Parent spawn supervisor. Always spawn+prompt. Echo [orchestrator]: or child role tag only. No parent search/write except /parent.'
|
|
4
4
|
tools:
|
|
5
5
|
- view_file
|
|
6
6
|
- grep_search
|
|
@@ -13,7 +13,7 @@ inheritMcp: false
|
|
|
13
13
|
---
|
|
14
14
|
Voice: `.rulesync/reference/output.md` — must follow.
|
|
15
15
|
Protocol: `.rulesync/reference/cascade.md` (peek/kill table need:/resume). Do not paste. Do not open first.
|
|
16
|
-
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[
|
|
16
|
+
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[orchestrator]:` or child role tag + child output only.
|
|
17
17
|
Spawn only `grunt` | `implementer` | `thinker`. Omit `model`. Isolation `none` unless asked. Never spawn `orchestrator`. Children never spawn.
|
|
18
18
|
grunt ← tools. World fact → grunt `job: web`. Never memory. implementer ← specified solution (not the spec). thinker ← plan/deep reason/spec.
|
|
19
19
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
package/.agents/hooks.json
CHANGED
|
@@ -10,7 +10,7 @@ Session mode, not one-turn. Persists until `/cascade` or session end.
|
|
|
10
10
|
This session: cascade suspended. You are one normal agent.
|
|
11
11
|
|
|
12
12
|
- Do not spawn. No grunt|implementer|thinker.
|
|
13
|
-
- No `[
|
|
13
|
+
- No recap tags. Do not recap `[orchestrator]:`. Answer the user directly.
|
|
14
14
|
- No spawn-first rule. No `need:`/`verdict:` grammar.
|
|
15
15
|
- Voice unchanged: `.rulesync/reference/output.md`. `/explain` is still the voice escape.
|
|
16
16
|
|
package/.claude/agents/grunt.md
CHANGED
|
@@ -26,7 +26,7 @@ You CAN run npm/git/bash via `Bash`. Do NOT wait on MCP. Do NOT request MCP for
|
|
|
26
26
|
Run tools. Return output. Never spawn. Never implement complex/feature solutions (implementer agent). Never plan (thinker agent).
|
|
27
27
|
May write simple structural folders/files (scaffold/mkdir/touch/boilerplate only). Not feature solutions.
|
|
28
28
|
Low-reason write: mechanical/repetitive/obvious; volume OK.
|
|
29
|
-
Scratch/tmp →
|
|
29
|
+
Scratch/tmp → `{workspaceRoot}/.tmp/grunt/` only (mkdir as needed). Never `.tmp/` root, `.tmp/plans/`, `.tmp/grunt/handoffs/`, or `.tmp/orchestrator-logs/`. Repo scaffold/mkdir/touch/boilerplate stays in the real tree.
|
|
30
30
|
|
|
31
31
|
`job: search|exec` → first action `node scripts/grunt-job.mjs --job search|exec --query …`; whole reply = stdout.
|
|
32
32
|
`job: test` → grunt-job; `FALLBACK` (exit 2) → LLM tools.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: orchestrator
|
|
3
3
|
description: >-
|
|
4
|
-
Parent spawn supervisor. Always spawn+prompt. Echo [
|
|
5
|
-
search/write except /parent.
|
|
4
|
+
Parent spawn supervisor. Always spawn+prompt. Echo [orchestrator]: or child
|
|
5
|
+
role tag only. No parent search/write except /parent.
|
|
6
6
|
model: haiku
|
|
7
7
|
tools:
|
|
8
8
|
- Read
|
|
@@ -16,7 +16,7 @@ effort: low
|
|
|
16
16
|
---
|
|
17
17
|
Voice: `.rulesync/reference/output.md` — must follow.
|
|
18
18
|
Protocol: `.rulesync/reference/cascade.md` (peek/kill table need:/resume). Do not paste. Do not open first.
|
|
19
|
-
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[
|
|
19
|
+
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[orchestrator]:` or child role tag + child output only.
|
|
20
20
|
Spawn only `grunt` | `implementer` | `thinker`. Omit `model`. Isolation `none` unless asked. Never spawn `orchestrator`. Children never spawn.
|
|
21
21
|
grunt ← tools. World fact → grunt `job: web`. Never memory. implementer ← specified solution (not the spec). thinker ← plan/deep reason/spec.
|
|
22
22
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
package/.claude/settings.json
CHANGED
|
@@ -10,7 +10,7 @@ Session mode, not one-turn. Persists until `/cascade` or session end.
|
|
|
10
10
|
This session: cascade suspended. You are one normal agent.
|
|
11
11
|
|
|
12
12
|
- Do not spawn. No grunt|implementer|thinker.
|
|
13
|
-
- No `[
|
|
13
|
+
- No recap tags. Do not recap `[orchestrator]:`. Answer the user directly.
|
|
14
14
|
- No spawn-first rule. No `need:`/`verdict:` grammar.
|
|
15
15
|
- Voice unchanged: `.rulesync/reference/output.md`. `/explain` is still the voice escape.
|
|
16
16
|
|
package/.codex/agents/grunt.toml
CHANGED
|
@@ -13,7 +13,7 @@ You CAN run npm/git/bash via `run_terminal_command`. Do NOT wait on MCP. Do NOT
|
|
|
13
13
|
Run tools. Return output. Never spawn. Never implement complex/feature solutions (implementer agent). Never plan (thinker agent).
|
|
14
14
|
May write simple structural folders/files (scaffold/mkdir/touch/boilerplate only). Not feature solutions.
|
|
15
15
|
Low-reason write: mechanical/repetitive/obvious; volume OK.
|
|
16
|
-
Scratch/tmp →
|
|
16
|
+
Scratch/tmp → `{workspaceRoot}/.tmp/grunt/` only (mkdir as needed). Never `.tmp/` root, `.tmp/plans/`, `.tmp/grunt/handoffs/`, or `.tmp/orchestrator-logs/`. Repo scaffold/mkdir/touch/boilerplate stays in the real tree.
|
|
17
17
|
|
|
18
18
|
`job: search|exec` → first action `node scripts/grunt-job.mjs --job search|exec --query …`; whole reply = stdout.
|
|
19
19
|
`job: test` → grunt-job; `FALLBACK` (exit 2) → LLM tools.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name = "orchestrator"
|
|
2
|
-
description = "Parent spawn supervisor. Always spawn+prompt. Echo [
|
|
2
|
+
description = "Parent spawn supervisor. Always spawn+prompt. Echo [orchestrator]: or child role tag only. No parent search/write except /parent."
|
|
3
3
|
model = "gpt-5.4-mini"
|
|
4
4
|
model_reasoning_effort = "low"
|
|
5
5
|
sandbox_mode = "danger-full-access"
|
|
@@ -7,7 +7,7 @@ approval_policy = "never"
|
|
|
7
7
|
developer_instructions = '''
|
|
8
8
|
Voice: `.rulesync/reference/output.md` — must follow.
|
|
9
9
|
Protocol: `.rulesync/reference/cascade.md` (peek/kill table need:/resume). Do not paste. Do not open first.
|
|
10
|
-
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[
|
|
10
|
+
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[orchestrator]:` or child role tag + child output only.
|
|
11
11
|
Spawn only `grunt` | `implementer` | `thinker`. Omit `model`. Isolation `none` unless asked. Never spawn `orchestrator`. Children never spawn.
|
|
12
12
|
grunt ← tools. World fact → grunt `job: web`. Never memory. implementer ← specified solution (not the spec). thinker ← plan/deep reason/spec.
|
|
13
13
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
package/.codex/hooks.json
CHANGED
package/.grok/agents/grunt.md
CHANGED
|
@@ -15,7 +15,7 @@ You CAN run npm/git/bash via `run_terminal_command`. Do NOT wait on MCP. Do NOT
|
|
|
15
15
|
Run tools. Return output. Never spawn. Never implement complex/feature solutions (implementer agent). Never plan (thinker agent).
|
|
16
16
|
May write simple structural folders/files (scaffold/mkdir/touch/boilerplate only). Not feature solutions.
|
|
17
17
|
Low-reason write: mechanical/repetitive/obvious; volume OK.
|
|
18
|
-
Scratch/tmp →
|
|
18
|
+
Scratch/tmp → `{workspaceRoot}/.tmp/grunt/` only (mkdir as needed). Never `.tmp/` root, `.tmp/plans/`, `.tmp/grunt/handoffs/`, or `.tmp/orchestrator-logs/`. Repo scaffold/mkdir/touch/boilerplate stays in the real tree.
|
|
19
19
|
|
|
20
20
|
`job: search|exec` → first action `node scripts/grunt-job.mjs --job search|exec --query …`; whole reply = stdout.
|
|
21
21
|
`job: test` → grunt-job; `FALLBACK` (exit 2) → LLM tools.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: orchestrator
|
|
3
|
-
description: 'Parent spawn supervisor. Always spawn+prompt. Echo [
|
|
3
|
+
description: 'Parent spawn supervisor. Always spawn+prompt. Echo [orchestrator]: or child role tag only. No parent search/write except /parent.'
|
|
4
4
|
model: grok-4.5
|
|
5
5
|
permission_mode: bypassPermissions
|
|
6
6
|
agents_md: false
|
|
@@ -9,7 +9,7 @@ tools: spawn_subagent, read_file, grep, list_dir, write, todo_write, get_command
|
|
|
9
9
|
---
|
|
10
10
|
Voice: `.rulesync/reference/output.md` — must follow.
|
|
11
11
|
Protocol: `.rulesync/reference/cascade.md` (peek/kill table need:/resume). Do not paste. Do not open first.
|
|
12
|
-
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[
|
|
12
|
+
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[orchestrator]:` or child role tag + child output only.
|
|
13
13
|
Spawn only `grunt` | `implementer` | `thinker`. Omit `model`. Isolation `none` unless asked. Never spawn `orchestrator`. Children never spawn.
|
|
14
14
|
grunt ← tools. World fact → grunt `job: web`. Never memory. implementer ← specified solution (not the spec). thinker ← plan/deep reason/spec.
|
|
15
15
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
/** Parent-orchestrator gate: spawn/peek/kill/todo + persistPlan/persistHandoff writes.
|
|
3
3
|
Parent Read/Grep/Glob/Bash/Web denied unless parent-escape (fat-gate still).
|
|
4
4
|
SubagentStop intercepts need: search|exec with grunt-job verdicts.
|
|
5
|
-
Stop: [
|
|
6
|
-
|
|
5
|
+
Stop: any-line [orchestrator]:/[grunt]:/[implementer]:/[thinker]:/[handoff]: recap
|
|
6
|
+
| parent-escape once; else block. MAX_STOP=3. No isCheap/trivia.
|
|
7
|
+
Empty lastAssistantMessage → transcript_path tail-scan. Fail-open: parse/crash → empty stdout, exit 0.
|
|
7
8
|
*/
|
|
8
9
|
import fs from "node:fs";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
-
import { rewriteSpawnToolInput } from "../../scripts/scrub-spawn-prompt.mjs";
|
|
12
12
|
import {
|
|
13
|
+
MAX_PROMPT_CHARS,
|
|
14
|
+
rewriteSpawnToolInput,
|
|
15
|
+
spawnCapReason,
|
|
16
|
+
} from "../../scripts/scrub-spawn-prompt.mjs";
|
|
17
|
+
import {
|
|
18
|
+
denyResponse,
|
|
13
19
|
fatHookOutput,
|
|
14
20
|
fileSizeBytes,
|
|
15
21
|
isWorkspaceGruntJob,
|
|
16
22
|
processFatTools,
|
|
17
23
|
resolveReadPath,
|
|
24
|
+
rewriteGruntScratchPath,
|
|
18
25
|
subagentTypeOf,
|
|
19
26
|
} from "../../scripts/gate-fat-tools.mjs";
|
|
20
27
|
import { persistPlan } from "../../scripts/persist-plan.mjs";
|
|
@@ -24,26 +31,24 @@ import { resolveJobCwd, runJob } from "../../scripts/grunt-job.mjs";
|
|
|
24
31
|
import { logTelemetry, ORCHESTRATOR_LOGS_DIR } from "../../scripts/telemetry.mjs";
|
|
25
32
|
|
|
26
33
|
const DENY_REASON = "parent is orchestrator; spawn grunt|implementer|thinker";
|
|
27
|
-
const STOP_REASONS = [
|
|
28
|
-
"Violation: parent replied without a
|
|
34
|
+
export const STOP_REASONS = [
|
|
35
|
+
"Violation: parent replied without a recap tag.\n" +
|
|
29
36
|
"DO NOT stop. Continue IN THIS RESPONSE:\n" +
|
|
30
|
-
"1. Spawn grunt|implementer|thinker
|
|
31
|
-
"2.
|
|
32
|
-
"If every spawned child already returned and the turn is done,
|
|
33
|
-
|
|
34
|
-
"Second violation: still no [agent]: recap.\n" +
|
|
37
|
+
"1. Spawn grunt|implementer|thinker if work remains.\n" +
|
|
38
|
+
"2. Recap with `[orchestrator]:` or the child role tag (`[grunt]:` `[implementer]:` `[thinker]:` `[handoff]:`).\n" +
|
|
39
|
+
"If every spawned child already returned and the turn is done, recap now.",
|
|
40
|
+
"Second violation: still no recap tag.\n" +
|
|
35
41
|
"DO NOT stop. Right now, in this same response:\n" +
|
|
36
|
-
"1. Spawn grunt|implementer|thinker.\n" +
|
|
37
|
-
"2. Then
|
|
38
|
-
"Already done and every child returned? Send
|
|
39
|
-
"Context too long? Use /handoff.",
|
|
42
|
+
"1. Spawn grunt|implementer|thinker if needed.\n" +
|
|
43
|
+
"2. Then recap only with `[orchestrator]:` or the matching child role tag.\n" +
|
|
44
|
+
"Already done and every child returned? Send that recap immediately.",
|
|
40
45
|
"Third violation: recap still missing.\n" +
|
|
41
46
|
"This is the last check before fail-open. DO NOT stop:\n" +
|
|
42
|
-
"1. Spawn the correct agent for
|
|
43
|
-
"2. Reply with `[
|
|
44
|
-
"Complete with all children returned? Send the recap now
|
|
45
|
-
"Long context? Use /handoff.",
|
|
47
|
+
"1. Spawn the correct agent for remaining work if needed.\n" +
|
|
48
|
+
"2. Reply with `[orchestrator]:` or the child role tag only, nothing else.\n" +
|
|
49
|
+
"Complete with all children returned? Send the recap now.",
|
|
46
50
|
];
|
|
51
|
+
const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
|
|
47
52
|
const PARENT_TOOLS = new Set([
|
|
48
53
|
"todowrite",
|
|
49
54
|
"getcommandorsubagentoutput",
|
|
@@ -107,7 +112,8 @@ function preToolUse(data) {
|
|
|
107
112
|
return 0;
|
|
108
113
|
}
|
|
109
114
|
if (SPAWN_TOOLS.has(toolKey)) {
|
|
110
|
-
const updated = rewriteSpawn(toolInput);
|
|
115
|
+
const updated = rewriteSpawn(toolInput, data);
|
|
116
|
+
if (updated && updated.__denied) return 0;
|
|
111
117
|
if (updated) {
|
|
112
118
|
emit({
|
|
113
119
|
hookSpecificOutput: {
|
|
@@ -222,12 +228,14 @@ function parentWrite(data, toolInput) {
|
|
|
222
228
|
return 0;
|
|
223
229
|
}
|
|
224
230
|
const ws = workspaceRootOf(data);
|
|
225
|
-
|
|
231
|
+
let rawPath =
|
|
226
232
|
toolInput.file_path ||
|
|
227
233
|
toolInput.filePath ||
|
|
228
234
|
toolInput.path ||
|
|
229
235
|
toolInput.target_file ||
|
|
230
236
|
"";
|
|
237
|
+
const rewritten = rewriteGruntScratchPath(rawPath, ws);
|
|
238
|
+
if (rewritten) rawPath = rewritten;
|
|
231
239
|
const handoff = isUnderHandoffs(rawPath, ws);
|
|
232
240
|
if (!handoff && !isUnderPlans(rawPath, ws)) {
|
|
233
241
|
emit({ decision: "deny", reason: DENY_REASON });
|
|
@@ -256,9 +264,20 @@ function parentWrite(data, toolInput) {
|
|
|
256
264
|
return 0;
|
|
257
265
|
}
|
|
258
266
|
|
|
259
|
-
function rewriteSpawn(toolInput) {
|
|
267
|
+
function rewriteSpawn(toolInput, data) {
|
|
260
268
|
// Type default + intent-scrub in one updatedInput (last-wins with scrub-spawn).
|
|
261
|
-
|
|
269
|
+
const updated = rewriteSpawnToolInput(toolInput, { defaultGrunt: true });
|
|
270
|
+
const prompt =
|
|
271
|
+
updated && typeof updated.prompt === "string"
|
|
272
|
+
? updated.prompt
|
|
273
|
+
: toolInput && typeof toolInput.prompt === "string"
|
|
274
|
+
? toolInput.prompt
|
|
275
|
+
: "";
|
|
276
|
+
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
277
|
+
emit(denyResponse(spawnCapReason(workspaceRootOf(data))));
|
|
278
|
+
return { __denied: true };
|
|
279
|
+
}
|
|
280
|
+
return updated;
|
|
262
281
|
}
|
|
263
282
|
|
|
264
283
|
function postToolUse(data) {
|
|
@@ -305,24 +324,113 @@ function userPromptSubmit(data) {
|
|
|
305
324
|
return 0;
|
|
306
325
|
}
|
|
307
326
|
|
|
308
|
-
|
|
327
|
+
const RECAP_TAG_RE =
|
|
328
|
+
/^\[(?:orchestrator|grunt|implementer|thinker|handoff)\]:/;
|
|
329
|
+
|
|
330
|
+
export function isRecap(msg) {
|
|
309
331
|
const lines = String(msg || "").split("\n");
|
|
310
|
-
let firstNonEmpty = "";
|
|
311
332
|
for (const line of lines) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
333
|
+
const stripped = line.replace(/^[\s`*_>]+/, "");
|
|
334
|
+
if (RECAP_TAG_RE.test(stripped)) return true;
|
|
335
|
+
}
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function payloadAssistantMessage(data) {
|
|
340
|
+
if (!data || typeof data !== "object") return "";
|
|
341
|
+
const camel = data.lastAssistantMessage;
|
|
342
|
+
if (camel != null && String(camel) !== "") return String(camel);
|
|
343
|
+
const snake = data.last_assistant_message;
|
|
344
|
+
if (snake != null && String(snake) !== "") return String(snake);
|
|
345
|
+
return "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isAssistantRecord(obj) {
|
|
349
|
+
if (!obj || typeof obj !== "object") return false;
|
|
350
|
+
const type = String(obj.type || "").toLowerCase();
|
|
351
|
+
if (type === "user" || type === "human" || type === "tool" || type === "tool_result") {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
if (type === "assistant") return true;
|
|
355
|
+
const role = String(
|
|
356
|
+
obj.role || (obj.message && obj.message.role) || "",
|
|
357
|
+
).toLowerCase();
|
|
358
|
+
if (role === "user" || role === "human") return false;
|
|
359
|
+
return role === "assistant";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function assistantTextFromRecord(obj) {
|
|
363
|
+
if (!isAssistantRecord(obj)) return null;
|
|
364
|
+
const msg = obj.message && typeof obj.message === "object" ? obj.message : obj;
|
|
365
|
+
const content = msg.content ?? obj.content;
|
|
366
|
+
if (Array.isArray(content)) {
|
|
367
|
+
const texts = [];
|
|
368
|
+
for (const part of content) {
|
|
369
|
+
if (!part || typeof part !== "object") continue;
|
|
370
|
+
const pt = String(part.type || "");
|
|
371
|
+
if (pt === "tool_use" || pt === "tool_result" || pt === "function_call") continue;
|
|
372
|
+
if (typeof part.text === "string" && part.text) texts.push(part.text);
|
|
315
373
|
}
|
|
374
|
+
const joined = texts.join("\n").trim();
|
|
375
|
+
return joined ? joined : null;
|
|
316
376
|
}
|
|
317
|
-
|
|
318
|
-
|
|
377
|
+
if (typeof content === "string" && content.trim()) return content;
|
|
378
|
+
if (typeof obj.text === "string" && obj.text.trim()) return obj.text;
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function lastAssistantFromTranscript(filePath) {
|
|
383
|
+
try {
|
|
384
|
+
if (!filePath) return "";
|
|
385
|
+
const st = fs.statSync(filePath);
|
|
386
|
+
const start = Math.max(0, st.size - TRANSCRIPT_TAIL_BYTES);
|
|
387
|
+
const fd = fs.openSync(filePath, "r");
|
|
388
|
+
try {
|
|
389
|
+
const buf = Buffer.alloc(st.size - start);
|
|
390
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
391
|
+
const text = buf.toString("utf8");
|
|
392
|
+
const lines = text.split("\n");
|
|
393
|
+
if (start > 0 && lines.length) lines.shift();
|
|
394
|
+
let last = "";
|
|
395
|
+
for (const line of lines) {
|
|
396
|
+
const t = line.trim();
|
|
397
|
+
if (!t) continue;
|
|
398
|
+
let rec;
|
|
399
|
+
try {
|
|
400
|
+
rec = JSON.parse(t);
|
|
401
|
+
} catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const extracted = assistantTextFromRecord(rec);
|
|
405
|
+
if (extracted) last = extracted;
|
|
406
|
+
}
|
|
407
|
+
return last;
|
|
408
|
+
} finally {
|
|
409
|
+
fs.closeSync(fd);
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
return "";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function logParentStop(data, fields) {
|
|
417
|
+
logTelemetry("parent-stop", fields, workspaceRootOf(data));
|
|
319
418
|
}
|
|
320
419
|
|
|
321
420
|
function stop(data) {
|
|
322
421
|
if (data.subagentType || data.subagent_type) {
|
|
323
422
|
return interceptNeed(data, "Stop");
|
|
324
423
|
}
|
|
325
|
-
if (data.stopHookActive || data.stop_hook_active)
|
|
424
|
+
if (data.stopHookActive || data.stop_hook_active) {
|
|
425
|
+
logParentStop(data, {
|
|
426
|
+
recap: false,
|
|
427
|
+
recapSource: "none",
|
|
428
|
+
attempt: 0,
|
|
429
|
+
failOpen: true,
|
|
430
|
+
stopHookActive: true,
|
|
431
|
+
});
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
326
434
|
const reason = String(data.reason || "");
|
|
327
435
|
if (reason && reason !== "end_turn") return 0;
|
|
328
436
|
|
|
@@ -335,8 +443,28 @@ function stop(data) {
|
|
|
335
443
|
return 0;
|
|
336
444
|
}
|
|
337
445
|
|
|
338
|
-
const
|
|
339
|
-
|
|
446
|
+
const payloadMsg = payloadAssistantMessage(data);
|
|
447
|
+
let recapSource = "none";
|
|
448
|
+
let msg = "";
|
|
449
|
+
if (payloadMsg) {
|
|
450
|
+
msg = payloadMsg;
|
|
451
|
+
recapSource = "payload";
|
|
452
|
+
} else {
|
|
453
|
+
const tp = data.transcript_path || data.transcriptPath || "";
|
|
454
|
+
msg = lastAssistantFromTranscript(tp);
|
|
455
|
+
if (msg) recapSource = "transcript";
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (isRecap(msg)) {
|
|
459
|
+
logParentStop(data, {
|
|
460
|
+
recap: true,
|
|
461
|
+
recapSource,
|
|
462
|
+
attempt: 0,
|
|
463
|
+
failOpen: false,
|
|
464
|
+
stopHookActive: false,
|
|
465
|
+
});
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
340
468
|
|
|
341
469
|
const stopStamp = stampPath(data, "stop-block");
|
|
342
470
|
let n = 0;
|
|
@@ -344,11 +472,27 @@ function stop(data) {
|
|
|
344
472
|
n = parseInt(fs.readFileSync(stopStamp, "utf8"), 10);
|
|
345
473
|
if (!Number.isFinite(n) || n < 0) n = 0;
|
|
346
474
|
}
|
|
347
|
-
if (n >= MAX_STOP)
|
|
475
|
+
if (n >= MAX_STOP) {
|
|
476
|
+
logParentStop(data, {
|
|
477
|
+
recap: false,
|
|
478
|
+
recapSource,
|
|
479
|
+
attempt: n,
|
|
480
|
+
failOpen: true,
|
|
481
|
+
stopHookActive: false,
|
|
482
|
+
});
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
348
485
|
if (stopStamp) {
|
|
349
486
|
fs.mkdirSync(path.dirname(stopStamp), { recursive: true });
|
|
350
487
|
fs.writeFileSync(stopStamp, String(n + 1));
|
|
351
488
|
}
|
|
489
|
+
logParentStop(data, {
|
|
490
|
+
recap: false,
|
|
491
|
+
recapSource,
|
|
492
|
+
attempt: n + 1,
|
|
493
|
+
failOpen: false,
|
|
494
|
+
stopHookActive: false,
|
|
495
|
+
});
|
|
352
496
|
const reasonText = STOP_REASONS[Math.min(n, STOP_REASONS.length - 1)];
|
|
353
497
|
emit({ decision: "block", reason: reasonText });
|
|
354
498
|
return 0;
|
|
@@ -10,7 +10,7 @@ Session mode, not one-turn. Persists until `/cascade` or session end.
|
|
|
10
10
|
This session: cascade suspended. You are one normal agent.
|
|
11
11
|
|
|
12
12
|
- Do not spawn. No grunt|implementer|thinker.
|
|
13
|
-
- No `[
|
|
13
|
+
- No recap tags. Do not recap `[orchestrator]:`. Answer the user directly.
|
|
14
14
|
- No spawn-first rule. No `need:`/`verdict:` grammar.
|
|
15
15
|
- Voice unchanged: `.rulesync/reference/output.md`. `/explain` is still the voice escape.
|
|
16
16
|
|
package/.rulesync/hooks.jsonc
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
"timeout": 5
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
// Fat Read/Grep/Glob/Bash
|
|
15
|
-
|
|
14
|
+
// Fat Read/Grep/Glob/Bash + Write/Edit scratch rewrite (never deny writes).
|
|
15
|
+
// Parent-deny stays Grok-only (orchestrate-parent.js).
|
|
16
|
+
"matcher": "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
|
|
16
17
|
"type": "command",
|
|
17
18
|
"command": "node \"${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/scripts/gate-fat-tools.mjs\"",
|
|
18
19
|
"timeout": 5
|
|
@@ -28,9 +28,9 @@ Every 60s peek is real host status (no `sleep` then guess). `timeout_ms=60000` e
|
|
|
28
28
|
Quote host fields only (`status`, elapsed since spawn, last activity/output snippet) or quote the error. Never invent.
|
|
29
29
|
Classify `done` | `alive` | `stuck` vs prior peek: **done** (completed/failed), **alive** (running + fields/output changed), **stuck** (running + unchanged). Unchanged snippet = stuck, not “still working”.
|
|
30
30
|
Loop until done. No auto-kill. Kill only if the user asks and the host has a kill tool.
|
|
31
|
-
Then print recap as `[
|
|
31
|
+
Then print recap as `[orchestrator]: …` (or the child role tag) with real child output. Do not redo the child's work.
|
|
32
32
|
|
|
33
|
-
Parent Stop (`MAX_STOP=3`): do not waive fenced/impl finals for `tools-used` stamp. Allow
|
|
33
|
+
Parent Stop (`MAX_STOP=3`): do not waive fenced/impl finals for `tools-used` stamp. Allow any-line `[orchestrator|grunt|implementer|thinker|handoff]:` recap, or consume `parent-escape-{sid}` once. Else block. No `isCheap` / trivia / long definitions.
|
|
34
34
|
`/handoff` is the other in-parent turn: context large + work unfinished → parent writes one `.tmp/grunt/handoffs/` file from its own transcript (no child sees the session), recaps `[handoff]: serial=… path=…`, tells the user to continue in a new session. No spawn, one write, not a mode.
|
|
35
35
|
`/solo` is the session mode (not one-turn): `beforeSubmitPrompt` writes `.tmp/orchestrator-logs/grunt-off-{sid}`, every gate short-circuits while it exists, `/cascade` unlinks it. Everything above resumes next turn. Antigravity: skill-only, no stamp create (see hooks.md). `/parent` is one-turn (not a mode): UserPromptSubmit writes `.tmp/orchestrator-logs/parent-escape-{sid}`; Stop consumes+unlinks once. Next Stop resumes spawn/recap rules. Skills point here; do not paste this file.
|
|
36
36
|
|
|
@@ -12,7 +12,7 @@ Claude / Codex / Antigravity PreToolUse still run `scripts/scrub-spawn-prompt.mj
|
|
|
12
12
|
|
|
13
13
|
Parent `run_terminal_command` is deny-by-default except `node` (optional leading `rtk`) invoking `{workspace}/scripts/grunt-job.mjs` with `--job search|exec` and `--query`. Optional `--path` `--glob` (repeatable) `--cwd`. Not `web|test`, not other scripts. META strips `--query` value first so `|` inside query is allowed; pipes/`&&` outside query still deny. Never `cd &&`. Exec shell-meta in query is grunt-job FALLBACK (exit 2), not a parent-bash allow. Unknown flags → FALLBACK.
|
|
14
14
|
|
|
15
|
-
SubagentStop (and child Stop with `subagentType`) intercepts a parseable `need:` whose jobs are all `search|exec` (cap 4): runs grunt-job in-hook and continues the child with concatenated `verdict:` blobs. Mixed/web/test, parse fail, FALLBACK, `stopHookActive`, or more than 3 intercepts per child session → empty stdout (allow stop; parent LLM path). Parent `write` is deny-by-default except `.tmp/plans/` (`persist-plan.mjs`) and `.tmp/grunt/handoffs/` (`persist-handoff.mjs`); both validate + rewrite path/content, invalid body denies. Parent Stop (`MAX_STOP=3`): do not waive impl finals for `tools-used`. Allow
|
|
15
|
+
SubagentStop (and child Stop with `subagentType`) intercepts a parseable `need:` whose jobs are all `search|exec` (cap 4): runs grunt-job in-hook and continues the child with concatenated `verdict:` blobs. Mixed/web/test, parse fail, FALLBACK, `stopHookActive`, or more than 3 intercepts per child session → empty stdout (allow stop; parent LLM path). Parent `write` is deny-by-default except `.tmp/plans/` (`persist-plan.mjs`) and `.tmp/grunt/handoffs/` (`persist-handoff.mjs`); both validate + rewrite path/content, invalid body denies. Parent Stop (`MAX_STOP=3`): do not waive impl finals for `tools-used`. Allow any-line `[orchestrator|grunt|implementer|thinker|handoff]:` recap, or consume `parent-escape-{sid}` once (`/parent`). No `isCheap` / trivia. Stamps: `tools-used` / `stop-block` / `need-intercept` / `parent-escape`. Skills point to cascade; do not paste protocol here.
|
|
16
16
|
|
|
17
17
|
`/solo` writes `grunt-off-{sid}`; `/cascade` unlinks it (`beforeSubmitPrompt`, exact-match only). While set: PreToolUse allows parent tools (fat gate still runs) and skips the `defaultGrunt` spawn rewrite; Stop returns 0 **before** the `parent-escape` consume, so solo never burns the one-turn stamp. Session-scoped, fail-closed.
|
|
18
18
|
|
|
@@ -8,7 +8,7 @@ All output exclude AI attribution, Co-Authored-By, trailers, generated markers.
|
|
|
8
8
|
Protocol: `.rulesync/reference/cascade.md`. Do not paste. Do not open first.
|
|
9
9
|
Parent = this file not `.claude/agents/orchestrator.md`. You do not talk. Spawn wait echo.
|
|
10
10
|
Spawn only grunt|implementer|thinker. Never spawn orchestrator. Children never spawn.
|
|
11
|
-
First token = spawn. No try-then-spawn. No parent probe. After child: `[
|
|
11
|
+
First token = spawn. No try-then-spawn. No parent probe. After child: `[orchestrator]:` (or child role tag) echo only. No skip-spawn. /parent = one-turn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`, recap `[handoff]:`.
|
|
12
12
|
grunt ← tools (facts/search/exec/git/web/test/low-reason mechanical write; world fact: `job: web` never memory). implementer ← write defined solution. thinker ← plan/deep reason.
|
|
13
13
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
|
14
14
|
Peek every 60s/child; quote real host fields; `done|alive|stuck`; no invent; no auto-kill. `timeout_ms=60000` every peek. GAP: block on spawn return = `done`. Stuck/blocked: quote host fields only.
|
|
@@ -13,7 +13,7 @@ All output exclude AI attribution, Co-Authored-By, trailers, generated markers.
|
|
|
13
13
|
Protocol: `.rulesync/reference/cascade.md`. Do not paste. Do not open first.
|
|
14
14
|
Parent = orchestrator. You do not talk. Spawn wait echo.
|
|
15
15
|
Spawn only grunt|implementer|thinker. Never spawn orchestrator. Children never spawn.
|
|
16
|
-
First token = spawn. No try-then-spawn. No parent probe. After child: `[
|
|
16
|
+
First token = spawn. No try-then-spawn. No parent probe. After child: `[orchestrator]:` (or child role tag) echo only. No skip-spawn. /parent = one-turn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`, recap `[handoff]:`.
|
|
17
17
|
grunt ← tools (facts/search/exec/git/web/test/low-reason mechanical write; world fact: `job: web` never memory). implementer ← write defined solution. thinker ← plan/deep reason.
|
|
18
18
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
|
19
19
|
Peek every 60s/child; quote real host fields; `done|alive|stuck`; no invent; no auto-kill. `timeout_ms=60000` every peek. GAP: block on spawn return = `done`. Stuck/blocked: quote host fields only.
|
|
@@ -10,7 +10,7 @@ Session mode, not one-turn. Persists until `/cascade` or session end.
|
|
|
10
10
|
This session: cascade suspended. You are one normal agent.
|
|
11
11
|
|
|
12
12
|
- Do not spawn. No grunt|implementer|thinker.
|
|
13
|
-
- No `[
|
|
13
|
+
- No recap tags. Do not recap `[orchestrator]:`. Answer the user directly.
|
|
14
14
|
- No spawn-first rule. No `need:`/`verdict:` grammar.
|
|
15
15
|
- Voice unchanged: `.rulesync/reference/output.md`. `/explain` is still the voice escape.
|
|
16
16
|
|
|
@@ -38,7 +38,7 @@ You CAN run npm/git/bash via `run_terminal_command`. Do NOT wait on MCP. Do NOT
|
|
|
38
38
|
Run tools. Return output. Never spawn. Never implement complex/feature solutions (implementer agent). Never plan (thinker agent).
|
|
39
39
|
May write simple structural folders/files (scaffold/mkdir/touch/boilerplate only). Not feature solutions.
|
|
40
40
|
Low-reason write: mechanical/repetitive/obvious; volume OK.
|
|
41
|
-
Scratch/tmp →
|
|
41
|
+
Scratch/tmp → `{workspaceRoot}/.tmp/grunt/` only (mkdir as needed). Never `.tmp/` root, `.tmp/plans/`, `.tmp/grunt/handoffs/`, or `.tmp/orchestrator-logs/`. Repo scaffold/mkdir/touch/boilerplate stays in the real tree.
|
|
42
42
|
|
|
43
43
|
`job: search|exec` → first action `node scripts/grunt-job.mjs --job search|exec --query …`; whole reply = stdout.
|
|
44
44
|
`job: test` → grunt-job; `FALLBACK` (exit 2) → LLM tools.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: orchestrator
|
|
3
|
-
description: "Parent spawn supervisor. Always spawn+prompt. Echo [
|
|
3
|
+
description: "Parent spawn supervisor. Always spawn+prompt. Echo [orchestrator]: or child role tag only. No parent search/write except /parent."
|
|
4
4
|
targets:
|
|
5
5
|
- claudecode
|
|
6
6
|
- grokcli
|
|
@@ -34,7 +34,7 @@ geminicli:
|
|
|
34
34
|
---
|
|
35
35
|
Voice: `.rulesync/reference/output.md` — must follow.
|
|
36
36
|
Protocol: `.rulesync/reference/cascade.md` (peek/kill table need:/resume). Do not paste. Do not open first.
|
|
37
|
-
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[
|
|
37
|
+
You do not talk. First token = spawn. No try-then-spawn. No parent probe. No skip-spawn. No trivia/cheap. User-visible = `[orchestrator]:` or child role tag + child output only.
|
|
38
38
|
Spawn only `grunt` | `implementer` | `thinker`. Omit `model`. Isolation `none` unless asked. Never spawn `orchestrator`. Children never spawn.
|
|
39
39
|
grunt ← tools. World fact → grunt `job: web`. Never memory. implementer ← specified solution (not the spec). thinker ← plan/deep reason/spec.
|
|
40
40
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
package/AGENTS.md
CHANGED
|
@@ -3,7 +3,7 @@ All output exclude AI attribution, Co-Authored-By, trailers, generated markers.
|
|
|
3
3
|
Protocol: `.rulesync/reference/cascade.md`. Do not paste. Do not open first.
|
|
4
4
|
Parent = orchestrator. You do not talk. Spawn wait echo.
|
|
5
5
|
Spawn only grunt|implementer|thinker. Never spawn orchestrator. Children never spawn.
|
|
6
|
-
First token = spawn. No try-then-spawn. No parent probe. After child: `[
|
|
6
|
+
First token = spawn. No try-then-spawn. No parent probe. After child: `[orchestrator]:` (or child role tag) echo only. No skip-spawn. /parent = one-turn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`, recap `[handoff]:`.
|
|
7
7
|
grunt ← tools (facts/search/exec/git/web/test/low-reason mechanical write; world fact: `job: web` never memory). implementer ← write defined solution. thinker ← plan/deep reason.
|
|
8
8
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
|
9
9
|
Peek every 60s/child; quote real host fields; `done|alive|stuck`; no invent; no auto-kill. `timeout_ms=60000` every peek. GAP: block on spawn return = `done`. Stuck/blocked: quote host fields only.
|
package/CLAUDE.md
CHANGED
|
@@ -3,7 +3,7 @@ All output exclude AI attribution, Co-Authored-By, trailers, generated markers.
|
|
|
3
3
|
Protocol: `.rulesync/reference/cascade.md`. Do not paste. Do not open first.
|
|
4
4
|
Parent = this file not `.claude/agents/orchestrator.md`. You do not talk. Spawn wait echo.
|
|
5
5
|
Spawn only grunt|implementer|thinker. Never spawn orchestrator. Children never spawn.
|
|
6
|
-
First token = spawn. No try-then-spawn. No parent probe. After child: `[
|
|
6
|
+
First token = spawn. No try-then-spawn. No parent probe. After child: `[orchestrator]:` (or child role tag) echo only. No skip-spawn. /parent = one-turn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`, recap `[handoff]:`.
|
|
7
7
|
grunt ← tools (facts/search/exec/git/web/test/low-reason mechanical write; world fact: `job: web` never memory). implementer ← write defined solution. thinker ← plan/deep reason.
|
|
8
8
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
|
9
9
|
Peek every 60s/child; quote real host fields; `done|alive|stuck`; no invent; no auto-kill. `timeout_ms=60000` every peek. GAP: block on spawn return = `done`. Stuck/blocked: quote host fields only.
|
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ Contributors to this repo only: local `npm i && npm test`.
|
|
|
27
27
|
context engineering
|
|
28
28
|
- max reasoning and effort where needed and minimal reasoning and effort where not needed
|
|
29
29
|
- agents:
|
|
30
|
-
- orchestrator (low model, low effort) -> always spawn+prompt; never user-facing tokens except `[
|
|
30
|
+
- orchestrator (low model, low effort) -> always spawn+prompt; never user-facing tokens except `[orchestrator]:` (or child role tag) echo. `/parent` one-turn only; `/handoff` writes `.tmp/grunt/handoffs/{serial}-{slug}-{stamp}.md` and hands the session to a fresh one.
|
|
31
31
|
- subagents:
|
|
32
32
|
- grunt (low model, low effort) -> tools. facts/search/exec/git/web/test/low-reason mechanical write. never feature solution. never spawn.
|
|
33
33
|
- implementer (medium model, medium effort) -> write already-defined solution. does not plan or invent design. never spawn.
|
package/package.json
CHANGED
|
@@ -42,6 +42,14 @@ const READ_TOOLS = new Set(["read", "readfile"]);
|
|
|
42
42
|
const GREP_TOOLS = new Set(["grep", "grepsearch"]);
|
|
43
43
|
const GLOB_TOOLS = new Set(["glob", "listdir"]);
|
|
44
44
|
const BASH_TOOLS = new Set(["bash", "runterminalcommand"]);
|
|
45
|
+
const WRITE_TOOLS = new Set(["write", "edit", "searchreplace"]);
|
|
46
|
+
const WRITE_PATH_FIELDS = [
|
|
47
|
+
"path",
|
|
48
|
+
"file_path",
|
|
49
|
+
"filePath",
|
|
50
|
+
"target_file",
|
|
51
|
+
"targetFile",
|
|
52
|
+
];
|
|
45
53
|
const PATH_FIELDS = [
|
|
46
54
|
"path",
|
|
47
55
|
"file_path",
|
|
@@ -124,6 +132,47 @@ export function hookResponse(updated) {
|
|
|
124
132
|
};
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
export function rewriteGruntScratchPath(filePath, workspaceRoot) {
|
|
136
|
+
if (filePath == null) return null;
|
|
137
|
+
const raw = String(filePath);
|
|
138
|
+
if (!raw || !workspaceRoot) return null;
|
|
139
|
+
const posix = raw.replace(/\\/g, "/");
|
|
140
|
+
const marker = ".tmp/grunt/";
|
|
141
|
+
const idx = posix.indexOf(marker);
|
|
142
|
+
if (idx === -1) return null;
|
|
143
|
+
|
|
144
|
+
const wsRoot = path.resolve(workspaceRoot);
|
|
145
|
+
const absIn = path.isAbsolute(raw)
|
|
146
|
+
? path.resolve(raw)
|
|
147
|
+
: path.resolve(wsRoot, raw);
|
|
148
|
+
const relWs = path.relative(wsRoot, absIn);
|
|
149
|
+
if (relWs !== "" && !relWs.startsWith("..") && !path.isAbsolute(relWs)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const rel = posix.slice(idx + marker.length);
|
|
154
|
+
if (!rel) return null;
|
|
155
|
+
if (path.posix.isAbsolute(rel) || rel.startsWith("/")) return null;
|
|
156
|
+
const relNorm = path.posix.normalize(rel);
|
|
157
|
+
if (
|
|
158
|
+
!relNorm ||
|
|
159
|
+
relNorm === "." ||
|
|
160
|
+
relNorm === ".." ||
|
|
161
|
+
relNorm.startsWith("../") ||
|
|
162
|
+
rel.split("/").includes("..") ||
|
|
163
|
+
relNorm.split("/").includes("..")
|
|
164
|
+
) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (path.posix.isAbsolute(relNorm)) return null;
|
|
168
|
+
|
|
169
|
+
const destRoot = path.resolve(wsRoot, ".tmp", "grunt");
|
|
170
|
+
const dest = path.resolve(destRoot, relNorm);
|
|
171
|
+
const check = path.relative(destRoot, dest);
|
|
172
|
+
if (!check || check.startsWith("..") || path.isAbsolute(check)) return null;
|
|
173
|
+
return dest;
|
|
174
|
+
}
|
|
175
|
+
|
|
127
176
|
export function workspaceRootOf(data) {
|
|
128
177
|
return (
|
|
129
178
|
process.env.GROK_WORKSPACE_ROOT ||
|
|
@@ -337,6 +386,21 @@ export function processFatTools(data) {
|
|
|
337
386
|
const toolKey = eventKey(data.toolName || data.tool_name || "");
|
|
338
387
|
const input = toolInputOf(data);
|
|
339
388
|
if (!input) return null;
|
|
389
|
+
const isWrite = WRITE_TOOLS.has(toolKey);
|
|
390
|
+
if (isWrite) {
|
|
391
|
+
const ws = workspaceRootOf(data);
|
|
392
|
+
const next = Object.assign({}, input);
|
|
393
|
+
let changed = false;
|
|
394
|
+
for (const key of WRITE_PATH_FIELDS) {
|
|
395
|
+
if (typeof next[key] !== "string" || !next[key]) continue;
|
|
396
|
+
const dest = rewriteGruntScratchPath(next[key], ws);
|
|
397
|
+
if (dest && dest !== next[key]) {
|
|
398
|
+
next[key] = dest;
|
|
399
|
+
changed = true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return changed ? { type: "rewrite", updatedInput: next } : null;
|
|
403
|
+
}
|
|
340
404
|
const sub = subagentTypeOf(data);
|
|
341
405
|
if (sub === "grunt") return null;
|
|
342
406
|
|
package/scripts/hooks-union.mjs
CHANGED
|
@@ -103,7 +103,7 @@ function gruntPreToolUse() {
|
|
|
103
103
|
hooks: [hookCmd(SCRUB, 5)],
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command",
|
|
106
|
+
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
|
|
107
107
|
hooks: [hookCmd(GATE_FAT, 5)],
|
|
108
108
|
},
|
|
109
109
|
];
|
|
@@ -205,7 +205,7 @@ function consumerNamed(root) {
|
|
|
205
205
|
};
|
|
206
206
|
named["gate-fat-tools"] = {
|
|
207
207
|
event: "PreToolUse",
|
|
208
|
-
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command",
|
|
208
|
+
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
|
|
209
209
|
command: GATE_FAT,
|
|
210
210
|
timeout: 5,
|
|
211
211
|
};
|
|
@@ -4,9 +4,10 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { scrubText } from "./scrub-text-lib.mjs";
|
|
7
|
+
import { denyResponse } from "./gate-fat-tools.mjs";
|
|
7
8
|
|
|
8
9
|
const ALLOWED_TYPES = new Set(["grunt", "implementer", "thinker"]);
|
|
9
|
-
export const MAX_PROMPT_CHARS =
|
|
10
|
+
export const MAX_PROMPT_CHARS = 100000;
|
|
10
11
|
export const TRUNCATE_SUFFIX = "…[truncated]";
|
|
11
12
|
/** `router` is a legacy transcript prefix; keep matching it to strip old pastes. */
|
|
12
13
|
const TRANSCRIPT_PREFIX =
|
|
@@ -72,14 +73,25 @@ export function extractVerdictBlocks(text) {
|
|
|
72
73
|
return { body: body.join("\n"), blocks };
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
export function truncatePrompt(text,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
export function truncatePrompt(text, _max = MAX_PROMPT_CHARS) {
|
|
77
|
+
return String(text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function workspaceRootOf(data) {
|
|
81
|
+
return (
|
|
82
|
+
process.env.GROK_WORKSPACE_ROOT ||
|
|
83
|
+
(data && (data.workspaceRoot || data.workspace_root || data.cwd)) ||
|
|
84
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
85
|
+
process.cwd() ||
|
|
86
|
+
""
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function spawnCapReason(workspaceRoot) {
|
|
91
|
+
const root = workspaceRoot || process.cwd() || "";
|
|
92
|
+
return (
|
|
93
|
+
`spawn prompt exceeds ${MAX_PROMPT_CHARS} chars after scrub; write it under ${root}/.tmp/plans/ and re-spawn with that abs path`
|
|
94
|
+
);
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
const INLINE_TRANSCRIPT =
|
|
@@ -108,13 +120,7 @@ export function capSpawnPrompt(prompt) {
|
|
|
108
120
|
const again = extractVerdictBlocks(s);
|
|
109
121
|
const blocks = extracted.blocks.concat(again.blocks);
|
|
110
122
|
const verdictPart = blocks.length ? "\n" + blocks.join("\n") : "";
|
|
111
|
-
|
|
112
|
-
let core = again.body;
|
|
113
|
-
if (core.length > maxCore) {
|
|
114
|
-
const cut = Math.max(0, maxCore - TRUNCATE_SUFFIX.length);
|
|
115
|
-
core = core.slice(0, cut) + TRUNCATE_SUFFIX;
|
|
116
|
-
}
|
|
117
|
-
return (core + verdictPart).trim();
|
|
123
|
+
return (again.body + verdictPart).trim();
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
export function rewriteSpawnToolInput(toolInput, { defaultGrunt = false } = {}) {
|
|
@@ -160,9 +166,19 @@ export function processHookPayload(data) {
|
|
|
160
166
|
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
|
161
167
|
let toolInput = data.toolInput;
|
|
162
168
|
if (toolInput == null) toolInput = data.tool_input;
|
|
163
|
-
|
|
169
|
+
const updated = rewriteSpawnToolInput(toolInput, {
|
|
164
170
|
defaultGrunt: grokDefaultGrunt(),
|
|
165
171
|
});
|
|
172
|
+
const prompt =
|
|
173
|
+
updated && typeof updated.prompt === "string"
|
|
174
|
+
? updated.prompt
|
|
175
|
+
: toolInput && typeof toolInput.prompt === "string"
|
|
176
|
+
? toolInput.prompt
|
|
177
|
+
: "";
|
|
178
|
+
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
179
|
+
return denyResponse(spawnCapReason(workspaceRootOf(data)));
|
|
180
|
+
}
|
|
181
|
+
return updated;
|
|
166
182
|
}
|
|
167
183
|
|
|
168
184
|
export function hookResponse(updated) {
|
|
@@ -179,6 +195,10 @@ function main() {
|
|
|
179
195
|
const data = readJsonValue();
|
|
180
196
|
const updated = processHookPayload(data);
|
|
181
197
|
if (!updated) return 0;
|
|
198
|
+
if (updated.decision === "deny") {
|
|
199
|
+
process.stdout.write(JSON.stringify(updated));
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
182
202
|
process.stdout.write(JSON.stringify(hookResponse(updated)));
|
|
183
203
|
return 0;
|
|
184
204
|
} catch {
|