mixdog 0.9.15 → 0.9.17

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 (134) hide show
  1. package/package.json +2 -1
  2. package/scripts/atomic-lock-tryonce-test.mjs +66 -0
  3. package/scripts/explore-bench.mjs +5 -4
  4. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  5. package/scripts/output-style-smoke.mjs +3 -3
  6. package/scripts/provider-toolcall-test.mjs +79 -2
  7. package/scripts/termio-input-smoke.mjs +199 -0
  8. package/scripts/tool-efficiency-diag.mjs +88 -0
  9. package/scripts/tool-smoke.mjs +40 -24
  10. package/scripts/tui-frame-harness-shim.mjs +32 -0
  11. package/scripts/tui-frame-harness.mjs +306 -0
  12. package/src/agents/heavy-worker/AGENT.md +6 -7
  13. package/src/agents/worker/AGENT.md +6 -7
  14. package/src/lib/keychain-cjs.cjs +28 -11
  15. package/src/lib/rules-builder.cjs +6 -10
  16. package/src/mixdog-session-runtime.mjs +102 -20
  17. package/src/output-styles/simple.md +22 -24
  18. package/src/rules/agent/00-core.md +12 -11
  19. package/src/rules/agent/30-explorer.md +22 -21
  20. package/src/rules/lead/01-general.md +8 -8
  21. package/src/rules/lead/lead-brief.md +11 -12
  22. package/src/rules/shared/01-tool.md +25 -14
  23. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  24. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +33 -1
  26. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +31 -1
  27. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +49 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  32. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  36. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  37. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +62 -0
  41. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  42. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  43. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +30 -1
  45. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  46. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  47. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +37 -19
  48. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  49. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +142 -3
  50. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  51. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +118 -22
  52. package/src/runtime/agent/orchestrator/session/store.mjs +38 -25
  53. package/src/runtime/agent/orchestrator/stall-policy.mjs +22 -12
  54. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -2
  55. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  56. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
  59. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  60. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +10 -2
  65. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +8 -2
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/runtime-paths.mjs +8 -19
  74. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  75. package/src/runtime/memory/index.mjs +132 -8
  76. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  77. package/src/runtime/memory/lib/embedding-warmup.mjs +3 -0
  78. package/src/runtime/memory/lib/ko-morph.mjs +1 -0
  79. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  82. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  83. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  84. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  85. package/src/runtime/memory/lib/memory.mjs +88 -1
  86. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  87. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  88. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  89. package/src/runtime/shared/atomic-file.mjs +110 -0
  90. package/src/runtime/shared/child-guardian.mjs +4 -2
  91. package/src/runtime/shared/open-url.mjs +2 -1
  92. package/src/runtime/shared/singleton-owner.mjs +26 -0
  93. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  94. package/src/runtime/shared/transcript-writer.mjs +49 -5
  95. package/src/session-runtime/config-helpers.mjs +7 -2
  96. package/src/session-runtime/cwd-plugins.mjs +6 -1
  97. package/src/session-runtime/effort.mjs +6 -1
  98. package/src/session-runtime/plugin-mcp.mjs +116 -12
  99. package/src/session-runtime/provider-models.mjs +47 -8
  100. package/src/session-runtime/settings-api.mjs +7 -1
  101. package/src/standalone/channel-worker.mjs +25 -3
  102. package/src/standalone/explore-tool.mjs +5 -5
  103. package/src/standalone/hook-bus/config.mjs +38 -2
  104. package/src/standalone/hook-bus/handlers.mjs +103 -11
  105. package/src/standalone/hook-bus.mjs +20 -6
  106. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  107. package/src/tui/App.jsx +214 -30
  108. package/src/tui/app/core-memory-picker.mjs +6 -6
  109. package/src/tui/app/model-options.mjs +10 -4
  110. package/src/tui/app/settings-picker.mjs +5 -30
  111. package/src/tui/app/slash-commands.mjs +0 -1
  112. package/src/tui/app/slash-dispatch.mjs +0 -16
  113. package/src/tui/app/text-layout.mjs +57 -0
  114. package/src/tui/app/transcript-window.mjs +162 -2
  115. package/src/tui/app/use-mouse-input.mjs +60 -47
  116. package/src/tui/app/use-transcript-window.mjs +96 -17
  117. package/src/tui/components/PromptInput.jsx +18 -1
  118. package/src/tui/components/StatusLine.jsx +1 -1
  119. package/src/tui/components/TextEntryPanel.jsx +97 -33
  120. package/src/tui/dist/index.mjs +856 -288
  121. package/src/tui/engine/tool-card-results.mjs +22 -5
  122. package/src/tui/engine/tui-steering-persist.mjs +66 -35
  123. package/src/tui/engine.mjs +41 -9
  124. package/src/tui/index.jsx +101 -7
  125. package/src/ui/statusline-segments.mjs +54 -36
  126. package/src/ui/statusline.mjs +141 -95
  127. package/src/workflows/default/WORKFLOW.md +27 -31
  128. package/vendor/ink/build/components/App.js +62 -17
  129. package/vendor/ink/build/ink.js +78 -3
  130. package/vendor/ink/build/input-parser.d.ts +9 -4
  131. package/vendor/ink/build/input-parser.js +45 -176
  132. package/vendor/ink/build/log-update.js +47 -2
  133. package/vendor/ink/build/termio-keypress.js +240 -0
  134. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -42,6 +42,7 @@ import { mergeSessionRowsIntoGlobal } from '../src/runtime/memory/lib/memory-ses
42
42
  import { TOOL_DEFS as SEARCH_TOOL_DEFS } from '../src/runtime/search/tool-defs.mjs';
43
43
  import { TOOL_DEFS as CHANNEL_TOOL_DEFS } from '../src/runtime/channels/tool-defs.mjs';
44
44
  import { AGENT_OWNER } from '../src/runtime/agent/orchestrator/agent-owner.mjs';
45
+ import { recursiveWrapperToolNameForPublicAgent } from '../src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs';
45
46
  import { composeSystemPrompt } from '../src/runtime/agent/orchestrator/context/collect.mjs';
46
47
  import { setInternalToolsProvider } from '../src/runtime/agent/orchestrator/internal-tools.mjs';
47
48
  import { prepareAgentSession } from '../src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
@@ -386,7 +387,7 @@ assertOk('glob explicit src', explicitSrcGlobOut, /src[\\/].*engine\.mjs/i);
386
387
 
387
388
  const globPathOnlyOut = await executeBuiltinTool('glob', {
388
389
  path: 'scripts',
389
- head_limit: 8,
390
+ head_limit: 200,
390
391
  }, root);
391
392
  assertOk('glob path-only default *', globPathOnlyOut, /tool-smoke\.mjs/i);
392
393
 
@@ -398,8 +399,8 @@ const grepNoPatternGlobOut = await executeBuiltinTool('grep', {
398
399
  assertOk('grep without pattern routes to glob', grepNoPatternGlobOut, /tool-smoke\.mjs/i);
399
400
 
400
401
  const grepManyPatterns = [
401
- ...Array.from({ length: 20 }, (_, i) => `__tool_smoke_miss_${i}__`),
402
402
  'tool-smoke',
403
+ ...Array.from({ length: 20 }, (_, i) => `__tool_smoke_miss_${i}__`),
403
404
  ];
404
405
  const grepManyPatternsOut = await executeBuiltinTool('grep', {
405
406
  pattern: grepManyPatterns,
@@ -407,12 +408,12 @@ const grepManyPatternsOut = await executeBuiltinTool('grep', {
407
408
  glob: '*.mjs',
408
409
  head_limit: 5,
409
410
  }, root);
410
- if (/exceeds the 20-pattern cap/i.test(String(grepManyPatternsOut))) {
411
- throw new Error(`grep should chunk >20 patterns instead of error:\n${grepManyPatternsOut.slice(0, 400)}`);
411
+ if (/exceeds the \d+-pattern cap/i.test(String(grepManyPatternsOut))) {
412
+ throw new Error(`grep should truncate oversized pattern[] instead of error:\n${grepManyPatternsOut.slice(0, 400)}`);
412
413
  }
413
- assertOk('grep >20 pattern chunk merge', grepManyPatternsOut, /tool-smoke\.mjs/i);
414
- if (!/\[pattern set split into 2 chunks\]/.test(String(grepManyPatternsOut))) {
415
- throw new Error(`grep >20 patterns should emit chunk advisory:\n${grepManyPatternsOut.slice(0, 400)}`);
414
+ assertOk('grep >10 pattern cap keeps first patterns', grepManyPatternsOut, /tool-smoke\.mjs/i);
415
+ if (!/\[capped at 10 of 21 patterns\]/.test(String(grepManyPatternsOut))) {
416
+ throw new Error(`grep >10 patterns should emit cap note:\n${grepManyPatternsOut.slice(0, 400)}`);
416
417
  }
417
418
 
418
419
  function grepCountTotalMatches(body) {
@@ -431,9 +432,8 @@ if (singleCountTotal == null || singleCountTotal < 1) {
431
432
  }
432
433
 
433
434
  const grepCountOverlapPatterns = [
434
- ...Array.from({ length: 19 }, (_, i) => `__count_overlap_a_${i}__`),
435
435
  'assertOk',
436
- ...Array.from({ length: 19 }, (_, i) => `__count_overlap_b_${i}__`),
436
+ ...Array.from({ length: 8 }, (_, i) => `__count_overlap_a_${i}__`),
437
437
  'assertOk',
438
438
  ];
439
439
  const grepCountOverlapOut = await executeBuiltinTool('grep', {
@@ -444,36 +444,37 @@ const grepCountOverlapOut = await executeBuiltinTool('grep', {
444
444
  const overlapCountTotal = grepCountTotalMatches(grepCountOverlapOut);
445
445
  if (overlapCountTotal !== singleCountTotal) {
446
446
  throw new Error(
447
- `chunked count must not double-count overlapping lines (single=${singleCountTotal} overlap=${overlapCountTotal}):\n${grepCountOverlapOut.slice(0, 600)}`,
447
+ `multi-pattern count must not double-count overlapping lines (single=${singleCountTotal} overlap=${overlapCountTotal}):\n${grepCountOverlapOut.slice(0, 600)}`,
448
448
  );
449
449
  }
450
450
 
451
451
  const grepChunkContextPatterns = [
452
- ...Array.from({ length: 20 }, (_, i) => `__ctx_chunk_miss_${i}__`),
453
452
  'grepCountTotalMatches',
453
+ ...Array.from({ length: 20 }, (_, i) => `__ctx_chunk_miss_${i}__`),
454
454
  ];
455
455
  const grepChunkContextOut = await executeBuiltinTool('grep', {
456
456
  pattern: grepChunkContextPatterns,
457
- path: 'scripts/tool-smoke.mjs',
457
+ path: 'scripts',
458
+ glob: 'tool-smoke.mjs',
458
459
  '-C': 1,
459
460
  head_limit: 30,
460
461
  }, root);
461
- if (!/\[pattern set split into 2 chunks\]/.test(String(grepChunkContextOut))) {
462
- throw new Error(`chunked -C grep should split patterns:\n${grepChunkContextOut.slice(0, 500)}`);
462
+ if (!/\[capped at 10 of 21 patterns\]/.test(String(grepChunkContextOut))) {
463
+ throw new Error(`oversized -C pattern[] should emit cap note:\n${grepChunkContextOut.slice(0, 500)}`);
463
464
  }
464
465
  if (!/tool-smoke\.mjs:\d+:/.test(String(grepChunkContextOut))) {
465
- throw new Error(`chunked -C must emit path-prefixed match lines:\n${grepChunkContextOut.slice(0, 800)}`);
466
+ throw new Error(`capped -C must emit path-prefixed match lines:\n${grepChunkContextOut.slice(0, 800)}`);
466
467
  }
467
468
  if (!/tool-smoke\.mjs-\d+-/.test(String(grepChunkContextOut))) {
468
- throw new Error(`chunked -C must keep path-prefixed context lines:\n${grepChunkContextOut.slice(0, 800)}`);
469
+ throw new Error(`capped -C must keep path-prefixed context lines:\n${grepChunkContextOut.slice(0, 800)}`);
469
470
  }
470
471
  const ctxBodyLines = String(grepChunkContextOut).split('\n').filter((l) => l && !/^\[/.test(l) && !/^\(no matches\)/.test(l));
471
472
  const orphanLineOnlyContext = ctxBodyLines.some((l) => /^\d+-/.test(l));
472
473
  if (orphanLineOnlyContext) {
473
- throw new Error(`chunked -C must not leave line-only context orphans:\n${grepChunkContextOut.slice(0, 800)}`);
474
+ throw new Error(`capped -C must not leave line-only context orphans:\n${grepChunkContextOut.slice(0, 800)}`);
474
475
  }
475
476
  if (!/function grepCountTotalMatches/.test(String(grepChunkContextOut))) {
476
- throw new Error(`chunked -C should include match span:\n${grepChunkContextOut.slice(0, 800)}`);
477
+ throw new Error(`capped -C should include match span:\n${grepChunkContextOut.slice(0, 800)}`);
477
478
  }
478
479
 
479
480
  const findOut = await executeBuiltinTool('find', {
@@ -1165,12 +1166,19 @@ setInternalToolsProvider({
1165
1166
  if (!/Read-only retrieval role/i.test(visible) || /# environment/i.test(visible) || /git operations deferred to Lead/i.test(visible)) {
1166
1167
  throw new Error(`explorer hidden retrieval context should stay slim: ${visible.slice(0, 1200)}`);
1167
1168
  }
1168
- if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/one-shot locator/i.test(systemVisible)) {
1169
+ if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/Coordinate locator/i.test(systemVisible)) {
1169
1170
  throw new Error(`explorer role md must ride BP2 system, not BP3 user reminder: system=${systemVisible.slice(0, 600)} user=${userReminderVisible.slice(0, 600)}`);
1170
1171
  }
1171
- const visibleBytes = Buffer.byteLength(visible, 'utf8');
1172
- if (visibleBytes > 1800) {
1173
- throw new Error(`explorer hidden retrieval context too large: ${visibleBytes} bytes`);
1172
+ // System layers (BP1 tool policy + BP2 role md) are shared/frozen and sized
1173
+ // elsewhere; the slimness cap guards only the per-session injected layers
1174
+ // (BP3 user reminder etc.), so measure non-system messages only.
1175
+ const injectedVisible = (explorerSession.messages || [])
1176
+ .filter((m) => m?.role !== 'system')
1177
+ .map((m) => String(m.content || ''))
1178
+ .join('\n');
1179
+ const injectedBytes = Buffer.byteLength(injectedVisible, 'utf8');
1180
+ if (injectedBytes > 1800) {
1181
+ throw new Error(`explorer hidden retrieval context too large: ${injectedBytes} bytes (injected layers only)`);
1174
1182
  }
1175
1183
  } finally {
1176
1184
  closeSession(explorerSession.id, 'tool-smoke');
@@ -1298,8 +1306,16 @@ setInternalToolsProvider({
1298
1306
  if (!fullTools.includes('explore')) {
1299
1307
  throw new Error(`full agent schema must expose explore: full=${fullTools.join(', ')}`);
1300
1308
  }
1301
- if (publicExploreTools.includes('explore')) {
1302
- throw new Error(`public explore role must not expose recursive explore tool: ${publicExploreTools.join(', ')}`);
1309
+ // Unified-shard policy (v0.9.13): the explore wrapper stays IN the schema
1310
+ // for every read role including the explore agent itself — so the
1311
+ // read-only bundle is bit-identical across roles (one cache group).
1312
+ // Recursion is broken at call time in pre-dispatch-deny.mjs via
1313
+ // recursiveWrapperToolNameForPublicAgent, not by schema stripping.
1314
+ if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedReadTools)) {
1315
+ throw new Error(`public explore role must ship the shared read-only bundle (incl. explore): expected=${expectedReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
1316
+ }
1317
+ if (recursiveWrapperToolNameForPublicAgent('explore') !== 'explore') {
1318
+ throw new Error('call-time anti-recursion must map public explore agent to its own wrapper tool');
1303
1319
  }
1304
1320
  } finally {
1305
1321
  closeSession(readAgentSession.id, 'tool-smoke');
@@ -0,0 +1,32 @@
1
+ // Mirror of vendor/ink/build/ink.js shouldClearTerminalForFrame, with the
2
+ // Windows branch parameterized so the harness can probe both platforms without
3
+ // relying on process.platform. Kept byte-faithful to the source predicate so
4
+ // the harness reflects real clear decisions.
5
+ export function shouldClearTerminalForFrameProbe({
6
+ isTty, viewportRows, previousViewportRows, previousOutputHeight,
7
+ nextOutputHeight, isUnmounting, isWindows,
8
+ }) {
9
+ if (!isTty) return false;
10
+ const priorViewportRows = previousViewportRows ?? viewportRows;
11
+ const hadPreviousFrame = previousOutputHeight > 0;
12
+ const wasFullscreen = previousOutputHeight >= priorViewportRows;
13
+ const wasOverflowing = previousOutputHeight > priorViewportRows;
14
+ const isOverflowing = nextOutputHeight > viewportRows;
15
+ const isFullscreen = nextOutputHeight >= viewportRows;
16
+ const isLeavingFullscreen = wasFullscreen && nextOutputHeight < viewportRows;
17
+ const isShrinkingAtViewport = hadPreviousFrame &&
18
+ nextOutputHeight < previousOutputHeight &&
19
+ (wasFullscreen || isFullscreen || wasOverflowing || isOverflowing);
20
+ const shouldClearOnUnmount = isUnmounting && wasFullscreen;
21
+ const viewportResized = previousViewportRows != null && previousViewportRows !== viewportRows;
22
+ if (isWindows && (wasFullscreen || isFullscreen) &&
23
+ (viewportResized || isShrinkingAtViewport || isLeavingFullscreen)) {
24
+ return true;
25
+ }
26
+ return (
27
+ wasOverflowing ||
28
+ (isOverflowing && hadPreviousFrame) ||
29
+ isLeavingFullscreen ||
30
+ isShrinkingAtViewport ||
31
+ shouldClearOnUnmount);
32
+ }
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/tui-frame-harness.mjs — renderer-level frame-grid evidence harness.
4
+ *
5
+ * Drives vendor/ink log-update createIncremental(fakeStream) through the SAME
6
+ * wrapper logic ink.js renderInteractiveFrame() applies (fullscreen detect +
7
+ * trailing-newline normalization + shouldClearTerminalForFrame), replays the
8
+ * emitted ANSI into a minimal VT grid interpreter, and asserts the absolute row
9
+ * of a tracked marker line (the "prompt" / "statusline") across each frame.
10
+ *
11
+ * A deviant frame = the marker's absolute row changes for a single frame then
12
+ * snaps back. That is the one-row-low dip reported under Windows Terminal.
13
+ *
14
+ * Run: node scripts/tui-frame-harness.mjs
15
+ */
16
+ // [harness] log-update reads process.platform/WT_SESSION AT IMPORT to pick its
17
+ // Windows-safe absolute-cursor branch. Force WT_SESSION on BEFORE importing it
18
+ // so POSIX/CI runs exercise the same branch WT users hit. Must precede the
19
+ // dynamic import below.
20
+ process.env.WT_SESSION = process.env.WT_SESSION || '1';
21
+ const { default: logUpdate } = await import('../vendor/ink/build/log-update.js');
22
+ const { shouldClearTerminalForFrameProbe } = await import('./tui-frame-harness-shim.mjs');
23
+ const { default: ansiEscapes } = await import('ansi-escapes');
24
+
25
+ // Fail loudly if the Windows-safe branch is NOT engaged: without it the harness
26
+ // silently tests the wrong path and reports a false pass. Probe by driving a
27
+ // fullscreen→one-short pair through a throwaway log and asserting the branch's
28
+ // absolute cursorTo(0,y) addressing (not a relative cursorUp walk) is emitted.
29
+ function assertWindowsBranchEngaged() {
30
+ const chunks = [];
31
+ const fs = { write: (s) => { chunks.push(s); return true; }, isTTY: true, columns: 20, rows: 4 };
32
+ const log = logUpdate.create(fs, { incremental: true });
33
+ log.sync('a\nb\nc\nd'); // 4 lines, fullscreen, no trailing nl
34
+ chunks.length = 0;
35
+ log('a\nX\nc\nd'); // change one middle row, still fullscreen
36
+ const emitted = chunks.join('');
37
+ // Windows-safe branch uses absolute cursorTo(0, i) => CSI <row>;1H. The POSIX
38
+ // relative branch uses cursorUp / cursorNextLine (CSI A / E) with no ;1H rows.
39
+ const hasAbsolute = /\x1b\[\d+;1H/.test(emitted);
40
+ if (!hasAbsolute) {
41
+ console.error('tui-frame-harness: FAIL — log-update Windows-safe branch NOT engaged '
42
+ + '(WT_SESSION/platform did not force it at import). Emitted:', JSON.stringify(emitted));
43
+ process.exit(1);
44
+ }
45
+ }
46
+ assertWindowsBranchEngaged();
47
+
48
+ // ---- Minimal VT grid interpreter -----------------------------------------
49
+ // Supports the escapes log-update / ink emit: cursorTo(x[,y]), cursorUp/Down/
50
+ // NextLine, eraseLine, eraseEndLine, eraseLines(n), clearTerminal, plain text,
51
+ // newline, SGR (ignored for geometry), hide/show cursor (ignored).
52
+ class VT {
53
+ constructor(rows, cols) {
54
+ this.rows = rows;
55
+ this.cols = cols;
56
+ this.grid = Array.from({ length: rows }, () => '');
57
+ this.cx = 0;
58
+ this.cy = 0;
59
+ }
60
+ _clampRow() { if (this.cy < 0) this.cy = 0; if (this.cy >= this.rows) this.cy = this.rows - 1; }
61
+ write(data) {
62
+ let i = 0;
63
+ while (i < data.length) {
64
+ const ch = data[i];
65
+ if (ch === '\u001B') {
66
+ // CSI
67
+ if (data[i + 1] === '[') {
68
+ let j = i + 2;
69
+ let params = '';
70
+ while (j < data.length && /[0-9;]/.test(data[j])) { params += data[j]; j++; }
71
+ const cmd = data[j];
72
+ const nums = params.split(';').map((p) => (p === '' ? undefined : Number(p)));
73
+ this._csi(cmd, nums, params);
74
+ i = j + 1;
75
+ continue;
76
+ }
77
+ // other escapes (e.g. \u001B[?25l already handled via '[' path); skip 2
78
+ i += 1;
79
+ continue;
80
+ }
81
+ if (ch === '\n') { this.cy += 1; this.cx = 0; this._clampRow(); i++; continue; }
82
+ if (ch === '\r') { this.cx = 0; i++; continue; }
83
+ // printable
84
+ this._clampRow();
85
+ const row = this.grid[this.cy];
86
+ const padded = row.length < this.cx ? row + ' '.repeat(this.cx - row.length) : row;
87
+ this.grid[this.cy] = padded.slice(0, this.cx) + ch + padded.slice(this.cx + 1);
88
+ this.cx += 1;
89
+ i++;
90
+ }
91
+ }
92
+ _csi(cmd, nums, params) {
93
+ const n = nums[0];
94
+ switch (cmd) {
95
+ case 'H': case 'f': { // cursor position (1-based row;col)
96
+ this.cy = (nums[0] ?? 1) - 1; this.cx = (nums[1] ?? 1) - 1; this._clampRow(); break;
97
+ }
98
+ case 'A': this.cy -= (n ?? 1); this._clampRow(); break; // up
99
+ case 'B': this.cy += (n ?? 1); this._clampRow(); break; // down
100
+ case 'C': this.cx += (n ?? 1); break; // forward
101
+ case 'D': this.cx -= (n ?? 1); if (this.cx < 0) this.cx = 0; break; // back
102
+ case 'E': this.cy += (n ?? 1); this.cx = 0; this._clampRow(); break; // next line
103
+ case 'F': this.cy -= (n ?? 1); this.cx = 0; this._clampRow(); break; // prev line
104
+ case 'G': this.cx = (n ?? 1) - 1; break; // column (cursorTo(x) → \u001B[(x+1)G)
105
+ case 'J': { // erase display; 2 = whole screen (clearTerminal uses 2J + H)
106
+ if (n === 2 || n === 3) { this.grid = Array.from({ length: this.rows }, () => ''); }
107
+ break;
108
+ }
109
+ case 'K': { // erase line (eraseEndLine=0/none; eraseLine full = 2K)
110
+ if (n === undefined || n === 0) { this.grid[this.cy] = this.grid[this.cy].slice(0, this.cx); }
111
+ else if (n === 1) { this.grid[this.cy] = ' '.repeat(this.cx) + this.grid[this.cy].slice(this.cx); }
112
+ else if (n === 2) { this.grid[this.cy] = ''; }
113
+ break;
114
+ }
115
+ // SGR (m), hide/show cursor (h/l), etc. — no geometry effect
116
+ default: break;
117
+ }
118
+ }
119
+ markerRow(marker) {
120
+ for (let r = 0; r < this.rows; r++) {
121
+ if (this.grid[r].includes(marker)) return r;
122
+ }
123
+ return -1;
124
+ }
125
+ dump() {
126
+ return this.grid.map((r, i) => `${String(i).padStart(2)}|${r}`).join('\n');
127
+ }
128
+ }
129
+
130
+ // ---- Wrapper mirroring ink.js renderInteractiveFrame ----------------------
131
+ function makeDriver({ rows, cols, isWindows }) {
132
+ const chunks = [];
133
+ const fakeStream = { write: (s) => { chunks.push(s); return true; }, isTTY: true, columns: cols, rows };
134
+ // Force the incremental renderer's Windows branch by faking env/platform is
135
+ // out of scope here; log-update reads process.platform/WT_SESSION at import.
136
+ const log = logUpdate.create(fakeStream, { incremental: true });
137
+ const vt = new VT(rows, cols);
138
+ let lastOutput = '';
139
+ let lastOutputHeight = 0;
140
+ let lastViewportRows = rows;
141
+ let lastOneShortPadded = false;
142
+ const commit = (output) => {
143
+ chunks.length = 0;
144
+ // [FAITHFUL] outputHeight = output.get().height = Output.height = the
145
+ // Yoga-computed ROOT height (renderer.js L45). The App pins the outer
146
+ // column to height=resizeState.rows, so a steady frame reports height=rows.
147
+ // BUT when the App's row accounting is off by one for a single commit
148
+ // (a reclaimed panel/hint row not yet refilled), the root lays out at
149
+ // rows-1 and outputHeight==rows-1. Model the height as the caller states
150
+ // it via a marker: the frame string's real line count is authoritative
151
+ // here because we construct each frame to physically carry `heightRows`
152
+ // lines. So derive it from the string.
153
+ const lineCount = output.split('\n').length;
154
+ let outputHeight = output === '' ? 0 : lineCount;
155
+ // Mirror ink.js (post-fix): an exactly-one-row-short frame following a
156
+ // fullscreen frame is padded with a leading blank line so the bottom
157
+ // cluster stays at its steady rows and the fullscreen path stays engaged.
158
+ const wasFullscreenFrame = lastOutputHeight >= lastViewportRows && lastOutputHeight > 0;
159
+ // Mirror ink.js guard exactly: Windows-like only + one-commit transient.
160
+ const isExactlyOneRowShort = isWindows && outputHeight === rows - 1
161
+ && wasFullscreenFrame && !lastOneShortPadded;
162
+ if (isExactlyOneRowShort) {
163
+ output = '\n' + output;
164
+ outputHeight = rows;
165
+ lastOneShortPadded = true;
166
+ } else {
167
+ lastOneShortPadded = false;
168
+ }
169
+ const isFullscreen = outputHeight >= rows;
170
+ let outputToRender = isFullscreen ? output : output + '\n';
171
+ if (isFullscreen && outputToRender.endsWith('\n')) outputToRender += '\u001B[0m';
172
+ const clearDecision = shouldClearTerminalForFrameProbe({
173
+ isTty: true, viewportRows: rows, previousViewportRows: lastViewportRows,
174
+ previousOutputHeight: lastOutputHeight, nextOutputHeight: outputHeight,
175
+ isUnmounting: false, isWindows,
176
+ });
177
+ if (clearDecision) {
178
+ fakeStream.write('\u001B[0m' + ansiEscapes.clearTerminal + outputToRender);
179
+ log.sync(outputToRender);
180
+ } else {
181
+ log(outputToRender);
182
+ }
183
+ for (const c of chunks) vt.write(c);
184
+ lastOutput = output;
185
+ lastOutputHeight = outputHeight;
186
+ lastViewportRows = rows;
187
+ const trailingNL = outputToRender.endsWith('\n');
188
+ return { outputHeight, trailingNL, clearDecision, isFullscreen };
189
+ };
190
+ return { vt, commit };
191
+ }
192
+
193
+ // ---- Scenario builders ----------------------------------------------------
194
+ // Build an App-like frame with the prompt+statusline ALWAYS pinned to the two
195
+ // bottom-most non-blank rows (matching App.jsx: the bottom bar never moves).
196
+ // The App keeps total painted rows == viewport by ceding transcript rows to a
197
+ // panel; so the prompt row is invariant whether or not the palette is open.
198
+ // The only thing that changes between commits is: does the serialized output
199
+ // end on a blank row (→ trailing-newline flip, fs classification flip)?
200
+ // `bottomBlank` models a frame whose LAST painted row is blank padding.
201
+ // `shortByOne` = the root Yoga height is rows-1 for this commit (a reclaimed
202
+ // row that the App's accounting has NOT yet refilled). This is the documented
203
+ // deviant: outputHeight = rows-1 < viewportRows → ink.js takes output+'\n'
204
+ // (NON-fullscreen branch) → log-update relative cursorUp walk → one-row-low
205
+ // dip under Windows Terminal. A steady frame (shortByOne=false) fills the
206
+ // viewport, stays on the absolute cursorTo path, and is stable.
207
+ function frame({ rows, cols, palette, shortByOne, heightRows }) {
208
+ // [FAITHFUL] App.jsx pins the bottom cluster with the outer full-height
209
+ // column (height=resizeState.rows) + flexShrink={0} on the bottom bar. So the
210
+ // Yoga root height is `rows` when accounting is correct. When a reclaimed row
211
+ // is momentarily unaccounted, the laid-out tree is `rows-1` tall for one
212
+ // commit — that is `shortByOne`. In that frame the WHOLE column (including
213
+ // the pinned bottom cluster) sits one physical row higher.
214
+ // heightRows (explicit) overrides shortByOne — lets a caller drive an
215
+ // arbitrary frame height (e.g. rows-3) to exercise the real leave-fullscreen
216
+ // shrink chain, which the boolean shortByOne cannot express.
217
+ const height = heightRows != null ? heightRows : (shortByOne ? rows - 1 : rows);
218
+ const statusRow = height - 1;
219
+ const promptRow = statusRow - 1;
220
+ const lines = [];
221
+ for (let r = 0; r < height; r++) {
222
+ if (r === statusRow) { lines.push('STATUSLINE'); continue; }
223
+ if (r === promptRow) { lines.push('PROMPT>'); continue; }
224
+ if (palette && r === promptRow - 1) { lines.push('SLASHPALETTE'); continue; }
225
+ lines.push(`t${r}`);
226
+ }
227
+ // Serialized string carries exactly `height` lines (no trailing newline).
228
+ return lines.join('\n');
229
+ }
230
+
231
+ function run() {
232
+ const rows = 40, cols = 120;
233
+ const isWindows = true;
234
+ console.log(`# renderer harness rows=${rows} cols=${cols} isWindows(assumed)=${isWindows}`);
235
+ console.log(`# NOTE: log-update Windows branch active iff process.platform===win32||WT_SESSION at import`);
236
+ console.log(`# (set WT_SESSION=1 to force it on non-Windows)\n`);
237
+
238
+ // Each frame: { palette, shortByOne }. shortByOne=true is the ONE deviant
239
+ // commit where the App's row accounting leaves the laid-out tree one row
240
+ // short of the viewport (reclaimed panel/hint row not yet refilled).
241
+ const scenarios = [
242
+ { name: 'palette close, always viewport-filling (correct accounting)', seq: [
243
+ { palette: true, shortByOne: false },
244
+ { palette: false, shortByOne: false },
245
+ { palette: false, shortByOne: false },
246
+ ]},
247
+ { name: 'palette close, close commit ONE ROW SHORT (deviant accounting)', seq: [
248
+ { palette: true, shortByOne: false },
249
+ { palette: false, shortByOne: true }, // reclaimed row unaccounted
250
+ { palette: false, shortByOne: false },
251
+ ]},
252
+ { name: 'prompt newline remove, transitional ONE ROW SHORT', seq: [
253
+ { palette: false, shortByOne: false },
254
+ { palette: false, shortByOne: true },
255
+ { palette: false, shortByOne: false },
256
+ ]},
257
+ // Steady one-short: repeated rows-1 frames. The pad must fire ONCE then
258
+ // stop (lastOneShortPadded), so f2/f3 are NOT re-padded and settle at the
259
+ // rows-1 layout (prompt rows-3 / status rows-2) with correct clear
260
+ // decisions — no infinite downward shift.
261
+ { name: 'steady ONE ROW SHORT (repeated rows-1) — pad once, then stop', seq: [
262
+ { palette: false, shortByOne: false },
263
+ { palette: false, shortByOne: true },
264
+ { palette: false, shortByOne: true },
265
+ { palette: false, shortByOne: true },
266
+ ]},
267
+ // Real leave-fullscreen shrink chain rows→rows-1→rows-3: after the padded
268
+ // rows-1 frame, a further shrink to rows-3 must reach the shrink/clear path
269
+ // (not be masked by a stale pad) and settle cleanly.
270
+ { name: 'real shrink chain fullscreen→rows-1→rows-3', seq: [
271
+ { palette: false, heightRows: 40 },
272
+ { palette: false, heightRows: 39 },
273
+ { palette: false, heightRows: 37 },
274
+ { palette: false, heightRows: 37 },
275
+ ]},
276
+ ];
277
+
278
+ let anyDeviant = false;
279
+ for (const sc of scenarios) {
280
+ const { vt, commit } = makeDriver({ rows, cols, isWindows });
281
+ const promptRows = [];
282
+ console.log(`## ${sc.name}`);
283
+ sc.seq.forEach((f, idx) => {
284
+ const out = frame({ rows, cols, palette: f.palette, shortByOne: f.shortByOne, heightRows: f.heightRows });
285
+ const info = commit(out);
286
+ const pRow = vt.markerRow('PROMPT>');
287
+ const sRow = vt.markerRow('STATUSLINE');
288
+ promptRows.push(pRow);
289
+ console.log(` f${idx} short1=${f.shortByOne?1:0} h=${info.outputHeight} fs=${info.isFullscreen?1:0} trailNL=${info.trailingNL?1:0} clear=${info.clearDecision?1:0} promptRow=${pRow} statusRow=${sRow}`);
290
+ });
291
+ // Deviant = the prompt row BOUNCES (differs from the settled row for a
292
+ // transient frame then returns). A monotone shift to a new steady row
293
+ // (steady-one-short, real shrink) is NOT a bounce — check the LAST row is
294
+ // reached and held, and no interior frame differs from BOTH neighbors.
295
+ const settled = promptRows[promptRows.length - 1];
296
+ const bounce = promptRows.some((r, i) =>
297
+ i > 0 && i < promptRows.length - 1 &&
298
+ r >= 0 && r !== promptRows[i - 1] && r !== promptRows[i + 1]);
299
+ if (bounce) { anyDeviant = true; console.log(` >> DEVIANT(bounce): prompt rows ${JSON.stringify(promptRows)} vs settled ${settled}`); }
300
+ console.log('');
301
+ }
302
+ if (!anyDeviant) console.log('# no deviant frame reproduced at renderer level for these scenarios');
303
+ process.exitCode = 0;
304
+ }
305
+
306
+ run();
@@ -9,13 +9,12 @@ Bounded slices; smallest coherent change, not rewrite. Stop: unclear scope,
9
9
  growing blast radius, or Lead-only verification.
10
10
 
11
11
  EDIT-FIRST DISCIPLINE. Survey the slice with ONE batched read/grep round, then
12
- patch the first bounded piece — broad scope means edit incrementally, not read
13
- exhaustively. NEVER "one more confirming read": if an anchor is plausible, the
14
- next call is `apply_patch`. Repeated read-only turns without an edit = stalling;
15
- any runtime reminder is your cue to patch the piece you understand or report
16
- blocked — a blocked report is a valid completion. Self-check comes AFTER edits;
17
- deep verification is Lead's.
12
+ patch the first bounded piece — edit incrementally, don't read exhaustively.
13
+ NEVER "one more confirming read": a plausible anchor means the next call is
14
+ `apply_patch`. Repeated read-only turns without an edit = stalling; on a
15
+ runtime reminder, patch the piece you understand or report blocked — a valid
16
+ completion. Self-check comes AFTER edits; deep verification is Lead's.
18
17
 
19
18
  Minimal checks + how-to-verify. Hand off outcome as fragments anchored to
20
- `file:line`; no narration, no report bloat.
19
+ `file:line`; no narration or bloat.
21
20
 
@@ -8,13 +8,12 @@ Scoped implementation agent.
8
8
  Smallest scoped change; no drive-by cleanup. Stop when done/blocked.
9
9
 
10
10
  EDIT-FIRST DISCIPLINE. Brief anchors (`file:line`) are pre-verified — trust and
11
- patch. No anchor: locate with AT MOST 1-2 reads, then edit. NEVER "one more
12
- confirming read": if you know the file and the change, the next call is
13
- `apply_patch`. Repeated read-only turns without an edit = stalling; any runtime
14
- reminder is your cue to patch now or return blocked with what's missing — a
15
- blocked report is a valid completion. Threshold is "plausible", not "proven";
11
+ patch. No anchor: locate with AT MOST 1-2 reads, then edit NEVER "one more
12
+ confirming read"; if you know the file and the change, the next call is
13
+ `apply_patch`. Repeated read-only turns without an edit = stalling; on a
14
+ runtime reminder, patch now or return blocked with what's missing — a blocked
15
+ report is a valid completion. Threshold is "plausible", not "proven";
16
16
  self-check comes AFTER the edit, and Lead/Reviewer own final verification.
17
17
 
18
- Hand off outcome as fragments anchored to `file:line`; no narration, no report
19
- bloat.
18
+ Hand off outcome as fragments anchored to `file:line`; no narration or bloat.
20
19
 
@@ -182,23 +182,40 @@ function loadKeytar() {
182
182
  function keytarSync(method, ...args) {
183
183
  loadKeytar(); // throws if not installed — before spawning child
184
184
  const { spawnSync } = require('child_process');
185
- // Pass service/account/value via env to avoid shell injection entirely.
186
- const env = {
187
- ...process.env,
188
- _KEYTAR_METHOD: method,
189
- _KEYTAR_ARGS: JSON.stringify(args),
190
- };
185
+ // Pass service/account/value via stdin (not env) to avoid shell injection
186
+ // and to keep secret values out of /proc/<pid>/environ on Linux.
187
+ // Build a minimal child env instead of spreading process.env: the parent
188
+ // may hold *_API_KEY secrets in its own environment, and those would
189
+ // otherwise be visible via /proc/<pid>/environ for the lifetime of this
190
+ // child. Only pass what PATH resolution / locale / the libsecret D-Bus
191
+ // session bridge actually need.
192
+ const passthroughKeys = [
193
+ 'PATH', 'HOME', 'USER', 'LOGNAME',
194
+ 'LANG', 'LC_ALL',
195
+ 'TMPDIR', 'TMP', 'TEMP',
196
+ 'DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR', 'XDG_CURRENT_DESKTOP', 'XDG_DATA_DIRS',
197
+ ];
198
+ const env = { _KEYTAR_METHOD: method };
199
+ for (const key of passthroughKeys) {
200
+ if (process.env[key] !== undefined) env[key] = process.env[key];
201
+ }
191
202
  const script = [
192
203
  'const kt = require("keytar");',
193
204
  'const method = process.env._KEYTAR_METHOD;',
194
- 'const args = JSON.parse(process.env._KEYTAR_ARGS);',
195
- 'kt[method](...args)',
196
- ' .then(v => { process.stdout.write(JSON.stringify({ ok: true, value: v })); })',
197
- ' .catch(e => { process.stdout.write(JSON.stringify({ ok: false, error: e.message })); });',
205
+ 'let input = "";',
206
+ 'process.stdin.setEncoding("utf8");',
207
+ 'process.stdin.on("data", (chunk) => { input += chunk; });',
208
+ 'process.stdin.on("end", () => {',
209
+ ' const args = JSON.parse(input);',
210
+ ' kt[method](...args)',
211
+ ' .then(v => { process.stdout.write(JSON.stringify({ ok: true, value: v })); })',
212
+ ' .catch(e => { process.stdout.write(JSON.stringify({ ok: false, error: e.message })); });',
213
+ '});',
198
214
  ].join(' ');
199
215
  const r = spawnSync(process.execPath, ['-e', script], {
200
216
  env,
201
- stdio: ['ignore', 'pipe', 'pipe'],
217
+ input: JSON.stringify(args),
218
+ stdio: ['pipe', 'pipe', 'pipe'],
202
219
  timeout: 5000,
203
220
  encoding: 'utf8',
204
221
  windowsHide: true,
@@ -129,10 +129,10 @@ function buildProfilePreferencesContent(dataDir) {
129
129
  const profile = normalizeProfileConfig(readAgentConfig(dataDir).profile);
130
130
  const lines = [];
131
131
  if (profile.title) {
132
- lines.push(`- Use "${profile.title}" when directly addressing the user (greetings, answers, questions). Do not repeat it in routine progress updates or pre-tool preambles.`);
132
+ lines.push(`- Use "${profile.title}" when directly addressing the user (greetings, answers, questions); do not repeat it in routine progress updates or pre-tool preambles.`);
133
133
  }
134
134
  const shell = process.platform === 'win32' ? 'powershell' : 'bash';
135
- lines.push(`- Shell environment: ${shell}. When using shell, write commands and scripts in ${shell} syntax unless the user specifies otherwise. Keep commands, paths, symbols, and exact errors verbatim.`);
135
+ lines.push(`- Shell environment: ${shell}. Write shell commands and scripts in ${shell} syntax unless the user specifies otherwise; keep commands, paths, symbols, and exact errors verbatim.`);
136
136
  return lines.length ? `# Profile Preferences\n\n${lines.join('\n')}` : '';
137
137
  }
138
138
 
@@ -144,7 +144,7 @@ function buildLanguageSection(dataDir) {
144
144
  ? ` from system locale ${language.locale}`
145
145
  : '';
146
146
  const lines = [
147
- `- Default user-facing response language${source}: ${language.prompt}. Use it for ALL user-facing prose, including pre-tool preambles, progress updates, questions, final reports, and notices. Every such message even a single-line preamble before a tool call — MUST be written in ${language.prompt}, and in no other language. This overrides any tone implied by the output style. Switch languages only when the user writes in another language or explicitly asks you to.`,
147
+ `- Default user-facing response language${source}: ${language.prompt}. EVERY user-facing message — prose, pre-tool preambles (even single-line), progress updates, questions, final reports, notices — MUST be written in ${language.prompt} and no other language; this overrides any tone implied by the output style. Switch only when the user writes in another language or explicitly asks you to.`,
148
148
  `- Code identifiers, paths, commands, symbols, API names, and exact errors should remain in their original form.`,
149
149
  ];
150
150
  return `# Language\n\n${lines.join('\n')}`;
@@ -382,13 +382,9 @@ function buildAgentRoleContent({ PLUGIN_ROOT, profile = 'full' }) {
382
382
  function buildAgentRetrievalInjectionContent({ PLUGIN_ROOT }) {
383
383
  const AGENT_DIR = path.join(PLUGIN_ROOT, 'rules', 'agent');
384
384
  const core = readOptional(path.join(AGENT_DIR, '00-core.md'));
385
- const parts = [
386
- '# Tool Use',
387
- '',
388
- '- Batch independent read-only lookups in the same tool turn.',
389
- '- Use code_graph for symbols/dependencies, grep for exact text, find/glob/list for files, and read only known paths/windows.',
390
- '',
391
- ];
385
+ // Full shared tool policy (01-tool.md) now ships via BP1 for retrieval
386
+ // roles too; no compact duplicate here.
387
+ const parts = [];
392
388
  if (core) parts.push(core.trim());
393
389
  parts.push('', '- Read-only retrieval role: do not edit files, run shell, or use git.');
394
390
  return parts.join('\n');