mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -0,0 +1,17 @@
1
+ import { runExplore } from '../src/standalone/explore-tool.mjs';
2
+ const queries = [
3
+ 'standalone explore tool implementation entry point',
4
+ 'V4A patch format parsing/conversion implementation',
5
+ 'recall memory entries persistent store (sqlite) read/query implementation',
6
+ 'output styles: how style definitions are loaded and applied at session runtime',
7
+ 'Discord backend outbound message send implementation',
8
+ 'shell command destructive/danger policy scan before execution',
9
+ 'where does mixdog store per-user data and sessions on this machine (out of repo)',
10
+ 'channel runtime crash logging write path',
11
+ ];
12
+ const t0 = Date.now();
13
+ const res = await runExplore({ query: queries, cwd: 'C:/Project/mixdog' }, { callerCwd: 'C:/Project/mixdog' });
14
+ console.log('BATCH_ELAPSED_MS', Date.now() - t0);
15
+ const txt = res.content?.[0]?.text || '';
16
+ console.log('FAILED_COUNT', (txt.match(/EXPLORATION_FAILED/g) || []).length);
17
+ process.exit(0);
@@ -0,0 +1,145 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fuzzyRank } from '../src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs';
7
+ import { executeFuzzyFindTool } from '../src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs';
8
+ import { runRg } from '../src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs';
9
+
10
+ // ── fuzzyRank score floor ────────────────────────────────────────────────
11
+
12
+ test('fuzzyRank keeps an exact basename/substring hit and drops scattered noise', () => {
13
+ const items = [
14
+ { path: '.mixdog/data/tool-events.log' },
15
+ // subsequence-only junk: the query chars appear in order but scattered
16
+ // mid-word across dirs, no contiguity or word boundaries.
17
+ { path: 'pgadmin/xtx/oxoxl/exvxexnxtx/sxlxoxg/records.dat' },
18
+ ];
19
+ const ranked = fuzzyRank('tool-events.log', items);
20
+ assert.ok(ranked.length >= 1, 'the real file must survive the floor');
21
+ assert.equal(ranked[0].item.path, '.mixdog/data/tool-events.log');
22
+ assert.ok(
23
+ !ranked.some((r) => r.item.path.startsWith('pgadmin/')),
24
+ 'scattered subsequence junk must be filtered out',
25
+ );
26
+ });
27
+
28
+ test('fuzzyRank floor drops a purely scattered subsequence but keeps a substring', () => {
29
+ const strong = { path: 'src/abcdef.txt' };
30
+ const junk = { path: 'xax/xbxcx/xdxexfx/z.bin' };
31
+ const ranked = fuzzyRank('abcdef', [strong, junk]);
32
+ assert.deepEqual(ranked.map((r) => r.item.path), ['src/abcdef.txt']);
33
+ });
34
+
35
+ // ── hidden-directory discovery via the find tool ─────────────────────────
36
+
37
+ function makeRepo() {
38
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-hidden-'));
39
+ mkdirSync(join(root, '.mixdog', 'data'), { recursive: true });
40
+ writeFileSync(join(root, '.mixdog', 'data', 'tool-events.log'), 'x\n');
41
+ mkdirSync(join(root, 'src'), { recursive: true });
42
+ writeFileSync(join(root, 'src', 'unrelated.txt'), 'x\n');
43
+ // .git noise must stay pruned even with hidden default true.
44
+ mkdirSync(join(root, '.git'), { recursive: true });
45
+ writeFileSync(join(root, '.git', 'tool-events.log'), 'x\n');
46
+ return root;
47
+ }
48
+
49
+ test('find surfaces a file under a dot-directory top-ranked (hidden default true)', async () => {
50
+ const root = makeRepo();
51
+ try {
52
+ const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
53
+ const lines = out.split('\n').filter(Boolean);
54
+ assert.ok(lines.length >= 1, `expected a hit, got: ${out}`);
55
+ assert.equal(lines[0], '.mixdog/data/tool-events.log');
56
+ assert.ok(!out.includes('.git/'), '.git must stay pruned as noise');
57
+ } finally {
58
+ rmSync(root, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test('find hidden:false skips dot-directories', async () => {
63
+ const root = makeRepo();
64
+ try {
65
+ const out = await executeFuzzyFindTool({ query: 'tool-events.log', hidden: false }, root);
66
+ assert.ok(!out.includes('.mixdog'), `hidden:false must not descend dot-dirs: ${out}`);
67
+ } finally {
68
+ rmSync(root, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ // ── narrowed-pass merge / dedup backstop ─────────────────────────────────
73
+
74
+ test('exact-name hit survives among many decoys and is not duplicated', async () => {
75
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-merge-'));
76
+ try {
77
+ // A deep exact-name target plus a pile of unrelated decoys. The target
78
+ // matches both the broad enumeration and the query-narrowed pass, so it
79
+ // exercises the merge+dedup path.
80
+ mkdirSync(join(root, 'deep', 'nested', 'here'), { recursive: true });
81
+ writeFileSync(join(root, 'deep', 'nested', 'here', 'tool-events.log'), 'x\n');
82
+ mkdirSync(join(root, 'noise'), { recursive: true });
83
+ for (let i = 0; i < 200; i++) {
84
+ writeFileSync(join(root, 'noise', `decoy-${i}.bin`), 'x\n');
85
+ }
86
+ const out = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
87
+ const lines = out.split('\n').filter(Boolean);
88
+ assert.equal(lines[0], 'deep/nested/here/tool-events.log');
89
+ const hits = lines.filter((l) => l === 'deep/nested/here/tool-events.log');
90
+ assert.equal(hits.length, 1, `merge must dedup, got ${hits.length}: ${out}`);
91
+ } finally {
92
+ rmSync(root, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ // ── truncation warning surfaces when the broad pass reports .truncated ────
97
+
98
+ test('truncated broad enumeration emits a visible warning, still ranks the exact hit, and is not cached', async () => {
99
+ const root = makeRepo();
100
+ try {
101
+ // Test-only seam: wrap the real runRg so the BROAD pass (the one without
102
+ // `--iglob`) reports truncation via the boxed-String contract the tool
103
+ // relies on (.truncated=true), while the narrowed pass is left intact.
104
+ // Production options never carry __runRg, so the real path is unchanged.
105
+ const truncatingRunRg = async (argsList, execOptions) => {
106
+ const out = await runRg(argsList, execOptions);
107
+ if (argsList.includes('--iglob')) return out; // narrowed backstop unchanged
108
+ const boxed = new String(String(out));
109
+ boxed.truncated = true;
110
+ return boxed;
111
+ };
112
+ const out = await executeFuzzyFindTool(
113
+ { query: 'tool-events.log' }, root, { __runRg: truncatingRunRg },
114
+ );
115
+ assert.ok(out.includes('[warning]'), `truncated broad pass must warn: ${out}`);
116
+ assert.ok(out.includes('truncated at 20MB cap'), `warning text must surface: ${out}`);
117
+ const lines = out.split('\n').filter(Boolean);
118
+ assert.equal(lines[0], '.mixdog/data/tool-events.log', 'exact hit must still rank first');
119
+ // Not cached: a normal follow-up run (no injected truncation) must NOT
120
+ // return the warning — proving the truncated result was never cached.
121
+ const out2 = await executeFuzzyFindTool({ query: 'tool-events.log' }, root);
122
+ assert.ok(!out2.includes('[warning]'), `truncated result must not be cached/served: ${out2}`);
123
+ } finally {
124
+ rmSync(root, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ // ── narrowed backstop treats the query as a literal (glob metachars) ──────
129
+
130
+ test('a query with glob metachars still gets the narrowed backstop', async () => {
131
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-glob-'));
132
+ try {
133
+ // "[slug].tsx" contains a character-class metachar; a raw *[slug].tsx*
134
+ // iglob would match one of s/l/u/g, not the literal filename. The tool
135
+ // must escape it so the exact-name file is still surfaced.
136
+ mkdirSync(join(root, 'app'), { recursive: true });
137
+ writeFileSync(join(root, 'app', '[slug].tsx'), 'x\n');
138
+ writeFileSync(join(root, 'app', 'slug.tsx'), 'x\n'); // class-match decoy
139
+ const out = await executeFuzzyFindTool({ query: '[slug].tsx' }, root);
140
+ const lines = out.split('\n').filter(Boolean);
141
+ assert.ok(lines.includes('app/[slug].tsx'), `literal metachar hit must surface: ${out}`);
142
+ } finally {
143
+ rmSync(root, { recursive: true, force: true });
144
+ }
145
+ });
@@ -0,0 +1,149 @@
1
+ // Regression tests for turn-time deferred-manifest construction (claude-code
2
+ // style). An MCP server that finishes its handshake BETWEEN session-create and
3
+ // the user's FIRST send is folded — on the first turn, synchronously, with no
4
+ // boot await — into the INITIAL <available-deferred-tools> manifest (BP1) and
5
+ // pre-marked announced, so the first-turn tool-catalog reconcile emits NO late
6
+ // <system-reminder> for it. A server that connects AFTER the first turn keeps
7
+ // the append-only late-reminder path. Unit-style: exercise the tool-catalog
8
+ // exports directly (no runtime, no spawn) the way session-turn-api/runtime-core
9
+ // wire them.
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import {
13
+ applyDeferredToolSurface,
14
+ refreshInitialDeferredMcpSurface,
15
+ reconcileDeferredMcpToolCatalog,
16
+ } from '../src/session-runtime/tool-catalog.mjs';
17
+
18
+ function baseSession() {
19
+ return {
20
+ provider: 'legacy',
21
+ tools: [{ name: 'read', description: 'read a file' }, { name: 'grep', description: 'search' }],
22
+ messages: [{ role: 'system', content: 'BASE PROMPT' }],
23
+ };
24
+ }
25
+
26
+ // A freshly-created session: the create-time surface is baked WITHOUT any MCP
27
+ // (server still mid-handshake at create). `shell` is a deferred (non-active)
28
+ // standalone tool so BP1 carries a manifest block even before MCP arrives.
29
+ function createdSession() {
30
+ const session = baseSession();
31
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: 'legacy' });
32
+ return session;
33
+ }
34
+
35
+ function systemContent(session) {
36
+ return session.messages.find((m) => m.role === 'system').content;
37
+ }
38
+
39
+ const mcpTool = { name: 'mcp__unity__get_scene', description: 'Return the active Unity scene graph.' };
40
+ const mcpTool2 = { name: 'mcp__unity__run_tests', description: 'Run the Unity test runner.' };
41
+
42
+ // Mirror the session-turn-api first-turn gate: refresh runs ONCE, only for a
43
+ // session flagged fresh (deferredInitialRefreshPending) at create; a resumed
44
+ // session (reloaded transcript, flag absent) takes the late-reconcile path and
45
+ // its already-baked BP1 is never rebuilt or re-announced.
46
+ function firstTurnGate(session, liveMcp) {
47
+ if (session.deferredInitialRefreshPending) {
48
+ session.deferredInitialRefreshPending = false;
49
+ return refreshInitialDeferredMcpSurface(session, liveMcp) ? 'refreshed' : 'refresh-noop';
50
+ }
51
+ return 'late';
52
+ }
53
+
54
+ test('MCP connecting between session-create and first send lands in the INITIAL manifest, not a late reminder', () => {
55
+ const session = createdSession();
56
+ assert.ok(!systemContent(session).includes('mcp__unity__get_scene'), 'MCP absent from create-time BP1');
57
+
58
+ // First-turn refresh: the live registry now includes the connected server.
59
+ const changed = refreshInitialDeferredMcpSurface(session, [mcpTool]);
60
+ assert.equal(changed, true, 'first-turn refresh folded the newly-connected MCP tool');
61
+
62
+ const sys = systemContent(session);
63
+ assert.ok(sys.includes('<available-deferred-tools>'), 'manifest block present');
64
+ assert.ok(sys.includes('mcp__unity__get_scene'), 'MCP tool in the INITIAL manifest');
65
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'MCP tool pre-marked announced');
66
+
67
+ let enqueued = null;
68
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
69
+ enqueue: (text) => { enqueued = text; return true; },
70
+ });
71
+ assert.equal(result, null, 'no late-tool announcement for a first-turn-folded tool');
72
+ assert.equal(enqueued, null, 'nothing enqueued for a first-turn-folded tool');
73
+ });
74
+
75
+ test('an MCP server connecting AFTER the first turn is still announced via the late reminder', () => {
76
+ const session = createdSession();
77
+ // First turn: no MCP connected yet — nothing to fold.
78
+ assert.equal(refreshInitialDeferredMcpSurface(session, []), false, 'no live MCP => first-turn no-op');
79
+ assert.ok(!(session.deferredAnnouncedTools || []).includes('mcp__unity__get_scene'), 'MCP not pre-announced');
80
+
81
+ // A later turn: the server has since connected → late path fires.
82
+ let enqueued = null;
83
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
84
+ enqueue: (text) => { enqueued = text; return true; },
85
+ });
86
+ assert.deepEqual(result, ['mcp__unity__get_scene'], 'late tool announced');
87
+ assert.ok(enqueued && enqueued.includes('mcp__unity__get_scene'), 'late reminder enqueued');
88
+ assert.ok(enqueued.includes('connected after this session started'), 'reminder carries the late-tool sentinel');
89
+ });
90
+
91
+ test('recreated session (MCP already connected at create) seeds its manifest, no late re-announce', () => {
92
+ // Recreate/reset path: createCurrentSession folds the live MCP tools into the
93
+ // surface at create time, so a cwd-change recreate seeds its BP1 directly and
94
+ // never needs the first-turn refresh.
95
+ const session = baseSession();
96
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
97
+ assert.ok(systemContent(session).includes('mcp__unity__get_scene'), 'MCP in recreated BP1');
98
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'recreated MCP pre-marked announced');
99
+
100
+ let enqueued = null;
101
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
102
+ enqueue: (text) => { enqueued = text; return true; },
103
+ });
104
+ assert.equal(result, null, 'no late announcement on recreate');
105
+ assert.equal(enqueued, null, 'nothing enqueued on recreate');
106
+ });
107
+
108
+ test('first-turn refresh is idempotent: a re-render with no new MCP is a no-op and keeps ONE manifest block', () => {
109
+ const session = createdSession();
110
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), true, 'first fold applies');
111
+ const once = systemContent(session);
112
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), false, 'no genuinely-new MCP => no-op');
113
+ const twice = systemContent(session);
114
+ assert.equal(once, twice, 'BP1 byte-identical on re-render');
115
+ assert.equal((twice.match(/<available-deferred-tools>/g) || []).length, 1, 'exactly one manifest block (no duplicate)');
116
+ });
117
+
118
+ test('a second newly-connected MCP tool re-renders BP1 in place (both listed, still one block)', () => {
119
+ const session = createdSession();
120
+ refreshInitialDeferredMcpSurface(session, [mcpTool]);
121
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool, mcpTool2]), true, 're-fold applies the new tool');
122
+ const sys = systemContent(session);
123
+ assert.ok(sys.includes('mcp__unity__get_scene') && sys.includes('mcp__unity__run_tests'), 'both MCP tools listed');
124
+ assert.equal((sys.match(/<available-deferred-tools>/g) || []).length, 1, 'still exactly one block (rebuilt in place)');
125
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__run_tests'), 'the new tool is pre-announced too');
126
+ });
127
+
128
+ test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its next turn', () => {
129
+ // A prior run baked BP1 with the MCP tool; resume reloads that transcript.
130
+ const session = baseSession();
131
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
132
+ const before = systemContent(session);
133
+ const announcedBefore = [...session.deferredAnnouncedTools];
134
+ // Resume path never sets the per-session fresh flag.
135
+ assert.equal(session.deferredInitialRefreshPending, undefined, 'resumed session carries no fresh flag');
136
+ const route = firstTurnGate(session, [mcpTool, mcpTool2]);
137
+ assert.equal(route, 'late', 'resumed session takes the late-reconcile path, not the initial refresh');
138
+ assert.equal(systemContent(session), before, 'BP1 untouched on resume (no rebuild)');
139
+ assert.deepEqual([...session.deferredAnnouncedTools], announcedBefore, 'announced set unchanged on resume');
140
+ });
141
+
142
+ test('a fresh session (flagged) is refreshed exactly once, then falls to the late path', () => {
143
+ const session = createdSession();
144
+ session.deferredInitialRefreshPending = true;
145
+ assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
146
+ assert.equal(session.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
147
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
148
+ assert.equal(firstTurnGate(session, [mcpTool2]), 'late', 'second turn no longer refreshes');
149
+ });
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { acceptQuotaSnapshot } from '../src/ui/statusline.mjs';
4
+
5
+ // Monotonic hysteresis for the 5H/7D usage segment: once a value has rendered,
6
+ // an OLDER shared-cache snapshot (written by another mixdog instance) must not
7
+ // displace it, but a NEWER one — or confirmed own-instance live data — must.
8
+ test('older shared-cache snapshot is rejected, newer is accepted', () => {
9
+ const displayedOwnLive = { segments: ['5H 12%'], asOf: 2000, owned: true };
10
+
11
+ // Another instance overwrites the shared cache with an OLDER snapshot →
12
+ // metricsMatch flips false, source alternates to that unowned older snapshot.
13
+ assert.equal(
14
+ acceptQuotaSnapshot(displayedOwnLive, { asOf: 1000, owned: false }),
15
+ false,
16
+ 'older unowned snapshot must not displace displayed own-live value',
17
+ );
18
+
19
+ // A strictly newer shared snapshot is allowed to advance the value.
20
+ assert.equal(
21
+ acceptQuotaSnapshot(displayedOwnLive, { asOf: 3000, owned: false }),
22
+ true,
23
+ 'newer snapshot must replace the displayed value',
24
+ );
25
+ });
26
+
27
+ test('own-instance live data always wins; empty timestamps preserve prior behavior', () => {
28
+ const displayedShared = { segments: ['5H 12%'], asOf: 5000, owned: false };
29
+ // Own-instance live data replaces even an apparently newer shared snapshot.
30
+ assert.equal(acceptQuotaSnapshot(displayedShared, { asOf: 1, owned: true }), true);
31
+ // Nothing displayed yet → accept.
32
+ assert.equal(acceptQuotaSnapshot(undefined, { asOf: 1000, owned: false }), true);
33
+ // No comparable timestamps → accept (byte-for-byte prior behavior).
34
+ assert.equal(acceptQuotaSnapshot({ asOf: 0, owned: true }, { asOf: 0, owned: false }), true);
35
+ // Shared→shared same asOf → accept (idempotent refresh).
36
+ assert.equal(acceptQuotaSnapshot({ asOf: 2000, owned: false }, { asOf: 2000, owned: false }), true);
37
+ });
@@ -906,7 +906,7 @@ const fullDefaults = defaultDeferredToolNames(smokeCatalog, 'full');
906
906
  if (fullDefaults.size !== 10) {
907
907
  throw new Error(`full default surface should stay 10 tools, got ${fullDefaults.size}: ${[...fullDefaults].join(', ')}`);
908
908
  }
909
- for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', 'tool_search']) {
909
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'apply_patch', 'explore', 'Skill', 'load_tool']) {
910
910
  assertHas(fullDefaults, name);
911
911
  }
912
912
  for (const name of ['shell', 'task', 'agent', 'recall', 'search', 'web_fetch', 'cwd']) {
@@ -917,7 +917,7 @@ const leadDefaults = defaultDeferredToolNames(smokeCatalog, 'lead');
917
917
  if (leadDefaults.size !== 16) {
918
918
  throw new Error(`lead default surface should stay 16 tools for this static catalog, got ${leadDefaults.size}: ${[...leadDefaults].join(', ')}`);
919
919
  }
920
- for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', 'tool_search']) {
920
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'shell', 'task', 'apply_patch', 'explore', 'agent', 'recall', 'search', 'web_fetch', 'Skill', 'load_tool']) {
921
921
  assertHas(leadDefaults, name);
922
922
  }
923
923
  if (TOOL_SEARCH_TOOL.annotations?.agentHidden !== true) {
@@ -943,7 +943,7 @@ for (const [name, cap] of [
943
943
  ['recall', 2400],
944
944
  ['search', 3200],
945
945
  ['web_fetch', 900],
946
- ['tool_search', 900],
946
+ ['load_tool', 900],
947
947
  ]) {
948
948
  const tool = smokeCatalog.find((item) => item?.name === name);
949
949
  const size = toolSchemaSize(tool);
@@ -954,7 +954,7 @@ const readonlyDefaults = defaultDeferredToolNames(smokeCatalog, 'readonly');
954
954
  if (readonlyDefaults.size !== 9) {
955
955
  throw new Error(`readonly default surface should stay 9 tools, got ${readonlyDefaults.size}: ${[...readonlyDefaults].join(', ')}`);
956
956
  }
957
- for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', 'tool_search']) {
957
+ for (const name of ['read', 'code_graph', 'grep', 'find', 'glob', 'list', 'explore', 'Skill', 'load_tool']) {
958
958
  assertHas(readonlyDefaults, name);
959
959
  }
960
960
  for (const name of ['apply_patch', 'agent', 'shell']) {
@@ -1050,32 +1050,28 @@ const prevChannelSingleton = process.env.MIXDOG_CHANNEL_SINGLETON;
1050
1050
  const prevChannelWorkerProcess = process.env.MIXDOG_CHANNEL_WORKER_PROCESS;
1051
1051
  const prevRuntimeRoot = process.env.MIXDOG_RUNTIME_ROOT;
1052
1052
  const prevEnvOut = process.env.SMOKE_CHANNEL_ENV_OUT;
1053
+ const prevDaemonEntry = process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
1053
1054
  try {
1054
- const entry = join(channelWorkerTmp, 'entry.mjs');
1055
+ // Daemon-mode worker env coverage: start() spawn-or-attaches the machine
1056
+ // -global daemon (the stub daemon entry — no Discord token) instead of
1057
+ // forking `entry`, so assert the flags on the SPAWNED DAEMON's env (the stub
1058
+ // dumps them to SMOKE_CHANNEL_ENV_OUT). The old fork-path env assertion died
1059
+ // with the fork path itself; full flip/attach coverage lives in
1060
+ // scripts/channel-daemon-smoke.mjs.
1061
+ const stubEntry = join(root, 'scripts', 'channel-daemon-stub.mjs');
1055
1062
  const dataDir = join(channelWorkerTmp, 'data');
1056
1063
  const runtimeDir = join(channelWorkerTmp, 'runtime');
1057
1064
  const envOut = join(channelWorkerTmp, 'env.json');
1058
1065
  mkdirSync(dataDir, { recursive: true });
1059
1066
  mkdirSync(runtimeDir, { recursive: true });
1060
- writeFileSync(entry, `
1061
- import { writeFileSync } from 'node:fs';
1062
- writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
1063
- cliOwned: process.env.MIXDOG_CLI_OWNED,
1064
- daemon: process.env.MIXDOG_CHANNEL_DAEMON,
1065
- }));
1066
- process.send?.({ type: 'ready' });
1067
- process.on('message', (msg) => {
1068
- if (msg?.type === 'shutdown') process.exit(0);
1069
- });
1070
- setInterval(() => {}, 10000);
1071
- `);
1072
1067
  process.env.MIXDOG_CHANNEL_DAEMON = '1';
1073
1068
  process.env.MIXDOG_CHANNEL_SINGLETON = '1';
1074
1069
  process.env.MIXDOG_CHANNEL_WORKER_PROCESS = '1';
1075
1070
  process.env.MIXDOG_RUNTIME_ROOT = runtimeDir;
1076
1071
  process.env.SMOKE_CHANNEL_ENV_OUT = envOut;
1072
+ process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = stubEntry;
1077
1073
  channelEnvWorker = createStandaloneChannelWorker({
1078
- entry,
1074
+ entry: stubEntry,
1079
1075
  rootDir: root,
1080
1076
  dataDir,
1081
1077
  cwd: root,
@@ -1100,6 +1096,11 @@ setInterval(() => {}, 10000);
1100
1096
  else process.env.MIXDOG_RUNTIME_ROOT = prevRuntimeRoot;
1101
1097
  if (prevEnvOut == null) delete process.env.SMOKE_CHANNEL_ENV_OUT;
1102
1098
  else process.env.SMOKE_CHANNEL_ENV_OUT = prevEnvOut;
1099
+ if (prevDaemonEntry == null) delete process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
1100
+ else process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = prevDaemonEntry;
1101
+ // Detach only ends OUR attachment; the stub daemon self-shuts after its
1102
+ // client-grace window. Give it that window before deleting its tmp root.
1103
+ await new Promise((resolveWait) => setTimeout(resolveWait, 700));
1103
1104
  rmSync(channelWorkerTmp, { recursive: true, force: true });
1104
1105
  }
1105
1106
 
@@ -1191,10 +1192,10 @@ if (EXPLORE_TOOL.annotations?.agentHidden === true) {
1191
1192
  }
1192
1193
  }
1193
1194
  const exploreProps = EXPLORE_TOOL.inputSchema?.properties || {};
1194
- if (!/Repo anchor locator/i.test(EXPLORE_TOOL.description || '') || !/broad\/uncertain/i.test(EXPLORE_TOOL.description || '') || !/independent targets/i.test(EXPLORE_TOOL.description || '') || (EXPLORE_TOOL.description || '').length > 90) {
1195
- throw new Error('explore description must stay compact and anchor-oriented');
1195
+ if (!/broad\/uncertain/i.test(EXPLORE_TOOL.description || '') || !/machine-wide/i.test(EXPLORE_TOOL.description || '') || !/independent targets/i.test(EXPLORE_TOOL.description || '') || (EXPLORE_TOOL.description || '').length > 600) {
1196
+ throw new Error('explore description must keep the locator + facet fan-out contract');
1196
1197
  }
1197
- if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent targets/i.test(exploreProps.query?.description || '') || !/Project\/root/i.test(exploreProps.cwd?.description || '')) {
1198
+ if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent facets/i.test(exploreProps.query?.description || '') || !/Project\/root/i.test(exploreProps.cwd?.description || '')) {
1198
1199
  throw new Error('explore schema must stay compact and preserve query/cwd shape');
1199
1200
  }
1200
1201
  const normalizedExplore = normalizeExploreQueries('["where is model selection?"," ","which file owns agent async?"]');
@@ -1359,8 +1360,8 @@ setInternalToolsProvider({
1359
1360
  throw new Error(`agent context must not inject environment reminder: ${visible.slice(0, 1200)}`);
1360
1361
  }
1361
1362
  const workerToolNames = (workerSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1362
- if (workerToolNames.includes('tool_search')) {
1363
- throw new Error(`agent session schema must not expose deferred tool_search: ${workerToolNames.join(', ')}`);
1363
+ if (workerToolNames.includes('load_tool')) {
1364
+ throw new Error(`agent session schema must not expose deferred load_tool: ${workerToolNames.join(', ')}`);
1364
1365
  }
1365
1366
  for (const name of ['shell', 'task']) {
1366
1367
  if (!workerToolNames.includes(name)) {
@@ -1412,7 +1413,11 @@ setInternalToolsProvider({
1412
1413
  const writeTools = (writeAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1413
1414
  const fullTools = (fullAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1414
1415
  const publicExploreTools = (publicExploreSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1415
- const expectedReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch', 'Skill'];
1416
+ // Read-role AGENT sessions carry shell/task so review/debug agents can run
1417
+ // their own verification (build/test); the plain readonly preset (public
1418
+ // explore role) still omits them.
1419
+ const expectedReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
1420
+ const expectedPublicReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch', 'Skill'];
1416
1421
  const expectedWriteTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'apply_patch', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
1417
1422
  if (JSON.stringify(readTools) !== JSON.stringify(expectedReadTools)) {
1418
1423
  throw new Error(`read agent schema must be fixed allow-list: expected=${expectedReadTools.join(', ')} actual=${readTools.join(', ')}`);
@@ -1420,15 +1425,18 @@ setInternalToolsProvider({
1420
1425
  if (JSON.stringify(writeTools) !== JSON.stringify(expectedWriteTools)) {
1421
1426
  throw new Error(`read-write agent schema must be fixed allow-list: expected=${expectedWriteTools.join(', ')} actual=${writeTools.join(', ')}`);
1422
1427
  }
1423
- if (readTools.includes('tool_search') || writeTools.includes('tool_search')) {
1424
- throw new Error(`agent session fixed schemas must omit tool_search: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1428
+ if (readTools.includes('load_tool') || writeTools.includes('load_tool')) {
1429
+ throw new Error(`agent session fixed schemas must omit load_tool: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1425
1430
  }
1426
- if (readTools.includes('shell')) {
1427
- throw new Error(`read agent schema must omit shell: read=${readTools.join(', ')}`);
1431
+ if (readTools.includes('apply_patch')) {
1432
+ throw new Error(`read agent schema must omit apply_patch: read=${readTools.join(', ')}`);
1428
1433
  }
1429
- for (const name of ['shell', 'apply_patch', 'task']) {
1430
- if (readTools.includes(name)) {
1431
- throw new Error(`read agent schema must omit non-read tool ${name}: read=${readTools.join(', ')}`);
1434
+ for (const name of ['shell', 'task']) {
1435
+ if (!readTools.includes(name)) {
1436
+ throw new Error(`read agent schema must carry verification tool ${name}: read=${readTools.join(', ')}`);
1437
+ }
1438
+ if (publicExploreTools.includes(name)) {
1439
+ throw new Error(`public explore role must omit ${name}: explore=${publicExploreTools.join(', ')}`);
1432
1440
  }
1433
1441
  }
1434
1442
  for (const name of ['apply_patch', 'shell', 'task']) {
@@ -1450,13 +1458,13 @@ setInternalToolsProvider({
1450
1458
  if (!fullTools.includes('explore')) {
1451
1459
  throw new Error(`full agent schema must expose explore: full=${fullTools.join(', ')}`);
1452
1460
  }
1453
- // Unified-shard policy (v0.9.13): the explore wrapper stays IN the schema
1454
- // for every read role including the explore agent itself — so the
1455
- // read-only bundle is bit-identical across roles (one cache group).
1456
- // Recursion is broken at call time in pre-dispatch-deny.mjs via
1457
- // recursiveWrapperToolNameForPublicAgent, not by schema stripping.
1458
- if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedReadTools)) {
1459
- throw new Error(`public explore role must ship the shared read-only bundle (incl. explore): expected=${expectedReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
1461
+ // The explore wrapper stays IN the schema for every read role — including
1462
+ // the explore agent itself. Recursion is broken at call time in
1463
+ // pre-dispatch-deny.mjs via recursiveWrapperToolNameForPublicAgent, not by
1464
+ // schema stripping. (Read AGENT sessions add shell/task on top, so the
1465
+ // public explore bundle is its own cache group now.)
1466
+ if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedPublicReadTools)) {
1467
+ throw new Error(`public explore role must ship the readonly bundle (incl. explore): expected=${expectedPublicReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
1460
1468
  }
1461
1469
  if (recursiveWrapperToolNameForPublicAgent('explore') !== 'explore') {
1462
1470
  throw new Error('call-time anti-recursion must map public explore agent to its own wrapper tool');
@@ -1846,25 +1854,38 @@ if (!/Use after search/i.test(webFetchTool?.description || '') || !webFetchProps
1846
1854
  if (!/offset/i.test(webFetchProps.startIndex?.description || '') || !/Maximum characters/i.test(webFetchProps.maxLength?.description || '')) {
1847
1855
  throw new Error('web_fetch schema must describe paging window fields');
1848
1856
  }
1849
- if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '') || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
1850
- throw new Error('tool_search schema must preserve selection guidance and select field');
1857
+ if (!/deferred tools/i.test(TOOL_SEARCH_TOOL.description || '')
1858
+ || !TOOL_SEARCH_TOOL.inputSchema?.properties?.names
1859
+ || !TOOL_SEARCH_TOOL.inputSchema?.properties?.select) {
1860
+ throw new Error('load_tool schema must preserve loader guidance plus names + legacy select fields');
1851
1861
  }
1852
1862
  const toolSearchSession = {
1853
1863
  tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1854
1864
  deferredToolCatalog: smokeCatalog.slice(),
1855
1865
  deferredSelectedTools: [...fullDefaults],
1856
1866
  };
1857
- // A plain query is a case-insensitive substring filter over name+description.
1858
- // It lists matches only it never auto-loads or ranks.
1867
+ // load_tool is a pure loader: a free-text query is NOT a search. It loads
1868
+ // nothing, returns an error steering to names[], and never activates tools.
1859
1869
  const listQueryResult = JSON.parse(__renderToolSearchForTest({ query: 'shell' }, toolSearchSession, 'full'));
1860
- if (listQueryResult.selected) {
1861
- throw new Error(`tool_search plain query must not auto-load: ${JSON.stringify(listQueryResult.selected)}`);
1870
+ if (listQueryResult.selected || (Array.isArray(listQueryResult.loaded) && listQueryResult.loaded.length)) {
1871
+ throw new Error(`load_tool free-text query must not load: ${JSON.stringify(listQueryResult)}`);
1862
1872
  }
1863
- if (!listQueryResult.matches.some((row) => row.name === 'shell')) {
1864
- throw new Error(`tool_search plain query should list substring matches: ${JSON.stringify(listQueryResult.matches)}`);
1873
+ if (!listQueryResult.error || !/names/i.test(listQueryResult.error)) {
1874
+ throw new Error(`load_tool free-text query must steer to names[]: ${JSON.stringify(listQueryResult)}`);
1865
1875
  }
1866
1876
  if (listQueryResult.activeTools.includes('shell') || (Array.isArray(listQueryResult.discoveredTools) && listQueryResult.discoveredTools.includes('shell'))) {
1867
- throw new Error(`tool_search plain query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
1877
+ throw new Error(`load_tool free-text query must not activate/discover tools: ${JSON.stringify(listQueryResult)}`);
1878
+ }
1879
+ // names[] is the primary loader input (aliases expand, tools activate).
1880
+ const namesLoadResult = JSON.parse(__renderToolSearchForTest({ names: ['shell', 'recall'] }, {
1881
+ tools: smokeCatalog.filter((tool) => fullDefaults.has(tool?.name)),
1882
+ deferredToolCatalog: smokeCatalog.slice(),
1883
+ deferredSelectedTools: [...fullDefaults],
1884
+ }, 'full'));
1885
+ for (const name of ['shell', 'recall']) {
1886
+ if (!namesLoadResult.activeTools.includes(name) || !namesLoadResult.loaded.includes(name)) {
1887
+ throw new Error(`load_tool names[] must load ${name}: ${JSON.stringify(namesLoadResult)}`);
1888
+ }
1868
1889
  }
1869
1890
  // query "select:a,b" is the explicit query-side loader (aliases expand).
1870
1891
  const bulkSelectResult = JSON.parse(__renderToolSearchForTest({ query: 'select:shell,recall' }, toolSearchSession, 'full'));
@@ -22,6 +22,12 @@ synonyms, library/domain names), plus `find` with name fragments, plus `code_gra
22
22
  symbol_search when the query names an identifier. A single-pattern,
23
23
  single-tool first turn is a defect.
24
24
 
25
+ A turn-1 broad grep MUST set `output_mode:"files_with_matches"` — an
26
+ unscoped `content`/`content_with_context` scan reads every match body and is
27
+ a defect (a full-content scan across the tree costs seconds). Use
28
+ `content_with_context` ONLY against a `path` that appeared in an earlier
29
+ result THIS session, and always with `head_limit`.
30
+
25
31
  Search tokens are CODE tokens: first translate natural-language or
26
32
  non-English queries into probable English identifiers (e.g. "최대 루프 반복
27
33
  횟수" → maxLoop, loop-policy, iterations). Grep non-ASCII text only when
@@ -4,12 +4,19 @@
4
4
  prior result. Merge equivalent variants/scopes into ONE call wherever the
5
5
  schema accepts arrays (`pattern[]`, `path[]`, `symbols[]`, `query[]`).
6
6
  - Route by what is already known: known symbol/relation → `code_graph`;
7
- exact text in a known scope → `grep`; unknown location or concept-level
8
- question `explore`; name fragment → `find`; exact name pattern → `glob`;
9
- known directory → `list`; known file/region → `read`.
7
+ exact text in a known scope → `grep`; unknown location, machine-wide/
8
+ out-of-repo whereabouts, or concept-level question → `explore` (which uses
9
+ the hardened `find` internally); name fragment → `find`; exact name pattern
10
+ → `glob`; known directory → `list`; known file/region → `read`.
11
+ - `explore` fan-out: at task start, decompose what the task needs to know
12
+ into independent facets (implementation site, config/load path, tests,
13
+ error origin, ...) and send them as ONE `query[]` call — facets run in
14
+ parallel. Never fan out rephrasings of the same target; on
15
+ EXPLORATION_FAILED, retry once with changed tokens.
10
16
  - Valid anchors come from user input or tool output in this session; locate
11
17
  anything else with `find` before `grep`/`read`. On ENOENT the next call is
12
- `find` on the basename — never a retried guess.
18
+ `find` on the basename — never a retried guess; and never guess an absolute
19
+ path outside the project — `find` from a verified broad root instead.
13
20
  - Retrieval stops when evidence covers the deliverable: single-answer tasks
14
21
  end at the first sufficient anchor; enumeration tasks (review, audit) end
15
22
  when the stated scope is covered. Never re-verify a hit already on screen;