@lovrozagar/grunt 0.5.0 → 0.6.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.
Files changed (153) hide show
  1. package/.agents/agents/grunt.md +2 -23
  2. package/.agents/agents/orchestrator.md +5 -45
  3. package/.agents/skills/ask/SKILL.md +11 -0
  4. package/.agents/skills/auto/SKILL.md +13 -0
  5. package/.agents/skills/browser/SKILL.md +4 -7
  6. package/.agents/skills/clasp/SKILL.md +11 -0
  7. package/.agents/skills/commit/SKILL.md +3 -1
  8. package/.agents/skills/commit-push/SKILL.md +2 -0
  9. package/.agents/skills/commit-push-deploy/SKILL.md +2 -0
  10. package/.agents/skills/commit-push-release/SKILL.md +2 -0
  11. package/.agents/skills/explain/SKILL.md +7 -13
  12. package/.agents/skills/google-workspace/SKILL.md +13 -0
  13. package/.agents/skills/handoff/SKILL.md +7 -11
  14. package/.agents/skills/implement-plan/SKILL.md +5 -108
  15. package/.agents/skills/listen/SKILL.md +15 -0
  16. package/.agents/skills/pickup/SKILL.md +6 -12
  17. package/.agents/skills/speak/SKILL.md +13 -0
  18. package/.agents/skills/tmp/SKILL.md +54 -0
  19. package/.agents/skills/write-plan/SKILL.md +5 -74
  20. package/.claude/agents/grunt.md +2 -25
  21. package/.claude/agents/orchestrator.md +12 -45
  22. package/.claude/skills/ask/SKILL.md +11 -0
  23. package/.claude/skills/auto/SKILL.md +13 -0
  24. package/.claude/skills/browser/SKILL.md +4 -7
  25. package/.claude/skills/clasp/SKILL.md +11 -0
  26. package/.claude/skills/commit/SKILL.md +3 -1
  27. package/.claude/skills/commit-push/SKILL.md +2 -0
  28. package/.claude/skills/commit-push-deploy/SKILL.md +2 -0
  29. package/.claude/skills/commit-push-release/SKILL.md +2 -0
  30. package/.claude/skills/explain/SKILL.md +7 -13
  31. package/.claude/skills/google-workspace/SKILL.md +13 -0
  32. package/.claude/skills/handoff/SKILL.md +7 -11
  33. package/.claude/skills/implement-plan/SKILL.md +5 -108
  34. package/.claude/skills/listen/SKILL.md +15 -0
  35. package/.claude/skills/pickup/SKILL.md +6 -12
  36. package/.claude/skills/speak/SKILL.md +13 -0
  37. package/.claude/skills/tmp/SKILL.md +54 -0
  38. package/.claude/skills/write-plan/SKILL.md +5 -74
  39. package/.codex/agents/grunt.toml +2 -25
  40. package/.codex/agents/orchestrator.toml +4 -45
  41. package/.grok/agents/grunt.md +2 -23
  42. package/.grok/agents/orchestrator.md +4 -43
  43. package/.grok/global-settings.toml +1 -3
  44. package/.grok/hooks/orchestrate-parent.js +278 -228
  45. package/.grok/skills/ask/SKILL.md +11 -0
  46. package/.grok/skills/auto/SKILL.md +13 -0
  47. package/.grok/skills/browser/SKILL.md +4 -7
  48. package/.grok/skills/clasp/SKILL.md +11 -0
  49. package/.grok/skills/commit/SKILL.md +3 -1
  50. package/.grok/skills/commit-push/SKILL.md +2 -0
  51. package/.grok/skills/commit-push-deploy/SKILL.md +2 -0
  52. package/.grok/skills/commit-push-release/SKILL.md +2 -0
  53. package/.grok/skills/explain/SKILL.md +7 -13
  54. package/.grok/skills/google-workspace/SKILL.md +13 -0
  55. package/.grok/skills/handoff/SKILL.md +7 -11
  56. package/.grok/skills/implement-plan/SKILL.md +5 -108
  57. package/.grok/skills/listen/SKILL.md +15 -0
  58. package/.grok/skills/pickup/SKILL.md +6 -12
  59. package/.grok/skills/speak/SKILL.md +13 -0
  60. package/.grok/skills/tmp/SKILL.md +54 -0
  61. package/.grok/skills/write-plan/SKILL.md +5 -74
  62. package/.rulesync/hooks.jsonc +1 -1
  63. package/.rulesync/mcp-policy.jsonc +0 -2
  64. package/.rulesync/reference/INDEX.md +30 -17
  65. package/.rulesync/reference/browser.md +13 -6
  66. package/.rulesync/reference/cascade.md +6 -84
  67. package/.rulesync/reference/clasp.md +21 -0
  68. package/.rulesync/reference/google-workspace.md +79 -0
  69. package/.rulesync/reference/hooks.md +7 -9
  70. package/.rulesync/reference/implementation-format.md +113 -0
  71. package/.rulesync/reference/law.md +5 -3
  72. package/.rulesync/reference/listen.md +80 -0
  73. package/.rulesync/reference/map.md +39 -31
  74. package/.rulesync/reference/output.md +3 -41
  75. package/.rulesync/reference/plan-format.md +14 -12
  76. package/.rulesync/reference/refs-map.md +12 -5
  77. package/.rulesync/reference/rtk.md +3 -2
  78. package/.rulesync/reference/scope.md +37 -0
  79. package/.rulesync/reference/setup.md +27 -0
  80. package/.rulesync/reference/skills-map.md +13 -9
  81. package/.rulesync/reference/speak.md +59 -0
  82. package/.rulesync/rules/CLAUDE.md +3 -44
  83. package/.rulesync/rules/overview.md +9 -42
  84. package/.rulesync/skills/ask/SKILL.md +11 -0
  85. package/.rulesync/skills/auto/SKILL.md +11 -0
  86. package/.rulesync/skills/browser/SKILL.md +4 -8
  87. package/.rulesync/skills/clasp/SKILL.md +9 -0
  88. package/.rulesync/skills/commit/SKILL.md +3 -1
  89. package/.rulesync/skills/commit-push/SKILL.md +2 -0
  90. package/.rulesync/skills/commit-push-deploy/SKILL.md +2 -0
  91. package/.rulesync/skills/commit-push-release/SKILL.md +2 -0
  92. package/.rulesync/skills/explain/SKILL.md +7 -14
  93. package/.rulesync/skills/google-workspace/SKILL.md +11 -0
  94. package/.rulesync/skills/handoff/SKILL.md +7 -11
  95. package/.rulesync/skills/implement-plan/SKILL.md +5 -116
  96. package/.rulesync/skills/listen/SKILL.md +13 -0
  97. package/.rulesync/skills/pickup/SKILL.md +6 -12
  98. package/.rulesync/skills/speak/SKILL.md +13 -0
  99. package/.rulesync/skills/tmp/SKILL.md +54 -0
  100. package/.rulesync/skills/write-plan/SKILL.md +5 -80
  101. package/.rulesync/subagents/grunt.md +2 -23
  102. package/.rulesync/subagents/orchestrator.md +14 -54
  103. package/AGENTS.md +9 -42
  104. package/CHANGELOG.md +89 -0
  105. package/CLAUDE.md +1 -44
  106. package/README.md +97 -88
  107. package/cli/grunt.mjs +29 -13
  108. package/cli/grunt.test.ts +62 -20
  109. package/cli/init.mjs +218 -31
  110. package/cli/init.test.ts +669 -46
  111. package/cli/prompt.mjs +8 -0
  112. package/cli/prompt.test.ts +14 -0
  113. package/package.json +13 -1
  114. package/scripts/browser.mjs +98 -5
  115. package/scripts/doctor.mjs +82 -1
  116. package/scripts/emit-gemini.mjs +1 -1
  117. package/scripts/emit-mcp-policy.mjs +1 -1
  118. package/scripts/gate-fat-tools.mjs +223 -173
  119. package/scripts/google-workspace.mjs +713 -0
  120. package/scripts/grunt-job.mjs +453 -69
  121. package/scripts/listen.mjs +829 -0
  122. package/scripts/parse-need.mjs +21 -2
  123. package/scripts/persist-implementation.mjs +278 -0
  124. package/scripts/persist-plan.mjs +17 -2
  125. package/scripts/persist-tmp.mjs +123 -0
  126. package/scripts/scrub-spawn-prompt.mjs +4 -51
  127. package/scripts/setup.mjs +746 -0
  128. package/scripts/speak.mjs +291 -0
  129. package/.agents/agents/implementer.md +0 -32
  130. package/.agents/agents/thinker.md +0 -28
  131. package/.agents/skills/cascade/SKILL.md +0 -26
  132. package/.agents/skills/parent/SKILL.md +0 -6
  133. package/.agents/skills/solo/SKILL.md +0 -23
  134. package/.claude/agents/implementer.md +0 -37
  135. package/.claude/agents/thinker.md +0 -35
  136. package/.claude/skills/cascade/SKILL.md +0 -26
  137. package/.claude/skills/parent/SKILL.md +0 -6
  138. package/.claude/skills/solo/SKILL.md +0 -23
  139. package/.codex/agents/implementer.toml +0 -26
  140. package/.codex/agents/thinker.toml +0 -25
  141. package/.grok/agents/implementer.md +0 -27
  142. package/.grok/agents/thinker.md +0 -26
  143. package/.grok/parent.md +0 -1
  144. package/.grok/roles/implementer.toml +0 -5
  145. package/.grok/roles/thinker.toml +0 -5
  146. package/.grok/skills/cascade/SKILL.md +0 -26
  147. package/.grok/skills/parent/SKILL.md +0 -6
  148. package/.grok/skills/solo/SKILL.md +0 -23
  149. package/.rulesync/skills/cascade/SKILL.md +0 -26
  150. package/.rulesync/skills/parent/SKILL.md +0 -6
  151. package/.rulesync/skills/solo/SKILL.md +0 -23
  152. package/.rulesync/subagents/implementer.md +0 -49
  153. package/.rulesync/subagents/thinker.md +0 -48
@@ -1,10 +1,6 @@
1
1
  #!/usr/bin/env node
2
- /** PreToolUse parent-deny on Claude+Grok+Antigravity; Stop = first-non-empty-line recap; stop-block survives host banners; SubagentStop is single-registration intercept.
3
- Parent Read/Grep/Glob/Bash/Web denied unless parent-escape (fat-gate still).
4
- SubagentStop intercepts need: search|exec with grunt-job verdicts.
5
- Stop: first-non-empty-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.
2
+ /** PreToolUse fat-gate + persist rewrite. Stop writes session receipt. SubagentStop intercepts need: search|exec|slice|fetch.
3
+ Fail-open: parse/crash → empty stdout, exit 0.
8
4
  */
9
5
  import fs from "node:fs";
10
6
  import path from "node:path";
@@ -17,33 +13,21 @@ import {
17
13
  import {
18
14
  denyResponse,
19
15
  fatHookOutput,
20
- isWorkspaceGruntJob,
21
- isParentOrchestrator,
22
16
  processFatTools,
23
17
  rewriteGruntScratchPath,
24
- subagentTypeOf,
25
18
  } from "../../scripts/gate-fat-tools.mjs";
26
19
  import { persistPlan } from "../../scripts/persist-plan.mjs";
27
20
  import { persistHandoff } from "../../scripts/persist-handoff.mjs";
21
+ import { persistImplementation, isFullImplementationBody, validateImplementation } from "../../scripts/persist-implementation.mjs";
22
+ import { persistTmp } from "../../scripts/persist-tmp.mjs";
28
23
  import { parseNeed } from "../../scripts/parse-need.mjs";
29
24
  import { resolveJobCwd, runJob } from "../../scripts/grunt-job.mjs";
30
25
 
31
- export const ORCHESTRATOR_LOGS_DIR = ".tmp/orchestrator-logs";
32
- export const DENY_REASON =
33
- "First action=spawn implementer|grunt|thinker. Deny expected. Only /solo this session escapes.";
34
- export const STOP_REASONS = [
35
- "Spawn: ⚠/validate/sim findings or writes remain after Implement pick/`/implement-plan`/explicit implement or parent just tried to Write → spawn implementer. Else facts → grunt. Else no spec/not small/simple → thinker then recap-stop. Else small/simple/defined → implementer. Thinker recap ≠ spec-ready. `ok`/`yes` ≠ implement. Else recap `[orchestrator]:` one recap line; advise leftover 1./2. each on own line after.",
36
- "Still spawn: ⚠/validate/sim findings or writes remain after Implement pick/`/implement-plan`/explicit implement or parent just tried to Write → spawn implementer. Else facts → grunt. Else no spec/not small/simple → thinker then recap-stop. Else small/simple/defined → implementer. Thinker recap ≠ spec-ready. `ok`/`yes` ≠ implement. Else recap `[orchestrator]:` one recap line; advise leftover 1./2. each on own line after.",
37
- "Last spawn: ⚠/validate/sim findings or writes remain after Implement pick/`/implement-plan`/explicit implement or parent just tried to Write → spawn implementer. Else facts → grunt. Else no spec/not small/simple → thinker then recap-stop. Else small/simple/defined → implementer. Thinker recap ≠ spec-ready. `ok`/`yes` ≠ implement. Else recap `[orchestrator]:` one recap line; advise leftover 1./2. each on own line after.",
38
- ];
26
+ export const ORCHESTRATOR_LOGS_DIR = ".tmp/grunt/orchestrator-logs";
27
+ /** One-release dual-read; drop next release. */
28
+ export const LEGACY_ORCHESTRATOR_LOGS_DIR = ".tmp/orchestrator-logs";
29
+ export const DENY_REASON = "denied";
39
30
  const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
40
- const PARENT_TOOLS = new Set([
41
- "todowrite",
42
- "getcommandorsubagentoutput",
43
- "gettaskoutput",
44
- "killcommandorsubagent",
45
- "killtask",
46
- ]);
47
31
  const SPAWN_TOOLS = new Set([
48
32
  "spawnsubagent",
49
33
  "task",
@@ -56,26 +40,16 @@ const WRITE_TOOLS = new Set([
56
40
  "searchreplace",
57
41
  "replacefilecontent",
58
42
  ]);
59
- const BASH_TOOLS = new Set(["bash", "runterminalcommand", "runcommand"]);
60
- const SKILL_TOOLS = new Set(["skill"]);
61
- const PARENT_SKILLS = new Set([
62
- "parent",
63
- "explain",
64
- "solo",
65
- "cascade",
66
- "handoff",
67
- "pickup",
68
- "write-plan",
69
- "implement-plan",
70
- ]);
71
- const INTERCEPT_JOBS = new Set(["search", "exec"]);
72
- const MAX_STOP = 3;
43
+ const INTERCEPT_JOBS = new Set(["search", "exec", "slice", "fetch"]);
73
44
  const MAX_INTERCEPT = 3;
74
- /** `/solo` enters single-agent mode; `/cascade` restores the orchestrator. */
75
- const SOLO_RE = /^\s*\/solo\s*$/;
76
- const CASCADE_RE = /^\s*\/cascade\s*$/;
77
- const SOLO_STAMP = "grunt-off";
78
-
45
+ const MAX_STOP = 3;
46
+ const AUTO_RE = /^\s*\/auto\s*$/;
47
+ const ASK_RE = /^\s*\/ask\s*$/;
48
+ export const SESSION_GATE_STAMP = "session-gate";
49
+ /** One-release dual-read of 0.5 leftover stamp. */
50
+ export const AUTO_ASK_STAMP = "auto-ask";
51
+ export const ASK_STOP_REASON = "ask after this step";
52
+ const WAIT_GRUNT = "[orchestrator]: wait grunt";
79
53
 
80
54
  function main() {
81
55
  try {
@@ -86,7 +60,6 @@ function main() {
86
60
  "",
87
61
  );
88
62
  if (event === "pretooluse") return preToolUse(data || {});
89
- if (event === "posttooluse") return postToolUse(data || {});
90
63
  if (event === "userpromptsubmit") return userPromptSubmit(data || {});
91
64
  if (event === "stop") return stop(data || {});
92
65
  if (event === "subagentstop") return interceptNeed(data || {}, "SubagentStop");
@@ -102,18 +75,6 @@ function preToolUse(data) {
102
75
  let toolInput = data.toolInput;
103
76
  if (toolInput == null) toolInput = data.tool_input;
104
77
 
105
- const sub = subagentTypeOf(data);
106
- if (sub || !isParentOrchestrator(data)) {
107
- const code = emitFat(data);
108
- return code == null ? 0 : code;
109
- }
110
- if (isSoloMode(data)) {
111
- // Single-agent session: no parent-deny, no spawn rewrite. Fat gate still applies.
112
- const fatCode = emitFat(data);
113
- if (fatCode !== null) return fatCode;
114
- emit({ decision: "allow" });
115
- return 0;
116
- }
117
78
  if (SPAWN_TOOLS.has(toolKey)) {
118
79
  const updated = rewriteSpawn(toolInput, data);
119
80
  if (updated && updated.__denied) return 0;
@@ -130,45 +91,15 @@ function preToolUse(data) {
130
91
  return 0;
131
92
  }
132
93
  if (WRITE_TOOLS.has(toolKey)) {
133
- return parentWrite(data, toolInput);
134
- }
135
- if (PARENT_TOOLS.has(toolKey)) {
136
- emit({ decision: "allow" });
137
- return 0;
138
- }
139
- if (BASH_TOOLS.has(toolKey) && isAllowedParentGruntJob(bashCommandOf(toolInput), workspaceRootOf(data))) {
140
- emit({ decision: "allow" });
141
- return 0;
142
- }
143
- if (SKILL_TOOLS.has(toolKey) && isAllowedParentSkill(toolInput)) {
144
- emit({ decision: "allow" });
145
- return 0;
146
- }
147
- if (hasParentEscape(data)) {
148
- const fatCode = emitFat(data);
149
- if (fatCode !== null) return fatCode;
150
- emit({ decision: "allow" });
151
- return 0;
94
+ const persistCode = parentWrite(data, toolInput);
95
+ if (persistCode !== null) return persistCode;
152
96
  }
153
- emit({ decision: "deny", reason: DENY_REASON });
97
+ const fatCode = emitFat(data);
98
+ if (fatCode !== null) return fatCode;
99
+ emit({ decision: "allow" });
154
100
  return 0;
155
101
  }
156
102
 
157
- /** Session flag, not one-turn. Fail-closed: an unreadable stamp keeps grunt on. */
158
- export function isSoloMode(data) {
159
- try {
160
- const p = soloStampPath(data);
161
- return Boolean(p && fs.existsSync(p));
162
- } catch {
163
- return false;
164
- }
165
- }
166
-
167
- function hasParentEscape(data) {
168
- const p = stampPath(data, "parent-escape");
169
- return Boolean(p && fs.existsSync(p));
170
- }
171
-
172
103
  function emitFat(data) {
173
104
  const fat = fatHookOutput(processFatTools(data));
174
105
  if (!fat) return null;
@@ -198,49 +129,35 @@ function isUnderDir(filePath, workspaceRoot, segments) {
198
129
  }
199
130
 
200
131
  export function isUnderPlans(filePath, workspaceRoot) {
201
- return isUnderDir(filePath, workspaceRoot, [".tmp", "plans"]);
132
+ return isUnderDir(filePath, workspaceRoot, [".tmp", "grunt", "plans"]);
202
133
  }
203
134
 
204
135
  export function isUnderHandoffs(filePath, workspaceRoot) {
205
136
  return isUnderDir(filePath, workspaceRoot, [".tmp", "grunt", "handoffs"]);
206
137
  }
207
138
 
208
- export function isAllowedParentGruntJob(command, workspaceRoot) {
209
- return isWorkspaceGruntJob(command, workspaceRoot, ["search", "exec"]);
139
+ export function isUnderImplementations(filePath, workspaceRoot) {
140
+ return isUnderDir(filePath, workspaceRoot, [".tmp", "grunt", "implementations"]);
210
141
  }
211
142
 
212
- function bashCommandOf(toolInput) {
213
- if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
214
- return "";
215
- }
216
- return String(toolInput.command ?? toolInput.cmd ?? "");
217
- }
218
-
219
- function skillNameOf(toolInput) {
220
- if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
221
- return "";
222
- }
223
- const raw =
224
- toolInput.skill ??
225
- toolInput.skill_name ??
226
- toolInput.skillName ??
227
- toolInput.name ??
228
- "";
229
- const s = String(raw).trim().toLowerCase().replace(/\\/g, "/");
230
- const base = s.split("/").pop() || "";
231
- return base.replace(/\.md$/, "").replace(/_/g, "-");
232
- }
233
-
234
- export function isAllowedParentSkill(toolInput) {
235
- return PARENT_SKILLS.has(skillNameOf(toolInput));
236
- }
143
+ export const TMP_RESERVED_DIRS = new Set([
144
+ "plans",
145
+ "handoffs",
146
+ "implementations",
147
+ "browser",
148
+ "orchestrator-logs",
149
+ "stash",
150
+ "sessions",
151
+ ]);
237
152
 
238
- function isHostStopBanner(prompt) {
239
- const p = String(prompt || "");
240
- if (/^\s*Stop hook feedback:/.test(p)) return true;
241
- if (/Blocked by stop hook/.test(p)) return true;
242
- if (/task[-_ ]?notification/i.test(p)) return true;
243
- return false;
153
+ export function isUnderTmp(filePath, workspaceRoot) {
154
+ if (!filePath || !workspaceRoot) return false;
155
+ const abs = path.isAbsolute(filePath)
156
+ ? path.resolve(filePath)
157
+ : path.resolve(workspaceRoot, filePath);
158
+ const root = path.resolve(workspaceRoot, ".tmp", "grunt");
159
+ if (path.dirname(abs) !== root) return false;
160
+ return !TMP_RESERVED_DIRS.has(path.basename(abs));
244
161
  }
245
162
 
246
163
  function parentWrite(data, toolInput) {
@@ -257,22 +174,59 @@ function parentWrite(data, toolInput) {
257
174
  "";
258
175
  const rewritten = rewriteGruntScratchPath(rawPath, ws);
259
176
  if (rewritten) rawPath = rewritten;
260
- const handoff = isUnderHandoffs(rawPath, ws);
261
- if (!handoff && !isUnderPlans(rawPath, ws)) {
262
- if (hasParentEscape(data)) {
263
- emit({ decision: "allow" });
177
+ let persist;
178
+ let invalid = "invalid plan";
179
+ if (isUnderPlans(rawPath, ws)) {
180
+ persist = persistPlan;
181
+ invalid = "invalid plan";
182
+ } else if (isUnderHandoffs(rawPath, ws)) {
183
+ persist = persistHandoff;
184
+ invalid = "invalid handoff";
185
+ } else if (isUnderImplementations(rawPath, ws)) {
186
+ persist = persistImplementation;
187
+ invalid = "invalid implementation";
188
+ const abs = path.isAbsolute(rawPath)
189
+ ? path.resolve(rawPath)
190
+ : path.resolve(ws, rawPath);
191
+ const content = typeof toolInput.content === "string" ? toolInput.content : "";
192
+ const named = /^\s*IMPL_NAME:/m.test(content);
193
+ let exists = false;
194
+ try {
195
+ exists = fs.statSync(abs).isFile();
196
+ } catch {
197
+ exists = false;
198
+ }
199
+ if (!named && exists) {
200
+ if (!isFullImplementationBody(content)) return null;
201
+ const errors = validateImplementation(path.basename(abs), content);
202
+ if (errors.length) {
203
+ emit({ decision: "deny", reason: errors.join("; ") || invalid });
204
+ return 0;
205
+ }
206
+ const next = Object.assign({}, toolInput, {
207
+ file_path: abs,
208
+ content,
209
+ });
210
+ emit({
211
+ hookSpecificOutput: {
212
+ hookEventName: "PreToolUse",
213
+ updatedInput: next,
214
+ },
215
+ });
264
216
  return 0;
265
217
  }
266
- emit({ decision: "deny", reason: DENY_REASON });
267
- return 0;
218
+ } else if (isUnderTmp(rawPath, ws)) {
219
+ persist = persistTmp;
220
+ invalid = "invalid tmp";
221
+ } else {
222
+ return null;
268
223
  }
269
224
  const content = typeof toolInput.content === "string" ? toolInput.content : "";
270
- const persist = handoff ? persistHandoff : persistPlan;
271
225
  const result = persist({ workspaceRoot: ws, content });
272
226
  if (!result.ok) {
273
227
  emit({
274
228
  decision: "deny",
275
- reason: result.error || (handoff ? "invalid handoff" : "invalid plan"),
229
+ reason: result.error || invalid,
276
230
  });
277
231
  return 0;
278
232
  }
@@ -290,7 +244,6 @@ function parentWrite(data, toolInput) {
290
244
  }
291
245
 
292
246
  function rewriteSpawn(toolInput, data) {
293
- // Type default + intent-scrub in one updatedInput (last-wins with scrub-spawn).
294
247
  const updated = rewriteSpawnToolInput(toolInput, { defaultGrunt: true });
295
248
  const prompt =
296
249
  updated && typeof updated.prompt === "string"
@@ -305,69 +258,93 @@ function rewriteSpawn(toolInput, data) {
305
258
  return updated;
306
259
  }
307
260
 
308
- function postToolUse(data) {
309
- if (data.subagentType || data.subagent_type) return 0;
310
- const stamp = stampPath(data, "tools-used");
311
- if (!stamp) return 0;
312
- fs.mkdirSync(path.dirname(stamp), { recursive: true });
313
- fs.writeFileSync(stamp, "1");
314
- return 0;
315
- }
316
-
317
261
  function userPromptOf(data) {
318
262
  if (!data || typeof data !== "object") return "";
319
263
  return String(data.prompt ?? data.userPrompt ?? data.user_prompt ?? data.content ?? "");
320
264
  }
321
265
 
322
- function isParentEscapePrompt(prompt) {
323
- return /^\s*\/parent(?:\s|$)/.test(String(prompt || ""));
266
+ function isHostStopBanner(prompt) {
267
+ const p = String(prompt || "");
268
+ if (/^\s*Stop hook feedback:/.test(p)) return true;
269
+ if (/Blocked by stop hook/.test(p)) return true;
270
+ if (/task[-_ ]?notification/i.test(p)) return true;
271
+ return false;
324
272
  }
325
273
 
326
- function userPromptSubmit(data) {
327
- unlinkQuiet(stampPath(data, "tools-used"));
328
- const prompt = userPromptOf(data);
329
- if (!isHostStopBanner(prompt)) {
330
- unlinkQuiet(stampPath(data, "stop-block"));
331
- }
332
- // Sticky: only /solo and /cascade move it. Every other prompt leaves it alone.
333
- if (SOLO_RE.test(prompt)) {
334
- const solo = soloStampPath(data);
335
- if (solo) {
336
- fs.mkdirSync(path.dirname(solo), { recursive: true });
337
- fs.writeFileSync(solo, "1");
338
- }
339
- } else if (CASCADE_RE.test(prompt)) {
340
- unlinkQuiet(soloStampPath(data));
341
- }
342
- if (isParentEscapePrompt(prompt)) {
343
- const p = stampPath(data, "parent-escape");
344
- if (p) {
345
- fs.mkdirSync(path.dirname(p), { recursive: true });
346
- fs.writeFileSync(p, "1");
347
- }
348
- } else {
349
- unlinkQuiet(stampPath(data, "parent-escape"));
274
+ function countLines(file) {
275
+ try {
276
+ return fs
277
+ .readFileSync(file, "utf8")
278
+ .split("\n")
279
+ .map((l) => l.trim())
280
+ .filter(Boolean).length;
281
+ } catch {
282
+ return 0;
350
283
  }
351
- return 0;
352
284
  }
353
285
 
354
- const RECAP_TAG_RE =
355
- /^\[(?:orchestrator|grunt|implementer|thinker|handoff)\]:/;
356
- const WAIT_GRUNT = "[orchestrator]: wait grunt";
286
+ function sessionReceiptContext(data) {
287
+ const sid = sessionIdOf(data);
288
+ if (!sid || sid === "default") return "";
289
+ const dir = path.join(
290
+ workspaceRootOf(data),
291
+ ".tmp",
292
+ "grunt",
293
+ "sessions",
294
+ sid,
295
+ );
296
+ if (!fs.existsSync(dir)) return "";
297
+ const wrote = countLines(path.join(dir, "wrote.txt"));
298
+ const read = countLines(path.join(dir, "read.txt"));
299
+ return `${wrote} wrote, ${read} read. session=.tmp/grunt/sessions/${sid}/`;
300
+ }
357
301
 
358
- function firstNonEmptyStripped(msg) {
359
- for (const line of String(msg || "").split("\n")) {
360
- if (!line.trim()) continue;
361
- return line.replace(/^[\s`*_>]+/, "");
302
+ function writeSessionReceipt(data, msg) {
303
+ const sid = sessionIdOf(data);
304
+ if (!sid || sid === "default") return;
305
+ const dir = path.join(
306
+ workspaceRootOf(data),
307
+ ".tmp",
308
+ "grunt",
309
+ "sessions",
310
+ sid,
311
+ );
312
+ try {
313
+ fs.mkdirSync(dir, { recursive: true });
314
+ } catch {
315
+ return;
316
+ }
317
+ const wrote = countLines(path.join(dir, "wrote.txt"));
318
+ const read = countLines(path.join(dir, "read.txt"));
319
+ const first = firstNonEmptyStripped(msg).slice(0, 200);
320
+ const lines = [
321
+ first,
322
+ `${wrote} wrote, ${read} read.`,
323
+ `session=.tmp/grunt/sessions/${sid}/`,
324
+ ].filter(Boolean);
325
+ try {
326
+ fs.writeFileSync(path.join(dir, "receipt.txt"), lines.join("\n") + "\n");
327
+ } catch {
328
+ /* fail-open */
362
329
  }
363
- return "";
364
330
  }
365
331
 
366
- export function isRecap(msg) {
367
- return RECAP_TAG_RE.test(firstNonEmptyStripped(msg));
332
+ function userPromptSubmit(data) {
333
+ const prompt = userPromptOf(data);
334
+ if (isHostStopBanner(prompt)) return 0;
335
+ unlinkStamp(data, "stop-block");
336
+ applySessionGateSlash(data, prompt);
337
+ const ctx = effectiveGruntContext(data);
338
+ if (!ctx) return 0;
339
+ emit({
340
+ hookSpecificOutput: {
341
+ hookEventName: "UserPromptSubmit",
342
+ additionalContext: ctx,
343
+ },
344
+ });
345
+ return 0;
368
346
  }
369
347
 
370
- /** Mid-turn wait must be exactly `[orchestrator]: wait grunt` (no leftover lines). */
371
348
  export function isWaitGruntExact(msg) {
372
349
  const nonempty = String(msg || "")
373
350
  .split("\n")
@@ -376,6 +353,23 @@ export function isWaitGruntExact(msg) {
376
353
  return nonempty[0].replace(/^[\s`*_>]+/, "") === WAIT_GRUNT;
377
354
  }
378
355
 
356
+ export function hasStepAsk(msg) {
357
+ if (isWaitGruntExact(msg)) return true;
358
+ return String(msg || "")
359
+ .split("\n")
360
+ .map((l) => l.replace(/^[\s`*_>]+/, "").trim())
361
+ .filter(Boolean)
362
+ .some((l) => l.includes("?"));
363
+ }
364
+
365
+ function firstNonEmptyStripped(msg) {
366
+ for (const line of String(msg || "").split("\n")) {
367
+ if (!line.trim()) continue;
368
+ return line.replace(/^[\s`*_>]+/, "");
369
+ }
370
+ return "";
371
+ }
372
+
379
373
  function payloadAssistantMessage(data) {
380
374
  if (!data || typeof data !== "object") return "";
381
375
  const camel = data.lastAssistantMessage;
@@ -463,44 +457,21 @@ function stop(data) {
463
457
  const reason = String(data.reason || "");
464
458
  if (reason && reason !== "end_turn") return 0;
465
459
 
466
- // Before the parent-escape consume: solo must never burn the one-turn stamp.
467
- if (isSoloMode(data)) return 0;
468
-
469
- const escapeStamp = stampPath(data, "parent-escape");
470
- if (escapeStamp && fs.existsSync(escapeStamp)) {
471
- unlinkQuiet(escapeStamp);
472
- return 0;
473
- }
474
-
475
460
  const payloadMsg = payloadAssistantMessage(data);
476
461
  let msg = payloadMsg;
477
462
  if (!msg) {
478
463
  const tp = data.transcript_path || data.transcriptPath || "";
479
464
  msg = lastAssistantFromTranscript(tp);
480
465
  }
481
-
482
- const waitFirst = firstNonEmptyStripped(msg) === WAIT_GRUNT;
483
- if (waitFirst && !isWaitGruntExact(msg)) {
484
- // fall through to block — leftover lines not allowed on mid-turn wait
485
- } else if (isRecap(msg)) {
486
- return 0;
487
- }
488
-
489
- const stopStamp = stampPath(data, "stop-block");
490
- let n = 0;
491
- if (stopStamp && fs.existsSync(stopStamp)) {
492
- n = parseInt(fs.readFileSync(stopStamp, "utf8"), 10);
493
- if (!Number.isFinite(n) || n < 0) n = 0;
494
- }
495
- if (n >= MAX_STOP) {
466
+ writeSessionReceipt(data, msg);
467
+ if (isWaitGruntExact(msg)) return 0;
468
+ if (sessionGateOf(data) === "ask" && !hasStepAsk(msg)) {
469
+ let n = readStampInt(data, "stop-block");
470
+ if (n >= MAX_STOP) return 0;
471
+ writeStamp(data, "stop-block", String(n + 1));
472
+ emit({ decision: "block", reason: ASK_STOP_REASON });
496
473
  return 0;
497
474
  }
498
- if (stopStamp) {
499
- fs.mkdirSync(path.dirname(stopStamp), { recursive: true });
500
- fs.writeFileSync(stopStamp, String(n + 1));
501
- }
502
- const reasonText = STOP_REASONS[Math.min(n, STOP_REASONS.length - 1)];
503
- emit({ decision: "block", reason: reasonText });
504
475
  return 0;
505
476
  }
506
477
 
@@ -522,12 +493,7 @@ function interceptNeed(data, hookEventName) {
522
493
  return 0;
523
494
  }
524
495
 
525
- const interceptStamp = stampPath(data, "need-intercept");
526
- let n = 0;
527
- if (interceptStamp && fs.existsSync(interceptStamp)) {
528
- n = parseInt(fs.readFileSync(interceptStamp, "utf8"), 10);
529
- if (!Number.isFinite(n) || n < 0) n = 0;
530
- }
496
+ let n = readStampInt(data, "need-intercept");
531
497
  if (n >= MAX_INTERCEPT) {
532
498
  return 0;
533
499
  }
@@ -547,6 +513,9 @@ function interceptNeed(data, hookEventName) {
547
513
  cwd: jobCwd || cwd,
548
514
  path: job.path,
549
515
  glob: job.glob,
516
+ stash: job.stash,
517
+ from: job.from,
518
+ to: job.to,
550
519
  });
551
520
  }
552
521
  } catch {
@@ -557,10 +526,7 @@ function interceptNeed(data, hookEventName) {
557
526
  }
558
527
  parts.push(String(result.text || "").trimEnd());
559
528
  }
560
- if (interceptStamp) {
561
- fs.mkdirSync(path.dirname(interceptStamp), { recursive: true });
562
- fs.writeFileSync(interceptStamp, String(n + 1));
563
- }
529
+ writeStamp(data, "need-intercept", String(n + 1));
564
530
  const reason = parts.join("\n");
565
531
  emit({
566
532
  decision: "block",
@@ -579,6 +545,37 @@ function stampPath(data, prefix) {
579
545
  return path.join(root, ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
580
546
  }
581
547
 
548
+ function legacyStampPath(data, prefix) {
549
+ const root = workspaceRootOf(data);
550
+ const sid = sessionIdOf(data) || "default";
551
+ if (!root) return null;
552
+ return path.join(root, LEGACY_ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
553
+ }
554
+
555
+ function resolveStamp(data, prefix) {
556
+ const neu = stampPath(data, prefix);
557
+ if (neu && fs.existsSync(neu)) return neu;
558
+ const old = legacyStampPath(data, prefix);
559
+ if (old && fs.existsSync(old)) return old;
560
+ return null;
561
+ }
562
+
563
+ function writeStamp(data, prefix, body) {
564
+ const p = stampPath(data, prefix);
565
+ if (!p) return null;
566
+ fs.mkdirSync(path.dirname(p), { recursive: true });
567
+ fs.writeFileSync(p, body);
568
+ return p;
569
+ }
570
+
571
+ function readStampInt(data, prefix) {
572
+ const p = resolveStamp(data, prefix);
573
+ if (!p) return 0;
574
+ const n = parseInt(fs.readFileSync(p, "utf8"), 10);
575
+ if (!Number.isFinite(n) || n < 0) return 0;
576
+ return n;
577
+ }
578
+
582
579
  function sessionIdOf(data) {
583
580
  return String(
584
581
  process.env.GROK_SESSION_ID ||
@@ -587,21 +584,74 @@ function sessionIdOf(data) {
587
584
  );
588
585
  }
589
586
 
590
- /** Solo is a mode: never share a `default` stamp across sid-less sessions. */
591
- function soloStampPath(data) {
587
+ function unlinkQuiet(p) {
588
+ if (!p) return;
589
+ try {
590
+ fs.unlinkSync(p);
591
+ } catch {
592
+ /* missing is fine */
593
+ }
594
+ }
595
+
596
+ function unlinkStamp(data, prefix) {
597
+ unlinkQuiet(stampPath(data, prefix));
598
+ unlinkQuiet(legacyStampPath(data, prefix));
599
+ }
600
+
601
+ function sessionGateStampPath(data, prefix) {
592
602
  const root = workspaceRootOf(data);
593
603
  const sid = sessionIdOf(data);
594
- if (!root || !sid) return null;
595
- return path.join(root, ORCHESTRATOR_LOGS_DIR, SOLO_STAMP + "-" + sid);
604
+ if (!root || !sid || sid === "default") return null;
605
+ return path.join(root, ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
596
606
  }
597
607
 
598
- function unlinkQuiet(p) {
608
+ function resolveSessionGateStamp(data) {
609
+ for (const prefix of [SESSION_GATE_STAMP, AUTO_ASK_STAMP]) {
610
+ const neu = sessionGateStampPath(data, prefix);
611
+ if (neu && fs.existsSync(neu)) return neu;
612
+ const old = path.join(
613
+ workspaceRootOf(data),
614
+ LEGACY_ORCHESTRATOR_LOGS_DIR,
615
+ prefix + "-" + sessionIdOf(data),
616
+ );
617
+ if (sessionIdOf(data) && fs.existsSync(old)) return old;
618
+ }
619
+ return null;
620
+ }
621
+
622
+ function applySessionGateSlash(data, prompt) {
623
+ let slash = "";
624
+ if (AUTO_RE.test(prompt)) slash = "auto";
625
+ else if (ASK_RE.test(prompt)) slash = "ask";
626
+ if (!slash) return;
627
+ const p = sessionGateStampPath(data, SESSION_GATE_STAMP);
599
628
  if (!p) return;
629
+ unlinkQuiet(sessionGateStampPath(data, AUTO_ASK_STAMP));
630
+ if (slash === "auto") {
631
+ unlinkQuiet(p);
632
+ return;
633
+ }
634
+ fs.mkdirSync(path.dirname(p), { recursive: true });
635
+ fs.writeFileSync(p, slash);
636
+ }
637
+
638
+ export function sessionGateOf(data) {
600
639
  try {
601
- fs.unlinkSync(p);
640
+ const p = resolveSessionGateStamp(data);
641
+ if (p) {
642
+ const body = fs.readFileSync(p, "utf8").trim();
643
+ if (body === "auto" || body === "ask") return body;
644
+ }
602
645
  } catch {
603
- // missing is fine
646
+ /* fall through */
604
647
  }
648
+ return "auto";
649
+ }
650
+
651
+ export function effectiveGruntContext(data) {
652
+ const gate = sessionGateOf(data);
653
+ const receipt = sessionReceiptContext(data);
654
+ return receipt ? `sessionGate=${gate}. ${receipt}` : `sessionGate=${gate}`;
605
655
  }
606
656
 
607
657
  function eventKey(s) {