mixdog 0.9.45 → 0.9.47

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 (123) hide show
  1. package/package.json +5 -2
  2. package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
  3. package/scripts/agent-model-liveness-test.mjs +618 -0
  4. package/scripts/agent-trace-io-test.mjs +68 -4
  5. package/scripts/bench-run.mjs +30 -3
  6. package/scripts/compact-pressure-test.mjs +187 -4
  7. package/scripts/compact-prior-context-flatten-test.mjs +252 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +8 -6
  9. package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
  10. package/scripts/context-mcp-metering-test.mjs +75 -0
  11. package/scripts/explore-prompt-policy-test.mjs +15 -16
  12. package/scripts/explore-timeout-cancel-test.mjs +345 -0
  13. package/scripts/headless-pristine-execution-test.mjs +614 -0
  14. package/scripts/internal-comms-smoke.mjs +15 -6
  15. package/scripts/live-worker-smoke.mjs +1 -1
  16. package/scripts/memory-core-input-test.mjs +137 -0
  17. package/scripts/memory-rule-contract-test.mjs +5 -5
  18. package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
  19. package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
  20. package/scripts/parent-abort-link-test.mjs +22 -0
  21. package/scripts/provider-toolcall-test.mjs +22 -0
  22. package/scripts/reactive-compact-persist-smoke.mjs +8 -4
  23. package/scripts/session-bench.mjs +3 -70
  24. package/scripts/task-bench.mjs +0 -2
  25. package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
  26. package/scripts/tool-smoke.mjs +21 -21
  27. package/scripts/tool-tui-presentation-test.mjs +68 -0
  28. package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
  29. package/src/app.mjs +28 -103
  30. package/src/cli.mjs +17 -13
  31. package/src/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/headless-command.mjs +139 -0
  34. package/src/headless-role.mjs +121 -10
  35. package/src/help.mjs +4 -1
  36. package/src/rules/agent/00-common.md +3 -3
  37. package/src/rules/agent/00-core.md +8 -9
  38. package/src/rules/agent/20-skip-protocol.md +2 -3
  39. package/src/rules/agent/30-explorer.md +50 -56
  40. package/src/rules/agent/40-cycle1-agent.md +10 -12
  41. package/src/rules/agent/41-cycle2-agent.md +12 -9
  42. package/src/rules/agent/42-cycle3-agent.md +4 -6
  43. package/src/rules/lead/01-general.md +5 -6
  44. package/src/rules/lead/02-channels.md +1 -1
  45. package/src/rules/lead/lead-brief.md +14 -17
  46. package/src/rules/lead/lead-tool.md +3 -3
  47. package/src/rules/shared/01-tool.md +41 -43
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
  50. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
  51. package/src/runtime/agent/orchestrator/config.mjs +96 -30
  52. package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
  53. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
  55. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  56. package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
  57. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
  58. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
  60. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
  61. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
  64. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
  66. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
  67. package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
  68. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
  69. package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
  70. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
  71. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
  72. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
  73. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
  74. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
  75. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
  76. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
  77. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
  79. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
  80. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
  81. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
  82. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
  83. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
  86. package/src/runtime/memory/index.mjs +0 -1
  87. package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
  88. package/src/runtime/memory/lib/http-router.mjs +0 -193
  89. package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
  90. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  91. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  92. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  93. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  94. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  95. package/src/runtime/memory/tool-defs.mjs +5 -6
  96. package/src/runtime/shared/config.mjs +11 -34
  97. package/src/runtime/shared/pristine-execution-contract.json +75 -0
  98. package/src/runtime/shared/pristine-execution.mjs +356 -0
  99. package/src/runtime/shared/provider-api-key.mjs +43 -0
  100. package/src/runtime/shared/provider-auth-binding.mjs +21 -0
  101. package/src/session-runtime/context-status.mjs +61 -13
  102. package/src/session-runtime/mcp-glue.mjs +29 -2
  103. package/src/session-runtime/plugin-mcp.mjs +7 -0
  104. package/src/session-runtime/resource-api.mjs +38 -5
  105. package/src/session-runtime/runtime-core.mjs +5 -1
  106. package/src/session-runtime/session-turn-api.mjs +14 -2
  107. package/src/session-runtime/settings-api.mjs +5 -0
  108. package/src/session-runtime/tool-catalog.mjs +13 -2
  109. package/src/session-runtime/tool-defs.mjs +1 -3
  110. package/src/standalone/agent-task-status.mjs +50 -11
  111. package/src/standalone/agent-tool/tool-def.mjs +1 -1
  112. package/src/standalone/explore-tool.mjs +257 -49
  113. package/src/standalone/seeds.mjs +1 -0
  114. package/src/tui/App.jsx +23 -10
  115. package/src/tui/app/use-transcript-scroll.mjs +4 -3
  116. package/src/tui/app/use-transcript-window.mjs +12 -21
  117. package/src/tui/components/ContextPanel.jsx +19 -25
  118. package/src/tui/dist/index.mjs +77 -65
  119. package/src/tui/engine/agent-envelope.mjs +16 -5
  120. package/src/tui/engine/labels.mjs +1 -1
  121. package/src/workflows/default/WORKFLOW.md +21 -51
  122. package/src/workflows/solo/WORKFLOW.md +12 -17
  123. package/src/workflows/solo-review/WORKFLOW.md +0 -47
@@ -739,7 +739,10 @@ const compactedGuardCases = [
739
739
  ];
740
740
  for (const [label, patch] of compactedGuardCases) {
741
741
  const out = await executePatchTool('apply_patch', { base_path: root, dry_run: true, fuzzy: false, patch }, root);
742
- if (!/^Error[\s:[]/.test(String(out)) || !/compacted-history placeholder/i.test(String(out))) {
742
+ if (!/^Error[\s:[]/.test(String(out))
743
+ || !/compacted-history placeholder/i.test(String(out))
744
+ || !/re-read the current target file contents now/i.test(String(out))
745
+ || !/fresh full patch/i.test(String(out))) {
743
746
  throw new Error(`apply_patch must reject compacted placeholder (${label}):\n${out}`);
744
747
  }
745
748
  }
@@ -1366,7 +1369,7 @@ setInternalToolsProvider({
1366
1369
  if (!/Read-only retrieval role/i.test(visible) || /# environment/i.test(visible) || /git operations deferred to Lead/i.test(visible)) {
1367
1370
  throw new Error(`explorer hidden retrieval context should stay slim: ${visible.slice(0, 1200)}`);
1368
1371
  }
1369
- if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/deliver WHERE/i.test(systemVisible)) {
1372
+ if (!/# Role: explorer/i.test(systemVisible) || /# Role: explorer/i.test(userReminderVisible) || !/only WHERE/i.test(systemVisible)) {
1370
1373
  throw new Error(`explorer role md must ride BP2 system, not BP3 user reminder: system=${systemVisible.slice(0, 600)} user=${userReminderVisible.slice(0, 600)}`);
1371
1374
  }
1372
1375
  // System layers (BP1 tool policy + BP2 role md) are shared/frozen and sized
@@ -1728,10 +1731,10 @@ for (const requiredGrammarLine of [
1728
1731
  }
1729
1732
  const readPathSchema = BUILTIN_TOOLS.find((tool) => tool.name === 'read')?.inputSchema?.properties?.path || {};
1730
1733
  const readPathDescription = readPathSchema.description || '';
1731
- if (!/Verified file path/i.test(readPathDescription) || !/\{path,offset,limit\}\[\]/i.test(readPathDescription) || !/Pass arrays directly/i.test(readPathDescription) || !/legacy recovery only/i.test(readPathDescription)) {
1734
+ if (!/\{path,offset,limit\}\[\]/i.test(readPathDescription) || !/real arrays/i.test(readPathDescription)) {
1732
1735
  throw new Error('read schema must keep directory-vs-file guidance');
1733
1736
  }
1734
- if (!/Not for directory listing/i.test((BUILTIN_TOOLS.find((tool) => tool.name === 'read')?.description) || '')) {
1737
+ if (!/Not for director/i.test((BUILTIN_TOOLS.find((tool) => tool.name === 'read')?.description) || '')) {
1735
1738
  throw new Error('read description must keep directory-vs-file guidance');
1736
1739
  }
1737
1740
  const readTool = BUILTIN_TOOLS.find((tool) => tool.name === 'read');
@@ -1742,13 +1745,13 @@ const readArrayItemAnyOf = readArraySchema?.items?.anyOf || [];
1742
1745
  if (!readArrayItemAnyOf.some((entry) => entry?.type === 'object' && entry?.properties?.offset && entry?.properties?.limit)) {
1743
1746
  throw new Error('read schema must expose array-of-region objects for batched spans');
1744
1747
  }
1745
- if (/line\+context/i.test(readDescription) || !/verified file path/i.test(readDescription) || !/guessed path.*find first/i.test(readDescription) || !/Batch paths\/regions as real arrays/i.test(readDescription)) {
1748
+ if (/line\+context/i.test(readDescription) || !/Read file contents/i.test(readDescription) || !/\{path,offset,limit\}\[\]/i.test(readDescription)) {
1746
1749
  throw new Error('read description must expose offset/limit as the single window form');
1747
1750
  }
1748
1751
  if (readProps.line || readProps.context) {
1749
1752
  throw new Error('read schema must not expose legacy line/context window fields');
1750
1753
  }
1751
- if (readProps.offset?.minimum !== 0 || !/Numeric lines to skip/i.test(readProps.offset?.description || '') || !/offset:N/i.test(readProps.offset?.description || '') || !/Numeric max lines/i.test(readProps.limit?.description || '')) {
1754
+ if (readProps.offset?.minimum !== 0 || !/Lines to skip/i.test(readProps.offset?.description || '') || !/Max lines/i.test(readProps.limit?.description || '')) {
1752
1755
  throw new Error('read offset schema must describe Mixdog paging cursor semantics');
1753
1756
  }
1754
1757
  if (/line\/context/i.test(JSON.stringify(readTool?.inputSchema || {}))) {
@@ -1839,21 +1842,18 @@ if (codeGraphSymbolSearchErr) {
1839
1842
  // symbol/definition/caller lookups AWAY from repeated grep (the grep_retry +
1840
1843
  // find_symbol_noscope anti-patterns). It is allowed to be verbose enough to
1841
1844
  // enumerate modes, but must not drift into web-search territory.
1842
- if (!/code structure\/flow/i.test(codeGraphDescription) || !/symbols\/references\/calls\/deps/i.test(codeGraphDescription)) {
1845
+ if (!/Repo code structure/i.test(codeGraphDescription) || !/find_symbol\/symbol_search\/search\/references\/callers\/callees/i.test(codeGraphDescription)) {
1843
1846
  throw new Error('code_graph description must stay structure-oriented and name its symbol modes');
1844
1847
  }
1845
- if (!/files\[\] must be verified paths/i.test(codeGraphDescription) || !/Batch symbols\[\]\/files\[\]/i.test(codeGraphDescription)) {
1848
+ if (!/take files\[\]/i.test(codeGraphDescription) || !/Batch targets per mode/i.test(codeGraphDescription)) {
1846
1849
  throw new Error('code_graph description must route unknown file paths through locators first');
1847
1850
  }
1848
- if (!/repo-local/i.test(codeGraphDescription) || !/NOT web search|not web/i.test(codeGraphDescription)) {
1849
- throw new Error('code_graph description must mark itself repo-local (not web search)');
1850
- }
1851
- if (!/repo-local|not web/i.test(codeGraphProps.mode?.description || '') || !/Verified source file/i.test(codeGraphProps.files?.description || '')) {
1851
+ if (!/files\[\]/i.test(codeGraphProps.mode?.description || '') || !/Source file path/i.test(codeGraphProps.files?.description || '')) {
1852
1852
  throw new Error('code_graph schema must keep compact, repo-local field descriptions');
1853
1853
  }
1854
1854
  const recallTool = MEMORY_TOOL_DEFS.find((tool) => tool.name === 'recall');
1855
1855
  const recallProps = recallTool?.inputSchema?.properties || {};
1856
- if (!/Call when a task ties to prior work/i.test(recallTool?.description || '') || !recallProps.id?.anyOf || !/Do not invent ids/i.test(recallProps.id?.description || '')) {
1856
+ if (!/prior-work context/i.test(recallTool?.description || '') || !recallProps.id?.anyOf || !/Do not invent ids/i.test(recallProps.id?.description || '')) {
1857
1857
  throw new Error('recall schema must preserve scoped prior-context guidance and id lookup shape');
1858
1858
  }
1859
1859
  if (!/array for independent fan-out/i.test(recallProps.query?.description || '') || !/Project pool selector/i.test(recallProps.projectScope?.description || '')) {
@@ -2155,13 +2155,13 @@ const grepPathDescription = grepTool?.inputSchema?.properties?.path?.description
2155
2155
  const grepGlobDescription = grepTool?.inputSchema?.properties?.glob?.description || '';
2156
2156
  const grepOutputModeDescription = grepTool?.inputSchema?.properties?.output_mode?.description || '';
2157
2157
  const grepHeadLimitDescription = grepTool?.inputSchema?.properties?.head_limit?.description || '';
2158
- if (!/Array = variants in one call/i.test(grepPatternDescription) || !/Verified file\/dir/i.test(grepPathDescription)) {
2158
+ if (!/pattern\[\] batches variants/i.test(grepPatternDescription) || !/File\/dir scope/i.test(grepPathDescription)) {
2159
2159
  throw new Error('grep schema must keep compact pattern/path guidance');
2160
2160
  }
2161
- if (!/verified scope/i.test(grepTool?.description || '') || !/guessed path fragment.*find first/i.test(grepTool?.description || '')) {
2162
- throw new Error('grep description must require verified scopes and locator-first unknown paths');
2161
+ if (!/content search/i.test(grepTool?.description || '')) {
2162
+ throw new Error('grep description must state its content-search contract');
2163
2163
  }
2164
- if (!/Glob filter/i.test(grepGlobDescription) || !/no guessed src\/\*\*/i.test(grepGlobDescription)) {
2164
+ if (!/Glob filter/i.test(grepGlobDescription)) {
2165
2165
  throw new Error('grep glob schema must describe scope narrowing');
2166
2166
  }
2167
2167
  if (!/files_with_matches\/count/i.test(grepOutputModeDescription) || !/content_with_context/i.test(grepOutputModeDescription)) {
@@ -2176,16 +2176,16 @@ if (grepTool?.inputSchema?.properties?.type) {
2176
2176
  const globTool = BUILTIN_TOOLS.find((tool) => tool.name === 'glob');
2177
2177
  const findTool = BUILTIN_TOOLS.find((tool) => tool.name === 'find');
2178
2178
  const listTool = BUILTIN_TOOLS.find((tool) => tool.name === 'list');
2179
- if (!/exact glob from verified roots/i.test(globTool?.description || '')) {
2179
+ if (!/exact glob patterns/i.test(globTool?.description || '')) {
2180
2180
  throw new Error('glob description must route exact-pattern unknown paths before read/grep/list');
2181
2181
  }
2182
- if (!/Partial path\/name lookup/i.test(findTool?.description || '') || !/verify roots before grep\/glob/i.test(findTool?.description || '')) {
2182
+ if (!/unknown partial paths\/names/i.test(findTool?.description || '') || !/verifies paths for grep\/glob/i.test(findTool?.description || '')) {
2183
2183
  throw new Error('find description must advertise unverified path/name lookup and verified outputs');
2184
2184
  }
2185
- if (!/List verified directories/i.test(listTool?.description || '') || !/Guessed dir.*find first/i.test(listTool?.description || '') || !/Verified directory/i.test(listTool?.inputSchema?.properties?.path?.description || '')) {
2185
+ if (!/List directory entries/i.test(listTool?.description || '') || !/path\[\]/i.test(listTool?.inputSchema?.properties?.path?.description || '')) {
2186
2186
  throw new Error('list description must require verified directories and locator-first unknown dirs');
2187
2187
  }
2188
- if (!/Repo-local/i.test(codeGraphProps.mode?.description || '') || !/one call/i.test(codeGraphProps.symbols?.description || '')) {
2188
+ if (!/symbol modes use symbols\[\]/i.test(codeGraphProps.mode?.description || '') || !/symbols \(file outline\) use files\[\]/i.test(codeGraphProps.mode?.description || '') || !/one symbols\[\] call/i.test(codeGraphProps.symbols?.description || '')) {
2189
2189
  throw new Error('code_graph schema fields must stay compact and repo-local');
2190
2190
  }
2191
2191
 
@@ -20,6 +20,11 @@ import {
20
20
  appendAgentResponseTail,
21
21
  formatAgentResponseRaw,
22
22
  } from '../src/tui/engine/agent-response-tail.mjs';
23
+ import {
24
+ agentArgsWithResultMetadata,
25
+ parseAgentJob,
26
+ } from '../src/tui/engine/agent-envelope.mjs';
27
+ import { agentActionTitle } from '../src/tui/components/tool-execution/surface-detail.mjs';
23
28
 
24
29
  test('tool action copy keeps Add/Delete patch verbs and human read offsets', () => {
25
30
  assert.equal(
@@ -42,6 +47,69 @@ test('Agent aggregation is batch-scoped and keeps category action wording', () =
42
47
  );
43
48
  });
44
49
 
50
+ test('agent spawn cards retain resolved model metadata from split result envelopes', () => {
51
+ const parsed = parseAgentJob([
52
+ 'agent task: task_worker_1',
53
+ 'status: running',
54
+ 'type: spawn',
55
+ 'target: worker sess_worker_1',
56
+ 'agent: worker',
57
+ 'provider: openai-oauth',
58
+ 'model: gpt-5.6-terra',
59
+ 'preset: agent-worker',
60
+ 'effort: high',
61
+ ].join('\n'));
62
+ const args = agentArgsWithResultMetadata({ type: 'spawn', agent: 'worker' }, parsed);
63
+
64
+ assert.equal(args.tag, 'worker');
65
+ assert.equal(args.provider, 'openai-oauth');
66
+ assert.equal(args.model, 'gpt-5.6-terra');
67
+ assert.equal(args.effort, 'high');
68
+ assert.equal(agentActionTitle(args), 'Spawn Worker (GPT-5.6-Terra, worker)');
69
+ });
70
+
71
+ test('agent model enrichment preserves explicit args and combined envelopes', () => {
72
+ const combined = parseAgentJob([
73
+ 'agent task: task_worker_2',
74
+ 'type: spawn',
75
+ 'model: openai-oauth/gpt-5.6-terra',
76
+ ].join('\n'));
77
+ assert.equal(combined.provider, 'openai-oauth');
78
+ assert.equal(combined.model, 'gpt-5.6-terra');
79
+
80
+ const split = parseAgentJob([
81
+ 'agent task: task_worker_3',
82
+ 'provider: openai-oauth',
83
+ 'model: gpt-5.6-terra',
84
+ ].join('\n'));
85
+ const explicit = agentArgsWithResultMetadata({
86
+ provider: 'anthropic',
87
+ model: 'claude-opus-4-6',
88
+ }, split);
89
+ assert.equal(explicit.provider, 'anthropic');
90
+ assert.equal(explicit.model, 'claude-opus-4-6');
91
+ });
92
+
93
+ test('non-agent task diagnostics do not enrich model args', () => {
94
+ const shellDiagnostics = parseAgentJob([
95
+ 'task_id: shell_task_1',
96
+ 'provider: openai-oauth',
97
+ 'model: gpt-5.6-terra',
98
+ ].join('\n'));
99
+ const args = agentArgsWithResultMetadata({ type: 'shell' }, shellDiagnostics);
100
+
101
+ assert.equal(args.provider, undefined);
102
+ assert.equal(args.model, undefined);
103
+
104
+ const shellCombinedDiagnostics = parseAgentJob([
105
+ 'task_id: shell_task_2',
106
+ 'model: openai-oauth/gpt-5.6-terra',
107
+ ].join('\n'));
108
+ const combinedArgs = agentArgsWithResultMetadata({ type: 'shell' }, shellCombinedDiagnostics);
109
+ assert.equal(combinedArgs.provider, undefined);
110
+ assert.equal(combinedArgs.model, undefined);
111
+ });
112
+
45
113
  test('terminal result statuses stay separate from tool-call failure accounting', () => {
46
114
  assert.equal(normalizeToolTerminalStatus('denied'), 'denied');
47
115
  assert.equal(toolResultTerminalStatus('[status: cancelled]\n'), 'cancelled');
@@ -44,23 +44,32 @@ let commit = 0;
44
44
  const identity = (value) => value;
45
45
  const noop = () => {};
46
46
 
47
- function Harness({ text, step }) {
48
- const [scrollOffset, setScrollOffset] = React.useState(INITIAL_SCROLL);
47
+ function Harness({
48
+ text,
49
+ step,
50
+ initialScroll = INITIAL_SCROLL,
51
+ streamId = STREAM_ID,
52
+ releasedSelection = null,
53
+ onPaint = noop,
54
+ onFrame = noop,
55
+ recordFrame = true,
56
+ }) {
57
+ const [scrollOffset, setScrollOffset] = React.useState(initialScroll);
49
58
  const [measuredRowsVersion, setMeasuredRowsVersion] = React.useState(0);
50
59
  const transcriptAnchorRef = React.useRef(null);
51
60
  const transcriptAnchorDirtyRef = React.useRef(true);
52
- const scrollTargetRef = React.useRef(INITIAL_SCROLL);
53
- const scrollPositionRef = React.useRef(INITIAL_SCROLL);
61
+ const scrollTargetRef = React.useRef(initialScroll);
62
+ const scrollPositionRef = React.useRef(initialScroll);
54
63
  const maxScrollRowsRef = React.useRef(0);
55
64
  const transcriptGeomRef = React.useRef({});
56
65
  const followingRef = React.useRef(false);
57
- const dragRef = React.useRef({ active: false, rect: null });
66
+ const dragRef = React.useRef({ active: false, rect: releasedSelection });
58
67
  const transcriptViewportRef = React.useRef({ top: 0 });
59
68
  const selectionLayoutRef = React.useRef(null);
60
69
  const contentRef = React.useRef(null);
61
70
  const tailRef = React.useRef(null);
62
71
  const streamingTail = React.useMemo(() => ({
63
- id: STREAM_ID,
72
+ id: streamId,
64
73
  kind: 'assistant',
65
74
  text,
66
75
  streaming: true,
@@ -100,7 +109,7 @@ function Harness({ text, step }) {
100
109
  transcriptViewportRef,
101
110
  selectionLayoutRef,
102
111
  withSelectionClip: identity,
103
- paintSelectionRect: noop,
112
+ paintSelectionRect: onPaint,
104
113
  stopSmoothScroll: noop,
105
114
  measuredRowsVersion,
106
115
  setMeasuredRowsVersion,
@@ -121,7 +130,7 @@ function Harness({ text, step }) {
121
130
  const renderScrollOffset = transcriptWindow.effectiveScrollOffset;
122
131
  const visibleTopIndexed = transcriptWindow.totalRows - renderScrollOffset - VIEW_ROWS;
123
132
  const visibleTopPhysical = physicalRows - renderScrollOffset - VIEW_ROWS;
124
- frames.push({
133
+ const frame = {
125
134
  commit: ++commit,
126
135
  step,
127
136
  char: text.at(-1) === '\n' ? '\\n' : (text.at(-1) || ''),
@@ -145,7 +154,9 @@ function Harness({ text, step }) {
145
154
  scrollTarget: scrollTargetRef.current,
146
155
  following: followingRef.current,
147
156
  anchor: transcriptAnchorRef.current?.id || '-',
148
- });
157
+ };
158
+ if (recordFrame) frames.push(frame);
159
+ onFrame(frame);
149
160
  }, [step, text, measuredRowsVersion, transcriptWindow.totalRows, transcriptWindow.effectiveScrollOffset,
150
161
  transcriptAnchorRef, transcriptGeomRef]);
151
162
 
@@ -159,7 +170,7 @@ function Harness({ text, step }) {
159
170
  marginBottom={-transcriptWindow.effectiveScrollOffset}
160
171
  >
161
172
  {renderedTranscriptItems.map((item, index, all) => {
162
- const hookRef = item.id === STREAM_ID ? combinedTailRef : transcriptMeasureRef(item);
173
+ const hookRef = item.id === streamId ? combinedTailRef : transcriptMeasureRef(item);
163
174
  return (
164
175
  <Box key={item.id} ref={hookRef} flexDirection="column" flexShrink={0}>
165
176
  <Item
@@ -295,6 +306,76 @@ if (dips.length > 0) {
295
306
  if (suppressValues.size !== 1 || !suppressValues.has(true)) {
296
307
  throw new Error('repro unexpectedly toggled suppressMeasuredRowHeights');
297
308
  }
309
+
310
+ const SELECTION_STREAM_ID = 'released-selection-tail';
311
+ const releasedSelection = { mode: 'linear', x1: 0, y1: 3, x2: 4, y2: 3 };
312
+ const selectionHarvests = [];
313
+ const selectionFrames = [];
314
+ streamingMeasuredRowsById.delete(SELECTION_STREAM_ID);
315
+ const selectionInstance = render(
316
+ <Harness
317
+ text="seed"
318
+ step={0}
319
+ initialScroll={1}
320
+ streamId={SELECTION_STREAM_ID}
321
+ releasedSelection={releasedSelection}
322
+ recordFrame={false}
323
+ onPaint={(rect, options) => {
324
+ if (rect && options?.rememberText === false) selectionHarvests.push({ y1: rect.y1, y2: rect.y2 });
325
+ }}
326
+ onFrame={(frame) => selectionFrames.push(frame)}
327
+ />,
328
+ { stdout: fakeTty(COLUMNS, VIEW_ROWS), stderr: fakeTty(COLUMNS, VIEW_ROWS), stdin: fakeTty(COLUMNS, VIEW_ROWS), interactive: true, patchConsole: false, exitOnCtrlC: false, maxFps: 1000 },
329
+ );
330
+ await settle(selectionInstance);
331
+ const harvestsBeforeGrowth = selectionHarvests.length;
332
+ const framesBeforeGrowth = selectionFrames.length;
333
+ selectionInstance.rerender(
334
+ <Harness
335
+ text={'seed\none\nsecond\nthird'}
336
+ step={1}
337
+ initialScroll={1}
338
+ streamId={SELECTION_STREAM_ID}
339
+ releasedSelection={releasedSelection}
340
+ recordFrame={false}
341
+ onPaint={(rect, options) => {
342
+ if (rect && options?.rememberText === false) selectionHarvests.push({ y1: rect.y1, y2: rect.y2 });
343
+ }}
344
+ onFrame={(frame) => selectionFrames.push(frame)}
345
+ />,
346
+ );
347
+ await settle(selectionInstance);
348
+ selectionInstance.unmount();
349
+ await selectionInstance.waitUntilExit();
350
+ selectionInstance.cleanup();
351
+ const growthFrames = selectionFrames.slice(framesBeforeGrowth);
352
+ if (!growthFrames.some((frame) => frame.scrollTarget > 1)) {
353
+ throw new Error('released-selection repro did not apply anchored growth at offset 1');
354
+ }
355
+ const growthHarvests = selectionHarvests.slice(harvestsBeforeGrowth);
356
+ const initialRenderOffset = selectionFrames[framesBeforeGrowth - 1]?.renderScrollOffset ?? 0;
357
+ const maxGrowthRenderOffset = Math.max(
358
+ initialRenderOffset,
359
+ ...growthFrames.map((frame) => frame.renderScrollOffset),
360
+ );
361
+ const maxExpectedSelectionY = releasedSelection.y1 + maxGrowthRenderOffset - initialRenderOffset;
362
+ const doubleCountedHarvest = growthHarvests.find(
363
+ (harvest) => harvest.y1 > maxExpectedSelectionY || harvest.y2 > maxExpectedSelectionY,
364
+ );
365
+ const finalHarvest = growthHarvests.at(-1);
366
+ if (
367
+ doubleCountedHarvest
368
+ || !finalHarvest
369
+ || finalHarvest.y1 !== releasedSelection.y1
370
+ || finalHarvest.y2 !== releasedSelection.y2
371
+ ) {
372
+ throw new Error(`released selection harvested wrong row during anchored growth: ${JSON.stringify({
373
+ doubleCountedHarvest,
374
+ finalHarvest,
375
+ expectedFinal: releasedSelection,
376
+ maxExpectedSelectionY,
377
+ })}`);
378
+ }
298
379
  console.log('tui-transcript-jitter-harness: ok');
299
380
 
300
381
 
package/src/app.mjs CHANGED
@@ -3,31 +3,12 @@ import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { performance } from 'node:perf_hooks';
5
5
  import { ensureProcessListenerHeadroom } from './runtime/shared/process-listener-headroom.mjs';
6
+ import {
7
+ classifyCliInvocation,
8
+ parseHeadlessRoleCommand,
9
+ } from './headless-command.mjs';
6
10
 
7
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
- // --workflow is consumed (with its value) but ignored on the headless role
9
- // path: workflow selection is Lead-session-scoped. Accepting it keeps a
10
- // mistakenly passed `--workflow X` from leaking "X" into the role message.
11
- const VALUE_OPTIONS = new Set(['--provider', '--model', '--effort', '--workflow']);
12
- const FLAG_OPTIONS = new Set(['--readonly', '--help', '-h', '--plain', '--react', '--remote', '--onboarding']);
13
- const HEADLESS_FLAG_OPTIONS = new Set(['--fast']);
14
- const HEADLESS_ROLE_ALIASES = new Map([
15
- ['explorer', 'explore'],
16
- ['explore', 'explore'],
17
- ['maint', 'maintainer'],
18
- ['maintenance', 'maintainer'],
19
- ['maintainer', 'maintainer'],
20
- ['worker', 'worker'],
21
- ['heavy', 'heavy-worker'],
22
- ['heavyworker', 'heavy-worker'],
23
- ['heavy-worker', 'heavy-worker'],
24
- ['review', 'reviewer'],
25
- ['reviewer', 'reviewer'],
26
- ['debug', 'debugger'],
27
- ['debugger', 'debugger'],
28
- ['web', 'web-researcher'],
29
- ['web-researcher', 'web-researcher'],
30
- ]);
31
12
  const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ''));
32
13
  const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
33
14
 
@@ -49,55 +30,7 @@ function bootProfile(event, fields = {}) {
49
30
  try { process.stderr.write(`${parts.join(' ')}\n`); } catch {}
50
31
  }
51
32
 
52
- function unknownOption(argv) {
53
- for (let i = 0; i < argv.length; i += 1) {
54
- const arg = argv[i];
55
- if (VALUE_OPTIONS.has(arg)) {
56
- i += 1;
57
- continue;
58
- }
59
- if (FLAG_OPTIONS.has(arg)) continue;
60
- if (HEADLESS_FLAG_OPTIONS.has(arg)) continue;
61
- if (String(arg || '').startsWith('-')) return arg;
62
- }
63
- return null;
64
- }
65
-
66
- function positionalArgs(argv) {
67
- const out = [];
68
- for (let i = 0; i < argv.length; i += 1) {
69
- const arg = argv[i];
70
- if (VALUE_OPTIONS.has(arg)) {
71
- i += 1;
72
- continue;
73
- }
74
- if (FLAG_OPTIONS.has(arg)) continue;
75
- if (HEADLESS_FLAG_OPTIONS.has(arg)) continue;
76
- if (String(arg || '').startsWith('-')) continue;
77
- out.push(arg);
78
- }
79
- return out;
80
- }
81
-
82
- function normalizeHeadlessAgent(value) {
83
- const key = String(value || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
84
- return HEADLESS_ROLE_ALIASES.get(key) || null;
85
- }
86
-
87
- export function parseHeadlessRoleCommand(argv = []) {
88
- const args = positionalArgs(argv);
89
- if (args.length === 0) return null;
90
-
91
- if (String(args[0] || '').toLowerCase() === 'role') {
92
- return { error: 'usage: mixdog <role> <message...>' };
93
- }
94
-
95
- const agent = normalizeHeadlessAgent(args[0]);
96
- if (!agent) return null;
97
- const message = args.slice(1).join(' ').trim();
98
- if (!message) return { error: `usage: mixdog ${args[0]} <message...>` };
99
- return { agent, message };
100
- }
33
+ export { parseHeadlessRoleCommand };
101
34
 
102
35
  /**
103
36
  * mixdog launcher.
@@ -107,57 +40,49 @@ export function parseHeadlessRoleCommand(argv = []) {
107
40
  * from this entry point.
108
41
  */
109
42
  export async function run(argv = []) {
110
- bootProfile('run:start', { argv: argv.join(',') });
111
- const badOption = unknownOption(argv);
112
- if (badOption) {
113
- process.stderr.write(`mixdog: unknown option ${badOption}\n`);
43
+ const invocation = classifyCliInvocation(argv);
44
+ // Headless commands establish their own audited environment after route
45
+ // validation; do not let an inherited MIXDOG_BOOT_PROFILE affect that path.
46
+ if (invocation.kind !== 'headless' && invocation.skipHostPrelude !== true) {
47
+ bootProfile('run:start', { argv: argv.join(',') });
48
+ }
49
+ if (invocation.kind === 'error') {
50
+ process.stderr.write(`mixdog: ${invocation.error}\n`);
114
51
  return 1;
115
52
  }
116
-
117
- const provIdx = argv.indexOf('--provider');
118
- const modelIdx = argv.indexOf('--model');
119
- const effortIdx = argv.indexOf('--effort');
120
- const toolMode = argv.includes('--readonly') ? 'readonly' : 'full';
121
- const remote = argv.includes('--remote');
122
- const forceOnboarding = argv.includes('--onboarding');
123
- const opts = {
124
- provider: provIdx >= 0 ? argv[provIdx + 1] : undefined,
125
- model: modelIdx >= 0 ? argv[modelIdx + 1] : undefined,
126
- effort: effortIdx >= 0 ? argv[effortIdx + 1] : undefined,
127
- fast: argv.includes('--fast'),
128
- toolMode,
129
- remote,
130
- forceOnboarding,
131
- };
53
+ const opts = invocation.options;
132
54
 
133
55
  // `--help` / `-h`: keep this path tiny; do not import the REPL/runtime stack.
134
- if (argv.includes('--help') || argv.includes('-h')) {
56
+ if (invocation.kind === 'help') {
135
57
  const { printHelp } = await import('./help.mjs');
136
58
  printHelp();
137
59
  return 0;
138
60
  }
139
61
 
140
62
  // `--plain`: the OLD readline REPL, kept as a strangler fallback.
141
- if (argv.includes('--plain')) {
63
+ if (invocation.kind === 'plain') {
142
64
  const { runRepl } = await import('./repl.mjs');
143
65
  return await runRepl(opts);
144
66
  }
145
67
 
146
- if (argv.includes('--react')) {
68
+ if (invocation.kind === 'react') {
147
69
  process.stderr.write('mixdog: --react was removed; run `mixdog` for the canonical TUI.\n');
148
70
  return 1;
149
71
  }
150
72
 
151
- const headless = parseHeadlessRoleCommand(argv);
152
- if (headless?.error) {
153
- process.stderr.write(`mixdog: ${headless.error}\n`);
154
- return 1;
155
- }
156
- if (headless) {
73
+ if (invocation.kind === 'headless') {
74
+ const { validateExplicitPristineRoute } = await import(
75
+ './runtime/shared/pristine-execution.mjs'
76
+ );
77
+ const routeError = validateExplicitPristineRoute(opts);
78
+ if (routeError) {
79
+ process.stderr.write(`mixdog: ${routeError}\n`);
80
+ return 1;
81
+ }
157
82
  const { runHeadlessRole } = await import('./headless-role.mjs');
158
83
  return await runHeadlessRole({
159
- agent: headless.agent,
160
- message: headless.message,
84
+ agent: invocation.headless.agent,
85
+ message: invocation.headless.message,
161
86
  provider: opts.provider,
162
87
  model: opts.model,
163
88
  effort: opts.effort,
package/src/cli.mjs CHANGED
@@ -1,23 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { fileURLToPath } from 'node:url';
4
- import { performPendingSwap, registerLiveSession } from './runtime/shared/staged-update.mjs';
4
+ import { classifyCliInvocation } from './headless-command.mjs';
5
5
 
6
6
  const argv = process.argv.slice(2);
7
7
 
8
- // Pre-import self-update swap: BEFORE any runtime module is imported, if a
9
- // completed staged newer version exists and no other mixdog session is live,
10
- // atomically swap the global package dir into place. Doing it here (and only
11
- // here) means the process goes on to load the NEW files, and a live session
12
- // never has its files overwritten mid-run. Any obstacle → false, run current.
13
8
  let swapped = false;
14
- try { swapped = performPendingSwap(); } catch { swapped = false; }
15
-
16
- // Register this process in the live-session refcount BEFORE any runtime module
17
- // loads, so a concurrently-launching mixdog's liveness check can see us and
18
- // defer its own swap. Unregister is hooked on process 'exit' (and on graceful
19
- // close). Harmless for the swap decision above — our own pid is excluded.
20
- try { registerLiveSession(); } catch { /* advisory refcount only */ }
9
+ const invocation = classifyCliInvocation(argv);
10
+ const skipHostPrelude = invocation.kind === 'headless' || invocation.skipHostPrelude === true;
11
+ if (!skipHostPrelude) {
12
+ // Interactive/general sessions retain the staged-update and live-session
13
+ // semantics. Headless role commands skip both because those helpers touch the
14
+ // host data tree before the pristine boundary exists.
15
+ try {
16
+ const { performPendingSwap, registerLiveSession } = await import(
17
+ './runtime/shared/staged-update.mjs'
18
+ );
19
+ swapped = performPendingSwap();
20
+ try { registerLiveSession(); } catch { /* advisory refcount only */ }
21
+ } catch {
22
+ swapped = false;
23
+ }
24
+ }
21
25
 
22
26
  async function main() {
23
27
  // If we actually swapped, re-exec a fresh node process so the new package
@@ -23,7 +23,9 @@ CORE is durable standing knowledge that lands in one of three layers:
23
23
  - **L3 — Current map:** one-line project-landscape summaries, live long-running
24
24
  goals, environment anchors documented nowhere else.
25
25
 
26
- Every entry should be ONE short clause (≤120 chars). CORE is not a log.
26
+ Every entry must be ONE compact ENGLISH clause (≤120 chars), regardless of
27
+ source language. Keep identifiers, paths, and exact phrases coined by the user
28
+ verbatim. CORE is not a log.
27
29
 
28
30
  ## The distinction that decides every verdict
29
31
 
@@ -48,8 +50,10 @@ or inconclusive, keep the CORE entry.
48
50
 
49
51
  ## Verdicts
50
52
 
51
- - `keep` — durable and already one short clause.
52
- - `update` — durable but verbose or multi-sentence → rewrite as one ≤120-char clause.
53
+ - `keep` — durable and already one compact ENGLISH clause.
54
+ - `update` — durable but verbose, multi-sentence, or non-English → rewrite as
55
+ one compact ≤120-char ENGLISH clause; keep identifiers, paths, and exact
56
+ phrases coined by the user verbatim.
53
57
  - `merge` — duplicates another entry → fold into the survivor (same project pool).
54
58
  - `reclassify` — the entry is filed under the WRONG project pool. Its subject
55
59
  clearly belongs to a different pool shown among the entries under review: a
@@ -81,7 +85,9 @@ or inconclusive, keep the CORE entry.
81
85
  `<id>|delete` with no reason, or any reason outside that set, is treated as
82
86
  "needs confirmation" and only removed on an explicit APPLY CYCLE3 run.
83
87
 
84
- A verbose durable entry is always `update`, never `keep`.
88
+ A verbose, multi-sentence, or non-English durable entry is always `update`,
89
+ never `keep`; rewrite it as a ≤120-char English clause while keeping
90
+ identifiers, paths, and exact user-coined phrases verbatim.
85
91
  Delete is the rarest verdict. Prefer `keep` for durable rules/preferences and
86
92
  `update` for compression when the current behavior is still valid.
87
93
  Use `superseded` (not `delete`) when the entry WAS a valid durable rule/fact but
@@ -47,9 +47,10 @@ Use `why|A` for durable cross-session invariants (L1/L2). Use `why|B` for L3
47
47
  current-map entries: one-line project-landscape summaries, live long-running
48
48
  goals, and environment anchors documented nowhere else. `why` is validation
49
49
  evidence only; it is
50
- not stored. `core_summary` is injected into Core Memory, so keep it one
51
- self-contained clause, <=120 chars. For `merge`, `why` and `core` may use the
52
- survivor `target_row`.
50
+ not stored. `core_summary` is injected into Core Memory, so write it in ENGLISH
51
+ regardless of source language as one compact, self-contained clause, <=120
52
+ chars. Keep code identifiers, paths, and commands verbatim. For `merge`, `why`
53
+ and `core` may use the survivor `target_row`.
53
54
 
54
55
  Allowed primary verbs:
55
56