mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -22,7 +22,7 @@ test('server setup is not charged to the search budget', () => {
22
22
  assert.equal(_searchRemainingMsForTest(deadline, startedAt, 0, 9_000), 1);
23
23
  });
24
24
 
25
- test('a fuzzy deadline recovers a real candidate through one targeted path scan', async () => {
25
+ test('a fuzzy deadline returns promptly without starting another filesystem walk', async () => {
26
26
  const root = mkdtempSync(join(tmpdir(), 'mixdog-find-budget-'));
27
27
  try {
28
28
  writeFileSync(join(root, 'anchor.txt'), 'anchor\n');
@@ -30,28 +30,19 @@ test('a fuzzy deadline recovers a real candidate through one targeted path scan'
30
30
  new Error('native fuzzy search timed out after 1500ms.'),
31
31
  { code: 'NATIVE_SEARCH_TIMEOUT' },
32
32
  );
33
- let targetedEnumerationCalls = 0;
34
- let targetedArgs = null;
35
33
  const telemetry = {};
36
34
  const out = String(await runWithLocalSearchTelemetry(telemetry, () => executeFuzzyFindTool(
37
35
  { query: 'needle-that-never-ranks', path: '.', head_limit: 5 },
38
36
  root,
39
37
  {
40
38
  __tryServeFuzzySearch: async () => { throw timeout; },
41
- __runRg: async (args) => {
42
- targetedEnumerationCalls += 1;
43
- targetedArgs = args;
44
- return 'src/needle-that-never-ranks.ts\n';
45
- },
46
39
  },
47
40
  )));
48
41
  assert.ok(!/^Error[\s:[]/.test(out.trimStart()), `must not surface as a tool failure:\n${out}`);
49
- assert.match(out, /src\/needle-that-never-ranks\.ts/);
50
- assert.match(out, /targeted path matches shown/);
51
- assert.equal(targetedEnumerationCalls, 1);
52
- assert.ok(targetedArgs.includes('--iglob'), `expected a targeted --iglob scan: ${JSON.stringify(targetedArgs)}`);
42
+ assert.match(out, /no fuzzy match yet/);
43
+ assert.match(out, /inventory is still building/);
53
44
  assert.equal(telemetry.native_fuzzy_partials, 1);
54
- assert.equal(telemetry.native_fuzzy_targeted_hits, 1);
45
+ assert.equal(telemetry.native_fuzzy_targeted_hits, undefined);
55
46
  assert.equal(telemetry.native_fuzzy_errors, undefined);
56
47
  } finally {
57
48
  rmSync(root, { recursive: true, force: true });
@@ -76,7 +67,6 @@ test('a served partial still reports its ranked matches', async () => {
76
67
  scanErrors: 0,
77
68
  walkErrorDetails: [],
78
69
  }),
79
- __runRg: async () => '',
80
70
  },
81
71
  ));
82
72
  assert.match(out, /src\/alpha\.ts/);
@@ -85,3 +75,29 @@ test('a served partial still reports its ranked matches', async () => {
85
75
  rmSync(root, { recursive: true, force: true });
86
76
  }
87
77
  });
78
+
79
+ test('an empty served partial is returned without another filesystem walk', async () => {
80
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-find-empty-partial-'));
81
+ try {
82
+ const out = String(await executeFuzzyFindTool(
83
+ { query: 'missing', path: '.', head_limit: 5 },
84
+ root,
85
+ {
86
+ __tryServeFuzzySearch: async () => ({
87
+ matches: [],
88
+ hasMore: false,
89
+ totalMatches: 0,
90
+ totalSeen: 1,
91
+ complete: false,
92
+ partial: true,
93
+ timeout: true,
94
+ scanErrors: 0,
95
+ walkErrorDetails: [],
96
+ }),
97
+ },
98
+ ));
99
+ assert.match(out, /inventory is still building/);
100
+ } finally {
101
+ rmSync(root, { recursive: true, force: true });
102
+ }
103
+ });
@@ -11,6 +11,12 @@ const ALWAYS_READ = new Set([
11
11
  ]);
12
12
 
13
13
  const SHELL_OPERATOR_CHARS = '|&;<>()';
14
+ const OPERATION_ALIASES = new Map([
15
+ ['--version', 'version'],
16
+ ['-v', 'version'],
17
+ ['--help', 'help'],
18
+ ['-h', 'help'],
19
+ ]);
14
20
 
15
21
  function atTokenBoundary(char) {
16
22
  return char === undefined || /\s/.test(char);
@@ -61,19 +67,23 @@ export function gitActionOf(operation, args) {
61
67
 
62
68
  export function gitPlanIsReadOnly(plan) {
63
69
  const { operation, args } = plan;
70
+ if (args.some((value) => value === '--output' || value.startsWith('--output='))) return false;
64
71
  if (operation === 'fsck' && args.includes('--lost-found')) return false;
65
72
  if (ALWAYS_READ.has(operation)) return true;
66
- if (operation === 'reflog') return !['delete', 'expire'].includes(gitActionOf(operation, args));
73
+ if (operation === 'reflog') return ['show', 'list', 'exists'].includes(gitActionOf(operation, args));
67
74
  if (operation === 'stash') return ['list', 'show'].includes(gitActionOf(operation, args));
68
75
  if (operation === 'worktree') return gitActionOf(operation, args) === 'list';
69
- if (operation === 'remote') return args.length === 0 || args.includes('-v') || ['get-url', 'show'].includes(gitActionOf(operation, args));
76
+ if (operation === 'remote') return ['list', 'get-url', 'show'].includes(gitActionOf(operation, args));
70
77
  if (operation === 'clean') return args.some((value) => value === '-n' || value === '--dry-run' || /^-[^-]*n/.test(value));
71
78
  if (operation === 'bundle') return ['list-heads', 'verify'].includes(gitActionOf(operation, args));
72
79
  if (operation === 'notes') return ['', 'list', 'show'].includes(gitActionOf(operation, args));
73
80
  if (operation === 'replace') return args.length === 0 || args.includes('--list');
74
81
  if (operation === 'sparse-checkout') return gitActionOf(operation, args) === 'list';
75
82
  if (operation === 'submodule') return ['', 'status', 'summary'].includes(gitActionOf(operation, args));
76
- if (operation === 'symbolic-ref') return args.filter((value) => !value.startsWith('-')).length <= 1;
83
+ if (operation === 'symbolic-ref') {
84
+ if (args.some((value) => value === '-d' || value === '--delete')) return false;
85
+ return args.filter((value) => !value.startsWith('-')).length <= 1;
86
+ }
77
87
  if (operation === 'hash-object') return !args.includes('-w') && !args.includes('--stdin-paths');
78
88
  if (operation === 'branch' || operation === 'tag') {
79
89
  return args.length === 0 || args.some((value) => ['--list', '-l', '-a', '--all', '-r', '--remotes'].includes(value));
@@ -112,7 +122,8 @@ function parsedGitOperation(command) {
112
122
  }
113
123
  break;
114
124
  }
115
- const operation = String(tokens[index] || '').toLowerCase();
125
+ const rawOperation = String(tokens[index] || '').toLowerCase();
126
+ const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
116
127
  return operation ? { operation, args: tokens.slice(index + 1) } : null;
117
128
  }
118
129
 
@@ -208,13 +208,19 @@ function parseCommand(command, workDir) {
208
208
  const rawOperation = String(tokens[index] || '').toLowerCase();
209
209
  const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
210
210
  if (!operation) throw new Error('git requires a subcommand');
211
+ const args = tokens.slice(index + 1);
212
+ if (args.includes('--help')
213
+ || (operation === 'help' && args.some((value) => !value.startsWith('-')))
214
+ || (operation === 'help' && args.some((value) => ['-w', '--web', '-m', '--man', '-i', '--info'].includes(value)))) {
215
+ throw new Error('git manual help can open an external viewer; use the subcommand -h form for terminal usage');
216
+ }
211
217
  if (GUI_OPERATIONS.has(operation)) {
212
218
  throw new Error(`git ${operation} opens an interactive GUI and would never return here; use its non-interactive form (diff, merge, add -p)`);
213
219
  }
214
220
  if (SERVER_OPERATIONS.has(operation)) {
215
221
  throw new Error(`git ${operation} is a long-running server; start it with the shell tool so it becomes a background task`);
216
222
  }
217
- return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
223
+ return { command: String(command), cwd, globalArgs, operation, args };
218
224
  }
219
225
 
220
226
  async function runProcess(program, argv, { cwd, signal, maxBytes = MAX_CAPTURE_BYTES } = {}) {
@@ -327,12 +333,25 @@ function semanticExit(plan, result) {
327
333
  if (result.exitCode === 0) return { exists: true };
328
334
  return result.exitCode === 1 || result.exitCode === 128 ? { exists: false } : null;
329
335
  }
336
+ if (operation === 'reflog' && actionOf(operation, args) === 'exists') {
337
+ if (result.exitCode === 0) return { exists: true };
338
+ return result.exitCode === 1 ? { exists: false } : null;
339
+ }
340
+ if ((operation === 'show-ref' && args.includes('--verify') && args.includes('--quiet'))
341
+ || (operation === 'rev-parse' && args.includes('--verify') && args.includes('--quiet'))) {
342
+ if (result.exitCode === 0) return { exists: true };
343
+ return result.exitCode === 1 ? { exists: false } : null;
344
+ }
345
+ if (operation === 'merge-base' && args.includes('--is-ancestor')) {
346
+ if (result.exitCode === 0) return { ancestor: true };
347
+ return result.exitCode === 1 ? { ancestor: false } : null;
348
+ }
330
349
  if (!['diff', 'diff-files', 'diff-index', 'diff-tree'].includes(operation)) return null;
331
350
  if (args.includes('--check')) {
332
351
  if (result.exitCode === 0) return { problems: false };
333
352
  return result.exitCode === 1 || result.exitCode === 2 ? { problems: true } : null;
334
353
  }
335
- if (args.some((value) => value === '--exit-code' || value === '--quiet')) {
354
+ if (args.some((value) => value === '--exit-code' || value === '--quiet' || value === '--no-index')) {
336
355
  if (result.exitCode === 0) return { changed: false };
337
356
  return result.exitCode === 1 ? { changed: true } : null;
338
357
  }
@@ -379,8 +398,40 @@ function hasOutputFormat(args) {
379
398
  return args.some((value) => /^(?:--format|--pretty)(?:=|$)/.test(value) || value === '--oneline');
380
399
  }
381
400
 
401
+ function hasPatchOutput(args) {
402
+ return args.some((value) => ['-p', '-u', '--patch', '--binary'].includes(value)
403
+ || /^-U\d*$/.test(value)
404
+ || /^--(?:patch|unified|word-diff|color-words)(?:=|$)/.test(value));
405
+ }
406
+
407
+ function needsTextDiffOutput(args) {
408
+ return args.some((value) => [
409
+ '-s', '--no-patch', '--raw', '--patch-with-raw', '--patch-with-stat',
410
+ '--numstat', '--shortstat', '--summary', '--compact-summary',
411
+ '--name-only', '--name-status', '--check', '--cumulative',
412
+ '--no-prefix', '--default-prefix',
413
+ ].includes(value)
414
+ || /^--(?:stat|dirstat|dirstat-by-file)(?:-|=|$)/.test(value)
415
+ || /^--(?:line-prefix|src-prefix|dst-prefix|output-indicator-(?:new|old|context))(?:=|$)/.test(value)
416
+ || /^--submodule(?:=|$)/.test(value)
417
+ || /^--(?:word-diff|color-words)(?:=|$)/.test(value));
418
+ }
419
+
420
+ function hasDiffPresentation(args) {
421
+ return hasPatchOutput(args) || needsTextDiffOutput(args);
422
+ }
423
+
424
+ function hasSpecialHistoryPresentation(args) {
425
+ return hasDiffPresentation(args) || args.some((value) => [
426
+ '-g', '--graph', '--source', '--show-signature', '--show-notes',
427
+ '--notes', '--no-notes', '--walk-reflogs', '--left-right',
428
+ '--cherry-mark', '--boundary', '--parents', '--children',
429
+ ].includes(value)
430
+ || /^--(?:decorate|decorate-refs|decorate-refs-exclude|show-notes|notes|date)(?:=|$)/.test(value));
431
+ }
432
+
382
433
  function hasLogLimit(args) {
383
- return args.some((value) => /^-\d+$/.test(value) || value === '-n' || value === '--max-count' || value.startsWith('--max-count='));
434
+ return args.some((value) => /^-(?:n)?\d+$/.test(value) || value === '-n' || value === '--max-count' || value.startsWith('--max-count='));
384
435
  }
385
436
 
386
437
  function prepare(plan, limit) {
@@ -390,22 +441,26 @@ function prepare(plan, limit) {
390
441
  return { argv: ['status', '--porcelain=v1', '-b', '--untracked-files=normal'], format: 'status', action: 'list' };
391
442
  }
392
443
  if (['diff', 'diff-files', 'diff-index', 'diff-tree'].includes(operation)) {
393
- // `--check` prints whitespace diagnostics, not a patch: compacting it as
394
- // a diff would drop the very lines that are the answer.
395
- const format = args.includes('--check') ? 'text' : 'diff';
444
+ // Plumbing diff commands default to raw records, and presentation flags
445
+ // can replace or prefix patch markers. Only compact a standard patch.
446
+ const patch = !needsTextDiffOutput(args) && (operation === 'diff' || hasPatchOutput(args));
447
+ const format = patch ? 'diff' : 'text';
396
448
  return { argv: [operation, '--no-ext-diff', '--no-color', ...args], format, action: 'list' };
397
449
  }
398
- if (operation === 'log' && !hasOutputFormat(args)) {
450
+ if (operation === 'log') {
399
451
  const limitArgs = hasLogLimit(args) ? [] : [`-n${limit}`];
400
- return {
401
- argv: ['log', ...limitArgs, '--date=iso-strict', '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%b%x1e', ...args],
402
- format: 'log',
403
- action: 'list',
404
- };
452
+ if (!hasOutputFormat(args) && !hasSpecialHistoryPresentation(args)) {
453
+ return {
454
+ argv: ['log', ...limitArgs, '--date=iso-strict', '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%b%x1e', ...args],
455
+ format: 'log',
456
+ action: 'list',
457
+ };
458
+ }
459
+ return { argv: ['log', ...limitArgs, '--no-color', ...args], format: 'text', action: 'list' };
405
460
  }
406
461
  if (operation === 'show') {
407
462
  const blob = args.some((value) => !value.startsWith('-') && value.includes(':'));
408
- const explicit = hasOutputFormat(args) || args.some((value) => /^(?:--stat|--numstat|--shortstat|--name-only|--name-status)$/.test(value));
463
+ const explicit = hasOutputFormat(args) || hasSpecialHistoryPresentation(args);
409
464
  if (!blob && !explicit) {
410
465
  return { argv: ['show', '--no-color', '--format=%x1e%H%x1f%h%x1f%an%x1f%aI%x1f%s%n', ...args], format: 'show', action: 'list' };
411
466
  }
@@ -417,10 +472,13 @@ function prepare(plan, limit) {
417
472
  action: 'list',
418
473
  };
419
474
  }
420
- if (operation === 'reflog' && isReadOnly(plan) && !args.some((value) => value.startsWith('--format'))) {
475
+ if (operation === 'reflog' && isReadOnly(plan) && actionOf(operation, args) === 'show') {
421
476
  const action = actionOf(operation, args);
422
477
  const rest = action === 'show' && args[0] === 'show' ? args.slice(1) : args;
423
478
  const limitArgs = hasLogLimit(rest) ? [] : [`-n${limit}`];
479
+ if (rest.some((value) => value.startsWith('--format'))) {
480
+ return { argv: ['reflog', 'show', ...limitArgs, ...rest], format: 'text', action: 'show' };
481
+ }
424
482
  return {
425
483
  argv: ['reflog', 'show', ...limitArgs, '--date=iso-strict', '--format=%gD%x00%H%x00%gs', ...rest],
426
484
  format: 'reflog',
@@ -915,4 +973,4 @@ export async function executeGitTool(input, workDir, options = {}) {
915
973
  })), { signal });
916
974
  }
917
975
 
918
- export const _gitCommandInternals = { creationTarget, failureText, foldProgressFrames, localizeConfigPlan, parseCommand };
976
+ export const _gitCommandInternals = { creationTarget, failureText, foldProgressFrames, localizeConfigPlan, parseCommand, prepare };
@@ -11,7 +11,7 @@ import {
11
11
  GIT_TOOL_DEF,
12
12
  _gitCommandInternals,
13
13
  } from './git-command-tool.mjs';
14
- import { commandHasShellSyntax } from './git-command-policy.mjs';
14
+ import { commandHasShellSyntax, gitCommandMutates } from './git-command-policy.mjs';
15
15
 
16
16
  function parseOk(result) {
17
17
  assert.doesNotMatch(String(result), /^Error:/, String(result));
@@ -163,6 +163,11 @@ test('git tool answers semantic exits and keeps literal operator characters', as
163
163
  // diff reports through the exit code for --quiet and --check.
164
164
  assert.equal(parseOk(await git(repo, 'diff --quiet')).changed, false);
165
165
  assert.equal(parseOk(await git(repo, 'diff --check')).problems, false);
166
+ assert.equal(parseOk(await git(repo, 'reflog exists HEAD')).exists, true);
167
+ assert.equal(parseOk(await git(repo, 'reflog exists refs/heads/missing')).exists, false);
168
+ assert.equal(parseOk(await git(repo, 'show-ref --verify --quiet refs/heads/missing')).exists, false);
169
+ assert.equal(parseOk(await git(repo, 'rev-parse --verify --quiet refs/heads/missing')).exists, false);
170
+ assert.equal(parseOk(await git(repo, 'merge-base --is-ancestor HEAD HEAD')).ancestor, true);
166
171
  writeFileSync(join(repo, 'base.txt'), 'base\ntrailing \n');
167
172
  assert.equal(parseOk(await git(repo, 'diff --quiet')).changed, true);
168
173
  const check = parseOk(await git(repo, 'diff --check'));
@@ -170,6 +175,73 @@ test('git tool answers semantic exits and keeps literal operator characters', as
170
175
  assert.match(JSON.stringify(check), /trailing whitespace/);
171
176
  });
172
177
 
178
+ test('git preserves non-patch diff and history presentations', async (t) => {
179
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-git-presentations-'));
180
+ t.after(() => rmSync(root, { recursive: true, force: true }));
181
+ const repo = join(root, 'repo');
182
+ parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
183
+ parseOk(await git(repo, 'config user.name "Mixdog Test"'));
184
+ parseOk(await git(repo, 'config user.email mixdog@example.invalid'));
185
+ writeFileSync(join(repo, 'base.txt'), 'base\n');
186
+ parseOk(await git(repo, 'add -- base.txt'));
187
+ parseOk(await git(repo, 'commit -m base'));
188
+ writeFileSync(join(repo, 'base.txt'), 'changed\n');
189
+
190
+ for (const command of [
191
+ 'diff --stat -- base.txt',
192
+ 'diff --raw -- base.txt',
193
+ 'diff --numstat -- base.txt',
194
+ 'diff --name-only -- base.txt',
195
+ 'diff --name-status -- base.txt',
196
+ 'diff-files -- base.txt',
197
+ 'diff-index HEAD -- base.txt',
198
+ ]) {
199
+ const result = parseOk(await git(repo, command));
200
+ assert.match(JSON.stringify(result), /base\.txt/, command);
201
+ }
202
+
203
+ parseOk(await git(repo, 'add -- base.txt'));
204
+ parseOk(await git(repo, 'commit -m changed'));
205
+ assert.match(JSON.stringify(parseOk(await git(repo, 'diff-tree --no-commit-id HEAD^ HEAD -- base.txt'))), /base\.txt/);
206
+ for (const command of ['log --stat -1', 'log --raw -1', 'log -p -1', 'show --raw HEAD']) {
207
+ const result = parseOk(await git(repo, command));
208
+ assert.equal(result.commits, undefined, command);
209
+ assert.match(JSON.stringify(result), /base\.txt/, command);
210
+ }
211
+ assert.match(JSON.stringify(parseOk(await git(repo, 'reflog list'))), /refs\/heads\//);
212
+ });
213
+
214
+ test('git policy distinguishes probes from output and ref mutations', () => {
215
+ for (const command of [
216
+ 'git --version',
217
+ 'git remote -v',
218
+ 'git reflog exists HEAD',
219
+ 'git symbolic-ref HEAD',
220
+ ]) {
221
+ assert.equal(gitCommandMutates({ command }), false, command);
222
+ }
223
+ for (const command of [
224
+ 'git remote -v update',
225
+ 'git reflog delete HEAD@{0}',
226
+ 'git symbolic-ref --delete HEAD',
227
+ 'git diff --output=outside.patch',
228
+ ]) {
229
+ assert.equal(gitCommandMutates({ command }), true, command);
230
+ }
231
+ });
232
+
233
+ test('git plans patch, plumbing, and custom history output without over-reading', () => {
234
+ const plan = (operation, args, limit = 7) => _gitCommandInternals.prepare({ operation, args }, limit);
235
+ assert.equal(plan('diff', []).format, 'diff');
236
+ assert.equal(plan('diff', ['--stat']).format, 'text');
237
+ assert.equal(plan('diff-files', []).format, 'text');
238
+ assert.equal(plan('diff-files', ['-p']).format, 'diff');
239
+ assert.deepEqual(plan('log', ['--oneline']).argv.slice(0, 3), ['log', '-n7', '--no-color']);
240
+ assert.deepEqual(plan('log', ['--stat']).argv.slice(0, 3), ['log', '-n7', '--no-color']);
241
+ assert.equal(plan('reflog', ['list']).argv[0], 'reflog');
242
+ assert.notEqual(plan('reflog', ['list']).argv[1], 'show');
243
+ });
244
+
173
245
  test('git failure detail folds progress frames and keeps the fatal tail', () => {
174
246
  const { failureText, foldProgressFrames } = _gitCommandInternals;
175
247
  assert.equal(
@@ -238,7 +310,7 @@ test('git forwards unknown subcommands to git instead of pre-rejecting them', as
238
310
  t.after(() => rmSync(root, { recursive: true, force: true }));
239
311
  const repo = join(root, 'repo');
240
312
  parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
241
- for (const command of ['git filter-repo --version', 'git fast-export --help']) {
313
+ for (const command of ['git filter-repo --version', 'git fast-export --no-data HEAD']) {
242
314
  assert.doesNotMatch(String(await executeGitTool({ command }, repo)), /unsupported git subcommand/);
243
315
  }
244
316
  });
@@ -254,6 +326,8 @@ test('git refuses only non-returning subcommands and routes servers to shell', a
254
326
  parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
255
327
  assert.match(String(await executeGitTool({ command: 'git mergetool' }, repo)), /interactive GUI/i);
256
328
  assert.match(String(await executeGitTool({ command: 'git daemon --export-all' }, repo)), /shell tool/i);
329
+ assert.match(String(await executeGitTool({ command: 'git fast-export --help' }, repo)), /external viewer/i);
330
+ assert.match(String(await executeGitTool({ command: 'git help fast-export' }, repo)), /external viewer/i);
257
331
  assert.match(String(await executeGitTool({ command: 'git' }, repo)), /requires a subcommand/i);
258
332
  });
259
333