mixdog 0.9.43 → 0.9.44

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 (32) hide show
  1. package/package.json +3 -2
  2. package/scripts/ansi-color-capability-test.mjs +90 -0
  3. package/scripts/generate-runtime-manifest.mjs +39 -22
  4. package/scripts/grok-oauth-refresh-race-test.mjs +198 -0
  5. package/scripts/internal-comms-smoke.mjs +24 -7
  6. package/scripts/maintenance-default-routes-test.mjs +164 -0
  7. package/scripts/openai-oauth-ws-1006-retry-test.mjs +123 -0
  8. package/scripts/routing-corpus.mjs +1 -1
  9. package/scripts/shell-jobs-windows-hide-test.mjs +164 -0
  10. package/scripts/tui-transcript-jitter-harness-entry.jsx +302 -0
  11. package/scripts/tui-transcript-jitter-harness.mjs +58 -0
  12. package/src/agents/reviewer/AGENT.md +5 -7
  13. package/src/rules/lead/lead-brief.md +5 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +40 -3
  15. package/src/runtime/agent/orchestrator/config.mjs +29 -14
  16. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +46 -21
  17. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -1
  18. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -5
  19. package/src/runtime/memory/data/runtime-manifest.json +11 -11
  20. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -2
  21. package/src/standalone/agent-tool.mjs +65 -50
  22. package/src/standalone/explore-tool.mjs +17 -16
  23. package/src/tui/app/provider-setup-picker.mjs +1 -0
  24. package/src/tui/app/use-transcript-window.mjs +5 -1
  25. package/src/tui/components/StatusLine.jsx +7 -6
  26. package/src/tui/dist/index.mjs +213 -77
  27. package/src/tui/engine/turn.mjs +1 -1
  28. package/src/tui/index.jsx +3 -2
  29. package/src/tui/markdown/streaming-markdown.mjs +35 -9
  30. package/src/tui/statusline-ansi-bridge.mjs +17 -7
  31. package/src/ui/ansi.mjs +85 -5
  32. package/src/ui/statusline-format.mjs +7 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.43",
3
+ "version": "0.9.44",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -58,8 +58,9 @@
58
58
  "test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
59
59
  "test:providers": "node --test scripts/provider-toolcall-test.mjs",
60
60
  "test:anthropic-oauth-race": "node --test scripts/anthropic-oauth-refresh-race-test.mjs",
61
+ "test:grok-oauth-race": "node --test scripts/grok-oauth-refresh-race-test.mjs",
61
62
  "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
62
- "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/memory-rule-contract-test.mjs",
63
+ "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/memory-rule-contract-test.mjs scripts/maintenance-default-routes-test.mjs",
63
64
  "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs",
64
65
  "test:session": "node --test scripts/session-orphan-sweep-test.mjs",
65
66
  "test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
@@ -0,0 +1,90 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ brightGreen,
6
+ refreshColorSupport,
7
+ rgb,
8
+ rgbBg,
9
+ rgbSgr,
10
+ rgbToAnsi256,
11
+ supportsTruecolor,
12
+ } from '../src/ui/ansi.mjs';
13
+ import {
14
+ remapCanonicalStatuslineTruecolor,
15
+ STATUSLINE_CANONICAL_TRUECOLOR,
16
+ } from '../src/tui/statusline-ansi-bridge.mjs';
17
+
18
+ const savedEnv = {
19
+ COLORTERM: process.env.COLORTERM,
20
+ FORCE_COLOR: process.env.FORCE_COLOR,
21
+ NO_COLOR: process.env.NO_COLOR,
22
+ TERM: process.env.TERM,
23
+ TERM_PROGRAM: process.env.TERM_PROGRAM,
24
+ WT_SESSION: process.env.WT_SESSION,
25
+ };
26
+
27
+ function restoreEnv() {
28
+ for (const [key, value] of Object.entries(savedEnv)) {
29
+ if (value === undefined) delete process.env[key];
30
+ else process.env[key] = value;
31
+ }
32
+ refreshColorSupport();
33
+ }
34
+
35
+ test.after(restoreEnv);
36
+
37
+ test('truecolor capability detection preserves known positives and defaults unknown to truecolor', () => {
38
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'Apple_Terminal', COLORTERM: 'truecolor' }, 'darwin'), false);
39
+ assert.equal(supportsTruecolor({ COLORTERM: 'truecolor' }, 'darwin'), true);
40
+ assert.equal(supportsTruecolor({ COLORTERM: '24bit' }, 'linux'), true);
41
+ assert.equal(supportsTruecolor({ WT_SESSION: 'abc' }, 'linux'), true);
42
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'iTerm.app' }, 'darwin'), true);
43
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'WezTerm' }, 'darwin'), true);
44
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'ghostty' }, 'darwin'), true);
45
+ assert.equal(supportsTruecolor({ TERM_PROGRAM: 'vscode' }, 'darwin'), true);
46
+ assert.equal(supportsTruecolor({ TERM: 'xterm-direct' }, 'linux'), true);
47
+ assert.equal(supportsTruecolor({}, 'win32'), true);
48
+ assert.equal(supportsTruecolor({}, 'linux'), true);
49
+ });
50
+
51
+ test('RGB helpers are byte-identical in truecolor mode and downsample foreground/background on Apple Terminal', () => {
52
+ process.env.FORCE_COLOR = '1';
53
+ delete process.env.NO_COLOR;
54
+ process.env.COLORTERM = 'truecolor';
55
+ delete process.env.TERM_PROGRAM;
56
+ refreshColorSupport();
57
+
58
+ assert.equal(rgb(215, 119, 87)('x'), '\x1b[38;2;215;119;87mx\x1b[0m');
59
+ assert.equal(rgbBg(55, 55, 55)('x'), '\x1b[48;2;55;55;55mx\x1b[0m');
60
+
61
+ process.env.TERM_PROGRAM = 'Apple_Terminal';
62
+ assert.equal(rgbSgr(215, 119, 87), '\x1b[38;5;173m');
63
+ assert.equal(rgb(215, 119, 87)('x'), '\x1b[38;5;173mx\x1b[0m');
64
+ assert.equal(rgbBg(55, 55, 55)('x'), '\x1b[48;5;237mx\x1b[0m');
65
+ assert.equal(brightGreen('x'), '\x1b[38;5;35mx\x1b[0m');
66
+ });
67
+
68
+ test('RGB to ANSI-256 conversion selects the nearest cube or grayscale entry', () => {
69
+ assert.equal(rgbToAnsi256(0, 170, 75), 35);
70
+ assert.equal(rgbToAnsi256(198, 198, 198), 251);
71
+ assert.equal(rgbToAnsi256(136, 136, 136), 102);
72
+ assert.equal(rgbToAnsi256(255, 255, 255), 231);
73
+ });
74
+
75
+ test('statusline theme remap recognizes canonical 256-color variants', () => {
76
+ const colors = {
77
+ STATUS: '<status>',
78
+ SUBTLE: '<subtle>',
79
+ SUCCESS: '<success>',
80
+ WARNING: '<warning>',
81
+ ERROR: '<error>',
82
+ };
83
+ const success256 = `\x1b[38;5;${rgbToAnsi256(...STATUSLINE_CANONICAL_TRUECOLOR.success)}m`;
84
+ const warning256 = `\x1b[38;5;${rgbToAnsi256(...STATUSLINE_CANONICAL_TRUECOLOR.warningBright)}m`;
85
+ const input = `${success256}ok ${warning256}warn`;
86
+ assert.equal(
87
+ remapCanonicalStatuslineTruecolor(input, colors),
88
+ '<success>ok <warning>warn',
89
+ );
90
+ });
@@ -3,13 +3,13 @@
3
3
  // Aggregator step run after all matrix build jobs succeed.
4
4
  // Reads release assets via gh CLI, filters mixdog-runtime-*.tar.gz,
5
5
  // fetches sha256 from .sha256 sidecar files, and emits runtime-manifest.json
6
- // to both dist/ and src/runtime/memory/data/ (for the auto-PR).
6
+ // to both dist/ and src/runtime/memory/data/ (for the workflow branch sync).
7
7
  //
8
8
  // Environment:
9
9
  // GITHUB_TOKEN — PAT or GITHUB_TOKEN secret with contents:read
10
10
  // RELEASE_TAG — e.g. "runtime-v0.4.0" (falls back to process.argv[2])
11
11
  // GITHUB_REPOSITORY — auto-set by Actions (e.g. "owner/repo")
12
- // RUNTIME_RELEASE_REPOSITORY — override repo for gh commands (legacy trib-plugin/mixdog)
12
+ // RUNTIME_RELEASE_REPOSITORY — override repo for gh commands
13
13
 
14
14
  import { execSync } from 'node:child_process';
15
15
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -77,23 +77,23 @@ for (const t of tarballs) {
77
77
  async function fetchSha256(sha256AssetName, _downloadUrl) {
78
78
  // Try matching .sha256 sidecar from the asset list first
79
79
  const sidecar = assets.find(a => a.name === sha256AssetName);
80
- if (sidecar) {
81
- // Download the sidecar file text
82
- try {
83
- const tmpDir = resolve(tmpdir(), 'mixdog-manifest-sha256');
84
- mkdirSync(tmpDir, { recursive: true });
85
- execSync(
86
- `gh release download ${RELEASE_TAG} --repo ${REPO} --pattern ${sha256AssetName} --dir "${tmpDir}" --clobber`,
87
- { encoding: 'utf8', env: process.env }
88
- );
89
- const content = readFileSync(resolve(tmpDir, sha256AssetName), 'utf8').trim();
90
- // Format: "<hex> <filename>"
91
- return content.split(/\s+/)[0];
92
- } catch (e) {
93
- console.warn(`Could not download sidecar ${sha256AssetName}: ${e.message}`);
94
- }
80
+ if (!sidecar) {
81
+ throw new Error(`Missing required checksum sidecar: ${sha256AssetName}`);
95
82
  }
96
- return 'TBD'; // fallback if sidecar not present
83
+
84
+ const tmpDir = resolve(tmpdir(), 'mixdog-manifest-sha256');
85
+ mkdirSync(tmpDir, { recursive: true });
86
+ execSync(
87
+ `gh release download ${RELEASE_TAG} --repo ${REPO} --pattern ${sha256AssetName} --dir "${tmpDir}" --clobber`,
88
+ { encoding: 'utf8', env: process.env }
89
+ );
90
+ const content = readFileSync(resolve(tmpDir, sha256AssetName), 'utf8').trim();
91
+ // Format: "<hex> <filename>"
92
+ const checksum = content.split(/\s+/)[0];
93
+ if (!/^[a-f0-9]{64}$/i.test(checksum)) {
94
+ throw new Error(`Invalid checksum in sidecar ${sha256AssetName}: ${checksum}`);
95
+ }
96
+ return checksum.toLowerCase();
97
97
  }
98
98
 
99
99
  // ---------------------------------------------------------------------------
@@ -118,12 +118,29 @@ for (const tarball of tarballs) {
118
118
  };
119
119
  }
120
120
 
121
- // Ensure all expected platforms are present (fill missing with TBD)
121
+ // A partial release must never produce a publishable or bundled manifest.
122
122
  const EXPECTED = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64'];
123
+ const missing = EXPECTED.filter(p => !assetsMap[p]);
124
+ if (missing.length > 0) {
125
+ console.error(`Missing required runtime asset(s): ${missing.join(', ')}`);
126
+ process.exit(1);
127
+ }
128
+
123
129
  for (const p of EXPECTED) {
124
- if (!assetsMap[p]) {
125
- console.warn(`WARNING: No asset found for platform ${p} — filling with TBD`);
126
- assetsMap[p] = { url: 'TBD', sha256: 'TBD', size: 0 };
130
+ const asset = assetsMap[p];
131
+ let url;
132
+ try {
133
+ url = new URL(asset.url);
134
+ } catch {
135
+ console.error(`Invalid asset URL for ${p}: ${asset.url}`);
136
+ process.exit(1);
137
+ }
138
+ if (url.protocol !== 'https:' ||
139
+ !/^[a-f0-9]{64}$/.test(asset.sha256) ||
140
+ !Number.isSafeInteger(asset.size) ||
141
+ asset.size <= 0) {
142
+ console.error(`Invalid release metadata for ${p}: ${JSON.stringify(asset)}`);
143
+ process.exit(1);
127
144
  }
128
145
  }
129
146
 
@@ -0,0 +1,198 @@
1
+ import test, { after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { spawn } from 'node:child_process';
14
+ import { join } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+ import { writeJsonAtomicSync } from '../src/runtime/shared/atomic-file.mjs';
17
+
18
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-grok-race-'));
19
+ const tokenPath = join(root, 'grok-oauth.json');
20
+ const moduleUrl = new URL(
21
+ '../src/runtime/agent/orchestrator/providers/grok-oauth.mjs',
22
+ import.meta.url,
23
+ ).href;
24
+
25
+ after(() => {
26
+ rmSync(root, { recursive: true, force: true });
27
+ });
28
+
29
+ function writeTokens(accessToken, refreshToken, expiresAt) {
30
+ writeJsonAtomicSync(tokenPath, {
31
+ access_token: accessToken,
32
+ refresh_token: refreshToken,
33
+ expires_at: expiresAt,
34
+ token_endpoint: 'https://auth.x.ai/oauth/token',
35
+ }, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
36
+ }
37
+
38
+ function waitForExit(child, label) {
39
+ return new Promise((resolve, reject) => {
40
+ let stderr = '';
41
+ child.stderr.setEncoding('utf8');
42
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
43
+ child.on('error', reject);
44
+ child.on('exit', (code) => {
45
+ if (code === 0) resolve();
46
+ else reject(new Error(`${label} exited ${code}: ${stderr}`));
47
+ });
48
+ });
49
+ }
50
+
51
+ async function waitForFiles(dir, count) {
52
+ const deadline = Date.now() + 30_000;
53
+ while (readdirSync(dir).length < count) {
54
+ if (Date.now() >= deadline) throw new Error('Grok race children did not become ready');
55
+ await new Promise((resolve) => setTimeout(resolve, 20));
56
+ }
57
+ }
58
+
59
+ test('contending Grok processes adopt a refresh-only token rotation', async () => {
60
+ const readyDir = join(root, 'ready');
61
+ const exchangeDir = join(root, 'exchanges');
62
+ const resultDir = join(root, 'results');
63
+ const startPath = join(root, 'start');
64
+ mkdirSync(readyDir);
65
+ mkdirSync(exchangeDir);
66
+ mkdirSync(resultDir);
67
+ writeTokens('fixture-old-access', 'fixture-old-refresh', Date.now() + 1_000);
68
+
69
+ const childSource = `
70
+ import { writeFileSync } from 'node:fs';
71
+ const { GrokOAuthProvider } = await import(${JSON.stringify(moduleUrl)});
72
+ const provider = Object.create(GrokOAuthProvider.prototype);
73
+ provider.config = {};
74
+ provider.tokens = {
75
+ ...JSON.parse(await (await import('node:fs/promises')).readFile(process.env.TOKEN_PATH, 'utf8')),
76
+ mtimeMs: (await (await import('node:fs/promises')).stat(process.env.TOKEN_PATH)).mtimeMs,
77
+ source: 'own'
78
+ };
79
+ provider._lastDiskScan = provider.tokens.mtimeMs;
80
+ provider._refreshFallbackUntil = 0;
81
+ writeFileSync(process.env.READY_PATH, 'ready');
82
+ while (!(await (await import('node:fs/promises')).stat(process.env.START_PATH).catch(() => null))) {
83
+ await new Promise((resolve) => setTimeout(resolve, 10));
84
+ }
85
+ globalThis.fetch = async () => {
86
+ const owner = String(process.pid);
87
+ writeFileSync(process.env.EXCHANGE_DIR + '/' + owner, 'exchange', { flag: 'wx' });
88
+ await new Promise((resolve) => setTimeout(resolve, 75));
89
+ return {
90
+ ok: true,
91
+ status: 200,
92
+ async text() {
93
+ return JSON.stringify({
94
+ access_token: 'fixture-old-access',
95
+ refresh_token: 'fixture-new-refresh-' + owner,
96
+ expires_in: 600
97
+ });
98
+ }
99
+ };
100
+ };
101
+ const tokens = await provider.ensureAuth();
102
+ writeFileSync(process.env.RESULT_PATH, tokens.refresh_token);
103
+ `;
104
+
105
+ const exits = [];
106
+ for (let index = 0; index < 10; index += 1) {
107
+ const child = spawn(process.execPath, ['--input-type=module', '--eval', childSource], {
108
+ env: {
109
+ ...process.env,
110
+ MIXDOG_DATA_DIR: root,
111
+ TOKEN_PATH: tokenPath,
112
+ READY_PATH: join(readyDir, String(index)),
113
+ START_PATH: startPath,
114
+ EXCHANGE_DIR: exchangeDir,
115
+ RESULT_PATH: join(resultDir, String(index)),
116
+ },
117
+ stdio: ['ignore', 'ignore', 'pipe'],
118
+ });
119
+ exits.push(waitForExit(child, `contention child ${index}`));
120
+ }
121
+
122
+ await waitForFiles(readyDir, 10);
123
+ writeFileSync(startPath, 'start');
124
+ await Promise.all(exits);
125
+
126
+ const exchanges = readdirSync(exchangeDir);
127
+ assert.equal(exchanges.length, 1);
128
+ const expectedRefresh = `fixture-new-refresh-${exchanges[0]}`;
129
+ const persisted = JSON.parse(readFileSync(tokenPath, 'utf8'));
130
+ assert.equal(persisted.access_token, 'fixture-old-access');
131
+ assert.equal(persisted.refresh_token, expectedRefresh);
132
+ for (const result of readdirSync(resultDir)) {
133
+ assert.equal(readFileSync(join(resultDir, result), 'utf8'), expectedRefresh);
134
+ }
135
+ });
136
+
137
+ test('invalid_grant adopts a valid token written by a peer without rotating it', async () => {
138
+ const requestStarted = join(root, 'invalid-request-started');
139
+ const peerWritten = join(root, 'invalid-peer-written');
140
+ const resultPath = join(root, 'invalid-result');
141
+ writeTokens('fixture-invalid-old-access', 'fixture-invalid-old-refresh', Date.now() + 1_000);
142
+
143
+ const childSource = `
144
+ import { writeFileSync } from 'node:fs';
145
+ const { GrokOAuthProvider } = await import(${JSON.stringify(moduleUrl)});
146
+ const provider = Object.create(GrokOAuthProvider.prototype);
147
+ provider.config = {};
148
+ provider.tokens = {
149
+ ...JSON.parse(await (await import('node:fs/promises')).readFile(process.env.TOKEN_PATH, 'utf8')),
150
+ mtimeMs: (await (await import('node:fs/promises')).stat(process.env.TOKEN_PATH)).mtimeMs,
151
+ source: 'own'
152
+ };
153
+ provider._lastDiskScan = provider.tokens.mtimeMs;
154
+ provider._refreshFallbackUntil = 0;
155
+ let calls = 0;
156
+ globalThis.fetch = async () => {
157
+ calls += 1;
158
+ writeFileSync(process.env.REQUEST_STARTED, 'started');
159
+ while (!(await (await import('node:fs/promises')).stat(process.env.PEER_WRITTEN).catch(() => null))) {
160
+ await new Promise((resolve) => setTimeout(resolve, 10));
161
+ }
162
+ return {
163
+ ok: false,
164
+ status: 400,
165
+ async text() { return JSON.stringify({ error: 'invalid_grant' }); }
166
+ };
167
+ };
168
+ const tokens = await provider.ensureAuth();
169
+ writeFileSync(process.env.RESULT_PATH, JSON.stringify({
170
+ adopted: tokens.access_token === 'fixture-peer-access',
171
+ calls
172
+ }));
173
+ `;
174
+ const child = spawn(process.execPath, ['--input-type=module', '--eval', childSource], {
175
+ env: {
176
+ ...process.env,
177
+ MIXDOG_DATA_DIR: root,
178
+ TOKEN_PATH: tokenPath,
179
+ REQUEST_STARTED: requestStarted,
180
+ PEER_WRITTEN: peerWritten,
181
+ RESULT_PATH: resultPath,
182
+ },
183
+ stdio: ['ignore', 'ignore', 'pipe'],
184
+ });
185
+ const exit = waitForExit(child, 'invalid_grant child');
186
+ const deadline = Date.now() + 30_000;
187
+ while (!existsSync(requestStarted)) {
188
+ if (Date.now() >= deadline) throw new Error('invalid_grant request did not start');
189
+ await new Promise((resolve) => setTimeout(resolve, 20));
190
+ }
191
+
192
+ writeTokens('fixture-peer-access', 'fixture-peer-refresh', Date.now() + 10 * 60_000);
193
+ writeFileSync(peerWritten, 'written');
194
+ await exit;
195
+
196
+ assert.deepEqual(JSON.parse(readFileSync(resultPath, 'utf8')), { adopted: true, calls: 1 });
197
+ assert.ok(statSync(tokenPath).size > 0);
198
+ });
@@ -62,7 +62,7 @@ const leadTool = readSrc('rules', 'lead', 'lead-tool.md');
62
62
  const core = readSrc('rules', 'agent', '00-core.md');
63
63
  const common = readSrc('rules', 'agent', '00-common.md');
64
64
  const skip = readSrc('rules', 'agent', '20-skip-protocol.md');
65
- const OPTIONAL_BRIEF_FIELDS = ['Anchors:', 'Allow/Forbid:', 'Deliver:', 'Verify:'];
65
+ const OPTIONAL_BRIEF_FIELDS = ['Anchors:', 'Allow/Forbid:', 'Deliver:'];
66
66
  const TOKEN_PRINCIPLE = /minimum (?:characters|chars), maximum (?:information|info)/i;
67
67
  function requireAll(text, label, patterns) {
68
68
  for (const pattern of patterns) assert(pattern.test(normalize(text).toLowerCase()), `${label}: missing ${pattern}`);
@@ -71,16 +71,21 @@ assert(TOKEN_PRINCIPLE.test(normalize(leadBrief)), 'lead-brief.md: brief must st
71
71
  assert(leadBrief.includes('Task:'), 'lead-brief.md: brief must require labeled field Task:');
72
72
  assert(!leadBrief.includes('Goal:'), 'lead-brief.md: Goal: must be replaced by Task:');
73
73
  for (const field of OPTIONAL_BRIEF_FIELDS) assert(leadBrief.includes(field), `lead-brief.md: brief missing optional labeled field ${field}`);
74
+ assert(!leadBrief.includes('Verify:'), 'lead-brief.md: must not prescribe Verify:');
75
+ assert(
76
+ !/copy a prior role(?:'s)? brief|worker\s*(?:->|→)\s*debugger\s*(?:->|→)\s*reviewer|\blead\s*\/\s*worker\b/i.test(leadBrief),
77
+ 'lead-brief.md: must not restore cross-role Task copying or lineage',
78
+ );
74
79
  requireAll(leadBrief, 'Lead brief task', [
75
80
  /`task:` is mandatory and lossless/, /intent/, /required outcomes/, /negative outcomes/,
76
81
  /completion\/stop boundary/, /all other fields are optional task-specific deltas/,
77
82
  /preserve user-supplied exact targets and exact replacements\/outputs in `task:`/,
78
83
  /never infer exactness from a task name, file count, or perceived difficulty/,
79
- /exact same `task:` across worker -> debugger -> reviewer/,
80
- /never summarize, rewrite, or replace it/,
84
+ /each role independently constructs a role-appropriate, lossless `task:` from/,
85
+ /the original request and official spec\/test acceptance criteria/,
81
86
  ]);
82
87
  assert(/role-known|already (?:owns|knows)|wasted cost|wasted/i.test(normalize(leadBrief)), 'lead-brief.md: brief must ban restating known rules/background as cost');
83
- assert(/spec\/test beats its summary/i.test(normalize(leadBrief)), 'lead-brief.md: spec/test must beat summary');
88
+ assert(/original request and official spec\/test acceptance criteria/i.test(normalize(leadBrief)), 'lead-brief.md: original request and official acceptance criteria must beat summary');
84
89
  requireAll(leadBrief, 'Lead brief lifecycle', [
85
90
  /full brief only for fresh spawn\/`respawned: true`/, /live follow-ups are delta/,
86
91
  /dead-tag send is cold: re-supply anchors/, /never `send` mid-run/,
@@ -109,6 +114,15 @@ const roles = {
109
114
  'reviewer/AGENT.md': readSrc('agents', 'reviewer', 'AGENT.md'),
110
115
  'debugger/AGENT.md': readSrc('agents', 'debugger', 'AGENT.md'),
111
116
  };
117
+ const reviewerRole = roles['reviewer/AGENT.md'];
118
+ assert(
119
+ !/construct the review\s*`task:` independently/i.test(reviewerRole),
120
+ 'reviewer/AGENT.md: must not restore Reviewer-specific Task construction',
121
+ );
122
+ assert(
123
+ !/treat prior reports as unverified claims rather than evidence/i.test(reviewerRole),
124
+ 'reviewer/AGENT.md: must not restore prior-report framing',
125
+ );
112
126
 
113
127
  // Semantic contracts deliberately avoid prose snapshots.
114
128
  function snapshot(actual, expected, label) {
@@ -208,9 +222,12 @@ Self-verify each checkpoint and the final slice with shell (targeted test/build)
208
222
  'reviewer/AGENT.md': `# Reviewer
209
223
  Independent regression/risk review agent.
210
224
 
211
- Review the approved intent, diff, and tests with independent judgment. Prioritize
212
- actionable correctness, regression, security, and verification risks; inspect
213
- affected boundaries. Do not reimplement the change or report non-risky nits.
225
+ Review the diff and tests with independent judgment. Prioritize actionable
226
+ correctness, regression, security, and verification risks; inspect affected
227
+ boundaries. Do not reimplement the change or report non-risky nits. Independently
228
+ evaluate the final deliverable with a critical lens, actively seeking errors,
229
+ unsupported assumptions, and counterexamples before confirming.
230
+
214
231
  Report findings first, severity-ordered, with one line per \`file:line\`. If clean,
215
232
  say so in one line and include only material residual risk.
216
233
 
@@ -0,0 +1,164 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawnSync } from 'node:child_process';
7
+ import { DEFAULT_MAINTENANCE } from '../src/runtime/agent/orchestrator/config.mjs';
8
+ import { resolveMaintenanceRoute } from '../src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs';
9
+ import { resolveAgentSpawnPreset } from '../src/standalone/agent-tool.mjs';
10
+ import { exploreResultCacheKey, resolveExploreRoute } from '../src/standalone/explore-tool.mjs';
11
+
12
+ const main = { id: 'main', name: 'MAIN', type: 'agent', provider: 'main-provider', model: 'main-model', tools: 'full' };
13
+ const configWithMain = (overrides = {}) => ({
14
+ default: 'main',
15
+ presets: [main],
16
+ maintenance: { ...DEFAULT_MAINTENANCE },
17
+ ...overrides,
18
+ });
19
+ const normalizedRoute = (route, provider = route.provider || 'anthropic-oauth') => ({
20
+ provider,
21
+ model: route.model,
22
+ effort: undefined,
23
+ fast: false,
24
+ });
25
+
26
+ test('empty Explore and Maintainer routes inherit Main in hidden and public dispatch', () => {
27
+ assert.equal(DEFAULT_MAINTENANCE.explore, undefined);
28
+ assert.equal(DEFAULT_MAINTENANCE.memory, undefined);
29
+ assert.deepEqual(DEFAULT_MAINTENANCE.scheduler, DEFAULT_MAINTENANCE.webhook);
30
+ assert.equal(DEFAULT_MAINTENANCE.scheduler.provider, 'anthropic-oauth');
31
+ assert.match(DEFAULT_MAINTENANCE.scheduler.model, /haiku/i);
32
+
33
+ const config = configWithMain();
34
+ assert.equal(resolveMaintenanceRoute({ agent: 'explorer', config }), 'main');
35
+ assert.equal(resolveMaintenanceRoute({ agent: 'cycle1-agent', config }), 'main');
36
+ assert.equal(resolveMaintenanceRoute({ agent: 'scheduler-task', config }), DEFAULT_MAINTENANCE.scheduler);
37
+ assert.equal(resolveAgentSpawnPreset(config, { agent: 'explore' }).preset, main);
38
+ assert.equal(resolveAgentSpawnPreset(config, { agent: 'maintainer' }).preset, main);
39
+ });
40
+
41
+ test('public agent, workflow, and maintenance routes override Main inheritance', () => {
42
+ const exploreAgent = { provider: 'agent-provider', model: 'agent-model' };
43
+ const maintainerWorkflow = { provider: 'workflow-provider', model: 'workflow-model' };
44
+ const exploreMaintenance = { provider: 'maintenance-provider', model: 'maintenance-model' };
45
+ assert.deepEqual(
46
+ resolveAgentSpawnPreset(configWithMain({ agents: { explore: exploreAgent } }), { agent: 'explore' }).preset,
47
+ { id: 'agent-explore', name: 'AGENT EXPLORE', type: 'agent', ...exploreAgent, effort: undefined, fast: false, tools: 'full' },
48
+ );
49
+ assert.deepEqual(
50
+ resolveAgentSpawnPreset(configWithMain({
51
+ workflowRoutes: { memory: maintainerWorkflow },
52
+ maintenance: { ...DEFAULT_MAINTENANCE, memory: { provider: 'ignored-provider', model: 'ignored-model' } },
53
+ }), { agent: 'maintainer' }).preset,
54
+ { id: 'agent-maintainer', name: 'AGENT MAINTAINER', type: 'agent', ...maintainerWorkflow, effort: undefined, fast: false, tools: 'full' },
55
+ );
56
+ const config = configWithMain({
57
+ maintenance: {
58
+ ...DEFAULT_MAINTENANCE,
59
+ explore: exploreMaintenance,
60
+ },
61
+ });
62
+ assert.deepEqual(resolveMaintenanceRoute({ agent: 'explorer', config }), normalizedRoute(exploreMaintenance));
63
+ assert.deepEqual(
64
+ resolveAgentSpawnPreset(config, { agent: 'explore' }).preset,
65
+ { id: 'agent-explore', name: 'AGENT EXPLORE', type: 'agent', ...exploreMaintenance, effort: undefined, fast: false, tools: 'full' },
66
+ );
67
+ });
68
+
69
+ test('hidden Explore and Maintainer routes use public precedence, including the legacy maintainer alias', () => {
70
+ const agentRoute = { provider: 'agent-provider', model: 'agent-model' };
71
+ const workflowRoute = { provider: 'workflow-provider', model: 'workflow-model' };
72
+ const maintenanceRoute = { provider: 'maintenance-provider', model: 'maintenance-model' };
73
+ assert.deepEqual(resolveMaintenanceRoute({
74
+ agent: 'explorer',
75
+ config: configWithMain({
76
+ agents: { explore: agentRoute },
77
+ workflowRoutes: { explorer: workflowRoute },
78
+ maintenance: { ...DEFAULT_MAINTENANCE, explore: maintenanceRoute },
79
+ }),
80
+ }), normalizedRoute(agentRoute));
81
+ assert.deepEqual(resolveMaintenanceRoute({
82
+ agent: 'cycle1-agent',
83
+ config: configWithMain({
84
+ workflowRoutes: { memory: workflowRoute },
85
+ maintenance: { ...DEFAULT_MAINTENANCE, memory: maintenanceRoute },
86
+ }),
87
+ }), normalizedRoute(workflowRoute));
88
+ assert.deepEqual(resolveMaintenanceRoute({
89
+ agent: 'cycle2-agent',
90
+ config: configWithMain({
91
+ maintenance: { ...DEFAULT_MAINTENANCE, memory: maintenanceRoute },
92
+ }),
93
+ }), normalizedRoute(maintenanceRoute));
94
+ assert.deepEqual(resolveMaintenanceRoute({
95
+ agent: 'cycle3-agent',
96
+ config: configWithMain({ agents: { maintenance: agentRoute } }),
97
+ }), normalizedRoute(agentRoute));
98
+ });
99
+
100
+ test('Explore cache identity follows the effective route when Main changes', () => {
101
+ const first = configWithMain();
102
+ const secondMain = { ...main, model: 'replacement-main-model' };
103
+ const second = configWithMain({ presets: [secondMain] });
104
+ const query = 'locate the route';
105
+ const cwd = '/tmp/project';
106
+ assert.deepEqual(resolveExploreRoute(first), main);
107
+ assert.deepEqual(resolveExploreRoute(second), secondMain);
108
+ assert.notEqual(
109
+ exploreResultCacheKey({ cwd, route: resolveExploreRoute(first), query }),
110
+ exploreResultCacheKey({ cwd, route: resolveExploreRoute(second), query }),
111
+ );
112
+ });
113
+
114
+ test('hidden precedence normalizes model-only routes with the public provider fallback', () => {
115
+ const modelOnly = { model: 'model-only' };
116
+ assert.deepEqual(
117
+ resolveMaintenanceRoute({ agent: 'explorer', config: configWithMain({ defaultProvider: 'openai', agents: { explore: modelOnly } }) }),
118
+ normalizedRoute(modelOnly, 'openai'),
119
+ );
120
+ assert.deepEqual(
121
+ resolveMaintenanceRoute({ agent: 'cycle1-agent', config: configWithMain({ defaultProvider: 'gemini', agents: { maintenance: modelOnly } }) }),
122
+ normalizedRoute(modelOnly, 'gemini'),
123
+ );
124
+ assert.deepEqual(
125
+ resolveMaintenanceRoute({ agent: 'explorer', config: configWithMain({ defaultProvider: 'xai', workflowRoutes: { explorer: modelOnly } }) }),
126
+ normalizedRoute(modelOnly, 'xai'),
127
+ );
128
+ assert.deepEqual(
129
+ resolveMaintenanceRoute({ agent: 'cycle2-agent', config: configWithMain({ maintenance: { ...DEFAULT_MAINTENANCE, memory: modelOnly } }) }),
130
+ normalizedRoute(modelOnly),
131
+ );
132
+ });
133
+
134
+ test('loadConfig preserves persisted model-only Explore and Maintainer routes with the configured provider', () => {
135
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-maintenance-config-'));
136
+ writeFileSync(join(dataDir, 'mixdog-config.json'), JSON.stringify({
137
+ agent: {
138
+ defaultProvider: 'openai',
139
+ maintenance: {
140
+ explore: { model: 'gpt-test' },
141
+ memory: { model: 'gpt-maintainer-test' },
142
+ },
143
+ },
144
+ }));
145
+ const runner = [
146
+ "import { loadConfig } from './src/runtime/agent/orchestrator/config.mjs';",
147
+ "console.log(JSON.stringify(loadConfig({ secrets: false }).maintenance));",
148
+ ].join(' ');
149
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', runner], {
150
+ cwd: process.cwd(),
151
+ env: { ...process.env, MIXDOG_DATA_DIR: dataDir },
152
+ encoding: 'utf8',
153
+ });
154
+ assert.equal(result.status, 0, result.stderr);
155
+ const maintenance = JSON.parse(result.stdout);
156
+ assert.deepEqual(maintenance.explore, { provider: 'openai', model: 'gpt-test' });
157
+ assert.deepEqual(maintenance.memory, { provider: 'openai', model: 'gpt-maintainer-test' });
158
+ });
159
+
160
+ test('config maintenance migration has no standalone provider runtime dependency', () => {
161
+ const source = readFileSync(new URL('../src/runtime/agent/orchestrator/config.mjs', import.meta.url), 'utf8');
162
+ assert.doesNotMatch(source, /standalone\/provider-admin/);
163
+ assert.match(source, /CONFIG_PROVIDER_IDS/);
164
+ });