@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
@@ -2,6 +2,7 @@
2
2
  /** Unified prereq doctor. Print-only install hints. Never runs installs. */
3
3
  import { execFileSync } from "node:child_process";
4
4
  import fs from "node:fs";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import {
@@ -166,11 +167,72 @@ export function installHints(id, platform = process.platform) {
166
167
  }
167
168
  return ["sudo apt install chromium"];
168
169
  }
170
+ if (id === "ffmpeg") {
171
+ if (platform === "win32") return ["winget install Gyan.FFmpeg"];
172
+ if (platform === "darwin") return ["brew install ffmpeg"];
173
+ return ["sudo apt install ffmpeg"];
174
+ }
175
+ if (id === "whisper-cli") {
176
+ if (platform === "win32") {
177
+ return ["whisper.cpp release: whisper-cli.exe on PATH", "https://github.com/ggml-org/whisper.cpp"];
178
+ }
179
+ if (platform === "darwin") return ["brew install whisper-cpp"];
180
+ return ["https://github.com/ggml-org/whisper.cpp"];
181
+ }
169
182
  return [];
170
183
  }
171
184
 
185
+ function isFile(abs) {
186
+ try {
187
+ return fs.statSync(abs).isFile();
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ export function speakDoctorStatus({ env = process.env, home = os.homedir() } = {}) {
194
+ const names = [];
195
+ if (String(env.ELEVENLABS_API_KEY || "").trim()) names.push("elevenlabs");
196
+ if (String(env.OPENAI_API_KEY || "").trim()) names.push("openai");
197
+ const hasFile = isFile(path.join(home, ".grunt", "speak.json"));
198
+ if (!names.length && !hasFile) return { ok: false, extra: "" };
199
+ return { ok: true, extra: names.length ? names.join(",") : "config" };
200
+ }
201
+
202
+ const WORKSPACE_ACCOUNT_RE = /^[a-zA-Z0-9_-]{1,40}$/;
203
+ const WORKSPACE_KIND_ORDER = ["oauth", "tokens", "adc", "clasprc"];
204
+
205
+ export function workspaceDoctorStatus({ home = os.homedir() } = {}) {
206
+ const grunt = path.join(home, ".grunt");
207
+ const kinds = new Set();
208
+ const scanAccountDir = (dir) => {
209
+ if (isFile(path.join(dir, "google-oauth.json"))) kinds.add("oauth");
210
+ if (isFile(path.join(dir, "tokens.json"))) kinds.add("tokens");
211
+ };
212
+ if (isFile(path.join(grunt, "google-oauth.json"))) kinds.add("oauth");
213
+ if (isFile(path.join(grunt, "workspace-tokens.json"))) kinds.add("tokens");
214
+ scanAccountDir(path.join(grunt, "workspace", "default"));
215
+ const root = path.join(grunt, "workspace");
216
+ try {
217
+ for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
218
+ if (!ent.isDirectory() || !WORKSPACE_ACCOUNT_RE.test(ent.name)) continue;
219
+ scanAccountDir(path.join(root, ent.name));
220
+ }
221
+ } catch {
222
+ /* none */
223
+ }
224
+ if (
225
+ isFile(path.join(home, ".config", "gcloud", "application_default_credentials.json"))
226
+ ) {
227
+ kinds.add("adc");
228
+ }
229
+ if (isFile(path.join(home, ".clasprc.json"))) kinds.add("clasprc");
230
+ if (!kinds.size) return { ok: false, extra: "" };
231
+ return { ok: true, extra: WORKSPACE_KIND_ORDER.filter((k) => kinds.has(k)).join(",") };
232
+ }
233
+
172
234
  function row(name, status, extra = "") {
173
- const a = String(name).padEnd(14);
235
+ const a = String(name).padEnd(18);
174
236
  const b = String(status).padEnd(18);
175
237
  const c = extra ? String(extra).trim() : "";
176
238
  return c ? `${a} ${b} ${c}` : `${a} ${b}`;
@@ -205,6 +267,8 @@ export function runDoctor({
205
267
  platform = process.platform,
206
268
  execPath = process.execPath,
207
269
  nodeVersion = process.version,
270
+ env = process.env,
271
+ home = os.homedir(),
208
272
  } = {}) {
209
273
  const nmPath = withNmBin(cwd, pathEnv, platform);
210
274
  const pathNode = whichBin("node", pathEnv, platform);
@@ -226,6 +290,10 @@ export function runDoctor({
226
290
  const lightpanda = whichBin("lightpanda", pathEnv, platform);
227
291
  const chromium = lookupChromium(pathEnv, platform);
228
292
  const gh = whichBin("gh", pathEnv, platform);
293
+ const clasp = whichBin("clasp", pathEnv, platform);
294
+ const ffmpeg = whichBin("ffmpeg", pathEnv, platform);
295
+ const whisperCli =
296
+ whichBin("whisper-cli", pathEnv, platform) || whichBin("whisper-cpp", pathEnv, platform);
229
297
 
230
298
  const found = {
231
299
  node: nodeOk ? nodeBin : "",
@@ -252,6 +320,19 @@ export function runDoctor({
252
320
  lines.push(lightpanda ? row("lightpanda", "ok", lightpanda) : row("lightpanda", "missing"));
253
321
  lines.push(chromium ? row("chromium", "ok", chromium) : row("chromium", "missing"));
254
322
  lines.push(gh ? row("gh", "ok", gh) : row("gh", "missing (optional)"));
323
+ lines.push(clasp ? row("clasp", "ok", clasp) : row("clasp", "missing (optional)"));
324
+ const workspace = workspaceDoctorStatus({ home });
325
+ lines.push(
326
+ workspace.ok
327
+ ? row("google-workspace", "ok", workspace.extra)
328
+ : row("google-workspace", "missing (optional)"),
329
+ );
330
+ lines.push(ffmpeg ? row("ffmpeg", "ok", ffmpeg) : row("ffmpeg", "missing (optional)"));
331
+ lines.push(
332
+ whisperCli ? row("whisper-cli", "ok", whisperCli) : row("whisper-cli", "missing (optional)"),
333
+ );
334
+ const speak = speakDoctorStatus({ env, home });
335
+ lines.push(speak.ok ? row("speak", "ok", speak.extra) : row("speak", "missing (optional)"));
255
336
 
256
337
  const missingRequired = REQUIRED.filter((k) => (k === "node" ? !nodeOk : !found[k]));
257
338
  if (missingRequired.length) {
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { guardedMarkdownDrift, writeMergedGuardedFile } from "../cli/init.mjs";
7
7
 
8
- export const AGENT_IDS = ["orchestrator", "implementer", "thinker", "grunt"];
8
+ export const AGENT_IDS = ["orchestrator", "grunt"];
9
9
  const SSOT_REL = ".rulesync/subagents";
10
10
  const GEMINI_MD_REL = "GEMINI.md";
11
11
  const AGENTS_REL = ".gemini/agents";
@@ -12,7 +12,7 @@ const MCP_JSON_REL = ".mcp.json";
12
12
  const CODEX_REL = ".codex/config.toml";
13
13
  const AGENTS_MCP_REL = ".agents/mcp_config.json";
14
14
  const GEMINI_SETTINGS_REL = ".gemini/settings.json";
15
- const AGENT_IDS = ["orchestrator", "implementer", "thinker", "grunt"];
15
+ const AGENT_IDS = ["orchestrator", "grunt"];
16
16
  const MCP_DENY = "mcp__*";
17
17
  const AGENT_ORCHESTRATOR_DENY = "Agent(orchestrator)";
18
18
  const GROK_HEADER =
@@ -4,13 +4,13 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
- /** Parent Grep head_limit when the model omits one. */
7
+ /** Session Grep head_limit when the model omits one. */
8
8
  export const DEFAULT_GREP_HEAD_LIMIT = 50;
9
- /** Parent Read line limit when the model omits one. */
9
+ /** Session Read line limit when the model omits one. */
10
10
  export const DEFAULT_READ_LIMIT = 200;
11
- /** Implementer (and unknown non-grunt) Grep head_limit when omitted. */
11
+ /** Unknown non-grunt child Grep head_limit when omitted. */
12
12
  export const CHILD_GREP_HEAD_LIMIT = 150;
13
- /** Implementer/thinker (and unknown non-grunt) Read line limit when omitted. */
13
+ /** Unknown non-grunt child Read line limit when omitted. */
14
14
  export const CHILD_READ_LIMIT = 400;
15
15
  /** Deny if requested Grep head_limit or Read limit exceeds this. */
16
16
  export const MAX_REQUEST_LIMIT = 500;
@@ -30,15 +30,13 @@ export const DENY_LOCKFILES = new Set([
30
30
  "yarn.lock",
31
31
  ]);
32
32
 
33
- export const REASON_DENYLIST =
34
- "path denylist; use package API / spawn grunt job:search";
35
- export const REASON_HEAD_LIMIT =
36
- "spawn grunt job:search; head_limit>500";
37
- export const REASON_FILE_SIZE = "spawn grunt job:search; file >200KB";
38
- export const REASON_IMPLEMENTER_BASH =
39
- "need: grunt job:exec|test query:…";
40
- export const REASON_THINKER_TOOLS = "need: grunt job:search query:…";
41
- export const REASON_IMPLEMENTER_WRITE = "need: path not in spec/plan";
33
+ export const REASON_DENYLIST = "path denylist; use package API";
34
+ export const REASON_HEAD_LIMIT = "head_limit>500";
35
+ export const REASON_FILE_SIZE = "file >200KB";
36
+ export const REASON_IMPLEMENTER_BASH = "need: grunt job:exec|test query:…";
37
+ export const REASON_BASH_DUMP = REASON_IMPLEMENTER_BASH;
38
+ export const REASON_REREAD =
39
+ "already wrote this session; Read with offset+limit";
42
40
 
43
41
  const READ_TOOLS = new Set(["read", "readfile"]);
44
42
  const GREP_TOOLS = new Set(["grep", "grepsearch"]);
@@ -71,7 +69,36 @@ const BASH_WEB = /\b(curl|wget)\b/;
71
69
  const BASH_TEST =
72
70
  /\b(?:npm|pnpm|yarn|bun)\s+test\b|\bcargo\s+test\b|\bpytest\b|\bvitest\b|\bjest\b/;
73
71
  export const SHELL_META = /[|&;`$(){}<>\n\r]/;
74
- const GRUNT_JOB_FLAGS = new Set(["--job", "--query", "--path", "--glob", "--cwd"]);
72
+ const GRUNT_JOB_FLAGS = new Set([
73
+ "--job",
74
+ "--query",
75
+ "--path",
76
+ "--glob",
77
+ "--cwd",
78
+ "--stash",
79
+ "--from",
80
+ "--to",
81
+ ]);
82
+ const PRODUCT_ROOT_NAMES = new Set([
83
+ "README.md",
84
+ "CHANGELOG.md",
85
+ "AGENTS.md",
86
+ "CLAUDE.md",
87
+ "GEMINI.md",
88
+ "package.json",
89
+ "LICENSE",
90
+ ]);
91
+ const PRODUCT_ROOT_DIRS = new Set([
92
+ "src",
93
+ "scripts",
94
+ ".rulesync",
95
+ ".grok",
96
+ ".claude",
97
+ ".agents",
98
+ "cli",
99
+ ]);
100
+ const SCRATCH_ROOT_NAMES = new Set(["notes.md", "scratch.md", "output.txt"]);
101
+ const BASH_TREE = /\b(ls|tree)\b/;
75
102
 
76
103
  export function eventKey(s) {
77
104
  return String(s || "")
@@ -225,6 +252,104 @@ export function rewriteGruntScratchPath(filePath, workspaceRoot) {
225
252
  return dest;
226
253
  }
227
254
 
255
+ export function sessionIdOf(data) {
256
+ const sid = String(
257
+ (data && (data.sessionId || data.session_id)) || "",
258
+ ).trim();
259
+ if (!sid || sid === "default") return "";
260
+ return sid;
261
+ }
262
+
263
+ export function wroteListPath(workspaceRoot, sid) {
264
+ if (!workspaceRoot || !sid) return "";
265
+ return path.join(workspaceRoot, ".tmp", "grunt", "sessions", sid, "wrote.txt");
266
+ }
267
+
268
+ export function recordWrote(workspaceRoot, sid, filePath) {
269
+ if (!workspaceRoot || !sid || !filePath) return;
270
+ const abs = path.isAbsolute(filePath)
271
+ ? path.resolve(filePath)
272
+ : path.resolve(workspaceRoot, filePath);
273
+ const rel = path.relative(workspaceRoot, abs).replace(/\\/g, "/");
274
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return;
275
+ const dest = wroteListPath(workspaceRoot, sid);
276
+ try {
277
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
278
+ } catch {
279
+ return;
280
+ }
281
+ let cur = "";
282
+ try {
283
+ cur = fs.readFileSync(dest, "utf8");
284
+ } catch {
285
+ cur = "";
286
+ }
287
+ const lines = cur.split(/\n/).map((l) => l.trim()).filter(Boolean);
288
+ if (lines.includes(rel)) return;
289
+ lines.push(rel);
290
+ fs.writeFileSync(dest, lines.join("\n") + "\n");
291
+ }
292
+
293
+ export function wasWroteThisSession(workspaceRoot, sid, filePath) {
294
+ if (!workspaceRoot || !sid || !filePath) return false;
295
+ const dest = wroteListPath(workspaceRoot, sid);
296
+ let cur = "";
297
+ try {
298
+ cur = fs.readFileSync(dest, "utf8");
299
+ } catch {
300
+ return false;
301
+ }
302
+ const abs = path.isAbsolute(filePath)
303
+ ? path.resolve(filePath)
304
+ : path.resolve(workspaceRoot, filePath);
305
+ const rel = path.relative(workspaceRoot, abs).replace(/\\/g, "/");
306
+ return cur.split(/\n/).some((l) => l.trim() === rel);
307
+ }
308
+
309
+ const SCRATCH_ROOT_RE = /^(dump|scratch|notes|output)(\.|$)/i;
310
+
311
+ export function rewriteScratchWrite(filePath, workspaceRoot) {
312
+ const existing = rewriteGruntScratchPath(filePath, workspaceRoot);
313
+ if (existing) return existing;
314
+ if (filePath == null || !workspaceRoot) return null;
315
+ const wsRoot = path.resolve(workspaceRoot);
316
+ const abs = path.isAbsolute(filePath)
317
+ ? path.resolve(filePath)
318
+ : path.resolve(wsRoot, filePath);
319
+ const rel = path.relative(wsRoot, abs).replace(/\\/g, "/");
320
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
321
+ const segs = rel.split("/");
322
+ const base = segs[segs.length - 1] || "";
323
+ if (PRODUCT_ROOT_NAMES.has(base) && segs.length === 1) return null;
324
+ if (PRODUCT_ROOT_DIRS.has(segs[0])) return null;
325
+ if (rel.startsWith(".tmp/grunt/")) return null;
326
+ if (segs[0] === "tmp" || (segs[0] === ".tmp" && segs[1] !== "grunt")) {
327
+ const rest = segs[0] === "tmp" ? segs.slice(1) : segs.slice(2);
328
+ const dest = path.resolve(wsRoot, ".tmp", "grunt", ...rest);
329
+ const check = path.relative(path.resolve(wsRoot, ".tmp", "grunt"), dest);
330
+ if (!check || check.startsWith("..") || path.isAbsolute(check)) return null;
331
+ return dest;
332
+ }
333
+ if (
334
+ segs.length === 1 &&
335
+ (SCRATCH_ROOT_NAMES.has(base) ||
336
+ /\.tmp$/i.test(base) ||
337
+ SCRATCH_ROOT_RE.test(base))
338
+ ) {
339
+ return path.resolve(wsRoot, ".tmp", "grunt", base);
340
+ }
341
+ return null;
342
+ }
343
+
344
+ function quoteQuery(q) {
345
+ return `"${String(q).replace(/"/g, '\\"')}"`;
346
+ }
347
+
348
+ export function gruntJobCommand(workspaceRoot, args) {
349
+ const script = path.join(workspaceRoot, "scripts", "grunt-job.mjs");
350
+ return `node ${script} ${args}`;
351
+ }
352
+
228
353
  export function workspaceRootOf(data) {
229
354
  return (
230
355
  (data && (data.workspaceRoot || data.workspace_root || data.cwd)) ||
@@ -389,6 +514,7 @@ export function parseGruntJobCommand(command, workspaceRoot) {
389
514
  if (!rest) return null;
390
515
  let job = "";
391
516
  let hasQuery = false;
517
+ let hasStash = false;
392
518
  for (let j = 0; j < rest.length; j++) {
393
519
  const a = rest[j];
394
520
  let flag = a;
@@ -403,23 +529,27 @@ export function parseGruntJobCommand(command, workspaceRoot) {
403
529
  job = String(inline ? a.slice(a.indexOf("=") + 1) : rest[j] || "").toLowerCase();
404
530
  } else if (flag === "--query") {
405
531
  hasQuery = true;
532
+ } else if (flag === "--stash") {
533
+ hasStash = true;
406
534
  }
407
535
  }
408
- return { job, hasQuery };
536
+ return { job, hasQuery, hasStash };
409
537
  }
410
538
 
411
539
  export function isWorkspaceGruntJob(command, workspaceRoot, allowedJobs) {
412
540
  const parsed = parseGruntJobCommand(command, workspaceRoot);
413
- if (!parsed || !parsed.hasQuery) return false;
414
- const allow = allowedJobs || ["search", "exec", "test"];
415
- return allow.includes(parsed.job);
541
+ if (!parsed) return false;
542
+ const allow = allowedJobs || ["search", "exec", "test", "slice", "fetch"];
543
+ if (!allow.includes(parsed.job)) return false;
544
+ if (parsed.job === "slice") return parsed.hasStash || parsed.hasQuery;
545
+ return parsed.hasQuery;
416
546
  }
417
547
 
418
548
  export function implementerBashReason(command, workspaceRoot) {
419
549
  const cmd = String(command || "");
420
550
  if (bashTargetsDenylist(cmd)) return REASON_DENYLIST;
421
551
  if (alreadyRtk(cmd)) return null;
422
- if (isWorkspaceGruntJob(cmd, workspaceRoot, ["search", "exec", "test"])) {
552
+ if (isWorkspaceGruntJob(cmd, workspaceRoot, ["search", "exec", "test", "slice", "fetch"])) {
423
553
  return null;
424
554
  }
425
555
  const wsGrunt = isWorkspaceGruntJobScript(cmd, workspaceRoot);
@@ -430,125 +560,6 @@ export function implementerBashReason(command, workspaceRoot) {
430
560
  return null;
431
561
  }
432
562
 
433
- function resolveWriteAbs(filePath, workspaceRoot) {
434
- if (!filePath || typeof filePath !== "string") return null;
435
- return path.isAbsolute(filePath)
436
- ? path.resolve(filePath)
437
- : path.resolve(workspaceRoot, filePath);
438
- }
439
-
440
- function isUnderPlansDir(abs, workspaceRoot) {
441
- if (!abs || !workspaceRoot) return false;
442
- const dir = path.resolve(workspaceRoot, ".tmp", "grunt", "plans");
443
- if (abs === dir) return true;
444
- const rel = path.relative(dir, abs);
445
- return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
446
- }
447
-
448
- function isUnsolicitedDocPath(abs) {
449
- if (!abs) return false;
450
- if (path.basename(abs) === "README.md") return true;
451
- const segs = abs.replace(/\\/g, "/").split("/");
452
- return segs.includes("docs") || segs.includes("examples");
453
- }
454
-
455
- function addPlanPath(set, raw, workspaceRoot) {
456
- let s = String(raw || "").trim();
457
- if (!s) return;
458
- if (/^https?:\/\//i.test(s)) return;
459
- s = s.replace(/[.,;:]+$/, "");
460
- if (!s || /^https?:\/\//i.test(s)) return;
461
- if (/^\/[A-Za-z][\w-]*$/.test(s)) return;
462
- if (!s.includes("/") && !s.includes("\\") && !s.includes(".")) return;
463
- const abs = resolveWriteAbs(s, workspaceRoot);
464
- if (abs) set.add(abs);
465
- }
466
-
467
- function extractPlanPaths(text, workspaceRoot) {
468
- const set = new Set();
469
- const body = String(text || "");
470
- for (const m of body.matchAll(/`([^`\n]+)`/g)) {
471
- addPlanPath(set, m[1], workspaceRoot);
472
- }
473
- for (const m of body.matchAll(
474
- /(^|[\s("'[=])(\/(?:[A-Za-z0-9._@+-]+\/)+[A-Za-z0-9._@+-]+)/g,
475
- )) {
476
- addPlanPath(set, m[2], workspaceRoot);
477
- }
478
- return set;
479
- }
480
-
481
- function inProgressStatus(text) {
482
- const s = String(text || "");
483
- if (!s.startsWith("---\n")) return false;
484
- const end = s.indexOf("\n---\n", 4);
485
- if (end === -1) return false;
486
- return /^status:\s*in-progress\s*$/m.test(s.slice(4, end));
487
- }
488
-
489
- function loadInProgressPlans(workspaceRoot) {
490
- const dir = path.resolve(workspaceRoot, ".tmp", "grunt", "plans");
491
- let names;
492
- try {
493
- names = fs.readdirSync(dir);
494
- } catch {
495
- return [];
496
- }
497
- const out = [];
498
- for (const name of names) {
499
- if (!name.endsWith(".md")) continue;
500
- const abs = path.resolve(dir, name);
501
- let text;
502
- try {
503
- text = fs.readFileSync(abs, "utf8");
504
- } catch {
505
- continue;
506
- }
507
- if (!inProgressStatus(text)) continue;
508
- out.push({ abs, text });
509
- }
510
- return out;
511
- }
512
-
513
- function specTextOf(data) {
514
- if (!data || typeof data !== "object" || Array.isArray(data)) return "";
515
- const parts = [];
516
- for (const k of ["prompt", "userPrompt", "user_prompt", "content"]) {
517
- if (typeof data[k] === "string" && data[k]) parts.push(data[k]);
518
- }
519
- const agent = data.agent;
520
- if (agent && typeof agent === "object" && !Array.isArray(agent)) {
521
- if (typeof agent.prompt === "string" && agent.prompt) parts.push(agent.prompt);
522
- }
523
- return parts.join("\n");
524
- }
525
-
526
- function implementerWriteDeny(input, workspaceRoot, data) {
527
- const ws = path.resolve(workspaceRoot);
528
- const plans = loadInProgressPlans(ws);
529
- const allow = new Set();
530
- for (const p of plans) {
531
- allow.add(p.abs);
532
- for (const extracted of extractPlanPaths(p.text, ws)) {
533
- allow.add(extracted);
534
- }
535
- }
536
- const promptPaths = extractPlanPaths(specTextOf(data), ws);
537
- for (const abs of promptPaths) allow.add(abs);
538
- const restrictive = promptPaths.size > 0;
539
- for (const key of WRITE_PATH_FIELDS) {
540
- if (typeof input[key] !== "string" || !input[key]) continue;
541
- const abs = resolveWriteAbs(input[key], ws);
542
- if (!abs) continue;
543
- if (isUnderPlansDir(abs, ws)) continue;
544
- if (allow.has(abs)) continue;
545
- if (isUnsolicitedDocPath(abs) || restrictive) {
546
- return { type: "deny", reason: REASON_IMPLEMENTER_WRITE };
547
- }
548
- }
549
- return null;
550
- }
551
-
552
563
  /**
553
564
  * @returns {null | { type: "deny", reason: string } | { type: "rewrite", updatedInput: object }}
554
565
  */
@@ -564,15 +575,19 @@ export function processFatTools(data) {
564
575
  let changed = false;
565
576
  for (const key of WRITE_PATH_FIELDS) {
566
577
  if (typeof next[key] !== "string" || !next[key]) continue;
567
- const dest = rewriteGruntScratchPath(next[key], ws);
578
+ const dest = rewriteScratchWrite(next[key], ws);
568
579
  if (dest && dest !== next[key]) {
569
580
  next[key] = dest;
570
581
  changed = true;
571
582
  }
572
583
  }
573
- if (subagentTypeOf(data) === "implementer") {
574
- const denied = implementerWriteDeny(next, ws, data);
575
- if (denied) return denied;
584
+ const sid = sessionIdOf(data);
585
+ if (sid) {
586
+ for (const key of WRITE_PATH_FIELDS) {
587
+ if (typeof next[key] === "string" && next[key]) {
588
+ recordWrote(ws, sid, next[key]);
589
+ }
590
+ }
576
591
  }
577
592
  return changed ? { type: "rewrite", updatedInput: next } : null;
578
593
  }
@@ -585,10 +600,6 @@ export function processFatTools(data) {
585
600
  const isGlob = GLOB_TOOLS.has(toolKey);
586
601
  const isBash = BASH_TOOLS.has(toolKey);
587
602
 
588
- if (sub === "thinker" && (isGrep || isGlob || isBash)) {
589
- return { type: "deny", reason: REASON_THINKER_TOOLS };
590
- }
591
-
592
603
  if (isRead || isGrep || isGlob) {
593
604
  for (const v of collectPathValues(input)) {
594
605
  if (pathIsDenied(v)) return { type: "deny", reason: REASON_DENYLIST };
@@ -609,22 +620,24 @@ export function processFatTools(data) {
609
620
  }
610
621
  }
611
622
 
612
- if (isGrep) {
613
- const n = requestedLimit(input, "head_limit", "headLimit");
614
- if (n != null && n > MAX_REQUEST_LIMIT) {
615
- return { type: "deny", reason: REASON_HEAD_LIMIT };
616
- }
617
- }
618
623
  if (isRead) {
619
- const n = requestedLimit(input, "limit", "limit");
620
- if (n != null && n > MAX_REQUEST_LIMIT) {
621
- return { type: "deny", reason: REASON_HEAD_LIMIT };
622
- }
623
624
  const abs = resolveReadPath(readFilePath(input), data);
624
625
  const size = fileSizeBytes(abs);
625
626
  if (size != null && size > DENY_FILE_BYTES) {
626
627
  return { type: "deny", reason: REASON_FILE_SIZE };
627
628
  }
629
+ const sid = sessionIdOf(data);
630
+ const hasSlice =
631
+ hasLimitField(input, "offset", "offset") ||
632
+ hasLimitField(input, "limit", "limit");
633
+ if (
634
+ sid &&
635
+ abs &&
636
+ !hasSlice &&
637
+ wasWroteThisSession(workspaceRootOf(data), sid, abs)
638
+ ) {
639
+ return { type: "deny", reason: REASON_REREAD };
640
+ }
628
641
  }
629
642
 
630
643
  const grepDefault = parent ? DEFAULT_GREP_HEAD_LIMIT : CHILD_GREP_HEAD_LIMIT;
@@ -632,24 +645,37 @@ export function processFatTools(data) {
632
645
  const next = Object.assign({}, input);
633
646
  let changed = false;
634
647
  if (isGrep) {
635
- changed =
636
- setMissingLimit(
637
- next,
638
- input,
639
- "head_limit",
640
- "headLimit",
641
- grepDefault,
642
- ) || changed;
648
+ const n = requestedLimit(input, "head_limit", "headLimit");
649
+ if (n != null && n > MAX_REQUEST_LIMIT) {
650
+ if (usesCamelInput(input)) next.headLimit = grepDefault;
651
+ else next.head_limit = grepDefault;
652
+ changed = true;
653
+ } else {
654
+ changed =
655
+ setMissingLimit(
656
+ next,
657
+ input,
658
+ "head_limit",
659
+ "headLimit",
660
+ grepDefault,
661
+ ) || changed;
662
+ }
643
663
  }
644
664
  if (isRead) {
645
- changed =
646
- setMissingLimit(
647
- next,
648
- input,
649
- "limit",
650
- "limit",
651
- readDefault,
652
- ) || changed;
665
+ const n = requestedLimit(input, "limit", "limit");
666
+ if (n != null && n > MAX_REQUEST_LIMIT) {
667
+ next.limit = readDefault;
668
+ changed = true;
669
+ } else {
670
+ changed =
671
+ setMissingLimit(
672
+ next,
673
+ input,
674
+ "limit",
675
+ "limit",
676
+ readDefault,
677
+ ) || changed;
678
+ }
653
679
  }
654
680
  if (isGlob) {
655
681
  changed = setMissingIgnore(next, input) || changed;
@@ -662,8 +688,32 @@ export function processFatTools(data) {
662
688
  if (bashTargetsDenylist(cmd)) {
663
689
  return { type: "deny", reason: REASON_DENYLIST };
664
690
  }
691
+ const ws = workspaceRootOf(data);
692
+ if (isWorkspaceGruntJobScript(cmd, ws)) return null;
693
+ const next = Object.assign({}, input);
694
+ if (BASH_WEB.test(cmd)) {
695
+ const url = (cmd.match(/https?:\/\/\S+/) || [])[0] || "";
696
+ if (url) {
697
+ next.command = gruntJobCommand(
698
+ ws,
699
+ `--job fetch --query ${quoteQuery(url)}`,
700
+ );
701
+ return { type: "rewrite", updatedInput: next };
702
+ }
703
+ return { type: "deny", reason: REASON_DENYLIST };
704
+ }
705
+ if (BASH_DUMP_CMDS.test(cmd) && !alreadyRtk(cmd)) {
706
+ const job = BASH_SEARCH.test(cmd) ? "search" : "exec";
707
+ const q = cmd.replace(/^\s*(rtk\s+)?/, "").trim();
708
+ next.command = gruntJobCommand(ws, `--job ${job} --query ${quoteQuery(q)}`);
709
+ return { type: "rewrite", updatedInput: next };
710
+ }
711
+ if (BASH_TREE.test(cmd) && !alreadyRtk(cmd)) {
712
+ next.command = `rtk ${cmd.trim()}`;
713
+ return { type: "rewrite", updatedInput: next };
714
+ }
665
715
  if (sub === "implementer") {
666
- const reason = implementerBashReason(cmd, workspaceRootOf(data));
716
+ const reason = implementerBashReason(cmd, ws);
667
717
  if (reason) return { type: "deny", reason };
668
718
  }
669
719
  return null;