mixdog 0.9.37 → 0.9.39

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 (127) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  8. package/scripts/execution-completion-dedup-test.mjs +157 -0
  9. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  10. package/scripts/execution-resume-esc-integration-test.mjs +174 -0
  11. package/scripts/explore-bench.mjs +101 -8
  12. package/scripts/explore-prompt-policy-test.mjs +156 -11
  13. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  14. package/scripts/internal-comms-bench-test.mjs +226 -0
  15. package/scripts/internal-comms-bench.mjs +185 -58
  16. package/scripts/internal-comms-smoke.mjs +171 -23
  17. package/scripts/live-worker-smoke.mjs +38 -2
  18. package/scripts/memory-cycle-routing-test.mjs +111 -0
  19. package/scripts/memory-rule-contract-test.mjs +93 -0
  20. package/scripts/notify-completion-mirror-test.mjs +73 -0
  21. package/scripts/output-style-smoke.mjs +2 -2
  22. package/scripts/rg-runner-test.mjs +240 -0
  23. package/scripts/routing-corpus-test.mjs +349 -0
  24. package/scripts/routing-corpus.mjs +211 -32
  25. package/scripts/session-orphan-sweep-test.mjs +83 -0
  26. package/scripts/session-sweep.mjs +266 -0
  27. package/scripts/steering-drain-buckets-test.mjs +179 -0
  28. package/scripts/tool-smoke.mjs +21 -13
  29. package/scripts/tool-tui-presentation-test.mjs +202 -0
  30. package/src/agents/heavy-worker/AGENT.md +10 -7
  31. package/src/agents/reviewer/AGENT.md +6 -4
  32. package/src/agents/worker/AGENT.md +7 -5
  33. package/src/rules/agent/00-common.md +4 -4
  34. package/src/rules/agent/00-core.md +11 -14
  35. package/src/rules/agent/20-skip-protocol.md +3 -3
  36. package/src/rules/agent/30-explorer.md +56 -48
  37. package/src/rules/agent/40-cycle1-agent.md +15 -24
  38. package/src/rules/agent/41-cycle2-agent.md +33 -57
  39. package/src/rules/agent/42-cycle3-agent.md +28 -42
  40. package/src/rules/lead/01-general.md +7 -10
  41. package/src/rules/lead/lead-brief.md +11 -14
  42. package/src/rules/lead/lead-tool.md +6 -5
  43. package/src/rules/shared/01-tool.md +44 -41
  44. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +37 -1
  45. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  46. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  47. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  48. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +6 -6
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  53. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  55. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +18 -52
  57. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  58. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  59. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +41 -2
  60. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/session/store.mjs +339 -63
  62. package/src/runtime/agent/orchestrator/stall-policy.mjs +37 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  66. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  67. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  68. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  74. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  75. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  76. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  77. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  80. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  81. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  82. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  83. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  84. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  85. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  86. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  87. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  88. package/src/runtime/shared/tool-primitives.mjs +4 -1
  89. package/src/runtime/shared/tool-status.mjs +27 -0
  90. package/src/runtime/shared/tool-surface.mjs +6 -3
  91. package/src/session-runtime/config-helpers.mjs +14 -0
  92. package/src/session-runtime/context-status.mjs +1 -0
  93. package/src/session-runtime/effort.mjs +6 -2
  94. package/src/session-runtime/model-recency.mjs +5 -2
  95. package/src/session-runtime/provider-models.mjs +3 -3
  96. package/src/session-runtime/runtime-core.mjs +78 -10
  97. package/src/session-runtime/tool-catalog.mjs +34 -0
  98. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  99. package/src/standalone/agent-tool/notify.mjs +13 -0
  100. package/src/standalone/agent-tool.mjs +45 -69
  101. package/src/standalone/explore-tool.mjs +6 -7
  102. package/src/tui/App.jsx +31 -0
  103. package/src/tui/app/model-options.mjs +5 -3
  104. package/src/tui/app/model-picker.mjs +12 -24
  105. package/src/tui/app/transcript-window.mjs +1 -0
  106. package/src/tui/components/ToolExecution.jsx +11 -6
  107. package/src/tui/components/TranscriptItem.jsx +1 -1
  108. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  109. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  110. package/src/tui/dist/index.mjs +533 -143
  111. package/src/tui/engine/agent-job-feed.mjs +153 -16
  112. package/src/tui/engine/agent-response-tail.mjs +68 -0
  113. package/src/tui/engine/notification-plan.mjs +16 -0
  114. package/src/tui/engine/queue-helpers.mjs +8 -0
  115. package/src/tui/engine/session-api.mjs +8 -2
  116. package/src/tui/engine/session-flow.mjs +34 -2
  117. package/src/tui/engine/tool-card-results.mjs +54 -32
  118. package/src/tui/engine/tool-result-status.mjs +75 -21
  119. package/src/tui/engine/turn.mjs +83 -43
  120. package/src/tui/engine.mjs +63 -2
  121. package/src/workflows/bench/WORKFLOW.md +25 -35
  122. package/src/workflows/default/WORKFLOW.md +38 -32
  123. package/src/workflows/solo/WORKFLOW.md +19 -22
  124. package/scripts/_jitter-fuzz.mjs +0 -44410
  125. package/scripts/_jitter-fuzz2.mjs +0 -44400
  126. package/scripts/_jitter-probe.mjs +0 -44397
  127. package/scripts/_jp2.mjs +0 -45614
@@ -0,0 +1,174 @@
1
+ // Production-path regression: runtime completion notification -> feed enqueue
2
+ // -> session drain -> real runTurn -> Esc. This deliberately uses the real
3
+ // feed, queue, API, and turn implementations rather than fabricating restore
4
+ // state, so ownership survives the same path used by the TUI.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+
8
+ import { createAgentJobFeed } from '../src/tui/engine/agent-job-feed.mjs';
9
+ import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
10
+ import { createRunTurn } from '../src/tui/engine/turn.mjs';
11
+ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
12
+ import { _clearDeliveredCompletions } from '../src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
13
+
14
+ const tick = () => new Promise((resolve) => setImmediate(resolve));
15
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+
17
+ function completion(executionId, body) {
18
+ return {
19
+ content: `Async agent task ${executionId} completed finished.\n\nResult:\n> ${body}`,
20
+ meta: { type: 'agent_task_result', execution_id: executionId, status: 'completed' },
21
+ };
22
+ }
23
+
24
+ function makeHarness() {
25
+ let seq = 0;
26
+ let notify = null;
27
+ let activeAsk = null;
28
+ let feed = null;
29
+ const asks = [];
30
+ const pending = [];
31
+ const itemIndexById = new Map();
32
+ const state = {
33
+ items: [],
34
+ queued: [],
35
+ busy: false,
36
+ commandBusy: false,
37
+ spinner: null,
38
+ thinking: null,
39
+ lastTurn: null,
40
+ stats: { turns: 0, inputTokens: 0, outputTokens: 0 },
41
+ };
42
+ const runtime = {
43
+ id: null,
44
+ toolMode: 'auto',
45
+ onNotification: (fn) => { notify = fn; return () => {}; },
46
+ consumePendingSessionReset: () => null,
47
+ ask: async (text, options) => new Promise((resolve, reject) => {
48
+ activeAsk = { text, options, reject };
49
+ asks.push(activeAsk);
50
+ }),
51
+ abort: () => {
52
+ const error = new Error('interrupted');
53
+ error.name = 'SessionClosedError';
54
+ activeAsk?.reject(error);
55
+ return true;
56
+ },
57
+ };
58
+ const bag = {
59
+ runtime,
60
+ nextId: () => `id_${++seq}`,
61
+ tuiDebug: () => {},
62
+ LEAD_TURN_TIMEOUT_MS: 300_000,
63
+ flags: { leadTurnEpoch: 0, drainEpoch: 0, disposed: false, draining: false, activePromptRestore: null },
64
+ pending,
65
+ pendingNotificationKeys: new Set(),
66
+ displayedExecutionNotificationKeys: new Set(),
67
+ listeners: new Set(),
68
+ itemIndexById,
69
+ getState: () => state,
70
+ set: (patch) => Object.assign(state, patch),
71
+ pushItem: (item) => {
72
+ state.items = [...state.items, item];
73
+ if (item?.id != null) itemIndexById.set(item.id, state.items.length - 1);
74
+ },
75
+ patchItem: (id, patch) => {
76
+ const index = state.items.findIndex((item) => item.id === id);
77
+ if (index < 0) return false;
78
+ state.items = state.items.map((item, i) => i === index ? { ...item, ...patch } : item);
79
+ return true;
80
+ },
81
+ replaceItems: (items) => items,
82
+ pushNotice: () => {},
83
+ pushUserOrSyntheticItem: (text, id) => {
84
+ state.items = [...state.items, { kind: 'injected', id, text }];
85
+ },
86
+ autoClearState: () => ({ enabled: false }),
87
+ agentStatusState: () => ({}),
88
+ routeState: () => ({}),
89
+ syncContextStats: () => {},
90
+ denyAllToolApprovals: () => {},
91
+ requestToolApproval: async () => ({ approved: false }),
92
+ markToolCallActive: () => {},
93
+ markToolCallDone: () => {},
94
+ clearActiveToolSummary: () => {},
95
+ patchToolCardResult: () => {},
96
+ flushToolResults: () => {},
97
+ // session-flow captures this dependency before the feed is constructed;
98
+ // keep that production ordering while forwarding to the live feed.
99
+ flushDeferredExecutionPendingResumeKick: () => feed?.flushDeferredExecutionPendingResumeKick(),
100
+ };
101
+
102
+ Object.assign(bag, createSessionFlow(bag));
103
+ feed = createAgentJobFeed({
104
+ runtime,
105
+ getState: bag.getState,
106
+ set: bag.set,
107
+ nextId: bag.nextId,
108
+ getDisposed: () => bag.flags.disposed,
109
+ patchItem: bag.patchItem,
110
+ enqueue: (...args) => bag.enqueue(...args),
111
+ drain: (...args) => bag.drain(...args),
112
+ pushUserOrSyntheticItem: bag.pushUserOrSyntheticItem,
113
+ makeQueueEntry: (...args) => bag.makeQueueEntry(...args),
114
+ getPending: () => pending,
115
+ agentStatusState: bag.agentStatusState,
116
+ displayedExecutionNotificationKeys: bag.displayedExecutionNotificationKeys,
117
+ pushNotice: bag.pushNotice,
118
+ });
119
+ Object.assign(bag, feed);
120
+ bag.runTurn = createRunTurn(bag);
121
+ const api = createEngineApiA(bag);
122
+ feed.subscribeRuntimeNotifications();
123
+
124
+ return {
125
+ api,
126
+ state,
127
+ asks,
128
+ deliver: (event) => notify(event),
129
+ activeAsk: () => activeAsk,
130
+ };
131
+ }
132
+
133
+ for (const phase of ['before first delta', 'after response progress']) {
134
+ test(`runtime completion Esc ${phase} does not restart it, while a new completion wakes`, async () => {
135
+ _clearDeliveredCompletions();
136
+ const harness = makeHarness();
137
+
138
+ const first = completion('execution_A', 'first result');
139
+ harness.deliver(first);
140
+ await tick();
141
+ assert.equal(harness.asks.length, 1, 'runtime notification drained into a real turn');
142
+ assert.match(harness.activeAsk().text, /first result/, 'completion body reached runtime.ask');
143
+ assert.equal(first.modelVisibleDelivered, true, 'notification was acknowledged on the TUI path');
144
+
145
+ if (phase === 'after response progress') {
146
+ harness.activeAsk().options.onTextDelta('partial response\n');
147
+ await wait(40);
148
+ assert.equal(
149
+ harness.state.items.some((item) => item.kind === 'assistant' && /partial response/.test(item.text)),
150
+ true,
151
+ 'response progress reached the live turn',
152
+ );
153
+ }
154
+
155
+ assert.equal(harness.api.abort().aborted, true, 'Esc aborts the active runTurn');
156
+ await tick();
157
+ assert.equal(harness.state.busy, false, 'busy clears after the real abort unwind');
158
+ assert.equal(harness.state.spinner, null, 'spinner clears after Esc');
159
+ assert.equal(harness.state.thinking, null, 'thinking clears after Esc');
160
+
161
+ harness.deliver(completion('execution_A', 'duplicate retry'));
162
+ await tick();
163
+ assert.equal(harness.asks.length, 1, 'duplicate execution delivery cannot restart the aborted completion');
164
+
165
+ harness.deliver(completion('execution_B', 'later result'));
166
+ await tick();
167
+ assert.equal(harness.asks.length, 2, 'a genuinely new execution still wakes a turn');
168
+ assert.match(harness.activeAsk().text, /later result/);
169
+
170
+ // Finish the second intentionally-open mocked provider request.
171
+ harness.api.abort();
172
+ await tick();
173
+ });
174
+ }
@@ -6,13 +6,77 @@
6
6
  // node scripts/explore-bench.mjs [roundLabel]
7
7
  // Fresh process per run = no in-memory result cache carryover.
8
8
  import { readFileSync, existsSync } from 'node:fs';
9
- import { join } from 'node:path';
9
+ import { join, isAbsolute } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { runExplore } from '../src/standalone/explore-tool.mjs';
12
12
 
13
13
  const ROUND = process.argv[2] || 'r0';
14
14
  const CWD = 'C:/Project/mixdog';
15
15
 
16
+ // Anchor helpers: an anchor line carries a `path:line`. Strict quality =
17
+ // expected token must land ON such a line (not merely somewhere in the text),
18
+ // and anchors===0 is always a miss (except expectFail). Spot-accuracy verifies
19
+ // the referenced file exists and the line number is within the file length.
20
+ function anchorLinesOf(text) {
21
+ return text.split('\n').filter((l) => /:\d+/.test(l));
22
+ }
23
+ // Variant-aware token matching for the fabricated-line guard. Split
24
+ // camelCase and snake_case/kebab joins into atomic sub-tokens, then compare
25
+ // on a light stem so plural/verb forms unify (importance/importanceScore/
26
+ // chunk_importance -> {importance,score,chunk}; scoring~score, memories~memory).
27
+ // Matching is exact stem-equality of sub-tokens (never substring), so it does
28
+ // not loosen into generic-word overlap.
29
+ function splitSubtokens(s) {
30
+ return String(s)
31
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase boundary
32
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ACRONYMWord boundary
33
+ .split(/[^A-Za-z0-9]+/) // snake_case / kebab / punctuation
34
+ .map((t) => t.toLowerCase())
35
+ .filter(Boolean);
36
+ }
37
+ function stemToken(t) {
38
+ if (t.length <= 4) return t;
39
+ return t
40
+ .replace(/ies$/, 'y')
41
+ .replace(/(ings|ing|edly|ed|es|ly|s)$/, '')
42
+ .replace(/e$/, '');
43
+ }
44
+ function checkAnchorLine(line, ctxTokens = []) {
45
+ const m = line.match(/([A-Za-z0-9_.\/\\-]+):(\d+)/);
46
+ if (!m) return null; // no parseable path:line
47
+ const p = m[1];
48
+ const ln = Number(m[2]);
49
+ const full = isAbsolute(p) ? p : join(CWD, p);
50
+ if (!existsSync(full)) return false;
51
+ try {
52
+ const fileLines = readFileSync(full, 'utf8').split('\n');
53
+ if (!(ln >= 1 && ln <= fileLines.length)) return false;
54
+ // Fabricated-line guard: the cited line's ±2 window must contain a
55
+ // query/expected token, so a plausible-but-wrong line number fails.
56
+ if (ctxTokens.length) {
57
+ // ±2 lines around the 1-based cited line (indices ln-3 .. ln+1 inclusive).
58
+ const win = fileLines.slice(Math.max(0, ln - 3), ln + 2).join('\n');
59
+ const winStems = [...new Set(splitSubtokens(win).map(stemToken))];
60
+ const wantStems = ctxTokens.map((t) => stemToken(t.toLowerCase()));
61
+ // Exact stem-equality unifies plural/verb variants; a prefix relation
62
+ // additionally bridges derivational forms (compact/compaction) without
63
+ // matching generic filler. Guard the bridge so a short generic line
64
+ // token can't satisfy a longer wanted token (impl~implementation,
65
+ // clas~classifier): require the shorter stem >=5 chars AND a
66
+ // shorter/longer length ratio >=0.6.
67
+ const prefixBridge = (s, w) => {
68
+ if (!(s.startsWith(w) || w.startsWith(s))) return false;
69
+ const short = Math.min(s.length, w.length);
70
+ const long = Math.max(s.length, w.length);
71
+ return short >= 5 && short / long >= 0.6;
72
+ };
73
+ const hitStem = (w) => winStems.some((s) => s === w || prefixBridge(s, w));
74
+ if (!wantStems.some(hitStem)) return false;
75
+ }
76
+ return true;
77
+ } catch { return false; }
78
+ }
79
+
16
80
  // High-fanout timing attribution: emit lightweight per-iteration send_ms
17
81
  // loop rows (no verbose payload estimate) so we can split LLM send time
18
82
  // from tool time under the parallel round below. Trace-only; no behavior
@@ -68,6 +132,13 @@ const QUERIES = [
68
132
  q: 'where is the mixdog config json file path resolved (data dir)',
69
133
  expect: ['config', 'plugin-paths'],
70
134
  },
135
+ {
136
+ // path-only class: the correct answer is a verified file/dir location on
137
+ // disk; a bare path (no :line) is an allowed HIT for this query.
138
+ q: 'where does mixdog store background shell job stdout logs on disk',
139
+ expect: ['shell-jobs', 'shell-job-paths', 'getShellJobsDir', 'shellJobStdout'],
140
+ pathOnly: true,
141
+ },
71
142
  {
72
143
  q: 'GraphQL schema stitching resolver implementation', // not in this repo
73
144
  expectFail: true,
@@ -98,17 +169,38 @@ function readTraceSince(ts) {
98
169
  const t0 = Date.now();
99
170
  // Parallel round: all queries fire at once. Per-session trace attribution is
100
171
  // by session_id so counts stay correct; per-query ms includes queueing.
101
- const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail }) => {
172
+ const results = await Promise.all(QUERIES.map(async ({ q, expect, expectFail, pathOnly }) => {
102
173
  const qt0 = Date.now();
103
174
  const res = await runExplore({ query: q, cwd: CWD }, { callerCwd: CWD });
104
175
  const text = res?.content?.[0]?.text || '';
105
- const anchors = text.split('\n').filter((l) => /:\d+/.test(l)).length;
176
+ const aLines = anchorLinesOf(text);
177
+ const anchors = aLines.length;
106
178
  const failed = /EXPLORATION_FAILED/.test(text);
107
- const lower = text.toLowerCase();
179
+ // Context tokens for the fabricated-line guard: expanded expected + query
180
+ // words, length>=4 to drop filler.
181
+ const ctxTokens = [
182
+ ...(expect || []).flatMap((e) => e.toLowerCase().split(/[^a-z0-9]+/)),
183
+ ...q.toLowerCase().split(/[^a-z0-9]+/),
184
+ ].filter((t) => t.length >= 4);
185
+ // Spot-check up to 2 anchors per query for file/line validity + line context.
186
+ let badAnchors = 0;
187
+ for (const l of aLines.slice(0, 2)) {
188
+ if (checkAnchorLine(l, ctxTokens) === false) badAnchors++;
189
+ }
190
+ const allLines = text.split('\n').filter((l) => l.trim());
191
+ // Strict hit: expected token must appear ON an anchor line (>=1 anchor). For
192
+ // the path-only class, an expected token on any verified path line HITs even
193
+ // without :line. expectFail wants EXPLORATION_FAILED with zero anchors.
108
194
  const hit = expectFail
109
195
  ? (failed && anchors === 0)
110
- : (!failed && (expect || []).some((e) => lower.includes(e.toLowerCase())));
111
- return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, bytes: text.length };
196
+ : pathOnly
197
+ ? (!failed && (expect || []).some(
198
+ (e) => allLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
199
+ ))
200
+ : (!failed && anchors > 0 && (expect || []).some(
201
+ (e) => aLines.some((l) => l.toLowerCase().includes(e.toLowerCase())),
202
+ ));
203
+ return { q: q.slice(0, 48), ms: Date.now() - qt0, anchors, failed, hit, badAnchors, bytes: text.length };
112
204
  }));
113
205
 
114
206
  // Identify explorer sessions from trace rows, then attribute both tool calls
@@ -145,9 +237,10 @@ const sendCount = [...sendBySession.values()].reduce((a, s) => a + s.sends, 0);
145
237
 
146
238
  console.log(`\n=== explore-bench round=${ROUND} ===`);
147
239
  for (const r of results) {
148
- console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
240
+ console.log(` [${String(r.ms).padStart(6)}ms] ${r.hit ? 'HIT ' : 'MISS'} anchors=${r.anchors} bad=${r.badAnchors} failed=${r.failed} bytes=${r.bytes} ${r.q}`);
149
241
  }
150
- console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits`);
242
+ const badTotal = results.reduce((a, r) => a + r.badAnchors, 0);
243
+ console.log(` quality: ${results.filter((r) => r.hit).length}/${results.length} hits (strict) bad-anchors=${badTotal}`);
151
244
  console.log(` sessions=${bySession.size} toolcalls p50=${p50} max=${callCounts[callCounts.length - 1] ?? 0} total=${callCounts.reduce((a, b) => a + b, 0)}`);
152
245
  for (const [id, s] of bySession) console.log(` ${id.slice(-8)}: ${s.tools.join(',')}`);
153
246
  const wallMs = Date.now() - t0;
@@ -3,25 +3,170 @@ import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { buildExplorerPrompt } from '../src/standalone/explore-tool.mjs';
6
+ import { EXPLORE_TOOL } from '../src/standalone/explore-tool.mjs';
6
7
  import { BUILTIN_TOOLS } from '../src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs';
8
+ import { CODE_GRAPH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs';
9
+ import { isEagerDispatchable } from '../src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs';
7
10
 
8
- test('explore per-query prompt leaves path policy to tool schemas', () => {
11
+ test('explore per-query prompt contains only escaped query XML', () => {
9
12
  const prompt = buildExplorerPrompt('display model usage show usage model_usage provider_usage session cache usage state');
10
- assert.doesNotMatch(prompt, /Path policy:/i);
11
- assert.doesNotMatch(prompt, /run find for that fragment before any grep\/glob/i);
13
+ assert.equal(prompt, '<query>display model usage show usage model_usage provider_usage session cache usage state</query>');
14
+ assert.doesNotMatch(prompt, /Reminder:|BUDGET|TURN 1|STOP and answer|verdicts|ratings|recommendations/i);
15
+ assert.equal(buildExplorerPrompt('where is <agent> & status?'), '<query>where is &lt;agent&gt; &amp; status?</query>');
12
16
  });
13
17
 
14
- test('grep glob find tool schemas carry unverified path policy', () => {
18
+ test('builtin route descriptions carry the shortest verified-path policy', () => {
15
19
  const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
16
- assert.match(byName.grep.description, /Unknown path\/name find first/i);
20
+ // Conditional find-first: only genuinely guessed fragments route through
21
+ // find, and in the SAME turn — project root itself is a verified scope.
22
+ assert.match(byName.grep.description, /project root counts as verified/i);
23
+ assert.match(byName.grep.description, /guessed path fragment → find first/i);
17
24
  assert.match(byName.grep.description, /no path "\." \+ guessed src\/\*\*/i);
18
- assert.match(byName.glob.description, /Unknown root\/name find first/i);
19
- assert.match(byName.find.description, /verify roots before grep\/glob/i);
25
+ assert.match(byName.glob.description, /project root is verified/i);
26
+ assert.match(byName.glob.description, /Guessed root\/name find first/i);
27
+ assert.match(byName.find.description, /output paths are verified/i);
28
+ assert.match(byName.read.description, /guessed path\/name → find first/i);
29
+ assert.match(byName.read.description, /Batch paths\/regions as real arrays.*path\[\]/i);
30
+ assert.match(byName.shell.description, /Shell\/write calls are serial/i);
31
+ assert.doesNotMatch(byName.shell.description, /parallel calls/i);
32
+ assert.match(byName.find.description, /lookup only for unknown partial paths\/names/i);
33
+ assert.match(byName.find.description, /project root or already-verified roots/i);
34
+ assert.doesNotMatch(byName.grep.description, /find for that fragment before any grep\/glob/i);
35
+ assert.match(byName.grep.description, /nonzero content_with_context result resolves that search concept/i);
36
+ assert.match(byName.grep.description, /Only zero\/error results may change tokens or scope/i);
37
+ assert.match(`${byName.code_graph?.description || ''} ${CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.symbols?.description || ''}`, /multiple exact symbols use one symbols\[\] call/i);
38
+ assert.match(CODE_GRAPH_TOOL_DEFS[0]?.description || '', /verified source files only/i);
20
39
  });
21
40
 
22
- test('explorer turn-1 contract includes batched find for unknown broad targets', () => {
41
+ test('shared tool policy routes facets without duplicate content acquisition', () => {
42
+ const rule = readFileSync(new URL('../src/rules/shared/01-tool.md', import.meta.url), 'utf8');
43
+ const policy = rule.replace(/\s+/g, ' ');
44
+ assert.match(policy, /for each facet choose exactly one shortest locator route/i);
45
+ assert.match(policy, /broad\/uncertain→\s*`explore`.*partial path\/name→\s*`find`.*verified root\+wildcard→\s*`glob`.*quoted\/non-identifier literal or regex→\s*`grep`.*exact code identifier\/relation→\s*`code_graph` before grep/i);
46
+ assert.match(policy, /grep only for a requested literal occurrence or after graph zero\/error/i);
47
+ assert.match(policy, /batch compatible targets/i);
48
+ assert.match(policy, /parallelize distinct facets only, never alternative routes for one facet/i);
49
+ assert.match(policy, /put independent read-only calls in one turn; they may run concurrently regardless of tool/i);
50
+ assert.match(policy, /after locator results, collect all known candidate files\/regions before inspection/i);
51
+ assert.match(policy, /batch compatible reads.*same-file regions.*in one `path\[\]` call and graph targets in arrays/i);
52
+ assert.match(policy, /parallelize independent incompatible read-only inspections/i);
53
+ assert.match(policy, /do not start a singleton while a known compatible candidate remains/i);
54
+ assert.match(policy, /shell\/write calls are serial/i);
55
+ assert.match(policy, /later turns are only for targets dependent on prior results or unresolved facets/i);
56
+ assert.match(policy, /stop when evidence covers the deliverable/i);
57
+ assert.match(policy, /sufficient contextual grep means no overlapping `read`/i);
58
+ assert.match(policy, /files_with_matches.*count.*capped.*insufficient context.*inspect only missing content/i);
59
+ assert.match(policy, /known file\/span→`read` directly without `grep`/i);
60
+ assert.doesNotMatch(policy, /retrieval[^.]{0,120}\b(?:one|at most \d+)\s+(?:lookup|inspection|turn|call)/i);
61
+ assert.doesNotMatch(policy, /\bput independent calls in one turn\b/i);
62
+ assert.doesNotMatch(policy, /\b(?:all|every)\s+(?:independent\s+)?(?:calls|tool calls)\b[^.]{0,80}\bone turn\b/i);
63
+ assert.doesNotMatch(policy, /\bone lookup\b[^.]{0,80}\bat most\b[^.]{0,40}\binspection\b/i);
64
+ assert.doesNotMatch(policy, /all (?:independent )?(?:calls|tool calls|targets)[^.]{0,100}\bone (?:tool )?(?:message|call)/i);
65
+ });
66
+
67
+ test('explorer locator policy retains its compact behavioral contract', () => {
23
68
  const rule = readFileSync(new URL('../src/rules/agent/30-explorer.md', import.meta.url), 'utf8');
24
- assert.match(rule, /Turn 1 is the whole search in ONE message/i);
25
- assert.match(rule, /unknown\/broad\s+targets\)\s+find `query\[\]`/i);
26
- assert.match(rule, /path\/name fragments from multiple tokens/i);
69
+ const policy = rule.replace(/\s+/g, ' ');
70
+ const required = [
71
+ /Locator only:[\s\S]*WHERE \(`path:line`\), never WHY[\s\S]*You ARE `explore`; never[\s\S]*call it/i,
72
+ /ONLY grep\/find\/glob\/code_graph[\s\S]*`read`\/`list` are forbidden/i,
73
+ /Turn 1 \(`turn 1\/3`\)[\s\S]*gathers all known facets in ONE message[\s\S]*Broad\/uncertain is Explorer input[\s\S]*split it into concrete facets[\s\S]*apply the shared one-route\/batch contract[\s\S]*concept facets use grep[\s\S]*single-tool turn is valid[\s\S]*follow-up is only for unresolved pre-anchor\/\s*zero-hit facets/i,
74
+ /follow-up is only for unresolved pre-anchor\/\s*zero-hit facets under this budget/i,
75
+ /broad searches use `output_mode:"files_with_matches"`[\s\S]*`content_with_context` plus `head_limit` only on a path returned THIS session/i,
76
+ /Each pattern is one identifier or camel\/snake variant[\s\S]*spaces are only verbatim copied quoted\s*error\/log literals[\s\S]*Translate non-English queries to English identifiers first[\s\S]*non-ASCII only for quoted literals[\s\S]*Include concept synonyms[\s\S]*not prose phrases/i,
77
+ /Scope is session cwd; omit `path` freely[\s\S]*only from an exact find-returned path \(turn-2 recovery earliest\)[\s\S]*never guess or\s*invent directories[\s\S]*`path:"\."` with guessed `src\/\*\*`[\s\S]*After zero hits, change TOKENS\/scope, not wording\/guessed paths/i,
78
+ /anchor is any `path:line` with a query token\/synonym[\s\S]*code_graph hits[\s\S]*generic-only schema\/handler\/config\/resolver\/index\/error words are zero[\s\S]*Never re-locate an anchor[\s\S]*without `:line`[\s\S]*PRE-anchor/i,
79
+ /After every result[\s\S]*specific-token anchor means STOP and answer NOW[\s\S]*pre-anchors count as zero[\s\S]*Never re-confirm\/upgrade an anchor/i,
80
+ /sole legal follow-up[\s\S]*code-location query[\s\S]*only with pre-anchors[\s\S]*one scoped `content_with_context` grep with `head_limit` on those paths[\s\S]*If zero, remaining zero-anchor recovery turns are legal under the 3-turn budget but must change tokens\/scope[\s\S]*never a second minting hop, anchor upgrade, or fabricated\/estimated line/i,
81
+ /at most 3 turns \(expect 1\)[\s\S]*every tool message `turn N\/3`[\s\S]*normally two messages[\s\S]*third\/extra tool call[\s\S]*unless turn 1 has zero anchors[\s\S]*must change tokens\/scope/i,
82
+ /Single-hop exception[\s\S]*first matching entry\/definition anchors concept\/value\/\s*default[\s\S]*do not trace chains\/value-search[\s\S]*explicit flow or default-resolution query[\s\S]*entry anchor but not its resolved value[\s\S]*ONE resolving hop, then stop/i,
83
+ /Answer in ≤3 lines[\s\S]*`path:line — symbol — short reason`[\s\S]*Copy every cited `path:line` VERBATIM[\s\S]*THIS-session tool result[\s\S]*never estimate\/adjust\/recall/i,
84
+ /Code-location answers require `:line` on every line[\s\S]*`EXPLORATION_FAILED`[\s\S]*never a bare filename or vague prose/i,
85
+ /file\/dir-location queries[\s\S]*exact verified file\/dir path without `:line`[\s\S]*do not force a line\/failure/i,
86
+ /Emit `EXPLORATION_FAILED` only after the budget is spent with zero anchors[\s\S]*prefer a weak anchor to a false miss/i,
87
+ ];
88
+ for (const behavior of required) assert.match(policy, behavior);
89
+ assert.doesNotMatch(policy, /grep[^.]{0,120}\band\b[^.]{0,120}code_graph[^.]{0,120}\band\b[^.]{0,120}find/i);
90
+ });
91
+
92
+ test('canonical schemas advertise safe batching without changing tool shapes', () => {
93
+ const graph = readFileSync(new URL('../src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs', import.meta.url), 'utf8');
94
+ const patch = readFileSync(new URL('../src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs', import.meta.url), 'utf8');
95
+ assert.match(graph, /Batch symbols\[\]\/files\[\] by mode/i);
96
+ assert.match(patch, /one patch/i);
97
+ assert.match(patch, /one file block per target/i);
98
+ });
99
+
100
+ test('code graph and eager-dispatch boundaries preserve runtime shape', () => {
101
+ const schema = CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties || {};
102
+ const fileModes = ['overview', 'imports', 'dependents', 'related', 'impact', 'symbols'];
103
+ const symbolModes = ['symbols', 'find_symbol', 'symbol_search', 'search', 'references', 'callers', 'callees'];
104
+ assert.deepEqual(new Set(schema.mode?.enum), new Set([...fileModes, ...symbolModes]));
105
+ const assertBatchShape = (field) => {
106
+ assert.equal(field?.anyOf?.length, 2);
107
+ assert.deepEqual(field.anyOf.map((entry) => entry.type), ['string', 'array']);
108
+ assert.equal(field.anyOf[1].items.type, 'string');
109
+ assert.equal(field.anyOf[1].minItems, 1);
110
+ };
111
+ assertBatchShape(schema.files);
112
+ assertBatchShape(schema.symbols);
113
+ assert.equal(schema.file, undefined);
114
+ assert.equal(schema.symbol, undefined);
115
+ assert.equal(schema.language, undefined);
116
+ const tools = [
117
+ ...BUILTIN_TOOLS,
118
+ { name: 'mcp_read', annotations: { readOnlyHint: true } },
119
+ { name: 'mcp_write', annotations: { readOnlyHint: false } },
120
+ ];
121
+ assert.equal(isEagerDispatchable('read', tools), true);
122
+ assert.equal(isEagerDispatchable('shell', tools), false);
123
+ assert.equal(isEagerDispatchable('mcp_read', tools), true);
124
+ assert.equal(isEagerDispatchable('mcp_write', tools), false);
125
+ });
126
+
127
+ test('code graph descriptions partition file and symbol targets', () => {
128
+ const description = CODE_GRAPH_TOOL_DEFS[0]?.description || '';
129
+ const mode = CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.mode?.description || '';
130
+ assert.match(mode, /file modes=\{overview,imports,dependents,related,impact\}.*symbols with files.*files\[\].*file outline/i);
131
+ assert.match(mode, /symbol modes=\{find_symbol,symbol_search,search,references,callers,callees\}.*fileless symbols.*symbol_search keywords/i);
132
+ assert.match(description, /exact identifiers.*find_symbol\/references\/callers\/callees.*keywords.*symbol_search\/search/i);
133
+ assert.match(description, /IDs→graph; literal\/zero→grep/i);
134
+ assert.match(description, /unsupported target arrays.*omitted.*never silently mixed/i);
135
+ assert.match(CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.files?.description || '', /supported targets only/i);
136
+ assert.match(CODE_GRAPH_TOOL_DEFS[0]?.inputSchema?.properties?.symbols?.description || '', /exact identifiers.*keywords/i);
137
+ const grep = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool])).grep;
138
+ assert.match(grep.description, /quoted\/non-identifier literal or regex→grep/i);
139
+ assert.doesNotMatch(grep.inputSchema.properties.pattern.description, /code_graph/i);
140
+ });
141
+
142
+ test('retrieval schemas require their primary arguments and preserve region paths', () => {
143
+ const byName = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool]));
144
+ const read = byName.read.inputSchema;
145
+ assert.deepEqual(read.required, ['path']);
146
+ const region = read.properties.path.anyOf[1].items.anyOf[1];
147
+ assert.deepEqual(region.required, ['path']);
148
+ assert.deepEqual(byName.grep.inputSchema.anyOf, [{ required: ['pattern'] }, { required: ['glob'] }]);
149
+ assert.deepEqual(byName.grep.inputSchema.properties.output_mode.enum, ['content_with_context', 'files_with_matches', 'count']);
150
+ const grepSchema = byName.grep.inputSchema;
151
+ const valid = (value) => grepSchema.anyOf.some((branch) => branch.required.every((key) => Object.hasOwn(value, key)));
152
+ assert.equal(valid({ pattern: 'x' }), true);
153
+ assert.equal(valid({ glob: '*.mjs' }), true);
154
+ assert.equal(valid({}), false);
155
+ });
156
+
157
+ test('grep scopes do not masquerade as read regions', () => {
158
+ const pattern = Object.fromEntries(BUILTIN_TOOLS.map((tool) => [tool.name, tool])).grep.inputSchema.properties.pattern.description;
159
+ assert.match(pattern, /path\[\] batches verified scopes only/i);
160
+ assert.match(pattern, /file\/span reads use read path\[\] regions/i);
161
+ assert.doesNotMatch(pattern, /known files\/spans use path\[\]/i);
162
+ });
163
+
164
+ test('explore freezes returned facets and avoids re-location', () => {
165
+ assert.match(EXPLORE_TOOL.description, /freezes the LOCATION only/i);
166
+ assert.match(EXPLORE_TOOL.description, /read\/code_graph detail inspection is valid when content was not returned/i);
167
+ assert.match(EXPLORE_TOOL.description, /never re-locate it/i);
168
+ assert.match(EXPLORE_TOOL.description, /search only unresolved facets/i);
169
+ const rule = readFileSync(new URL('../src/rules/shared/01-tool.md', import.meta.url), 'utf8');
170
+ assert.match(rule, /freezes the LOCATION only[\s\S]*read or[\s\S]*code_graph detail inspection is valid[\s\S]*never re-locate it/i);
171
+ assert.match(rule, /nonzero[\s\S]*content_with_context[\s\S]*result resolves[\s\S]*act directly/i);
27
172
  });
@@ -32,6 +32,93 @@ test('fuzzyRank floor drops a purely scattered subsequence but keeps a substring
32
32
  assert.deepEqual(ranked.map((r) => r.item.path), ['src/abcdef.txt']);
33
33
  });
34
34
 
35
+ // Frozen copy of the pre-optimization scorer: this must not call production
36
+ // fuzzy helpers, so rank parity also detects scoring regressions.
37
+ function legacyFuzzyScore(query, str) {
38
+ if (!query) return 0;
39
+ const normalizedQuery = String(query).replace(/[\/\\_.\-\s]+/g, '');
40
+ if (!normalizedQuery) return 0;
41
+ const q = normalizedQuery.toLowerCase();
42
+ const s = str.toLowerCase();
43
+ const qlen = q.length;
44
+ const slen = s.length;
45
+ if (qlen === 0) return 0;
46
+ if (qlen > slen) return null;
47
+
48
+ const lastSep = Math.max(str.lastIndexOf('/'), str.lastIndexOf('\\'));
49
+ let score = 0;
50
+ let si = 0;
51
+ let prevMatch = -2;
52
+ let firstMatchIdx = -1;
53
+ for (let qi = 0; qi < qlen; qi++) {
54
+ const qc = q[qi];
55
+ let found = -1;
56
+ for (let k = si; k < slen; k++) {
57
+ if (s[k] === qc) { found = k; break; }
58
+ }
59
+ if (found === -1) return null;
60
+ if (firstMatchIdx === -1) firstMatchIdx = found;
61
+ score += 1;
62
+ if (found === prevMatch + 1) score += 5;
63
+ const prevCh = found > 0 ? str[found - 1] : undefined;
64
+ if (prevCh === undefined
65
+ || prevCh === '/' || prevCh === '\\' || prevCh === '_' || prevCh === '-'
66
+ || prevCh === '.' || prevCh === ' '
67
+ || (/[a-z0-9]/.test(prevCh) && /[A-Z]/.test(str[found]))) {
68
+ score += 8;
69
+ }
70
+ if (str[found] === normalizedQuery[qi]) score += 1;
71
+ prevMatch = found;
72
+ si = found + 1;
73
+ }
74
+ if (firstMatchIdx > lastSep) score += 10;
75
+ score -= Math.floor(slen / 16);
76
+ score -= Math.floor(firstMatchIdx / 8);
77
+ return score;
78
+ }
79
+
80
+ function referenceFuzzyRank(query, items, limit = 0) {
81
+ const normQuery = String(query || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '');
82
+ const floor = normQuery.length * 4;
83
+ const scored = [];
84
+ for (const item of items) {
85
+ const p = String(item.path || '');
86
+ const pathScore = legacyFuzzyScore(query, p);
87
+ const base = p.split(/[\\/]/).pop() || '';
88
+ const baseScore = legacyFuzzyScore(query, base);
89
+ const score = Math.max(pathScore ?? -Infinity, baseScore === null ? -Infinity : baseScore + 40);
90
+ if (!Number.isFinite(score)) continue;
91
+ const strong = normQuery.length > 0
92
+ && (String(base || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '').includes(normQuery)
93
+ || String(p || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '').includes(normQuery));
94
+ if (!strong && score < floor) continue;
95
+ scored.push({ item, score });
96
+ }
97
+ scored.sort((a, b) => (b.score - a.score)
98
+ || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0));
99
+ return limit > 0 ? scored.slice(0, limit) : scored;
100
+ }
101
+
102
+ test('fuzzyRank exactly matches the frozen legacy full-sort reference ranker', () => {
103
+ const fragments = ['alpha', 'Beta', 'tool-events', 'src', 'x_y', 'log', 'camelCase', 'archive'];
104
+ const items = Array.from({ length: 1200 }, (_, i) => ({
105
+ path: i % 97 === 0
106
+ ? 'duplicate/tool-events.log'
107
+ : `${fragments[i % fragments.length]}/${fragments[(i * 7) % fragments.length]}-${i}.mjs`,
108
+ }));
109
+ for (const query of ['', 'tool', 'ToolEvents', 'a_b', 'camel']) {
110
+ for (const limit of [-5, 0, 0.5, 1, 2, 25, 300, 2000]) {
111
+ const expected = referenceFuzzyRank(query, items, limit);
112
+ const actual = fuzzyRank(query, items, limit);
113
+ assert.deepEqual(
114
+ actual.map(({ item, score }) => ({ index: items.findIndex((candidate) => candidate === item), score })),
115
+ expected.map(({ item, score }) => ({ index: items.findIndex((candidate) => candidate === item), score })),
116
+ `query=${JSON.stringify(query)}, limit=${limit}`,
117
+ );
118
+ }
119
+ }
120
+ });
121
+
35
122
  // ── hidden-directory discovery via the find tool ─────────────────────────
36
123
 
37
124
  function makeRepo() {
@@ -69,6 +156,41 @@ test('find hidden:false skips dot-directories', async () => {
69
156
  }
70
157
  });
71
158
 
159
+ test('find respects .gitignore on the common path, then deterministically falls back for an exact ignored filename', async () => {
160
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-gitignore-'));
161
+ try {
162
+ writeFileSync(join(root, '.gitignore'), 'ignored-tree/\n');
163
+ mkdirSync(join(root, 'src'), { recursive: true });
164
+ mkdirSync(join(root, 'ignored-tree'), { recursive: true });
165
+ writeFileSync(join(root, 'src', 'common-visible.mjs'), 'x\n');
166
+ writeFileSync(join(root, 'ignored-tree', 'common-ignored.mjs'), 'x\n');
167
+ writeFileSync(join(root, 'src', 'needle-target.mjs.bak'), 'x\n');
168
+ writeFileSync(join(root, 'ignored-tree', 'needle-target.mjs'), 'x\n');
169
+ writeFileSync(join(root, 'ignored-tree', 'LICENSE'), 'x\n');
170
+ writeFileSync(join(root, 'ignored-tree', '[slug].tsx'), 'x\n');
171
+ writeFileSync(join(root, 'ignored-tree', 'my file.txt'), 'x\n');
172
+
173
+ const common = await executeFuzzyFindTool({ query: 'common-visible.mjs', head_limit: 1 }, root);
174
+ assert.ok(common.includes('src/common-visible.mjs'), `common path must keep visible files: ${common}`);
175
+ assert.ok(!common.includes('ignored-tree/common-ignored.mjs'), `common path must skip .gitignored trees: ${common}`);
176
+ assert.ok(common.includes('[gitignored trees not searched; retry with include_noise:true]'), `filled common path must disclose skipped trees: ${common}`);
177
+
178
+ const exact = await executeFuzzyFindTool({ query: 'needle-target.mjs' }, root);
179
+ assert.ok(exact.includes('src/needle-target.mjs.bak'), `visible fuzzy decoy must remain in pass one: ${exact}`);
180
+ assert.ok(exact.includes('ignored-tree/needle-target.mjs'), `exact ignored filename must trigger fallback: ${exact}`);
181
+ assert.ok(!exact.includes('[gitignored trees not searched'), `fallback result must not claim ignored trees were skipped: ${exact}`);
182
+
183
+ const extensionless = await executeFuzzyFindTool({ query: 'LICENSE' }, root);
184
+ assert.ok(extensionless.includes('ignored-tree/LICENSE'), `extensionless exact filename must trigger fallback: ${extensionless}`);
185
+ const literalGlob = await executeFuzzyFindTool({ query: '[slug].tsx' }, root);
186
+ assert.ok(literalGlob.includes('ignored-tree/[slug].tsx'), `literal-glob exact filename must trigger fallback: ${literalGlob}`);
187
+ const spaced = await executeFuzzyFindTool({ query: 'my file.txt' }, root);
188
+ assert.ok(spaced.includes('ignored-tree/my file.txt'), `space-containing exact filename must trigger fallback: ${spaced}`);
189
+ } finally {
190
+ rmSync(root, { recursive: true, force: true });
191
+ }
192
+ });
193
+
72
194
  // ── narrowed-pass merge / dedup backstop ─────────────────────────────────
73
195
 
74
196
  test('exact-name hit survives among many decoys and is not duplicated', async () => {