@lovrozagar/grunt 0.5.2 → 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 -24
  2. package/.agents/agents/orchestrator.md +5 -50
  3. package/.agents/skills/ask/SKILL.md +5 -12
  4. package/.agents/skills/auto/SKILL.md +7 -12
  5. package/.agents/skills/browser/SKILL.md +4 -8
  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 -109
  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 +6 -10
  19. package/.agents/skills/write-plan/SKILL.md +5 -77
  20. package/.claude/agents/grunt.md +2 -26
  21. package/.claude/agents/orchestrator.md +12 -50
  22. package/.claude/skills/ask/SKILL.md +5 -12
  23. package/.claude/skills/auto/SKILL.md +7 -12
  24. package/.claude/skills/browser/SKILL.md +4 -8
  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 -109
  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 +6 -10
  38. package/.claude/skills/write-plan/SKILL.md +5 -77
  39. package/.codex/agents/grunt.toml +2 -26
  40. package/.codex/agents/orchestrator.toml +4 -50
  41. package/.grok/agents/grunt.md +2 -24
  42. package/.grok/agents/orchestrator.md +4 -48
  43. package/.grok/global-settings.toml +1 -3
  44. package/.grok/hooks/orchestrate-parent.js +190 -358
  45. package/.grok/skills/ask/SKILL.md +5 -12
  46. package/.grok/skills/auto/SKILL.md +7 -12
  47. package/.grok/skills/browser/SKILL.md +4 -8
  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 -109
  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 +6 -10
  61. package/.grok/skills/write-plan/SKILL.md +5 -77
  62. package/.rulesync/hooks.jsonc +1 -1
  63. package/.rulesync/mcp-policy.jsonc +0 -2
  64. package/.rulesync/reference/INDEX.md +30 -20
  65. package/.rulesync/reference/browser.md +13 -6
  66. package/.rulesync/reference/cascade.md +6 -86
  67. package/.rulesync/reference/clasp.md +21 -0
  68. package/.rulesync/reference/google-workspace.md +79 -0
  69. package/.rulesync/reference/hooks.md +7 -11
  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 -36
  74. package/.rulesync/reference/output.md +3 -51
  75. package/.rulesync/reference/plan-format.md +4 -2
  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 -12
  81. package/.rulesync/reference/speak.md +59 -0
  82. package/.rulesync/rules/CLAUDE.md +3 -49
  83. package/.rulesync/rules/overview.md +9 -47
  84. package/.rulesync/skills/ask/SKILL.md +5 -12
  85. package/.rulesync/skills/auto/SKILL.md +5 -12
  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 -119
  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 +6 -10
  100. package/.rulesync/skills/write-plan/SKILL.md +5 -84
  101. package/.rulesync/subagents/grunt.md +2 -24
  102. package/.rulesync/subagents/orchestrator.md +14 -59
  103. package/AGENTS.md +9 -47
  104. package/CHANGELOG.md +56 -0
  105. package/CLAUDE.md +1 -49
  106. package/README.md +88 -80
  107. package/cli/grunt.mjs +18 -2
  108. package/cli/grunt.test.ts +33 -0
  109. package/cli/init.mjs +103 -10
  110. package/cli/init.test.ts +147 -35
  111. package/cli/prompt.mjs +8 -0
  112. package/cli/prompt.test.ts +14 -0
  113. package/package.json +7 -2
  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/scrub-spawn-prompt.mjs +3 -50
  125. package/scripts/setup.mjs +746 -0
  126. package/scripts/speak.mjs +291 -0
  127. package/.agents/agents/implementer.md +0 -32
  128. package/.agents/agents/thinker.md +0 -29
  129. package/.agents/skills/cascade/SKILL.md +0 -28
  130. package/.agents/skills/parent/SKILL.md +0 -6
  131. package/.agents/skills/solo/SKILL.md +0 -25
  132. package/.claude/agents/implementer.md +0 -37
  133. package/.claude/agents/thinker.md +0 -36
  134. package/.claude/skills/cascade/SKILL.md +0 -28
  135. package/.claude/skills/parent/SKILL.md +0 -6
  136. package/.claude/skills/solo/SKILL.md +0 -25
  137. package/.codex/agents/implementer.toml +0 -26
  138. package/.codex/agents/thinker.toml +0 -26
  139. package/.grok/agents/implementer.md +0 -27
  140. package/.grok/agents/thinker.md +0 -27
  141. package/.grok/parent.md +0 -1
  142. package/.grok/roles/implementer.toml +0 -5
  143. package/.grok/roles/thinker.toml +0 -5
  144. package/.grok/skills/cascade/SKILL.md +0 -28
  145. package/.grok/skills/parent/SKILL.md +0 -6
  146. package/.grok/skills/solo/SKILL.md +0 -25
  147. package/.rulesync/grunt.config.jsonc +0 -7
  148. package/.rulesync/skills/cascade/SKILL.md +0 -28
  149. package/.rulesync/skills/parent/SKILL.md +0 -6
  150. package/.rulesync/skills/solo/SKILL.md +0 -25
  151. package/.rulesync/subagents/implementer.md +0 -49
  152. package/.rulesync/subagents/thinker.md +0 -49
  153. package/scripts/grunt-config.mjs +0 -50
@@ -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]:/[tmp]: 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,37 +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";
28
22
  import { persistTmp } from "../../scripts/persist-tmp.mjs";
29
23
  import { parseNeed } from "../../scripts/parse-need.mjs";
30
24
  import { resolveJobCwd, runJob } from "../../scripts/grunt-job.mjs";
31
- import { loadLeftoverGate, loadSpawnMode } from "../../scripts/grunt-config.mjs";
32
25
 
33
26
  export const ORCHESTRATOR_LOGS_DIR = ".tmp/grunt/orchestrator-logs";
34
27
  /** One-release dual-read; drop next release. */
35
28
  export const LEGACY_ORCHESTRATOR_LOGS_DIR = ".tmp/orchestrator-logs";
36
- export const DENY_REASON =
37
- "First action=spawn implementer|grunt|thinker. Deny expected. Only /solo this session escapes.";
38
- export const STOP_REASONS = [
39
- "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 numbered pick each on own line after (echo printed leftover; always-print typed triple).",
40
- "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 numbered pick each on own line after (echo printed leftover; always-print typed triple).",
41
- "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 numbered pick each on own line after (echo printed leftover; always-print typed triple).",
42
- ];
29
+ export const DENY_REASON = "denied";
43
30
  const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
44
- const PARENT_TOOLS = new Set([
45
- "todowrite",
46
- "getcommandorsubagentoutput",
47
- "gettaskoutput",
48
- "killcommandorsubagent",
49
- "killtask",
50
- ]);
51
31
  const SPAWN_TOOLS = new Set([
52
32
  "spawnsubagent",
53
33
  "task",
@@ -60,33 +40,16 @@ const WRITE_TOOLS = new Set([
60
40
  "searchreplace",
61
41
  "replacefilecontent",
62
42
  ]);
63
- const BASH_TOOLS = new Set(["bash", "runterminalcommand", "runcommand"]);
64
- const SKILL_TOOLS = new Set(["skill"]);
65
- export const PARENT_SKILLS = new Set([
66
- "parent",
67
- "explain",
68
- "solo",
69
- "cascade",
70
- "auto",
71
- "ask",
72
- "handoff",
73
- "tmp",
74
- "pickup",
75
- "write-plan",
76
- "implement-plan",
77
- ]);
78
- const INTERCEPT_JOBS = new Set(["search", "exec"]);
79
- const MAX_STOP = 3;
43
+ const INTERCEPT_JOBS = new Set(["search", "exec", "slice", "fetch"]);
80
44
  const MAX_INTERCEPT = 3;
81
- /** `/solo` enters single-agent mode; `/cascade` restores the orchestrator. */
82
- const SOLO_RE = /^\s*\/solo\s*$/;
83
- const CASCADE_RE = /^\s*\/cascade\s*$/;
45
+ const MAX_STOP = 3;
84
46
  const AUTO_RE = /^\s*\/auto\s*$/;
85
47
  const ASK_RE = /^\s*\/ask\s*$/;
86
- const SOLO_STAMP = "grunt-off";
48
+ export const SESSION_GATE_STAMP = "session-gate";
49
+ /** One-release dual-read of 0.5 leftover stamp. */
87
50
  export const AUTO_ASK_STAMP = "auto-ask";
88
- export const SPAWN_MODE_STAMP = "spawn-mode";
89
-
51
+ export const ASK_STOP_REASON = "ask after this step";
52
+ const WAIT_GRUNT = "[orchestrator]: wait grunt";
90
53
 
91
54
  function main() {
92
55
  try {
@@ -97,7 +60,6 @@ function main() {
97
60
  "",
98
61
  );
99
62
  if (event === "pretooluse") return preToolUse(data || {});
100
- if (event === "posttooluse") return postToolUse(data || {});
101
63
  if (event === "userpromptsubmit") return userPromptSubmit(data || {});
102
64
  if (event === "stop") return stop(data || {});
103
65
  if (event === "subagentstop") return interceptNeed(data || {}, "SubagentStop");
@@ -113,18 +75,6 @@ function preToolUse(data) {
113
75
  let toolInput = data.toolInput;
114
76
  if (toolInput == null) toolInput = data.tool_input;
115
77
 
116
- const sub = subagentTypeOf(data);
117
- if (sub || !isParentOrchestrator(data)) {
118
- const code = emitFat(data);
119
- return code == null ? 0 : code;
120
- }
121
- if (isSoloMode(data)) {
122
- // Single-agent session: no parent-deny, no spawn rewrite. Fat gate still applies.
123
- const fatCode = emitFat(data);
124
- if (fatCode !== null) return fatCode;
125
- emit({ decision: "allow" });
126
- return 0;
127
- }
128
78
  if (SPAWN_TOOLS.has(toolKey)) {
129
79
  const updated = rewriteSpawn(toolInput, data);
130
80
  if (updated && updated.__denied) return 0;
@@ -141,43 +91,15 @@ function preToolUse(data) {
141
91
  return 0;
142
92
  }
143
93
  if (WRITE_TOOLS.has(toolKey)) {
144
- return parentWrite(data, toolInput);
145
- }
146
- if (PARENT_TOOLS.has(toolKey)) {
147
- emit({ decision: "allow" });
148
- return 0;
94
+ const persistCode = parentWrite(data, toolInput);
95
+ if (persistCode !== null) return persistCode;
149
96
  }
150
- if (BASH_TOOLS.has(toolKey) && isAllowedParentGruntJob(bashCommandOf(toolInput), workspaceRootOf(data))) {
151
- emit({ decision: "allow" });
152
- return 0;
153
- }
154
- if (SKILL_TOOLS.has(toolKey) && isAllowedParentSkill(toolInput)) {
155
- emit({ decision: "allow" });
156
- return 0;
157
- }
158
- if (hasParentEscape(data)) {
159
- const fatCode = emitFat(data);
160
- if (fatCode !== null) return fatCode;
161
- emit({ decision: "allow" });
162
- return 0;
163
- }
164
- emit({ decision: "deny", reason: DENY_REASON });
97
+ const fatCode = emitFat(data);
98
+ if (fatCode !== null) return fatCode;
99
+ emit({ decision: "allow" });
165
100
  return 0;
166
101
  }
167
102
 
168
- /** Session flag, not one-turn. Fail-closed: unreadable spawn-mode stamp is not solo. */
169
- export function isSoloMode(data) {
170
- try {
171
- return spawnModeOf(data) === "solo";
172
- } catch {
173
- return false;
174
- }
175
- }
176
-
177
- function hasParentEscape(data) {
178
- return Boolean(resolveStamp(data, "parent-escape"));
179
- }
180
-
181
103
  function emitFat(data) {
182
104
  const fat = fatHookOutput(processFatTools(data));
183
105
  if (!fat) return null;
@@ -214,11 +136,18 @@ export function isUnderHandoffs(filePath, workspaceRoot) {
214
136
  return isUnderDir(filePath, workspaceRoot, [".tmp", "grunt", "handoffs"]);
215
137
  }
216
138
 
139
+ export function isUnderImplementations(filePath, workspaceRoot) {
140
+ return isUnderDir(filePath, workspaceRoot, [".tmp", "grunt", "implementations"]);
141
+ }
142
+
217
143
  export const TMP_RESERVED_DIRS = new Set([
218
144
  "plans",
219
145
  "handoffs",
146
+ "implementations",
220
147
  "browser",
221
148
  "orchestrator-logs",
149
+ "stash",
150
+ "sessions",
222
151
  ]);
223
152
 
224
153
  export function isUnderTmp(filePath, workspaceRoot) {
@@ -231,44 +160,6 @@ export function isUnderTmp(filePath, workspaceRoot) {
231
160
  return !TMP_RESERVED_DIRS.has(path.basename(abs));
232
161
  }
233
162
 
234
- export function isAllowedParentGruntJob(command, workspaceRoot) {
235
- return isWorkspaceGruntJob(command, workspaceRoot, ["search", "exec"]);
236
- }
237
-
238
- function bashCommandOf(toolInput) {
239
- if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
240
- return "";
241
- }
242
- return String(toolInput.command ?? toolInput.cmd ?? "");
243
- }
244
-
245
- function skillNameOf(toolInput) {
246
- if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
247
- return "";
248
- }
249
- const raw =
250
- toolInput.skill ??
251
- toolInput.skill_name ??
252
- toolInput.skillName ??
253
- toolInput.name ??
254
- "";
255
- const s = String(raw).trim().toLowerCase().replace(/\\/g, "/");
256
- const base = s.split("/").pop() || "";
257
- return base.replace(/\.md$/, "").replace(/_/g, "-");
258
- }
259
-
260
- export function isAllowedParentSkill(toolInput) {
261
- return PARENT_SKILLS.has(skillNameOf(toolInput));
262
- }
263
-
264
- function isHostStopBanner(prompt) {
265
- const p = String(prompt || "");
266
- if (/^\s*Stop hook feedback:/.test(p)) return true;
267
- if (/Blocked by stop hook/.test(p)) return true;
268
- if (/task[-_ ]?notification/i.test(p)) return true;
269
- return false;
270
- }
271
-
272
163
  function parentWrite(data, toolInput) {
273
164
  if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
274
165
  emit({ decision: "deny", reason: DENY_REASON });
@@ -291,16 +182,44 @@ function parentWrite(data, toolInput) {
291
182
  } else if (isUnderHandoffs(rawPath, ws)) {
292
183
  persist = persistHandoff;
293
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
+ });
216
+ return 0;
217
+ }
294
218
  } else if (isUnderTmp(rawPath, ws)) {
295
219
  persist = persistTmp;
296
220
  invalid = "invalid tmp";
297
221
  } else {
298
- if (hasParentEscape(data)) {
299
- emit({ decision: "allow" });
300
- return 0;
301
- }
302
- emit({ decision: "deny", reason: DENY_REASON });
303
- return 0;
222
+ return null;
304
223
  }
305
224
  const content = typeof toolInput.content === "string" ? toolInput.content : "";
306
225
  const result = persist({ workspaceRoot: ws, content });
@@ -325,7 +244,6 @@ function parentWrite(data, toolInput) {
325
244
  }
326
245
 
327
246
  function rewriteSpawn(toolInput, data) {
328
- // Type default + intent-scrub in one updatedInput (last-wins with scrub-spawn).
329
247
  const updated = rewriteSpawnToolInput(toolInput, { defaultGrunt: true });
330
248
  const prompt =
331
249
  updated && typeof updated.prompt === "string"
@@ -340,72 +258,93 @@ function rewriteSpawn(toolInput, data) {
340
258
  return updated;
341
259
  }
342
260
 
343
- function postToolUse(data) {
344
- if (data.subagentType || data.subagent_type) return 0;
345
- writeStamp(data, "tools-used", "1");
346
- return 0;
347
- }
348
-
349
261
  function userPromptOf(data) {
350
262
  if (!data || typeof data !== "object") return "";
351
263
  return String(data.prompt ?? data.userPrompt ?? data.user_prompt ?? data.content ?? "");
352
264
  }
353
265
 
354
- function isParentEscapePrompt(prompt) {
355
- 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;
356
272
  }
357
273
 
358
- function userPromptSubmit(data) {
359
- unlinkStamp(data, "tools-used");
360
- const prompt = userPromptOf(data);
361
- if (!isHostStopBanner(prompt)) {
362
- unlinkStamp(data, "stop-block");
363
- }
364
- // Sticky: only exact /solo and /cascade move spawn-mode. Always unlink grunt-off.
365
- if (SOLO_RE.test(prompt) || CASCADE_RE.test(prompt)) {
366
- const token = SOLO_RE.test(prompt) ? "solo" : "cascade";
367
- const cfg = loadSpawnMode(workspaceRootOf(data));
368
- if (token === cfg) {
369
- unlinkSpawnModeStamp(data);
370
- } else {
371
- writeSpawnModeStamp(data, token);
372
- }
373
- unlinkSoloStamp(data);
374
- }
375
- if (AUTO_RE.test(prompt) || ASK_RE.test(prompt)) {
376
- const token = AUTO_RE.test(prompt) ? "auto" : "ask";
377
- const cfg = loadLeftoverGate(workspaceRootOf(data));
378
- if (token === cfg) {
379
- unlinkAutoAskStamp(data);
380
- } else {
381
- writeAutoAskStamp(data, token);
382
- }
383
- }
384
- if (isParentEscapePrompt(prompt)) {
385
- writeStamp(data, "parent-escape", "1");
386
- } else {
387
- unlinkStamp(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;
388
283
  }
389
- return 0;
390
284
  }
391
285
 
392
- const RECAP_TAG_RE =
393
- /^\[(?:orchestrator|grunt|implementer|thinker|handoff|tmp)\]:/;
394
- 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
+ }
395
301
 
396
- function firstNonEmptyStripped(msg) {
397
- for (const line of String(msg || "").split("\n")) {
398
- if (!line.trim()) continue;
399
- 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 */
400
329
  }
401
- return "";
402
330
  }
403
331
 
404
- export function isRecap(msg) {
405
- 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;
406
346
  }
407
347
 
408
- /** Mid-turn wait must be exactly `[orchestrator]: wait grunt` (no leftover lines). */
409
348
  export function isWaitGruntExact(msg) {
410
349
  const nonempty = String(msg || "")
411
350
  .split("\n")
@@ -414,36 +353,21 @@ export function isWaitGruntExact(msg) {
414
353
  return nonempty[0].replace(/^[\s`*_>]+/, "") === WAIT_GRUNT;
415
354
  }
416
355
 
417
- function nonemptyLines(msg) {
356
+ export function hasStepAsk(msg) {
357
+ if (isWaitGruntExact(msg)) return true;
418
358
  return String(msg || "")
419
359
  .split("\n")
420
- .filter((l) => l.trim());
360
+ .map((l) => l.replace(/^[\s`*_>]+/, "").trim())
361
+ .filter(Boolean)
362
+ .some((l) => l.includes("?"));
421
363
  }
422
364
 
423
- /** Format-only leftover triple. No leftover-number → skill map. */
424
- export function hasLeftoverTriple(msg) {
425
- const lines = String(msg || "")
426
- .split("\n")
427
- .map((l) => l.replace(/^[\s`*_>]+/, "").trim());
428
- const hit = (re) => lines.some((l) => re.test(l));
429
- const tweak = hit(/^3\.\s+Tweak$/);
430
- const impl =
431
- hit(/^1\.\s+Implement with verbal plan$/) &&
432
- hit(/^2\.\s+Implement with file plan$/);
433
- const write =
434
- hit(/^1\.\s+Write with verbal plan$/) &&
435
- hit(/^2\.\s+Write with file plan$/);
436
- return Boolean(tweak && (impl || write));
437
- }
438
-
439
- /** Ask leftover-required: any `[thinker]:`; `[orchestrator]:` iff >1 nonempty line (except exact wait-grunt); `[grunt]|[implementer]|[handoff]|[tmp]:` exempt. Auto waives in Stop. */
440
- export function leftoverRequiredAsk(msg) {
441
- if (isWaitGruntExact(msg)) return false;
442
- const first = firstNonEmptyStripped(msg);
443
- if (/^\[(?:grunt|implementer|handoff|tmp)\]:/.test(first)) return false;
444
- if (/^\[thinker\]:/.test(first)) return true;
445
- if (/^\[orchestrator\]:/.test(first)) return nonemptyLines(msg).length > 1;
446
- return false;
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 "";
447
371
  }
448
372
 
449
373
  function payloadAssistantMessage(data) {
@@ -533,43 +457,21 @@ function stop(data) {
533
457
  const reason = String(data.reason || "");
534
458
  if (reason && reason !== "end_turn") return 0;
535
459
 
536
- // Before the parent-escape consume: solo must never burn the one-turn stamp.
537
- if (isSoloMode(data)) return 0;
538
-
539
- if (resolveStamp(data, "parent-escape")) {
540
- unlinkStamp(data, "parent-escape");
541
- return 0;
542
- }
543
-
544
460
  const payloadMsg = payloadAssistantMessage(data);
545
461
  let msg = payloadMsg;
546
462
  if (!msg) {
547
463
  const tp = data.transcript_path || data.transcriptPath || "";
548
464
  msg = lastAssistantFromTranscript(tp);
549
465
  }
550
-
551
- const waitFirst = firstNonEmptyStripped(msg) === WAIT_GRUNT;
552
- if (waitFirst && !isWaitGruntExact(msg)) {
553
- // fall through to block — leftover lines not allowed on mid-turn wait
554
- } else if (isRecap(msg)) {
555
- if (
556
- leftoverGateOf(data) !== "auto" &&
557
- leftoverRequiredAsk(msg) &&
558
- !hasLeftoverTriple(msg)
559
- ) {
560
- // fall through to block — leftover-required under ask
561
- } else {
562
- return 0;
563
- }
564
- }
565
-
566
- let n = readStampInt(data, "stop-block");
567
- 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 });
568
473
  return 0;
569
474
  }
570
- writeStamp(data, "stop-block", String(n + 1));
571
- const reasonText = STOP_REASONS[Math.min(n, STOP_REASONS.length - 1)];
572
- emit({ decision: "block", reason: reasonText });
573
475
  return 0;
574
476
  }
575
477
 
@@ -611,6 +513,9 @@ function interceptNeed(data, hookEventName) {
611
513
  cwd: jobCwd || cwd,
612
514
  path: job.path,
613
515
  glob: job.glob,
516
+ stash: job.stash,
517
+ from: job.from,
518
+ to: job.to,
614
519
  });
615
520
  }
616
521
  } catch {
@@ -663,11 +568,6 @@ function writeStamp(data, prefix, body) {
663
568
  return p;
664
569
  }
665
570
 
666
- function unlinkStamp(data, prefix) {
667
- unlinkQuiet(stampPath(data, prefix));
668
- unlinkQuiet(legacyStampPath(data, prefix));
669
- }
670
-
671
571
  function readStampInt(data, prefix) {
672
572
  const p = resolveStamp(data, prefix);
673
573
  if (!p) return 0;
@@ -684,142 +584,74 @@ function sessionIdOf(data) {
684
584
  );
685
585
  }
686
586
 
687
- /** Solo is a mode: never share a `default` stamp across sid-less sessions. */
688
- function soloStampPath(data) {
689
- const root = workspaceRootOf(data);
690
- const sid = sessionIdOf(data);
691
- if (!root || !sid) return null;
692
- return path.join(root, ORCHESTRATOR_LOGS_DIR, SOLO_STAMP + "-" + sid);
693
- }
694
-
695
- function legacySoloStampPath(data) {
696
- const root = workspaceRootOf(data);
697
- const sid = sessionIdOf(data);
698
- if (!root || !sid) return null;
699
- return path.join(root, LEGACY_ORCHESTRATOR_LOGS_DIR, SOLO_STAMP + "-" + sid);
700
- }
701
-
702
- function resolveSoloStamp(data) {
703
- const neu = soloStampPath(data);
704
- if (neu && fs.existsSync(neu)) return neu;
705
- const old = legacySoloStampPath(data);
706
- if (old && fs.existsSync(old)) return old;
707
- return null;
708
- }
709
-
710
- function unlinkSoloStamp(data) {
711
- unlinkQuiet(soloStampPath(data));
712
- unlinkQuiet(legacySoloStampPath(data));
713
- }
714
-
715
- /** Spawn-mode stamp: never share a `default` stamp across sid-less sessions. */
716
- export function spawnModeStampPath(data) {
717
- const root = workspaceRootOf(data);
718
- const sid = sessionIdOf(data);
719
- if (!root || !sid) return null;
720
- return path.join(root, ORCHESTRATOR_LOGS_DIR, SPAWN_MODE_STAMP + "-" + sid);
721
- }
722
-
723
- function legacySpawnModeStampPath(data) {
724
- const root = workspaceRootOf(data);
725
- const sid = sessionIdOf(data);
726
- if (!root || !sid) return null;
727
- return path.join(root, LEGACY_ORCHESTRATOR_LOGS_DIR, SPAWN_MODE_STAMP + "-" + sid);
728
- }
729
-
730
- function resolveSpawnModeStamp(data) {
731
- const neu = spawnModeStampPath(data);
732
- if (neu && fs.existsSync(neu)) return neu;
733
- const old = legacySpawnModeStampPath(data);
734
- if (old && fs.existsSync(old)) return old;
735
- return null;
736
- }
737
-
738
- function writeSpawnModeStamp(data, body) {
739
- const p = spawnModeStampPath(data);
740
- if (!p) return null;
741
- fs.mkdirSync(path.dirname(p), { recursive: true });
742
- fs.writeFileSync(p, body);
743
- return p;
744
- }
745
-
746
- function unlinkSpawnModeStamp(data) {
747
- unlinkQuiet(spawnModeStampPath(data));
748
- unlinkQuiet(legacySpawnModeStampPath(data));
749
- }
750
-
751
- /** Valid stamp solo|cascade wins; else grunt-off presence as solo; else config. Unreadable/bad body ignored. */
752
- export function spawnModeOf(data) {
587
+ function unlinkQuiet(p) {
588
+ if (!p) return;
753
589
  try {
754
- const p = resolveSpawnModeStamp(data);
755
- if (p) {
756
- const body = fs.readFileSync(p, "utf8").trim();
757
- if (body === "solo" || body === "cascade") return body;
758
- }
590
+ fs.unlinkSync(p);
759
591
  } catch {
760
- // unreadable stamp → fall through
592
+ /* missing is fine */
761
593
  }
762
- if (resolveSoloStamp(data)) return "solo";
763
- return loadSpawnMode(workspaceRootOf(data));
764
594
  }
765
595
 
766
- /** Leftover-gate stamp: never share a `default` stamp across sid-less sessions. */
767
- export function autoAskStampPath(data) {
768
- const root = workspaceRootOf(data);
769
- const sid = sessionIdOf(data);
770
- if (!root || !sid) return null;
771
- return path.join(root, ORCHESTRATOR_LOGS_DIR, AUTO_ASK_STAMP + "-" + sid);
596
+ function unlinkStamp(data, prefix) {
597
+ unlinkQuiet(stampPath(data, prefix));
598
+ unlinkQuiet(legacyStampPath(data, prefix));
772
599
  }
773
600
 
774
- function legacyAutoAskStampPath(data) {
601
+ function sessionGateStampPath(data, prefix) {
775
602
  const root = workspaceRootOf(data);
776
603
  const sid = sessionIdOf(data);
777
- if (!root || !sid) return null;
778
- return path.join(root, LEGACY_ORCHESTRATOR_LOGS_DIR, AUTO_ASK_STAMP + "-" + sid);
604
+ if (!root || !sid || sid === "default") return null;
605
+ return path.join(root, ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
779
606
  }
780
607
 
781
- function resolveAutoAskStamp(data) {
782
- const neu = autoAskStampPath(data);
783
- if (neu && fs.existsSync(neu)) return neu;
784
- const old = legacyAutoAskStampPath(data);
785
- if (old && fs.existsSync(old)) return old;
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
+ }
786
619
  return null;
787
620
  }
788
621
 
789
- function writeAutoAskStamp(data, body) {
790
- const p = autoAskStampPath(data);
791
- if (!p) return null;
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);
628
+ if (!p) return;
629
+ unlinkQuiet(sessionGateStampPath(data, AUTO_ASK_STAMP));
630
+ if (slash === "auto") {
631
+ unlinkQuiet(p);
632
+ return;
633
+ }
792
634
  fs.mkdirSync(path.dirname(p), { recursive: true });
793
- fs.writeFileSync(p, body);
794
- return p;
795
- }
796
-
797
- function unlinkAutoAskStamp(data) {
798
- unlinkQuiet(autoAskStampPath(data));
799
- unlinkQuiet(legacyAutoAskStampPath(data));
635
+ fs.writeFileSync(p, slash);
800
636
  }
801
637
 
802
- /** Stamp body auto|ask wins; else config; else ask. Bad stamp body ignored. */
803
- export function leftoverGateOf(data) {
638
+ export function sessionGateOf(data) {
804
639
  try {
805
- const p = resolveAutoAskStamp(data);
640
+ const p = resolveSessionGateStamp(data);
806
641
  if (p) {
807
642
  const body = fs.readFileSync(p, "utf8").trim();
808
643
  if (body === "auto" || body === "ask") return body;
809
644
  }
810
645
  } catch {
811
- // unreadable stamp → fall through
646
+ /* fall through */
812
647
  }
813
- return loadLeftoverGate(workspaceRootOf(data));
648
+ return "auto";
814
649
  }
815
650
 
816
- function unlinkQuiet(p) {
817
- if (!p) return;
818
- try {
819
- fs.unlinkSync(p);
820
- } catch {
821
- // missing is fine
822
- }
651
+ export function effectiveGruntContext(data) {
652
+ const gate = sessionGateOf(data);
653
+ const receipt = sessionReceiptContext(data);
654
+ return receipt ? `sessionGate=${gate}. ${receipt}` : `sessionGate=${gate}`;
823
655
  }
824
656
 
825
657
  function eventKey(s) {