@lovrozagar/grunt 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) 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 +40 -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 +67 -0
  105. package/CLAUDE.md +1 -49
  106. package/README.md +89 -80
  107. package/cli/grunt.mjs +21 -3
  108. package/cli/grunt.test.ts +47 -3
  109. package/cli/init.mjs +134 -234
  110. package/cli/init.test.ts +241 -58
  111. package/cli/prompt.mjs +9 -36
  112. package/cli/prompt.test.ts +14 -0
  113. package/package.json +10 -2
  114. package/scripts/browser.mjs +128 -7
  115. package/scripts/doctor.mjs +104 -10
  116. package/scripts/emit-gemini.mjs +2 -2
  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/guarded-md.mjs +230 -0
  122. package/scripts/guarded-roots.mjs +1 -1
  123. package/scripts/interactive.mjs +15 -0
  124. package/scripts/listen.mjs +829 -0
  125. package/scripts/parse-need.mjs +21 -2
  126. package/scripts/persist-implementation.mjs +278 -0
  127. package/scripts/prompt.mjs +32 -0
  128. package/scripts/scrub-spawn-prompt.mjs +3 -50
  129. package/scripts/setup.mjs +746 -0
  130. package/scripts/speak.mjs +291 -0
  131. package/.agents/agents/implementer.md +0 -32
  132. package/.agents/agents/thinker.md +0 -29
  133. package/.agents/skills/cascade/SKILL.md +0 -28
  134. package/.agents/skills/parent/SKILL.md +0 -6
  135. package/.agents/skills/solo/SKILL.md +0 -25
  136. package/.claude/agents/implementer.md +0 -37
  137. package/.claude/agents/thinker.md +0 -36
  138. package/.claude/skills/cascade/SKILL.md +0 -28
  139. package/.claude/skills/parent/SKILL.md +0 -6
  140. package/.claude/skills/solo/SKILL.md +0 -25
  141. package/.codex/agents/implementer.toml +0 -26
  142. package/.codex/agents/thinker.toml +0 -26
  143. package/.grok/agents/implementer.md +0 -27
  144. package/.grok/agents/thinker.md +0 -27
  145. package/.grok/parent.md +0 -1
  146. package/.grok/roles/implementer.toml +0 -5
  147. package/.grok/roles/thinker.toml +0 -5
  148. package/.grok/skills/cascade/SKILL.md +0 -28
  149. package/.grok/skills/parent/SKILL.md +0 -6
  150. package/.grok/skills/solo/SKILL.md +0 -25
  151. package/.rulesync/grunt.config.jsonc +0 -7
  152. package/.rulesync/skills/cascade/SKILL.md +0 -28
  153. package/.rulesync/skills/parent/SKILL.md +0 -6
  154. package/.rulesync/skills/solo/SKILL.md +0 -25
  155. package/.rulesync/subagents/implementer.md +0 -49
  156. package/.rulesync/subagents/thinker.md +0 -49
  157. package/scripts/grunt-config.mjs +0 -50
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
- /** Session browser rail: nav|snap|click|fill|shot|pdf|stop|doctor|ensure. Lightpanda default. */
3
- import { spawn } from "node:child_process";
2
+ /** Session browser rail: nav|snap|click|fill|scroll|wait|hover|select|shot|pdf|stop|doctor|ensure. Lightpanda default. */
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import http from "node:http";
7
7
  import net from "node:net";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { writeStash } from "./grunt-job.mjs";
10
11
  import {
11
12
  CHROMIUM_BINS,
12
13
  LIGHTPANDA_REPO as DOCTOR_LP_REPO,
@@ -64,9 +65,21 @@ export function isPaintHost(url) {
64
65
  if ((h === "google.com" || h.endsWith(".google.com")) && u.pathname.startsWith("/earth")) {
65
66
  return true;
66
67
  }
68
+ if (/(^|\.)amazon\./.test(h)) return true;
67
69
  return false;
68
70
  }
69
71
 
72
+ const BLOCKED_SNAP_RE =
73
+ /enable javascript|javascript (is )?(required|disabled)|checking your browser|just a moment|captcha|access denied|sorry.*robot|continue shopping|attention required|cf-browser-verification|verify you are a human/i;
74
+
75
+ export function isBlockedSnap(markdown, lines) {
76
+ const refs = Array.isArray(lines) ? lines.length : 0;
77
+ const md = String(markdown || "").trim();
78
+ if (BLOCKED_SNAP_RE.test(md)) return true;
79
+ const body = md.replace(/^#\s+\S+\s*$/m, "").trim();
80
+ return refs === 0 && body.length < 80;
81
+ }
82
+
70
83
  export function lookupBins(pathEnv, platform = process.platform) {
71
84
  const lightpanda = whichBin("lightpanda", pathEnv, platform);
72
85
  let chromium = "";
@@ -124,6 +137,21 @@ function alive(pid) {
124
137
 
125
138
  function reap(pid) {
126
139
  if (!pid) return;
140
+ if (process.platform === "win32") {
141
+ try {
142
+ process.kill(pid);
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ if (alive(pid)) {
147
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
148
+ stdio: "ignore",
149
+ timeout: 1500,
150
+ windowsHide: true,
151
+ });
152
+ }
153
+ return;
154
+ }
127
155
  try {
128
156
  process.kill(-pid, "SIGTERM");
129
157
  } catch {
@@ -348,8 +376,10 @@ async function handleCdp(state, method, params = {}) {
348
376
  state.markdown = `# ${state.url}\n\nhello`;
349
377
  return { frameId: "f1" };
350
378
  case "LP.getMarkdown":
379
+ if (state.thinSnap) return { markdown: `# ${state.url || ""}\n` };
351
380
  return { markdown: state.markdown || `# ${state.url || ""}\n\nhello` };
352
381
  case "Accessibility.getFullAXTree":
382
+ if (state.thinSnap) return { nodes: [] };
353
383
  return { nodes: state.axNodes || defaultAx(state.url) };
354
384
  case "DOM.resolveNode": {
355
385
  const id = params.backendNodeId;
@@ -378,6 +408,7 @@ export async function serveCdp(state = {}, { port = 0 } = {}) {
378
408
  url: "about:blank",
379
409
  markdown: "",
380
410
  failProbe: false,
411
+ thinSnap: false,
381
412
  engine: "lightpanda",
382
413
  ...state,
383
414
  };
@@ -464,6 +495,19 @@ function engineBin(engine, bins) {
464
495
  return bins.chromium;
465
496
  }
466
497
 
498
+ function spawnEngine(bin, args, opts) {
499
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(bin)) {
500
+ const mjs = `${String(bin).replace(/\.(cmd|bat)$/i, "")}.mjs`;
501
+ try {
502
+ fs.accessSync(mjs, fs.constants.F_OK);
503
+ return spawn(process.execPath, [mjs, ...args], opts);
504
+ } catch {
505
+ return spawn(bin, args, { ...opts, shell: true });
506
+ }
507
+ }
508
+ return spawn(bin, args, opts);
509
+ }
510
+
467
511
  function spawnArgs(engine, port, profile) {
468
512
  if (engine === "lightpanda") return ["serve", "--host", "127.0.0.1", "--port", String(port)];
469
513
  return [
@@ -485,7 +529,7 @@ async function launchEngine(engine, ctx) {
485
529
  const profile = path.join(dir, "profile");
486
530
  fs.mkdirSync(profile, { recursive: true });
487
531
  const port = await freePort();
488
- const child = spawn(bin, spawnArgs(engine, port, profile), {
532
+ const child = spawnEngine(bin, spawnArgs(engine, port, profile), {
489
533
  detached: true,
490
534
  stdio: ["ignore", "pipe", "pipe"],
491
535
  env: ctx.env,
@@ -606,7 +650,7 @@ export async function runBrowser(argv, opts = {}) {
606
650
  const verb = String(argv[0] || "").toLowerCase();
607
651
  try {
608
652
  if (!verb || verb === "help" || verb === "-h" || verb === "--help") {
609
- return ok("nav|snap|click|fill|shot|pdf|stop|doctor|ensure");
653
+ return ok("nav|snap|click|fill|scroll|wait|hover|select|shot|pdf|stop|doctor|ensure");
610
654
  }
611
655
  if (verb === "doctor" || verb === "ensure") {
612
656
  return runDoctor({ pathEnv: env.PATH, platform, cwd });
@@ -650,16 +694,79 @@ export async function runBrowser(argv, opts = {}) {
650
694
  return ok(`engine: ${session.engine}\nurl: ${session.lastURL}`);
651
695
  }
652
696
  if (verb === "snap") {
653
- const session = readSession(cwd);
697
+ let session = readSession(cwd);
654
698
  if (!session || !alive(session.pid)) return fail("no browser session; run nav");
655
- const { markdown, lines } = await snapshot(session);
699
+ let { markdown, lines } = await snapshot(session);
700
+ if (
701
+ session.engine === "lightpanda" &&
702
+ (session.swapCount || 0) < 1 &&
703
+ bins.chromium &&
704
+ isBlockedSnap(markdown, lines)
705
+ ) {
706
+ session = await swapToChromium(session, ctx);
707
+ ({ markdown, lines } = await snapshot(session));
708
+ }
656
709
  writeSession(cwd, session);
710
+ const raw = [markdown.trim(), "", ...lines].join("\n");
711
+ const headings = String(markdown || "")
712
+ .split(/\n/)
713
+ .filter((l) => /^#{1,6}\s/.test(l));
714
+ const shown = [...headings.slice(0, 6), ...lines.slice(0, 24)];
715
+ if (raw.length > 32 * 1024 || lines.length > 6) {
716
+ const stash = writeStash(cwd, "snap", raw);
717
+ return ok(
718
+ `engine: ${session.engine}\nurl: ${session.lastURL}\n${shown.length} shown. stash=${stash}\n${shown.join("\n")}`,
719
+ );
720
+ }
657
721
  const body = [markdown.trim(), "", ...lines].filter((x, i, a) => x !== "" || a[i - 1] !== "").join("\n");
658
722
  return ok(`engine: ${session.engine}\nurl: ${session.lastURL}\n\n${body}`);
659
723
  }
660
- if (verb === "click" || verb === "fill") {
724
+ if (verb === "wait") {
661
725
  const session = readSession(cwd);
662
726
  if (!session || !alive(session.pid)) return fail("no browser session; run nav");
727
+ const raw = String(argv[1] || "1000");
728
+ const ms = Math.min(15000, Math.max(0, Number(raw) || 0));
729
+ await sleep(ms);
730
+ return ok(`wait: ${ms}`);
731
+ }
732
+ if (verb === "scroll") {
733
+ let session = readSession(cwd);
734
+ if (!session || !alive(session.pid)) return fail("no browser session; run nav");
735
+ const arg = String(argv[1] || "down");
736
+ const refs = session.lastRefs || {};
737
+ const rec = refs[arg];
738
+ const act = async (sess) =>
739
+ withCdp(sess, async (cdp) => {
740
+ if (rec) {
741
+ const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: rec.backendNodeId });
742
+ const objectId = resolved.object && resolved.object.objectId;
743
+ if (!objectId) throw new Error("stale");
744
+ await cdp.send("Runtime.callFunctionOn", {
745
+ objectId,
746
+ functionDeclaration: "function() { this.scrollIntoView({ block: 'center' }); }",
747
+ });
748
+ return;
749
+ }
750
+ let dy = 600;
751
+ if (arg === "up") dy = -600;
752
+ else if (/^-?\d+$/.test(arg)) dy = Number(arg);
753
+ await cdp.send("Runtime.evaluate", {
754
+ expression: `window.scrollBy(0, ${dy})`,
755
+ });
756
+ });
757
+ try {
758
+ await act(session);
759
+ } catch (err) {
760
+ const msg = String(err && err.message ? err.message : err);
761
+ if (/stale|missing/i.test(msg)) return fail(`stale ref: ${arg}`);
762
+ return fail(msg);
763
+ }
764
+ writeSession(cwd, session);
765
+ return ok(`scroll: ${arg}`);
766
+ }
767
+ if (verb === "click" || verb === "fill" || verb === "hover" || verb === "select") {
768
+ let session = readSession(cwd);
769
+ if (!session || !alive(session.pid)) return fail("no browser session; run nav");
663
770
  const refs = session.lastRefs || {};
664
771
  if (!Object.keys(refs).length) return fail("no snap refs; run snap");
665
772
  const refId = String(argv[1] || "");
@@ -667,6 +774,7 @@ export async function runBrowser(argv, opts = {}) {
667
774
  if (!rec) return fail(`missing ref: ${refId}`);
668
775
  const text = argv.slice(2).join(" ");
669
776
  if (verb === "fill" && !text) return fail("fill <ref> <text>");
777
+ if (verb === "select" && !text) return fail("select <ref> <value>");
670
778
  const act = async (sess) =>
671
779
  withCdp(sess, async (cdp) => {
672
780
  const resolved = await cdp.send("DOM.resolveNode", { backendNodeId: rec.backendNodeId });
@@ -677,6 +785,19 @@ export async function runBrowser(argv, opts = {}) {
677
785
  objectId,
678
786
  functionDeclaration: "function() { this.click(); }",
679
787
  });
788
+ } else if (verb === "hover") {
789
+ await cdp.send("Runtime.callFunctionOn", {
790
+ objectId,
791
+ functionDeclaration:
792
+ "function() { this.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); }",
793
+ });
794
+ } else if (verb === "select") {
795
+ await cdp.send("Runtime.callFunctionOn", {
796
+ objectId,
797
+ arguments: [{ value: text }],
798
+ functionDeclaration:
799
+ "function(v) { this.value = v; this.dispatchEvent(new Event('change', { bubbles: true })); }",
800
+ });
680
801
  } else {
681
802
  await cdp.send("Runtime.callFunctionOn", {
682
803
  objectId,
@@ -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 {
@@ -41,20 +42,32 @@ export function nodeMajor(version) {
41
42
  return m ? Number(m[1]) : 0;
42
43
  }
43
44
 
45
+ function winShim(bin) {
46
+ return process.platform === "win32" && /\.(cmd|bat)$/i.test(String(bin || ""));
47
+ }
48
+
44
49
  export function whichBin(name, pathEnv = process.env.PATH, platform = process.platform) {
45
50
  if (!name) return "";
51
+ const hostWin = process.platform === "win32";
46
52
  const delim =
47
- platform === "win32" && String(pathEnv || "").includes(";") ? ";" : path.delimiter;
53
+ (platform === "win32" || hostWin) && String(pathEnv || "").includes(";")
54
+ ? ";"
55
+ : path.delimiter;
48
56
  const dirs = String(pathEnv || "").split(delim);
49
- const names = [name];
50
- if (platform === "win32" && !path.extname(name)) {
51
- const pathext = String(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM");
52
- for (const raw of pathext.split(";")) {
53
- if (!raw) continue;
54
- names.push(name + raw);
55
- names.push(name + raw.toLowerCase());
57
+ const names = [];
58
+ if ((hostWin || platform === "win32") && !path.extname(name)) {
59
+ if (hostWin) {
60
+ const pathext = String(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM");
61
+ for (const raw of pathext.split(";")) {
62
+ if (!raw) continue;
63
+ names.push(name + raw.toLowerCase());
64
+ names.push(name + raw);
65
+ }
56
66
  }
57
67
  names.push(`${name}.exe`);
68
+ names.push(name);
69
+ } else {
70
+ names.push(name);
58
71
  }
59
72
  const uniq = [...new Set(names)];
60
73
  for (const dir of dirs) {
@@ -65,7 +78,7 @@ export function whichBin(name, pathEnv = process.env.PATH, platform = process.pl
65
78
  fs.accessSync(candidate, fs.constants.X_OK);
66
79
  return candidate;
67
80
  } catch {
68
- if (platform === "win32") {
81
+ if (hostWin) {
69
82
  try {
70
83
  fs.accessSync(candidate, fs.constants.F_OK);
71
84
  return candidate;
@@ -96,6 +109,7 @@ function readNodeVersion(bin) {
96
109
  encoding: "utf8",
97
110
  timeout: 8000,
98
111
  stdio: ["ignore", "pipe", "pipe"],
112
+ shell: winShim(bin),
99
113
  }),
100
114
  ).trim();
101
115
  } catch {
@@ -166,11 +180,72 @@ export function installHints(id, platform = process.platform) {
166
180
  }
167
181
  return ["sudo apt install chromium"];
168
182
  }
183
+ if (id === "ffmpeg") {
184
+ if (platform === "win32") return ["winget install Gyan.FFmpeg"];
185
+ if (platform === "darwin") return ["brew install ffmpeg"];
186
+ return ["sudo apt install ffmpeg"];
187
+ }
188
+ if (id === "whisper-cli") {
189
+ if (platform === "win32") {
190
+ return ["whisper.cpp release: whisper-cli.exe on PATH", "https://github.com/ggml-org/whisper.cpp"];
191
+ }
192
+ if (platform === "darwin") return ["brew install whisper-cpp"];
193
+ return ["https://github.com/ggml-org/whisper.cpp"];
194
+ }
169
195
  return [];
170
196
  }
171
197
 
198
+ function isFile(abs) {
199
+ try {
200
+ return fs.statSync(abs).isFile();
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
205
+
206
+ export function speakDoctorStatus({ env = process.env, home = os.homedir() } = {}) {
207
+ const names = [];
208
+ if (String(env.ELEVENLABS_API_KEY || "").trim()) names.push("elevenlabs");
209
+ if (String(env.OPENAI_API_KEY || "").trim()) names.push("openai");
210
+ const hasFile = isFile(path.join(home, ".grunt", "speak.json"));
211
+ if (!names.length && !hasFile) return { ok: false, extra: "" };
212
+ return { ok: true, extra: names.length ? names.join(",") : "config" };
213
+ }
214
+
215
+ const WORKSPACE_ACCOUNT_RE = /^[a-zA-Z0-9_-]{1,40}$/;
216
+ const WORKSPACE_KIND_ORDER = ["oauth", "tokens", "adc", "clasprc"];
217
+
218
+ export function workspaceDoctorStatus({ home = os.homedir() } = {}) {
219
+ const grunt = path.join(home, ".grunt");
220
+ const kinds = new Set();
221
+ const scanAccountDir = (dir) => {
222
+ if (isFile(path.join(dir, "google-oauth.json"))) kinds.add("oauth");
223
+ if (isFile(path.join(dir, "tokens.json"))) kinds.add("tokens");
224
+ };
225
+ if (isFile(path.join(grunt, "google-oauth.json"))) kinds.add("oauth");
226
+ if (isFile(path.join(grunt, "workspace-tokens.json"))) kinds.add("tokens");
227
+ scanAccountDir(path.join(grunt, "workspace", "default"));
228
+ const root = path.join(grunt, "workspace");
229
+ try {
230
+ for (const ent of fs.readdirSync(root, { withFileTypes: true })) {
231
+ if (!ent.isDirectory() || !WORKSPACE_ACCOUNT_RE.test(ent.name)) continue;
232
+ scanAccountDir(path.join(root, ent.name));
233
+ }
234
+ } catch {
235
+ /* none */
236
+ }
237
+ if (
238
+ isFile(path.join(home, ".config", "gcloud", "application_default_credentials.json"))
239
+ ) {
240
+ kinds.add("adc");
241
+ }
242
+ if (isFile(path.join(home, ".clasprc.json"))) kinds.add("clasprc");
243
+ if (!kinds.size) return { ok: false, extra: "" };
244
+ return { ok: true, extra: WORKSPACE_KIND_ORDER.filter((k) => kinds.has(k)).join(",") };
245
+ }
246
+
172
247
  function row(name, status, extra = "") {
173
- const a = String(name).padEnd(14);
248
+ const a = String(name).padEnd(18);
174
249
  const b = String(status).padEnd(18);
175
250
  const c = extra ? String(extra).trim() : "";
176
251
  return c ? `${a} ${b} ${c}` : `${a} ${b}`;
@@ -205,6 +280,8 @@ export function runDoctor({
205
280
  platform = process.platform,
206
281
  execPath = process.execPath,
207
282
  nodeVersion = process.version,
283
+ env = process.env,
284
+ home = os.homedir(),
208
285
  } = {}) {
209
286
  const nmPath = withNmBin(cwd, pathEnv, platform);
210
287
  const pathNode = whichBin("node", pathEnv, platform);
@@ -226,6 +303,10 @@ export function runDoctor({
226
303
  const lightpanda = whichBin("lightpanda", pathEnv, platform);
227
304
  const chromium = lookupChromium(pathEnv, platform);
228
305
  const gh = whichBin("gh", pathEnv, platform);
306
+ const clasp = whichBin("clasp", pathEnv, platform);
307
+ const ffmpeg = whichBin("ffmpeg", pathEnv, platform);
308
+ const whisperCli =
309
+ whichBin("whisper-cli", pathEnv, platform) || whichBin("whisper-cpp", pathEnv, platform);
229
310
 
230
311
  const found = {
231
312
  node: nodeOk ? nodeBin : "",
@@ -252,6 +333,19 @@ export function runDoctor({
252
333
  lines.push(lightpanda ? row("lightpanda", "ok", lightpanda) : row("lightpanda", "missing"));
253
334
  lines.push(chromium ? row("chromium", "ok", chromium) : row("chromium", "missing"));
254
335
  lines.push(gh ? row("gh", "ok", gh) : row("gh", "missing (optional)"));
336
+ lines.push(clasp ? row("clasp", "ok", clasp) : row("clasp", "missing (optional)"));
337
+ const workspace = workspaceDoctorStatus({ home });
338
+ lines.push(
339
+ workspace.ok
340
+ ? row("google-workspace", "ok", workspace.extra)
341
+ : row("google-workspace", "missing (optional)"),
342
+ );
343
+ lines.push(ffmpeg ? row("ffmpeg", "ok", ffmpeg) : row("ffmpeg", "missing (optional)"));
344
+ lines.push(
345
+ whisperCli ? row("whisper-cli", "ok", whisperCli) : row("whisper-cli", "missing (optional)"),
346
+ );
347
+ const speak = speakDoctorStatus({ env, home });
348
+ lines.push(speak.ok ? row("speak", "ok", speak.extra) : row("speak", "missing (optional)"));
255
349
 
256
350
  const missingRequired = REQUIRED.filter((k) => (k === "node" ? !nodeOk : !found[k]));
257
351
  if (missingRequired.length) {
@@ -3,9 +3,9 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { guardedMarkdownDrift, writeMergedGuardedFile } from "../cli/init.mjs";
6
+ import { guardedMarkdownDrift, writeMergedGuardedFile } from "./guarded-md.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 =