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,111 @@
1
+ import { mkdtempSync, 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 { __resetGitSafetyState, analyzeGitCommand, checkPushGate, detectSensitiveStaging, recordCommandOutcome, stagingWarning, } from "./git-safety.js";
6
+ describe("analyzeGitCommand", () => {
7
+ it("detects git push (with flags and chained)", () => {
8
+ expect(analyzeGitCommand("git push").isPush).toBe(true);
9
+ expect(analyzeGitCommand("git push origin main").isPush).toBe(true);
10
+ expect(analyzeGitCommand("git -c x=y push --force").isPush).toBe(true);
11
+ expect(analyzeGitCommand("git add -A && git commit -m x && git push origin main").isPush).toBe(true);
12
+ });
13
+ it("does not match 'push' inside a quoted commit message", () => {
14
+ expect(analyzeGitCommand('git commit -m "fix git push regression"').isPush).toBe(false);
15
+ });
16
+ it("detects a real push on its own line in a multi-line script", () => {
17
+ expect(analyzeGitCommand("git config user.name x\ngit push origin main").isPush).toBe(true);
18
+ });
19
+ it("does not bleed across a newline into an unrelated command", () => {
20
+ // 'git status' then a separate line with the word 'push' is NOT a git push.
21
+ expect(analyzeGitCommand("git status\necho push-notification").isPush).toBe(false);
22
+ expect(analyzeGitCommand("git log\nrm push.txt").isPush).toBe(false);
23
+ });
24
+ it("detects broad staging (-A / . / --all / commit -a)", () => {
25
+ expect(analyzeGitCommand("git add -A").isBroadStage).toBe(true);
26
+ expect(analyzeGitCommand("git add .").isBroadStage).toBe(true);
27
+ expect(analyzeGitCommand("git add --all").isBroadStage).toBe(true);
28
+ expect(analyzeGitCommand("git commit -am 'x'").isBroadStage).toBe(true);
29
+ expect(analyzeGitCommand("git commit -a").isBroadStage).toBe(true);
30
+ });
31
+ it("detects broad staging even with git global options before the subcommand", () => {
32
+ expect(analyzeGitCommand("git -c core.editor=true commit -a").isBroadStage).toBe(true);
33
+ expect(analyzeGitCommand("git -c x=y add -A").isBroadStage).toBe(true);
34
+ });
35
+ it("does not flag explicit/narrow staging or non-staging flags", () => {
36
+ expect(analyzeGitCommand("git add src/foo.ts src/bar.ts").isBroadStage).toBe(false);
37
+ expect(analyzeGitCommand("git add ./src/foo.ts").isBroadStage).toBe(false);
38
+ expect(analyzeGitCommand("git commit -m 'message'").isBroadStage).toBe(false);
39
+ expect(analyzeGitCommand("git commit --amend").isBroadStage).toBe(false);
40
+ // -a must be a clean flag cluster — a malformed `-a--otherflag` is not `-a`.
41
+ expect(analyzeGitCommand("git commit -a--otherflag").isBroadStage).toBe(false);
42
+ });
43
+ });
44
+ describe("push gate", () => {
45
+ beforeEach(() => {
46
+ __resetGitSafetyState();
47
+ delete process.env.MUONROI_ALLOW_PUSH_ON_RED;
48
+ });
49
+ afterEach(() => {
50
+ delete process.env.MUONROI_ALLOW_PUSH_ON_RED;
51
+ });
52
+ it("does not block when no verification has failed", () => {
53
+ expect(checkPushGate("s1").blocked).toBe(false);
54
+ });
55
+ it("blocks push after a verification command fails", () => {
56
+ recordCommandOutcome("s1", "npm test", false);
57
+ const gate = checkPushGate("s1");
58
+ expect(gate.blocked).toBe(true);
59
+ expect(gate.failed).toContain("npm test");
60
+ });
61
+ it("clears the block when that same command re-runs green", () => {
62
+ recordCommandOutcome("s1", "npm test", false);
63
+ expect(checkPushGate("s1").blocked).toBe(true);
64
+ recordCommandOutcome("s1", "npm test", true);
65
+ expect(checkPushGate("s1").blocked).toBe(false);
66
+ });
67
+ it("a different verify passing does NOT clear an unrelated failed verify", () => {
68
+ recordCommandOutcome("s1", "npm test", false);
69
+ recordCommandOutcome("s1", "npm run build", true); // build green, tests still red
70
+ expect(checkPushGate("s1").blocked).toBe(true);
71
+ expect(checkPushGate("s1").failed).toEqual(["npm test"]);
72
+ });
73
+ it("is session-scoped (one session's failure does not gate another)", () => {
74
+ recordCommandOutcome("s1", "vitest run", false);
75
+ expect(checkPushGate("s1").blocked).toBe(true);
76
+ expect(checkPushGate("s2").blocked).toBe(false);
77
+ });
78
+ it("ignores non-verification command outcomes", () => {
79
+ recordCommandOutcome("s1", "git status", false);
80
+ recordCommandOutcome("s1", "ls -la", false);
81
+ expect(checkPushGate("s1").blocked).toBe(false);
82
+ });
83
+ it("respects the MUONROI_ALLOW_PUSH_ON_RED override", () => {
84
+ recordCommandOutcome("s1", "npm test", false);
85
+ process.env.MUONROI_ALLOW_PUSH_ON_RED = "1";
86
+ expect(checkPushGate("s1").blocked).toBe(false);
87
+ });
88
+ });
89
+ describe("sensitive staging detection", () => {
90
+ let dir;
91
+ beforeEach(() => {
92
+ dir = mkdtempSync(join(tmpdir(), "git-safety-"));
93
+ });
94
+ afterEach(() => {
95
+ rmSync(dir, { recursive: true, force: true });
96
+ });
97
+ it("flags .env and .muonroi-cli present in the repo root", () => {
98
+ writeFileSync(join(dir, ".env"), "SECRET=1");
99
+ writeFileSync(join(dir, ".muonroi-cli"), ""); // a file is enough for existsSync
100
+ const found = detectSensitiveStaging(dir);
101
+ expect(found).toContain(".env");
102
+ expect(found).toContain(".muonroi-cli");
103
+ expect(stagingWarning(dir)).toMatch(/WARNING/);
104
+ });
105
+ it("returns no warning for a clean repo", () => {
106
+ writeFileSync(join(dir, "README.md"), "# ok");
107
+ expect(detectSensitiveStaging(dir)).toEqual([]);
108
+ expect(stagingWarning(dir)).toBe("");
109
+ });
110
+ });
111
+ //# sourceMappingURL=git-safety.test.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * src/tools/native-tools.ts
3
+ *
4
+ * NATIVE in-process builtins for the capabilities that muonroi-tools previously
5
+ * exposed only via a self-spawned MCP subprocess: ee_health, ee_feedback,
6
+ * usage_forensics, lsp_query, setup_guide, and selfverify_* (start/status/
7
+ * result/list/cancel).
8
+ *
9
+ * Why native: muonroi-tools is THIS CLI. Self-spawning a 137MB CLI as an MCP
10
+ * server per turn cold-started ~2-3.5s and overran the build deadline (and a
11
+ * seed-time bug once persisted a vitest-worker command that crashed on launch).
12
+ * For the CLI's OWN inner agent these tools should run in-process — no subprocess,
13
+ * no MCP round-trip, no cold-start. The muonroi-tools MCP server (tools-server.ts)
14
+ * stays for EXTERNAL agents (Claude Code etc.). `ee_query` is already native
15
+ * (registry.ts) and is intentionally NOT duplicated here.
16
+ *
17
+ * Each tool reuses the SAME core the MCP server wraps (ee/search, cli/cost-
18
+ * forensics, lsp/runtime, the shared self-verify JobManager), so behaviour is
19
+ * identical across the two surfaces.
20
+ */
21
+ import { type ToolSet } from "ai";
22
+ /** The native tool names this module registers — used by the MCP-twin dedup. */
23
+ export declare const NATIVE_MUONROI_TOOL_NAMES: readonly ["ee_health", "ee_feedback", "usage_forensics", "lsp_query", "setup_guide", "selfverify_start", "selfverify_status", "selfverify_result", "selfverify_list", "selfverify_cancel"];
24
+ export interface NativeToolOpts {
25
+ /** Workspace cwd for lsp_query. Defaults to process.cwd(). */
26
+ cwd?: string;
27
+ }
28
+ /**
29
+ * Add the native muonroi-tools builtins to `tools`. Mutates and returns it.
30
+ */
31
+ export declare function registerNativeMuonroiTools(tools: ToolSet, opts?: NativeToolOpts): ToolSet;
@@ -0,0 +1,273 @@
1
+ /**
2
+ * src/tools/native-tools.ts
3
+ *
4
+ * NATIVE in-process builtins for the capabilities that muonroi-tools previously
5
+ * exposed only via a self-spawned MCP subprocess: ee_health, ee_feedback,
6
+ * usage_forensics, lsp_query, setup_guide, and selfverify_* (start/status/
7
+ * result/list/cancel).
8
+ *
9
+ * Why native: muonroi-tools is THIS CLI. Self-spawning a 137MB CLI as an MCP
10
+ * server per turn cold-started ~2-3.5s and overran the build deadline (and a
11
+ * seed-time bug once persisted a vitest-worker command that crashed on launch).
12
+ * For the CLI's OWN inner agent these tools should run in-process — no subprocess,
13
+ * no MCP round-trip, no cold-start. The muonroi-tools MCP server (tools-server.ts)
14
+ * stays for EXTERNAL agents (Claude Code etc.). `ee_query` is already native
15
+ * (registry.ts) and is intentionally NOT duplicated here.
16
+ *
17
+ * Each tool reuses the SAME core the MCP server wraps (ee/search, cli/cost-
18
+ * forensics, lsp/runtime, the shared self-verify JobManager), so behaviour is
19
+ * identical across the two surfaces.
20
+ */
21
+ import { dynamicTool, jsonSchema } from "ai";
22
+ import { LSP_TOOL_OPERATIONS } from "../lsp/types.js";
23
+ import { getSelfVerifyJobManager } from "../mcp/self-verify-runner.js";
24
+ import { SETUP_GUIDE_TEXT } from "../mcp/setup-guide-text.js";
25
+ /** The native tool names this module registers — used by the MCP-twin dedup. */
26
+ export const NATIVE_MUONROI_TOOL_NAMES = [
27
+ "ee_health",
28
+ "ee_feedback",
29
+ "usage_forensics",
30
+ "lsp_query",
31
+ "setup_guide",
32
+ "selfverify_start",
33
+ "selfverify_status",
34
+ "selfverify_result",
35
+ "selfverify_list",
36
+ "selfverify_cancel",
37
+ ];
38
+ const json = (data) => JSON.stringify(data);
39
+ const errLine = (error, message) => `ERROR ${error}: ${message}`;
40
+ /**
41
+ * Add the native muonroi-tools builtins to `tools`. Mutates and returns it.
42
+ */
43
+ export function registerNativeMuonroiTools(tools, opts = {}) {
44
+ // ── Experience Engine: health + feedback (ee_query is already native) ──────
45
+ tools.ee_health = dynamicTool({
46
+ description: "Check Experience Engine server reachability (returns {ok, status}).",
47
+ inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }),
48
+ execute: async () => {
49
+ try {
50
+ const { healthEE } = await import("../ee/search.js");
51
+ return json(await healthEE());
52
+ }
53
+ catch (e) {
54
+ return errLine("ee_unavailable", e instanceof Error ? e.message : String(e));
55
+ }
56
+ },
57
+ });
58
+ tools.ee_feedback = dynamicTool({
59
+ description: "Rate an Experience Engine recall entry so the brain keeps what helped and prunes the rest. Call after " +
60
+ "acting on an ee_query result — once per `[id col]` you used or judged. verdict: 'followed' (you changed " +
61
+ "your approach because of it), 'ignored' (topical but did not apply this time), 'noise' (wrong by category — " +
62
+ "REQUIRES reason: wrong_repo | wrong_language | wrong_task | stale_rule). id may be a short prefix.",
63
+ inputSchema: jsonSchema({
64
+ type: "object",
65
+ properties: {
66
+ id: { type: "string", description: "Entry id (short prefix accepted)" },
67
+ collection: { type: "string", description: "EE collection the entry came from" },
68
+ verdict: { type: "string", enum: ["followed", "ignored", "noise"] },
69
+ reason: { type: "string", enum: ["wrong_repo", "wrong_language", "wrong_task", "stale_rule"] },
70
+ },
71
+ required: ["id", "collection", "verdict"],
72
+ }),
73
+ execute: async (input) => {
74
+ const id = typeof input?.id === "string" ? input.id.trim() : "";
75
+ const collection = typeof input?.collection === "string" ? input.collection.trim() : "";
76
+ const verdict = input?.verdict;
77
+ const reason = input?.reason;
78
+ if (!id || !collection || !verdict) {
79
+ return errLine("invalid_args", "ee_feedback requires id, collection, and verdict");
80
+ }
81
+ if (verdict === "noise" && !reason) {
82
+ return errLine("reason_required", "verdict 'noise' requires reason: wrong_repo | wrong_language | wrong_task | stale_rule");
83
+ }
84
+ try {
85
+ const { feedbackEE } = await import("../ee/search.js");
86
+ const { sessionRecallLedger } = await import("../ee/recall-ledger.js");
87
+ const result = await feedbackEE(id, collection, verdict, reason);
88
+ if (!result.ok)
89
+ return errLine("feedback_failed", result.error ?? "feedback POST failed");
90
+ const clearedId = result.resolvedId ?? id;
91
+ sessionRecallLedger.clear(clearedId);
92
+ sessionRecallLedger.clear(id);
93
+ return json({
94
+ ok: true,
95
+ id: clearedId,
96
+ verdict: result.verdict,
97
+ ...(result.reason ? { reason: result.reason } : {}),
98
+ pendingRemaining: sessionRecallLedger.pendingCount(),
99
+ });
100
+ }
101
+ catch (e) {
102
+ return errLine("feedback_failed", e instanceof Error ? e.message : String(e));
103
+ }
104
+ },
105
+ });
106
+ // ── Self-diagnostics: usage_forensics ─────────────────────────────────────
107
+ tools.usage_forensics = dynamicTool({
108
+ description: "Per-session token-cost forensics by session-id prefix: peak input, cache-hit ratio, per-event breakdown.",
109
+ inputSchema: jsonSchema({
110
+ type: "object",
111
+ properties: { prefix: { type: "string", description: "Session id prefix (1-100 chars)" } },
112
+ required: ["prefix"],
113
+ }),
114
+ execute: async (input) => {
115
+ const prefix = typeof input?.prefix === "string" ? input.prefix.trim() : "";
116
+ if (!prefix)
117
+ return errLine("invalid_args", "usage_forensics requires a non-empty prefix");
118
+ try {
119
+ const { resolveSessionIds, collectCostForensics } = await import("../cli/cost-forensics.js");
120
+ const ids = await resolveSessionIds(prefix);
121
+ if (ids.length === 0)
122
+ return errLine("not_found", `no session matches prefix '${prefix}'`);
123
+ if (ids.length > 1)
124
+ return errLine("ambiguous", `prefix '${prefix}' matched ${ids.length} sessions`);
125
+ return json(await collectCostForensics(ids[0]));
126
+ }
127
+ catch (e) {
128
+ return errLine("db_error", e instanceof Error ? e.message : String(e));
129
+ }
130
+ },
131
+ });
132
+ // ── Code intelligence: lsp_query ──────────────────────────────────────────
133
+ tools.lsp_query = dynamicTool({
134
+ description: "Semantic code intelligence via language servers. operation is one of: goToDefinition, findReferences, hover, documentSymbol, workspaceSymbol, goToImplementation, prepareCallHierarchy, incomingCalls, outgoingCalls. " +
135
+ "filePath: absolute, or relative to the workspace root. line/character: 1-based — required for position-based ops; omit for documentSymbol; use query (not position) for workspaceSymbol. Returns {success, output}.",
136
+ inputSchema: jsonSchema({
137
+ type: "object",
138
+ properties: {
139
+ operation: { type: "string", enum: [...LSP_TOOL_OPERATIONS] },
140
+ filePath: { type: "string", description: "Absolute or workspace-relative path" },
141
+ line: { type: "number", description: "1-based line (position ops)" },
142
+ character: { type: "number", description: "1-based character (position ops)" },
143
+ query: { type: "string", description: "Symbol query (workspaceSymbol)" },
144
+ },
145
+ required: ["operation", "filePath"],
146
+ }),
147
+ execute: async (input) => {
148
+ const cwd = opts.cwd ?? process.cwd();
149
+ try {
150
+ const { queryLsp, isLspToolEnabled } = await import("../lsp/runtime.js");
151
+ if (!(await isLspToolEnabled(cwd))) {
152
+ return errLine("lsp_disabled", "LSP tool is disabled in settings (lsp.enabled / lsp.tool)");
153
+ }
154
+ return json(await queryLsp(cwd, input));
155
+ }
156
+ catch (e) {
157
+ return errLine("lsp_error", e instanceof Error ? e.message : String(e));
158
+ }
159
+ },
160
+ });
161
+ // ── Onboarding: setup_guide ───────────────────────────────────────────────
162
+ tools.setup_guide = dynamicTool({
163
+ description: "Returns the up-to-date setup / install / first-run / MCP wiring / verify guide for muonroi-cli. Call this " +
164
+ "when the user asks how to set up, install, or get started — instead of guessing, reading files, or shelling commands.",
165
+ inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }),
166
+ execute: async () => SETUP_GUIDE_TEXT,
167
+ });
168
+ // ── Self-QA harness: selfverify_* (shared JobManager, in-process) ──────────
169
+ tools.selfverify_start = dynamicTool({
170
+ description: "Start a self-verify run (mode=tier1 heuristic, or mode=agentic LLM-driven). Returns {runId} immediately; " +
171
+ "poll selfverify_status, then selfverify_result.",
172
+ inputSchema: jsonSchema({
173
+ type: "object",
174
+ properties: {
175
+ mode: { type: "string", enum: ["tier1", "agentic"] },
176
+ since: { type: "string" },
177
+ max: { type: "number" },
178
+ emit: { type: "boolean" },
179
+ out: { type: "string" },
180
+ goal: { type: "string" },
181
+ llm: { type: "string" },
182
+ turns: { type: "number" },
183
+ },
184
+ required: ["mode"],
185
+ }),
186
+ execute: async (input) => {
187
+ const jm = getSelfVerifyJobManager();
188
+ if (input?.mode === "agentic") {
189
+ if (!input?.goal || !input?.llm)
190
+ return errLine("invalid_args", "agentic mode requires both goal and llm");
191
+ const { getModelInfo } = await import("../models/registry.js");
192
+ if (!getModelInfo(input.llm))
193
+ return errLine("unknown_model", `llm '${input.llm}' is not in catalog.json`);
194
+ return json({ runId: jm.start({ kind: "agentic", goal: input.goal, llm: input.llm, turns: input.turns }) });
195
+ }
196
+ return json({
197
+ runId: jm.start({ kind: "tier1", since: input?.since, max: input?.max, emit: input?.emit, out: input?.out }),
198
+ });
199
+ },
200
+ });
201
+ tools.selfverify_status = dynamicTool({
202
+ description: "Get status + log tail of a self-verify run.",
203
+ inputSchema: jsonSchema({
204
+ type: "object",
205
+ properties: { runId: { type: "string" } },
206
+ required: ["runId"],
207
+ }),
208
+ execute: async (input) => {
209
+ const job = getSelfVerifyJobManager().status(input?.runId);
210
+ if (!job)
211
+ return errLine("not_found", `runId ${input?.runId} not found`);
212
+ const summary = job.report && job.kind === "tier1" && "summary" in job.report
213
+ ? job.report.summary
214
+ : job.report && job.kind === "agentic" && "verdict" in job.report
215
+ ? { verdict: job.report.verdict }
216
+ : null;
217
+ return json({
218
+ runId: job.runId,
219
+ status: job.status,
220
+ kind: job.kind,
221
+ startedAt: job.startedAt,
222
+ finishedAt: job.finishedAt,
223
+ elapsedMs: (job.finishedAt ?? Date.now()) - job.startedAt,
224
+ logTail: job.logBuffer.slice(-40),
225
+ summary,
226
+ error: job.error,
227
+ });
228
+ },
229
+ });
230
+ tools.selfverify_result = dynamicTool({
231
+ description: "Fetch the full report of a completed self-verify run.",
232
+ inputSchema: jsonSchema({
233
+ type: "object",
234
+ properties: { runId: { type: "string" } },
235
+ required: ["runId"],
236
+ }),
237
+ execute: async (input) => {
238
+ const job = getSelfVerifyJobManager().status(input?.runId);
239
+ if (!job)
240
+ return errLine("not_found", `runId ${input?.runId} not found`);
241
+ if (job.status === "running")
242
+ return errLine("still_running", "run not finished; poll selfverify_status first");
243
+ if (job.status === "error")
244
+ return errLine("run_error", job.error ?? "unknown error");
245
+ if (job.status === "cancelled")
246
+ return errLine("cancelled", "run was cancelled");
247
+ return json(job.report ?? {});
248
+ },
249
+ });
250
+ tools.selfverify_list = dynamicTool({
251
+ description: "List recent self-verify runs.",
252
+ inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }),
253
+ execute: async () => json(getSelfVerifyJobManager()
254
+ .list()
255
+ .map((j) => ({
256
+ runId: j.runId,
257
+ kind: j.kind,
258
+ status: j.status,
259
+ elapsedMs: (j.finishedAt ?? Date.now()) - j.startedAt,
260
+ }))),
261
+ });
262
+ tools.selfverify_cancel = dynamicTool({
263
+ description: "Cancel a running self-verify run (best-effort).",
264
+ inputSchema: jsonSchema({
265
+ type: "object",
266
+ properties: { runId: { type: "string" } },
267
+ required: ["runId"],
268
+ }),
269
+ execute: async (input) => json({ cancelled: getSelfVerifyJobManager().cancel(input?.runId) }),
270
+ });
271
+ return tools;
272
+ }
273
+ //# sourceMappingURL=native-tools.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Integration: git-safety guards wired into the bash tool (registry.ts).
3
+ * Unit logic lives in git-safety.test.ts; this asserts the WIRING — a blocked
4
+ * push returns the block message WITHOUT executing, and a broad stage appends
5
+ * the sensitive-path warning to the tool output.
6
+ */
7
+ export {};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Integration: git-safety guards wired into the bash tool (registry.ts).
3
+ * Unit logic lives in git-safety.test.ts; this asserts the WIRING — a blocked
4
+ * push returns the block message WITHOUT executing, and a broad stage appends
5
+ * the sensitive-path warning to the tool output.
6
+ */
7
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import os from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
+ import { BashTool } from "./bash.js";
12
+ import { clearBashOutputCache } from "./bash-output-cache.js";
13
+ import { __resetGitSafetyState, recordCommandOutcome } from "./git-safety.js";
14
+ import { createBuiltinTools } from "./registry.js";
15
+ async function runBash(tools, args) {
16
+ const t = tools.bash;
17
+ if (!t?.execute)
18
+ throw new Error("bash tool has no execute");
19
+ const out = await t.execute(args);
20
+ return typeof out === "string" ? out : JSON.stringify(out);
21
+ }
22
+ describe("git-safety wiring in bash tool", () => {
23
+ beforeEach(() => {
24
+ clearBashOutputCache();
25
+ globalThis.__muonroiBashRepeatState = new Map();
26
+ __resetGitSafetyState();
27
+ delete process.env.MUONROI_ALLOW_PUSH_ON_RED;
28
+ });
29
+ afterEach(() => {
30
+ delete process.env.MUONROI_ALLOW_PUSH_ON_RED;
31
+ });
32
+ it("BLOCKS git push (without executing) after a verification failed this session", async () => {
33
+ const bash = new BashTool(os.tmpdir());
34
+ const tools = createBuiltinTools(bash, "agent", { sessionId: "GS1" });
35
+ // Simulate a failed test earlier in the session.
36
+ recordCommandOutcome("GS1", "npm test", false);
37
+ const out = await runBash(tools, { command: "git push origin main", timeout: 10_000 });
38
+ expect(out).toMatch(/^BLOCKED:/);
39
+ expect(out).toMatch(/npm test/);
40
+ // The distinctive block message proves git push never ran (a real push in
41
+ // tmpdir would fail with a git error like "not a git repository", not this).
42
+ expect(out).not.toMatch(/not a git repository|fatal:/i);
43
+ }, 20_000);
44
+ it("ALLOWS git push once the failed verification re-runs green", async () => {
45
+ const bash = new BashTool(os.tmpdir());
46
+ const tools = createBuiltinTools(bash, "agent", { sessionId: "GS2" });
47
+ recordCommandOutcome("GS2", "npm test", false);
48
+ recordCommandOutcome("GS2", "npm test", true); // re-ran green
49
+ const out = await runBash(tools, { command: "git push origin main", timeout: 10_000 });
50
+ expect(out).not.toMatch(/^BLOCKED:/);
51
+ }, 20_000);
52
+ it("respects MUONROI_ALLOW_PUSH_ON_RED override", async () => {
53
+ process.env.MUONROI_ALLOW_PUSH_ON_RED = "1";
54
+ const bash = new BashTool(os.tmpdir());
55
+ const tools = createBuiltinTools(bash, "agent", { sessionId: "GS3" });
56
+ recordCommandOutcome("GS3", "vitest run", false);
57
+ const out = await runBash(tools, { command: "git push", timeout: 10_000 });
58
+ expect(out).not.toMatch(/^BLOCKED:/);
59
+ }, 20_000);
60
+ it("blocks push across registry rebuilds even with NO sessionId (stable process key)", async () => {
61
+ // Regression for the anon-key false negative: createBuiltinTools() without a
62
+ // sessionId must still gate the push, because production call sites
63
+ // (message-processor) don't thread sessionId and rebuild the registry every
64
+ // turn. A failing verify in one anon registry must block a push in the next.
65
+ const bash = new BashTool(os.tmpdir());
66
+ // `npm test` is a recognized verification command and fails fast here
67
+ // (no package.json in a temp dir) → recorded as a failed verify under the
68
+ // stable process key.
69
+ const toolsA = createBuiltinTools(bash, "agent"); // no sessionId
70
+ const failOut = await runBash(toolsA, { command: "npm test", timeout: 20_000 });
71
+ expect(failOut).toMatch(/ERROR/); // the verify failed
72
+ // Fresh anon registry (simulates the per-turn rebuild).
73
+ const toolsB = createBuiltinTools(bash, "agent"); // no sessionId
74
+ const pushOut = await runBash(toolsB, { command: "git push origin main", timeout: 10_000 });
75
+ expect(pushOut).toMatch(/^BLOCKED:/);
76
+ }, 30_000);
77
+ it("appends a sensitive-path WARNING on a broad git add when secrets exist", async () => {
78
+ const dir = mkdtempSync(join(os.tmpdir(), "gs-stage-"));
79
+ writeFileSync(join(dir, ".env"), "API_KEY=secret");
80
+ try {
81
+ const bash = new BashTool(dir);
82
+ const tools = createBuiltinTools(bash, "agent", { sessionId: "GS4" });
83
+ const out = await runBash(tools, { command: "git add -A", timeout: 10_000 });
84
+ expect(out).toMatch(/\[WARNING:/);
85
+ expect(out).toMatch(/\.env/);
86
+ }
87
+ finally {
88
+ rmSync(dir, { recursive: true, force: true });
89
+ }
90
+ }, 20_000);
91
+ });
92
+ //# sourceMappingURL=registry-git-safety.test.js.map
@@ -12,7 +12,9 @@ import { needsVisionProxy } from "../providers/vision-proxy.js";
12
12
  import { getBashRun, sliceBashOutput } from "./bash-output-cache.js";
13
13
  import { editFile, readFile, writeFile } from "./file.js";
14
14
  import { FileTracker } from "./file-tracker.js";
15
+ import { analyzeGitCommand, checkPushGate, pushBlockedMessage, recordCommandOutcome, stagingWarning, } from "./git-safety.js";
15
16
  import { executeGrep } from "./grep.js";
17
+ import { registerNativeMuonroiTools } from "./native-tools.js";
16
18
  import { VISION_TOOL_NAMES } from "./vision-gate.js";
17
19
  function getBashRepeatState() {
18
20
  if (!globalThis.__muonroiBashRepeatState) {
@@ -124,6 +126,15 @@ export function createBuiltinTools(bash, mode, opts) {
124
126
  // user turns / askcards no longer wipes it. See getBashRepeatState().
125
127
  const repeatState = getBashRepeatState();
126
128
  const repeatKey = resolveBashRepeatKey(opts?.sessionId);
129
+ // Git-safety state key. MUST be stable across createBuiltinTools() rebuilds
130
+ // within one process — otherwise a failed-test record made before a registry
131
+ // rebuild (askcard answer, sub-agent turn) would be invisible to the push
132
+ // gate after the rebuild. Unlike resolveBashRepeatKey's anon fallback (which
133
+ // intentionally generates a fresh key per instance to isolate repeat-reminder
134
+ // state), we want the gate to PERSIST: use the real sessionId when present,
135
+ // else a single process-stable key. Over-sharing here is the safe direction
136
+ // (it can only over-block a push, never wrongly allow one).
137
+ const gitSafetyKey = opts?.sessionId && opts.sessionId.length > 0 ? opts.sessionId : `__proc_default__:${process.pid}`;
127
138
  tools.bash = dynamicTool({
128
139
  description: "Execute a shell command. Output is automatically cached — every call returns a " +
129
140
  "run_id you can re-query via bash_output_get(run_id, mode=tail|head|grep|lines). " +
@@ -149,19 +160,36 @@ export function createBuiltinTools(bash, mode, opts) {
149
160
  if (typeof input.command !== "string" || input.command.trim() === "") {
150
161
  return 'ERROR: the `bash` tool requires a non-empty "command" string, but the call had empty arguments. Provide the shell command to run, e.g. {"command":"ls -la"}.';
151
162
  }
163
+ const cmd = typeof input.command === "string" ? input.command : "";
164
+ // Git safety (pre-execution). Block `git push` while a verification
165
+ // command failed this session and was not re-run green; warn on broad
166
+ // `git add -A` / `git commit -a` when sensitive paths exist. Applied to
167
+ // BOTH foreground and background paths. See git-safety.ts for the audit
168
+ // motivation (session 18285908637a). gitSafetyKey is STABLE per process
169
+ // (or the real sessionId) — unlike repeatKey, whose anon fallback changes
170
+ // on every registry rebuild and would silently drop the gate across turns.
171
+ const gitShape = analyzeGitCommand(cmd);
172
+ const stageWarn = gitShape.isBroadStage ? stagingWarning(bash.getCwd()) : "";
173
+ if (gitShape.isPush) {
174
+ const gate = checkPushGate(gitSafetyKey);
175
+ if (gate.blocked) {
176
+ return `${pushBlockedMessage(gate.failed)}${stageWarn}`;
177
+ }
178
+ }
152
179
  if (input.background) {
153
180
  const result = await bash.startBackground(input.command);
154
- return formatResult(result);
181
+ return `${formatResult(result)}${stageWarn}`;
155
182
  }
156
183
  // 3-3: compute canonical form BEFORE running so we can attach an
157
184
  // inline reminder if it matches the previous bash call.
158
- const cmd = typeof input.command === "string" ? input.command : "";
159
185
  const canonical = cmd ? canonicalizeBashCommand(cmd) : "";
160
186
  const entry = repeatState.get(repeatKey) ?? { lastCanonical: null, lastRunId: null };
161
187
  const repeatedIntent = canonical !== "" && canonical === entry.lastCanonical && entry.lastRunId !== null;
162
188
  const prevRunId = entry.lastRunId;
163
189
  const result = await bash.execute(input.command, input.timeout ?? 30000);
164
190
  const formatted = formatResult(result);
191
+ // Record verification outcome so a later `git push` can be gated on it.
192
+ recordCommandOutcome(gitSafetyKey, canonical, result.success);
165
193
  // Update last-canonical state AFTER we compared, so the current call's
166
194
  // runId becomes the comparison target for the next one. Session-scoped
167
195
  // map persists across createBuiltinTools() rebuilds (Phase 4R).
@@ -185,9 +213,9 @@ export function createBuiltinTools(bash, mode, opts) {
185
213
  const hint = chars >= 4_000
186
214
  ? ` — ${chars} chars cached; use bash_output_get(run_id, mode=tail|head|grep|lines) to re-query`
187
215
  : "";
188
- return `${formatted}\n\n[bash_run_id: ${result.bashRunId}${hint}]${reminder}`;
216
+ return `${formatted}\n\n[bash_run_id: ${result.bashRunId}${hint}]${reminder}${stageWarn}`;
189
217
  }
190
- return formatted;
218
+ return `${formatted}${stageWarn}`;
191
219
  },
192
220
  });
193
221
  // bash_output_get — re-query the cached full output of a previous bash run.
@@ -473,6 +501,13 @@ export function createBuiltinTools(bash, mode, opts) {
473
501
  }
474
502
  },
475
503
  });
504
+ // Native muonroi-tools builtins — ee_health, ee_feedback, usage_forensics,
505
+ // lsp_query, setup_guide, selfverify_*. These run IN-PROCESS; the CLI no
506
+ // longer self-spawns itself as an MCP server to expose them to its own inner
507
+ // agent (that self-spawn cold-started 2-3.5s and overran the build deadline,
508
+ // and a seed-time bug once persisted a crashing vitest-worker command). The
509
+ // muonroi-tools MCP server stays only for EXTERNAL agents. See native-tools.ts.
510
+ registerNativeMuonroiTools(tools, { cwd: bash.getCwd() });
476
511
  }
477
512
  // Vision proxy tools — only for text-only models (DeepSeek, etc.)
478
513
  if (opts?.modelId && needsVisionProxy(opts.modelId)) {
@@ -0,0 +1 @@
1
+ export {};