muonroi-cli 1.4.1 → 1.5.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 (172) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  4. package/dist/src/agent-harness/__tests__/mock-model.spec.js +48 -1
  5. package/dist/src/agent-harness/mock-model.d.ts +11 -0
  6. package/dist/src/agent-harness/mock-model.js +21 -0
  7. package/dist/src/cli/cost-forensics.js +12 -12
  8. package/dist/src/council/__tests__/clarification-prompt.test.js +51 -0
  9. package/dist/src/council/__tests__/clarifier-ready-gate.test.js +32 -0
  10. package/dist/src/council/__tests__/decisions-lock.test.js +17 -1
  11. package/dist/src/council/__tests__/oauth-reachable.test.d.ts +1 -0
  12. package/dist/src/council/__tests__/oauth-reachable.test.js +31 -0
  13. package/dist/src/council/__tests__/parse-outcome-fallback.test.js +11 -0
  14. package/dist/src/council/clarifier.js +9 -1
  15. package/dist/src/council/debate.js +5 -1
  16. package/dist/src/council/decisions-lock.js +3 -3
  17. package/dist/src/council/index.js +12 -5
  18. package/dist/src/council/leader.d.ts +0 -17
  19. package/dist/src/council/leader.js +22 -15
  20. package/dist/src/council/planner.js +1 -1
  21. package/dist/src/council/prompts.js +63 -57
  22. package/dist/src/council/types.d.ts +7 -0
  23. package/dist/src/ee/__tests__/ee-onboarding.test.d.ts +1 -0
  24. package/dist/src/ee/__tests__/ee-onboarding.test.js +32 -0
  25. package/dist/src/ee/auth.d.ts +9 -0
  26. package/dist/src/ee/auth.js +19 -0
  27. package/dist/src/ee/ee-onboarding.d.ts +5 -0
  28. package/dist/src/ee/ee-onboarding.js +76 -0
  29. package/dist/src/generated/version.d.ts +1 -1
  30. package/dist/src/generated/version.js +1 -1
  31. package/dist/src/headless/output.js +6 -4
  32. package/dist/src/headless/output.test.js +4 -3
  33. package/dist/src/index.js +20 -1
  34. package/dist/src/mcp/__tests__/auto-setup.test.js +74 -0
  35. package/dist/src/mcp/__tests__/client-pool.spec.d.ts +1 -0
  36. package/dist/src/mcp/__tests__/client-pool.spec.js +98 -0
  37. package/dist/src/mcp/__tests__/parallel-build.spec.d.ts +1 -0
  38. package/dist/src/mcp/__tests__/parallel-build.spec.js +67 -0
  39. package/dist/src/mcp/__tests__/smart-filter.test.js +56 -0
  40. package/dist/src/mcp/auto-setup.js +56 -2
  41. package/dist/src/mcp/client-pool.d.ts +46 -0
  42. package/dist/src/mcp/client-pool.js +212 -0
  43. package/dist/src/mcp/oauth-callback.js +2 -2
  44. package/dist/src/mcp/parse-headers.test.js +14 -14
  45. package/dist/src/mcp/runtime.d.ts +28 -0
  46. package/dist/src/mcp/runtime.js +117 -51
  47. package/dist/src/mcp/self-verify-runner.d.ts +14 -0
  48. package/dist/src/mcp/self-verify-runner.js +38 -0
  49. package/dist/src/mcp/setup-guide-text.d.ts +9 -0
  50. package/dist/src/mcp/setup-guide-text.js +84 -0
  51. package/dist/src/mcp/smart-filter.js +49 -0
  52. package/dist/src/mcp/smoke.test.js +43 -43
  53. package/dist/src/mcp/tools-server.d.ts +7 -0
  54. package/dist/src/mcp/tools-server.js +19 -22
  55. package/dist/src/models/catalog.json +349 -349
  56. package/dist/src/ops/__tests__/doctor-ee-health.test.js +21 -0
  57. package/dist/src/ops/doctor.d.ts +3 -2
  58. package/dist/src/ops/doctor.js +47 -11
  59. package/dist/src/ops/doctor.test.js +4 -3
  60. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.d.ts +1 -0
  61. package/dist/src/orchestrator/__tests__/mcp-capability-block.test.js +39 -0
  62. package/dist/src/orchestrator/__tests__/project-stack.test.d.ts +1 -0
  63. package/dist/src/orchestrator/__tests__/project-stack.test.js +65 -0
  64. package/dist/src/orchestrator/batch-turn-runner.js +7 -11
  65. package/dist/src/orchestrator/message-processor.js +57 -27
  66. package/dist/src/orchestrator/orchestrator.js +26 -0
  67. package/dist/src/orchestrator/prompts.d.ts +51 -0
  68. package/dist/src/orchestrator/prompts.js +257 -134
  69. package/dist/src/orchestrator/scope-ceiling.js +6 -1
  70. package/dist/src/orchestrator/stream-runner.js +20 -15
  71. package/dist/src/orchestrator/text-tool-call-detector.test.js +13 -13
  72. package/dist/src/pil/__tests__/clarity-gate.test.js +24 -215
  73. package/dist/src/pil/__tests__/config.test.js +1 -17
  74. package/dist/src/pil/__tests__/discovery.test.js +144 -11
  75. package/dist/src/pil/__tests__/layer1-intent-trace.test.js +7 -2
  76. package/dist/src/pil/__tests__/layer1-intent.test.js +3 -0
  77. package/dist/src/pil/__tests__/layer16-clarity.test.js +32 -116
  78. package/dist/src/pil/__tests__/layer4-gsd.test.js +37 -0
  79. package/dist/src/pil/__tests__/layer6-output.test.js +137 -18
  80. package/dist/src/pil/__tests__/llm-classify.test.js +49 -2
  81. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  82. package/dist/src/pil/agent-operating-contract.js +2 -0
  83. package/dist/src/pil/agent-operating-contract.test.js +7 -2
  84. package/dist/src/pil/cheap-model-playbook.js +35 -35
  85. package/dist/src/pil/cheap-model-workbooks.js +16 -13
  86. package/dist/src/pil/clarity-gate.d.ts +21 -19
  87. package/dist/src/pil/clarity-gate.js +26 -153
  88. package/dist/src/pil/config.d.ts +9 -1
  89. package/dist/src/pil/config.js +15 -4
  90. package/dist/src/pil/discovery.js +211 -136
  91. package/dist/src/pil/layer1-intent.d.ts +12 -0
  92. package/dist/src/pil/layer1-intent.js +283 -38
  93. package/dist/src/pil/layer1-intent.test.js +210 -4
  94. package/dist/src/pil/layer16-clarity.d.ts +25 -11
  95. package/dist/src/pil/layer16-clarity.js +19 -306
  96. package/dist/src/pil/layer4-gsd.js +18 -6
  97. package/dist/src/pil/layer6-output.d.ts +2 -0
  98. package/dist/src/pil/layer6-output.js +137 -22
  99. package/dist/src/pil/llm-classify.d.ts +26 -0
  100. package/dist/src/pil/llm-classify.js +34 -5
  101. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  102. package/dist/src/pil/native-capabilities-workbook.js +82 -76
  103. package/dist/src/pil/schema.d.ts +8 -0
  104. package/dist/src/pil/schema.js +12 -1
  105. package/dist/src/pil/task-tier-map.js +4 -0
  106. package/dist/src/pil/types.d.ts +11 -1
  107. package/dist/src/product-loop/done-gate.js +3 -3
  108. package/dist/src/product-loop/loop-driver.js +18 -18
  109. package/dist/src/product-loop/progress-snapshot.js +4 -4
  110. package/dist/src/providers/auth/gemini-oauth.js +6 -15
  111. package/dist/src/providers/auth/grok-oauth.js +6 -15
  112. package/dist/src/providers/auth/openai-oauth.js +6 -15
  113. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  114. package/dist/src/reporter/index.js +1 -1
  115. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  116. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  117. package/dist/src/scaffold/continuation-prompt.js +60 -60
  118. package/dist/src/scaffold/init-new.js +453 -453
  119. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  120. package/dist/src/self-qa/agentic-loop.js +24 -19
  121. package/dist/src/self-qa/spec-emitter.js +26 -23
  122. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  123. package/dist/src/storage/interaction-log.js +5 -5
  124. package/dist/src/storage/migrations.js +122 -122
  125. package/dist/src/storage/sessions.js +42 -42
  126. package/dist/src/storage/transcript.js +91 -84
  127. package/dist/src/storage/usage.js +14 -14
  128. package/dist/src/storage/workspaces.js +12 -12
  129. package/dist/src/tools/__tests__/native-tools.test.d.ts +1 -0
  130. package/dist/src/tools/__tests__/native-tools.test.js +53 -0
  131. package/dist/src/tools/git-safety.d.ts +61 -0
  132. package/dist/src/tools/git-safety.js +141 -0
  133. package/dist/src/tools/git-safety.test.d.ts +1 -0
  134. package/dist/src/tools/git-safety.test.js +111 -0
  135. package/dist/src/tools/native-tools.d.ts +31 -0
  136. package/dist/src/tools/native-tools.js +273 -0
  137. package/dist/src/tools/registry-git-safety.test.d.ts +7 -0
  138. package/dist/src/tools/registry-git-safety.test.js +92 -0
  139. package/dist/src/tools/registry.js +39 -4
  140. package/dist/src/ui/__tests__/markdown-render.test.d.ts +1 -0
  141. package/dist/src/ui/__tests__/markdown-render.test.js +48 -0
  142. package/dist/src/ui/app.js +0 -0
  143. package/dist/src/ui/components/message-view.js +4 -1
  144. package/dist/src/ui/components/structured-response-view.js +7 -3
  145. package/dist/src/ui/components/tool-group.js +7 -1
  146. package/dist/src/ui/markdown-render.d.ts +41 -0
  147. package/dist/src/ui/markdown-render.js +223 -0
  148. package/dist/src/ui/markdown.d.ts +10 -0
  149. package/dist/src/ui/markdown.js +12 -35
  150. package/dist/src/ui/slash/council-inspect.js +4 -4
  151. package/dist/src/ui/slash/export.js +4 -4
  152. package/dist/src/ui/utils/text.d.ts +8 -0
  153. package/dist/src/ui/utils/text.js +16 -0
  154. package/dist/src/ui/utils/text.test.d.ts +1 -0
  155. package/dist/src/ui/utils/text.test.js +23 -0
  156. package/dist/src/usage/ledger.js +48 -15
  157. package/dist/src/utils/__tests__/footprint-gitignore.test.d.ts +1 -0
  158. package/dist/src/utils/__tests__/footprint-gitignore.test.js +50 -0
  159. package/dist/src/utils/clipboard-image.js +23 -23
  160. package/dist/src/utils/open-url.d.ts +56 -0
  161. package/dist/src/utils/open-url.js +58 -0
  162. package/dist/src/utils/open-url.test.d.ts +1 -0
  163. package/dist/src/utils/open-url.test.js +86 -0
  164. package/dist/src/utils/settings.d.ts +12 -0
  165. package/dist/src/utils/settings.js +48 -0
  166. package/dist/src/utils/side-question.js +2 -2
  167. package/dist/src/utils/skills.js +3 -3
  168. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  169. package/dist/src/verify/environment.js +2 -1
  170. package/package.json +1 -1
  171. package/dist/src/pil/layer16-clarity.test.js +0 -31
  172. /package/dist/src/{pil/layer16-clarity.test.d.ts → council/__tests__/clarification-prompt.test.d.ts} +0 -0
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { ensureFootprintGitignored } from "../settings.js";
6
+ describe("ensureFootprintGitignored", () => {
7
+ let dir;
8
+ beforeEach(() => {
9
+ dir = mkdtempSync(join(tmpdir(), "footprint-gi-"));
10
+ });
11
+ afterEach(() => {
12
+ rmSync(dir, { recursive: true, force: true });
13
+ });
14
+ it("does nothing when cwd is NOT a git repo", () => {
15
+ ensureFootprintGitignored(dir);
16
+ expect(existsSync(join(dir, ".gitignore"))).toBe(false);
17
+ });
18
+ it("creates .gitignore with the footprint entry inside a git repo", () => {
19
+ mkdirSync(join(dir, ".git"));
20
+ ensureFootprintGitignored(dir);
21
+ const content = readFileSync(join(dir, ".gitignore"), "utf-8");
22
+ expect(content).toMatch(/^\.muonroi-cli\/$/m);
23
+ });
24
+ it("appends to an existing .gitignore without clobbering it", () => {
25
+ mkdirSync(join(dir, ".git"));
26
+ writeFileSync(join(dir, ".gitignore"), "node_modules\n/dist\n");
27
+ ensureFootprintGitignored(dir);
28
+ const content = readFileSync(join(dir, ".gitignore"), "utf-8");
29
+ expect(content).toMatch(/node_modules/);
30
+ expect(content).toMatch(/\/dist/);
31
+ expect(content).toMatch(/^\.muonroi-cli\/$/m);
32
+ });
33
+ it("is idempotent — does not duplicate the entry on repeated calls", () => {
34
+ mkdirSync(join(dir, ".git"));
35
+ ensureFootprintGitignored(dir);
36
+ ensureFootprintGitignored(dir);
37
+ ensureFootprintGitignored(dir);
38
+ const content = readFileSync(join(dir, ".gitignore"), "utf-8");
39
+ const occurrences = content.split(/\r?\n/).filter((l) => l.trim() === ".muonroi-cli/").length;
40
+ expect(occurrences).toBe(1);
41
+ });
42
+ it("recognizes an existing bare '.muonroi-cli' entry and does not re-add", () => {
43
+ mkdirSync(join(dir, ".git"));
44
+ writeFileSync(join(dir, ".gitignore"), ".muonroi-cli\n");
45
+ ensureFootprintGitignored(dir);
46
+ const content = readFileSync(join(dir, ".gitignore"), "utf-8");
47
+ expect(content).toBe(".muonroi-cli\n"); // unchanged
48
+ });
49
+ });
50
+ //# sourceMappingURL=footprint-gitignore.test.js.map
@@ -25,24 +25,24 @@ function readWin32() {
25
25
  try {
26
26
  const escaped = tmpFile.replace(/\\/g, "\\\\");
27
27
  // Run in STA thread with retry — clipboard can be locked by other processes
28
- const ps = `
29
- Add-Type -AssemblyName System.Windows.Forms
30
- Add-Type -AssemblyName System.Drawing
31
- $maxRetries = 3
32
- $img = $null
33
- for ($i = 0; $i -lt $maxRetries; $i++) {
34
- try {
35
- $img = [System.Windows.Forms.Clipboard]::GetImage()
36
- if ($img -ne $null) { break }
37
- } catch {
38
- # clipboard locked — wait and retry
39
- }
40
- Start-Sleep -Milliseconds 100
41
- }
42
- if ($img -ne $null) {
43
- $img.Save('${escaped}', [System.Drawing.Imaging.ImageFormat]::Png)
44
- $img.Dispose()
45
- }
28
+ const ps = `
29
+ Add-Type -AssemblyName System.Windows.Forms
30
+ Add-Type -AssemblyName System.Drawing
31
+ $maxRetries = 3
32
+ $img = $null
33
+ for ($i = 0; $i -lt $maxRetries; $i++) {
34
+ try {
35
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
36
+ if ($img -ne $null) { break }
37
+ } catch {
38
+ # clipboard locked — wait and retry
39
+ }
40
+ Start-Sleep -Milliseconds 100
41
+ }
42
+ if ($img -ne $null) {
43
+ $img.Save('${escaped}', [System.Drawing.Imaging.ImageFormat]::Png)
44
+ $img.Dispose()
45
+ }
46
46
  `;
47
47
  const result = spawnSync("powershell", ["-NoProfile", "-STA", "-Command", ps], { timeout: 8000 });
48
48
  if (result.error || result.status !== 0)
@@ -81,11 +81,11 @@ function readDarwin() {
81
81
  const pngpaste = spawnSync("pngpaste", [tmpFile], { timeout: 5000 });
82
82
  if (pngpaste.status !== 0) {
83
83
  spawnSync("screencapture", ["-c", "-x"]);
84
- const osascript = `
85
- set imgData to the clipboard as «class PNGf»
86
- set f to open for access POSIX file "${tmpFile}" with write permission
87
- write imgData to f
88
- close access f
84
+ const osascript = `
85
+ set imgData to the clipboard as «class PNGf»
86
+ set f to open for access POSIX file "${tmpFile}" with write permission
87
+ write imgData to f
88
+ close access f
89
89
  `;
90
90
  spawnSync("osascript", ["-e", osascript], { timeout: 5000 });
91
91
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Centralized, injection-safe "open a URL in the user's browser" helper.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ---------------
6
+ * The MCP OAuth `onOAuthRequired` handlers used to do:
7
+ *
8
+ * const cmd = win32 ? `start "" "${urlStr}"` : darwin ? `open "${urlStr}"` : `xdg-open "${urlStr}"`;
9
+ * exec(cmd);
10
+ *
11
+ * `exec()` runs the string through a shell. The authorization URL comes from
12
+ * the MCP server — i.e. it is UNTRUSTED. Shell command substitution (`$(...)`
13
+ * and backticks) executes even INSIDE double quotes, so a malicious server
14
+ * could return an auth URL like `https://x/?a=1$(rm -rf ~)` and achieve
15
+ * arbitrary command execution on the user's machine.
16
+ *
17
+ * This helper closes the vector:
18
+ * 1. Validates the URL parses and uses an http(s) scheme (rejects `file:`,
19
+ * `javascript:`, custom schemes).
20
+ * 2. Re-serializes through the WHATWG URL parser, which percent-encodes
21
+ * quotes, spaces and control characters.
22
+ * 3. Passes the URL as a SINGLE argv element to execFile — no shell ever
23
+ * interprets it.
24
+ *
25
+ * Windows note: we deliberately do NOT use `cmd /c start`. `cmd.exe` re-parses
26
+ * `&` as a command separator (proven: an OAuth URL with `&calc&` splits into
27
+ * separate commands) and mangles `%XX` percent-encodings via env-var
28
+ * expansion. `rundll32 url.dll,FileProtocolHandler <url>` receives the URL as a
29
+ * single argv element with no shell in the chain, so both `&` and `%` are
30
+ * preserved and no injection is possible.
31
+ */
32
+ export interface OpenCommand {
33
+ command: string;
34
+ args: string[];
35
+ }
36
+ export interface OpenUrlOptions {
37
+ /** Override platform detection (used by tests). */
38
+ platform?: NodeJS.Platform;
39
+ /**
40
+ * Injected runner (used by tests). Receives the resolved command + argv.
41
+ * The production default spawns via execFile with NO shell.
42
+ */
43
+ run?: (command: string, args: string[]) => void;
44
+ }
45
+ /**
46
+ * Resolve the platform-specific opener. The URL is ALWAYS its own argv element
47
+ * — it is never concatenated into a shell string or into another argument.
48
+ */
49
+ export declare function resolveOpenCommand(platform: NodeJS.Platform, url: string): OpenCommand;
50
+ /**
51
+ * Open an http(s) URL in the user's default browser without invoking a shell.
52
+ *
53
+ * @returns `true` if an opener was dispatched, `false` if the URL was rejected
54
+ * (malformed or a non-http(s) scheme).
55
+ */
56
+ export declare function openUrl(url: string | URL, options?: OpenUrlOptions): boolean;
@@ -0,0 +1,58 @@
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * Resolve the platform-specific opener. The URL is ALWAYS its own argv element
4
+ * — it is never concatenated into a shell string or into another argument.
5
+ */
6
+ export function resolveOpenCommand(platform, url) {
7
+ if (platform === "win32") {
8
+ return { command: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
9
+ }
10
+ if (platform === "darwin") {
11
+ return { command: "open", args: [url] };
12
+ }
13
+ return { command: "xdg-open", args: [url] };
14
+ }
15
+ function truncate(value, max = 200) {
16
+ return value.length > max ? `${value.slice(0, max)}…` : value;
17
+ }
18
+ /**
19
+ * Open an http(s) URL in the user's default browser without invoking a shell.
20
+ *
21
+ * @returns `true` if an opener was dispatched, `false` if the URL was rejected
22
+ * (malformed or a non-http(s) scheme).
23
+ */
24
+ export function openUrl(url, options = {}) {
25
+ const platform = options.platform ?? process.platform;
26
+ let parsed;
27
+ try {
28
+ parsed = url instanceof URL ? url : new URL(url);
29
+ }
30
+ catch (err) {
31
+ console.error(`[open-url] refusing to open malformed URL: ${truncate(String(url))}`, {
32
+ message: err instanceof Error ? err.message : String(err),
33
+ });
34
+ return false;
35
+ }
36
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
37
+ console.error(`[open-url] refusing to open non-http(s) URL scheme '${parsed.protocol}': ${truncate(parsed.toString())}`);
38
+ return false;
39
+ }
40
+ const target = parsed.toString();
41
+ const { command, args } = resolveOpenCommand(platform, target);
42
+ const run = options.run ??
43
+ ((cmd, cmdArgs) => {
44
+ // execFile — NOT exec — so no shell parses the URL. Shell metacharacters
45
+ // in `target` are inert because the URL is a single argv element.
46
+ execFile(cmd, cmdArgs, (err) => {
47
+ if (err) {
48
+ console.error(`[open-url] failed to launch browser opener '${cmd}': ${err.message}`, {
49
+ url: target,
50
+ stack: err.stack?.split("\n").slice(0, 3),
51
+ });
52
+ }
53
+ });
54
+ });
55
+ run(command, args);
56
+ return true;
57
+ }
58
+ //# sourceMappingURL=open-url.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { openUrl, resolveOpenCommand } from "./open-url.js";
3
+ /**
4
+ * Security regression suite for the centralized browser opener.
5
+ *
6
+ * Root cause being guarded: the MCP OAuth `onOAuthRequired` handlers used to
7
+ * build a shell command string from a server-supplied authorization URL and
8
+ * run it via child_process.exec(). A malicious/compromised MCP server could
9
+ * return an auth URL containing shell metacharacters and achieve command
10
+ * execution. The fix routes every opener through execFile with the URL as a
11
+ * SINGLE argv element and NO shell.
12
+ */
13
+ describe("resolveOpenCommand — URL is always one argv element, never a shell string", () => {
14
+ it("linux: xdg-open <url> (url as single arg)", () => {
15
+ const { command, args } = resolveOpenCommand("linux", "https://example.com/a?x=1&y=2");
16
+ expect(command).toBe("xdg-open");
17
+ expect(args).toEqual(["https://example.com/a?x=1&y=2"]);
18
+ });
19
+ it("darwin: open <url> (url as single arg)", () => {
20
+ const { command, args } = resolveOpenCommand("darwin", "https://example.com/a?x=1&y=2");
21
+ expect(command).toBe("open");
22
+ expect(args).toEqual(["https://example.com/a?x=1&y=2"]);
23
+ });
24
+ it("win32: routes through rundll32 (no cmd.exe) with url as a separate argv element", () => {
25
+ const { command, args } = resolveOpenCommand("win32", "https://example.com/a?x=1&y=2");
26
+ // cmd.exe re-parses '&' as a command separator even inside execFile argv,
27
+ // so we must NOT shell out to cmd on Windows.
28
+ expect(command).not.toBe("cmd");
29
+ expect(command).toBe("rundll32");
30
+ // The URL is its OWN argv element — never concatenated into the entrypoint.
31
+ expect(args[args.length - 1]).toBe("https://example.com/a?x=1&y=2");
32
+ expect(args).toContain("url.dll,FileProtocolHandler");
33
+ });
34
+ it.each([
35
+ "linux",
36
+ "darwin",
37
+ "win32",
38
+ ])("%s: the opener command is a real binary, never a shell", (platform) => {
39
+ const { command } = resolveOpenCommand(platform, "https://example.com/");
40
+ expect(["sh", "bash", "cmd", "powershell", "pwsh", "/bin/sh"]).not.toContain(command);
41
+ });
42
+ });
43
+ describe("openUrl — validation + injection safety", () => {
44
+ it("passes a metacharacter-laden URL as ONE argv element, not interpreted by a shell", () => {
45
+ const run = vi.fn();
46
+ // The canonical injection probe from the task description.
47
+ const ok = openUrl('https://x/?a=1";calc;"', { platform: "linux", run });
48
+ expect(ok).toBe(true);
49
+ expect(run).toHaveBeenCalledTimes(1);
50
+ const [command, args] = run.mock.calls[0];
51
+ expect(command).toBe("xdg-open");
52
+ // Exactly one argument — the whole URL — so `;calc;` can never become its
53
+ // own token / command.
54
+ expect(args).toHaveLength(1);
55
+ // Double-quotes are percent-encoded by WHATWG URL serialization.
56
+ expect(args[0]).toBe("https://x/?a=1%22;calc;%22");
57
+ // `calc` is never a standalone argv element — it stays embedded in the URL.
58
+ expect(args.some((a) => a === "calc")).toBe(false);
59
+ });
60
+ it("rejects a javascript: scheme (returns false, never spawns)", () => {
61
+ const run = vi.fn();
62
+ const ok = openUrl("javascript:alert(1)", { platform: "linux", run });
63
+ expect(ok).toBe(false);
64
+ expect(run).not.toHaveBeenCalled();
65
+ });
66
+ it("rejects a file: scheme (returns false, never spawns)", () => {
67
+ const run = vi.fn();
68
+ const ok = openUrl("file:///etc/passwd", { platform: "darwin", run });
69
+ expect(ok).toBe(false);
70
+ expect(run).not.toHaveBeenCalled();
71
+ });
72
+ it("rejects a malformed URL (returns false, never spawns)", () => {
73
+ const run = vi.fn();
74
+ const ok = openUrl("not a url", { platform: "linux", run });
75
+ expect(ok).toBe(false);
76
+ expect(run).not.toHaveBeenCalled();
77
+ });
78
+ it("accepts a URL object and re-serializes it through the WHATWG parser", () => {
79
+ const run = vi.fn();
80
+ const ok = openUrl(new URL("https://example.com/cb?code=abc&state=xyz"), { platform: "win32", run });
81
+ expect(ok).toBe(true);
82
+ const [, args] = run.mock.calls[0];
83
+ expect(args[args.length - 1]).toBe("https://example.com/cb?code=abc&state=xyz");
84
+ });
85
+ });
86
+ //# sourceMappingURL=open-url.test.js.map
@@ -150,6 +150,8 @@ export interface UserSettings {
150
150
  councilCostAware?: boolean;
151
151
  /** Set true after the user has been prompted (or skipped) the web-research onboarding. */
152
152
  webResearchPrompted?: boolean;
153
+ /** Set true after the user has been prompted (or skipped) the first-run Experience Engine setup. */
154
+ eeSetupPrompted?: boolean;
153
155
  /**
154
156
  * Unix ms timestamp of the last npm-registry update check. Used to throttle
155
157
  * checkForUpdate to once per day so the CLI never spams the registry on
@@ -238,6 +240,16 @@ export interface ProjectSettings {
238
240
  shell?: ShellSettings;
239
241
  lsp?: LspSettings;
240
242
  }
243
+ /**
244
+ * Ensure the CLI's own project-local footprint (`.muonroi-cli/`) is gitignored
245
+ * in `cwd`, so it is never swept into a commit by `git add -A`. The directory
246
+ * can hold provider API keys and sandbox secrets in `settings.json` /
247
+ * `environment.json`; a real session committed it to a public repo. We add the
248
+ * entry only inside an actual git repo (a `.git` dir/file present) to avoid
249
+ * littering `.gitignore` into arbitrary directories. Idempotent and silent on
250
+ * any I/O error — protection is best-effort and must never break the workflow.
251
+ */
252
+ export declare function ensureFootprintGitignored(cwd?: string): void;
241
253
  export declare function loadUserSettings(): UserSettings;
242
254
  export declare function saveUserSettings(partial: Partial<UserSettings>): void;
243
255
  export declare function loadProjectSettings(): ProjectSettings;
@@ -92,6 +92,51 @@ function writeJson(filePath, data) {
92
92
  ensureDir(path.dirname(filePath));
93
93
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
94
94
  }
95
+ /**
96
+ * Ensure the CLI's own project-local footprint (`.muonroi-cli/`) is gitignored
97
+ * in `cwd`, so it is never swept into a commit by `git add -A`. The directory
98
+ * can hold provider API keys and sandbox secrets in `settings.json` /
99
+ * `environment.json`; a real session committed it to a public repo. We add the
100
+ * entry only inside an actual git repo (a `.git` dir/file present) to avoid
101
+ * littering `.gitignore` into arbitrary directories. Idempotent and silent on
102
+ * any I/O error — protection is best-effort and must never break the workflow.
103
+ */
104
+ export function ensureFootprintGitignored(cwd = process.cwd()) {
105
+ const ENTRY = ".muonroi-cli/";
106
+ try {
107
+ if (!fs.existsSync(path.join(cwd, ".git")))
108
+ return; // not a git repo — skip
109
+ const gitignorePath = path.join(cwd, ".gitignore");
110
+ let content = "";
111
+ if (fs.existsSync(gitignorePath)) {
112
+ content = fs.readFileSync(gitignorePath, "utf-8");
113
+ // Already covered by an exact or directory entry (`.muonroi-cli` or
114
+ // `.muonroi-cli/`). Avoid matching unrelated lines via line-exact test.
115
+ const lines = content.split(/\r?\n/).map((l) => l.trim());
116
+ if (lines.includes(ENTRY) || lines.includes(".muonroi-cli"))
117
+ return;
118
+ }
119
+ const comment = "# muonroi-cli local state (may contain API keys) — auto-added";
120
+ let block;
121
+ if (content.length === 0) {
122
+ // Fresh file — no leading blank line.
123
+ block = `${comment}\n${ENTRY}\n`;
124
+ }
125
+ else {
126
+ const sep = content.endsWith("\n") ? "\n" : "\n\n";
127
+ block = `${sep}${comment}\n${ENTRY}\n`;
128
+ }
129
+ fs.appendFileSync(gitignorePath, block);
130
+ }
131
+ catch (err) {
132
+ // best-effort: permission denied / read-only fs — never break the caller.
133
+ // Log at debug level only (No Silent Catch Rule) so a failure is diagnosable
134
+ // without spamming normal runs.
135
+ if (process.env.MUONROI_DEBUG) {
136
+ console.error(`[settings] ensureFootprintGitignored failed for ${cwd}: ${err instanceof Error ? err.message : String(err)}`);
137
+ }
138
+ }
139
+ }
95
140
  export function loadUserSettings() {
96
141
  return readJson(USER_SETTINGS_PATH) || {};
97
142
  }
@@ -174,6 +219,9 @@ export function loadProjectSettings() {
174
219
  }
175
220
  export function saveProjectSettings(partial) {
176
221
  const projectPath = path.join(process.cwd(), ".muonroi-cli", "settings.json");
222
+ // Protect the footprint BEFORE the first write so the secrets-bearing file is
223
+ // gitignored from the moment it exists.
224
+ ensureFootprintGitignored(process.cwd());
177
225
  const current = loadProjectSettings();
178
226
  writeJson(projectPath, {
179
227
  ...current,
@@ -1,7 +1,7 @@
1
1
  import { generateText } from "ai";
2
2
  import { resolveModelRuntime } from "../providers/runtime.js";
3
- const SIDE_QUESTION_SYSTEM = `You are a helpful coding assistant answering a quick side question. The user is in the middle of a coding session and needs a fast, concise answer. Keep your response short and focused — this is a side question, not the main task.
4
-
3
+ const SIDE_QUESTION_SYSTEM = `You are a helpful coding assistant answering a quick side question. The user is in the middle of a coding session and needs a fast, concise answer. Keep your response short and focused — this is a side question, not the main task.
4
+
5
5
  If conversation context is provided below, use it to give a more relevant answer.`;
6
6
  export async function runSideQuestion(question, provider, modelId, conversationContext, signal) {
7
7
  const runtime = resolveModelRuntime(provider, modelId);
@@ -163,9 +163,9 @@ export function discoverSkills(projectRoot) {
163
163
  _skillsCache = { skills, cachedAt: now, cwd: projectRoot };
164
164
  return skills;
165
165
  }
166
- const SKILLS_INSTRUCTIONS = `AGENT SKILLS (optional):
167
- The following <available_skills> list specialized workflows. Use them when they might help the user's request — not only on exact keyword matches.
168
- If a skill's description fits the task or could improve consistency, read that skill's instructions first using read_file with the path from <location>, then follow the SKILL.md body.
166
+ const SKILLS_INSTRUCTIONS = `AGENT SKILLS (optional):
167
+ The following <available_skills> list specialized workflows. Use them when they might help the user's request — not only on exact keyword matches.
168
+ If a skill's description fits the task or could improve consistency, read that skill's instructions first using read_file with the path from <location>, then follow the SKILL.md body.
169
169
  Paths inside a skill (scripts/, references/, assets/) are relative to the skill directory (the folder containing SKILL.md); prefer absolute paths in tool calls.`;
170
170
  /** OpenCode-style XML catalog plus activation instructions for read_file. Returns null if no skills. */
171
171
  export function formatSkillsForPrompt(skills) {
@@ -1,38 +1,38 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { extractCoverageFromOutput, parseBunCoverage, parseJestCoverage, parsePytestCoverage, parseVitestCoverage, } from "../coverage-parsers.js";
3
- const BUN_OUTPUT = `
4
- [0.12ms] 11 tests passed
5
- [0.00ms] 0 tests failed
6
-
7
- -----------------------|---------|---------|---------|---------|-----------------------
8
- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
9
- -----------------------|---------|---------|---------|---------|-----------------------
10
- All files | 85.50 | 70.00 | 90.00 | 85.50 |
11
- index.ts | 85.50 | 70.00 | 90.00 | 85.50 | 10-15
12
- -----------------------|---------|---------|---------|---------|-----------------------
3
+ const BUN_OUTPUT = `
4
+ [0.12ms] 11 tests passed
5
+ [0.00ms] 0 tests failed
6
+
7
+ -----------------------|---------|---------|---------|---------|-----------------------
8
+ File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
9
+ -----------------------|---------|---------|---------|---------|-----------------------
10
+ All files | 85.50 | 70.00 | 90.00 | 85.50 |
11
+ index.ts | 85.50 | 70.00 | 90.00 | 85.50 | 10-15
12
+ -----------------------|---------|---------|---------|---------|-----------------------
13
13
  `;
14
- const VITEST_OUTPUT = `
15
- Files | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
16
- --------------------------|---------|---------|---------|---------|-----------------
17
- All files | 92.31 | 85.71 | 100 | 92.31 |
18
- reality-anchor.ts | 100 | 100 | 100 | 100 |
19
- verify-result.ts | 83.33 | 75 | 100 | 83.33 | 12-15
20
- --------------------------|---------|---------|---------|---------|-----------------
14
+ const VITEST_OUTPUT = `
15
+ Files | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
16
+ --------------------------|---------|---------|---------|---------|-----------------
17
+ All files | 92.31 | 85.71 | 100 | 92.31 |
18
+ reality-anchor.ts | 100 | 100 | 100 | 100 |
19
+ verify-result.ts | 83.33 | 75 | 100 | 83.33 | 12-15
20
+ --------------------------|---------|---------|---------|---------|-----------------
21
21
  `;
22
- const JEST_OUTPUT = `
23
- ----------|---------|----------|---------|---------|-------------------
24
- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
25
- ----------|---------|----------|---------|---------|-------------------
26
- All files | 75.42 | 62.15 | 80.52 | 75.42 |
27
- ----------|---------|----------|---------|---------|-------------------
22
+ const JEST_OUTPUT = `
23
+ ----------|---------|----------|---------|---------|-------------------
24
+ File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
25
+ ----------|---------|----------|---------|---------|-------------------
26
+ All files | 75.42 | 62.15 | 80.52 | 75.42 |
27
+ ----------|---------|----------|---------|---------|-------------------
28
28
  `;
29
- const PYTEST_OUTPUT = `
30
- Name Stmts Miss Cover
31
- ---------------------------------------------
32
- muonroi/__init__.py 0 0 100%
33
- muonroi/cli.py 42 8 81%
34
- ---------------------------------------------
35
- TOTAL 42 8 81%
29
+ const PYTEST_OUTPUT = `
30
+ Name Stmts Miss Cover
31
+ ---------------------------------------------
32
+ muonroi/__init__.py 0 0 100%
33
+ muonroi/cli.py 42 8 81%
34
+ ---------------------------------------------
35
+ TOTAL 42 8 81%
36
36
  `;
37
37
  describe("Coverage Parsers", () => {
38
38
  it("should parse bun coverage", () => {
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { mergeSandboxSettings, normalizeSandboxSettings } from "../utils/settings.js";
3
+ import { ensureFootprintGitignored, mergeSandboxSettings, normalizeSandboxSettings, } from "../utils/settings.js";
4
4
  import { normalizeVerifyRecipe } from "./recipes.js";
5
5
  const VERIFY_ENVIRONMENT_FILES = [".muonroi-cli/environment.json", "environment.json"];
6
6
  const GENERATED_VERIFY_ENVIRONMENT = ".muonroi-cli/environment.json";
@@ -90,6 +90,7 @@ function pickPersistentSandboxSettings(settings) {
90
90
  }
91
91
  export function saveVerifyEnvironment(cwd, recipe, sandboxSettings = {}) {
92
92
  const filePath = path.join(cwd, GENERATED_VERIFY_ENVIRONMENT);
93
+ ensureFootprintGitignored(cwd);
93
94
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
94
95
  const payload = {
95
96
  recipe: {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.4.1",
6
+ "version": "1.5.0",
7
7
  "description": "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.",
8
8
  "repository": {
9
9
  "type": "git",
@@ -1,31 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { pickBestScopeIndex } from "./layer16-clarity.js";
3
- /**
4
- * Bug (live obs 2026-06-04, deepseek session): the scope askcard hardcoded
5
- * defaultIndex 0, but buildScopeOptions lists recency-ranked (NOT prompt-matched)
6
- * bounded contexts first when nothing matches — so the "Recommended" default was
7
- * an arbitrary subdir (e.g. src/cli) for a repo-wide prompt, with "Entire
8
- * project" demoted to last. The default must prefer "Entire project" unless the
9
- * prompt names a specific module.
10
- */
11
- describe("pickBestScopeIndex", () => {
12
- const opts = ["src/cli (cli)", "src/council (council)", "Entire project"];
13
- it("recommends 'Entire project' when the prompt names no specific module", () => {
14
- expect(pickBestScopeIndex("đánh giá repo này: điểm mạnh, điểm yếu", opts)).toBe(2);
15
- expect(pickBestScopeIndex("summarize the whole project", opts)).toBe(2);
16
- expect(pickBestScopeIndex("tóm tắt repo", opts)).toBe(2);
17
- });
18
- it("recommends the matching bounded context when the prompt names it", () => {
19
- expect(pickBestScopeIndex("fix the cli command parser", opts)).toBe(0);
20
- expect(pickBestScopeIndex("refactor the council debate flow", opts)).toBe(1);
21
- });
22
- it("never recommends a ranked (non-matching) bounded context as the default", () => {
23
- // The core bug: options[0] is arbitrary when nothing matched → must not win.
24
- expect(pickBestScopeIndex("improve overall quality", opts)).not.toBe(0);
25
- });
26
- it("falls back to the last option when 'Entire project' is absent", () => {
27
- // Prompt names neither module → no match → last option is the safe fallback.
28
- expect(pickBestScopeIndex("xyz qrs", ["src/auth (auth)", "src/billing (billing)"])).toBe(1);
29
- });
30
- });
31
- //# sourceMappingURL=layer16-clarity.test.js.map