tmux-team 2.1.0 → 2.2.0

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.
@@ -63,6 +63,8 @@ function createDefaultConfig(): ResolvedConfig {
63
63
  timeout: 60,
64
64
  pollInterval: 0.1, // Fast polling for tests
65
65
  captureLines: 100,
66
+ preambleEvery: 3,
67
+ hideOrphanTasks: false,
66
68
  },
67
69
  agents: {},
68
70
  paneRegistry: {
@@ -85,7 +87,15 @@ function createContext(
85
87
  const exitError = new Error('exit called');
86
88
  (exitError as Error & { exitCode?: number }).exitCode = 0;
87
89
 
88
- const config = { ...createDefaultConfig(), ...overrides.config };
90
+ const baseConfig = createDefaultConfig();
91
+ const config = {
92
+ ...baseConfig,
93
+ ...overrides.config,
94
+ defaults: {
95
+ ...baseConfig.defaults,
96
+ ...overrides.config?.defaults,
97
+ },
98
+ };
89
99
  const flags: Flags = { json: false, verbose: false, ...overrides.flags };
90
100
 
91
101
  return {
@@ -199,6 +209,91 @@ describe('buildMessage (via cmdTalk)', () => {
199
209
 
200
210
  expect(tmux.sends[0].message).toBe('[SYSTEM: Test preamble]\n\nTest message');
201
211
  });
212
+
213
+ it('injects preamble based on preambleEvery config (every N messages)', async () => {
214
+ const paths = createTestPaths(testDir);
215
+ fs.mkdirSync(paths.globalDir, { recursive: true });
216
+
217
+ const config = {
218
+ preambleMode: 'always' as const,
219
+ agents: { claude: { preamble: 'Be brief' } },
220
+ defaults: {
221
+ timeout: 60,
222
+ pollInterval: 0.1,
223
+ captureLines: 100,
224
+ preambleEvery: 3,
225
+ hideOrphanTasks: false,
226
+ },
227
+ };
228
+
229
+ // Message 1: should include preamble (first message)
230
+ const tmux1 = createMockTmux();
231
+ await cmdTalk(createContext({ tmux: tmux1, paths, config }), 'claude', 'Hello 1');
232
+ expect(tmux1.sends[0].message).toContain('[SYSTEM: Be brief]');
233
+
234
+ // Message 2: should NOT include preamble
235
+ const tmux2 = createMockTmux();
236
+ await cmdTalk(createContext({ tmux: tmux2, paths, config }), 'claude', 'Hello 2');
237
+ expect(tmux2.sends[0].message).toBe('Hello 2');
238
+
239
+ // Message 3: should NOT include preamble
240
+ const tmux3 = createMockTmux();
241
+ await cmdTalk(createContext({ tmux: tmux3, paths, config }), 'claude', 'Hello 3');
242
+ expect(tmux3.sends[0].message).toBe('Hello 3');
243
+
244
+ // Message 4: should include preamble (4 - 1 = 3, divisible by 3)
245
+ const tmux4 = createMockTmux();
246
+ await cmdTalk(createContext({ tmux: tmux4, paths, config }), 'claude', 'Hello 4');
247
+ expect(tmux4.sends[0].message).toContain('[SYSTEM: Be brief]');
248
+ });
249
+
250
+ it('injects preamble every time when preambleEvery is 1', async () => {
251
+ const paths = createTestPaths(testDir);
252
+ fs.mkdirSync(paths.globalDir, { recursive: true });
253
+
254
+ const config = {
255
+ preambleMode: 'always' as const,
256
+ agents: { claude: { preamble: 'Be brief' } },
257
+ defaults: {
258
+ timeout: 60,
259
+ pollInterval: 0.1,
260
+ captureLines: 100,
261
+ preambleEvery: 1,
262
+ hideOrphanTasks: false,
263
+ },
264
+ };
265
+
266
+ // All messages should include preamble
267
+ for (let i = 0; i < 3; i++) {
268
+ const tmux = createMockTmux();
269
+ await cmdTalk(createContext({ tmux, paths, config }), 'claude', `Hello ${i}`);
270
+ expect(tmux.sends[0].message).toContain('[SYSTEM: Be brief]');
271
+ }
272
+ });
273
+
274
+ it('never injects preamble when preambleEvery is 0', async () => {
275
+ const paths = createTestPaths(testDir);
276
+ fs.mkdirSync(paths.globalDir, { recursive: true });
277
+
278
+ const config = {
279
+ preambleMode: 'always' as const,
280
+ agents: { claude: { preamble: 'Be brief' } },
281
+ defaults: {
282
+ timeout: 60,
283
+ pollInterval: 0.1,
284
+ captureLines: 100,
285
+ preambleEvery: 0,
286
+ hideOrphanTasks: false,
287
+ },
288
+ };
289
+
290
+ // No messages should include preamble
291
+ for (let i = 0; i < 3; i++) {
292
+ const tmux = createMockTmux();
293
+ await cmdTalk(createContext({ tmux, paths, config }), 'claude', `Hello ${i}`);
294
+ expect(tmux.sends[0].message).toBe(`Hello ${i}`);
295
+ }
296
+ });
202
297
  });
203
298
 
204
299
  describe('cmdTalk - basic send', () => {
@@ -369,7 +464,15 @@ describe('cmdTalk - --wait mode', () => {
369
464
  tmux,
370
465
  paths: createTestPaths(testDir),
371
466
  flags: { wait: true, timeout: 5 },
372
- config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100 } },
467
+ config: {
468
+ defaults: {
469
+ timeout: 5,
470
+ pollInterval: 0.01,
471
+ captureLines: 100,
472
+ preambleEvery: 3,
473
+ hideOrphanTasks: false,
474
+ },
475
+ },
373
476
  });
374
477
 
375
478
  await cmdTalk(ctx, 'claude', 'Hello');
@@ -404,7 +507,15 @@ describe('cmdTalk - --wait mode', () => {
404
507
  ui,
405
508
  paths: createTestPaths(testDir),
406
509
  flags: { wait: true, json: true, timeout: 5 },
407
- config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100 } },
510
+ config: {
511
+ defaults: {
512
+ timeout: 5,
513
+ pollInterval: 0.01,
514
+ captureLines: 100,
515
+ preambleEvery: 3,
516
+ hideOrphanTasks: false,
517
+ },
518
+ },
408
519
  });
409
520
 
410
521
  await cmdTalk(ctx, 'claude', 'Hello');
@@ -412,7 +523,7 @@ describe('cmdTalk - --wait mode', () => {
412
523
  expect(ui.jsonOutput).toHaveLength(1);
413
524
  const output = ui.jsonOutput[0] as Record<string, unknown>;
414
525
  expect(output.status).toBe('completed');
415
- expect(output.response).toBeDefined();
526
+ expect(output.response).toEqual(expect.stringContaining('Agent response here'));
416
527
  });
417
528
 
418
529
  it('returns timeout error with correct exit code', async () => {
@@ -427,7 +538,15 @@ describe('cmdTalk - --wait mode', () => {
427
538
  ui,
428
539
  paths: createTestPaths(testDir),
429
540
  flags: { wait: true, json: true, timeout: 0.1 },
430
- config: { defaults: { timeout: 0.1, pollInterval: 0.02, captureLines: 100 } },
541
+ config: {
542
+ defaults: {
543
+ timeout: 0.1,
544
+ pollInterval: 0.02,
545
+ captureLines: 100,
546
+ preambleEvery: 3,
547
+ hideOrphanTasks: false,
548
+ },
549
+ },
431
550
  });
432
551
 
433
552
  try {
@@ -468,7 +587,15 @@ describe('cmdTalk - --wait mode', () => {
468
587
  ui,
469
588
  paths: createTestPaths(testDir),
470
589
  flags: { wait: true, json: true, timeout: 5 },
471
- config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100 } },
590
+ config: {
591
+ defaults: {
592
+ timeout: 5,
593
+ pollInterval: 0.01,
594
+ captureLines: 100,
595
+ preambleEvery: 3,
596
+ hideOrphanTasks: false,
597
+ },
598
+ },
472
599
  });
473
600
 
474
601
  await cmdTalk(ctx, 'claude', 'Hello');
@@ -496,7 +623,15 @@ describe('cmdTalk - --wait mode', () => {
496
623
  tmux,
497
624
  paths,
498
625
  flags: { wait: true, timeout: 5 },
499
- config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100 } },
626
+ config: {
627
+ defaults: {
628
+ timeout: 5,
629
+ pollInterval: 0.01,
630
+ captureLines: 100,
631
+ preambleEvery: 3,
632
+ hideOrphanTasks: false,
633
+ },
634
+ },
500
635
  });
501
636
 
502
637
  await cmdTalk(ctx, 'claude', 'Hello');
@@ -517,7 +652,15 @@ describe('cmdTalk - --wait mode', () => {
517
652
  tmux,
518
653
  paths,
519
654
  flags: { wait: true, timeout: 0.05 },
520
- config: { defaults: { timeout: 0.05, pollInterval: 0.01, captureLines: 100 } },
655
+ config: {
656
+ defaults: {
657
+ timeout: 0.05,
658
+ pollInterval: 0.01,
659
+ captureLines: 100,
660
+ preambleEvery: 3,
661
+ hideOrphanTasks: false,
662
+ },
663
+ },
521
664
  });
522
665
 
523
666
  try {
@@ -565,7 +708,13 @@ describe('cmdTalk - --wait mode', () => {
565
708
  paths,
566
709
  flags: { wait: true, timeout: 5 },
567
710
  config: {
568
- defaults: { timeout: 5, pollInterval: 0.05, captureLines: 100 },
711
+ defaults: {
712
+ timeout: 5,
713
+ pollInterval: 0.05,
714
+ captureLines: 100,
715
+ preambleEvery: 3,
716
+ hideOrphanTasks: false,
717
+ },
569
718
  paneRegistry: {
570
719
  codex: { pane: '10.1' },
571
720
  gemini: { pane: '10.2' },
@@ -606,7 +755,13 @@ describe('cmdTalk - --wait mode', () => {
606
755
  paths,
607
756
  flags: { wait: true, timeout: 0.1, json: true },
608
757
  config: {
609
- defaults: { timeout: 0.1, pollInterval: 0.02, captureLines: 100 },
758
+ defaults: {
759
+ timeout: 0.1,
760
+ pollInterval: 0.02,
761
+ captureLines: 100,
762
+ preambleEvery: 3,
763
+ hideOrphanTasks: false,
764
+ },
610
765
  paneRegistry: {
611
766
  codex: { pane: '10.1' },
612
767
  gemini: { pane: '10.2' },
@@ -658,7 +813,13 @@ describe('cmdTalk - --wait mode', () => {
658
813
  paths,
659
814
  flags: { wait: true, timeout: 5 },
660
815
  config: {
661
- defaults: { timeout: 5, pollInterval: 0.02, captureLines: 100 },
816
+ defaults: {
817
+ timeout: 5,
818
+ pollInterval: 0.02,
819
+ captureLines: 100,
820
+ preambleEvery: 3,
821
+ hideOrphanTasks: false,
822
+ },
662
823
  paneRegistry: {
663
824
  codex: { pane: '10.1' },
664
825
  gemini: { pane: '10.2' },
@@ -718,7 +879,15 @@ describe('cmdTalk - nonce collision handling', () => {
718
879
  ui,
719
880
  paths: createTestPaths(testDir),
720
881
  flags: { wait: true, json: true, timeout: 5 },
721
- config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100 } },
882
+ config: {
883
+ defaults: {
884
+ timeout: 5,
885
+ pollInterval: 0.01,
886
+ captureLines: 100,
887
+ preambleEvery: 3,
888
+ hideOrphanTasks: false,
889
+ },
890
+ },
722
891
  });
723
892
 
724
893
  await cmdTalk(ctx, 'claude', 'Hello');
@@ -761,7 +930,15 @@ describe('cmdTalk - JSON output contract', () => {
761
930
  ui,
762
931
  paths: createTestPaths(testDir),
763
932
  flags: { wait: true, json: true, timeout: 5 },
764
- config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100 } },
933
+ config: {
934
+ defaults: {
935
+ timeout: 5,
936
+ pollInterval: 0.01,
937
+ captureLines: 100,
938
+ preambleEvery: 3,
939
+ hideOrphanTasks: false,
940
+ },
941
+ },
765
942
  });
766
943
 
767
944
  await cmdTalk(ctx, 'claude', 'Hello');
@@ -786,7 +963,15 @@ describe('cmdTalk - JSON output contract', () => {
786
963
  ui,
787
964
  paths: createTestPaths(testDir),
788
965
  flags: { wait: true, json: true, timeout: 0.05 },
789
- config: { defaults: { timeout: 0.05, pollInterval: 0.01, captureLines: 100 } },
966
+ config: {
967
+ defaults: {
968
+ timeout: 0.05,
969
+ pollInterval: 0.01,
970
+ captureLines: 100,
971
+ preambleEvery: 3,
972
+ hideOrphanTasks: false,
973
+ },
974
+ },
790
975
  });
791
976
 
792
977
  try {
@@ -7,7 +7,12 @@ import type { WaitResult } from '../types.js';
7
7
  import { ExitCodes } from '../exits.js';
8
8
  import { colors } from '../ui.js';
9
9
  import crypto from 'crypto';
10
- import { cleanupState, clearActiveRequest, setActiveRequest } from '../state.js';
10
+ import {
11
+ cleanupState,
12
+ clearActiveRequest,
13
+ setActiveRequest,
14
+ incrementPreambleCounter,
15
+ } from '../state.js';
11
16
  import { resolveActor } from '../pm/permissions.js';
12
17
 
13
18
  function sleepMs(ms: number): Promise<void> {
@@ -62,9 +67,12 @@ interface BroadcastWaitResult {
62
67
  /**
63
68
  * Build the final message with optional preamble.
64
69
  * Format: [SYSTEM: <preamble>]\n\n<message>
70
+ *
71
+ * Preamble injection frequency is controlled by preambleEvery config.
72
+ * Default: inject every 3 messages per agent to save tokens.
65
73
  */
66
74
  function buildMessage(message: string, agentName: string, ctx: Context): string {
67
- const { config, flags } = ctx;
75
+ const { config, flags, paths } = ctx;
68
76
 
69
77
  // Skip preamble if disabled or --no-preamble flag
70
78
  if (config.preambleMode === 'disabled' || flags.noPreamble) {
@@ -79,6 +87,22 @@ function buildMessage(message: string, agentName: string, ctx: Context): string
79
87
  return message;
80
88
  }
81
89
 
90
+ // Check preamble frequency (preambleEvery: 0 means never, 1 means always)
91
+ const preambleEvery = config.defaults.preambleEvery;
92
+ if (preambleEvery <= 0) {
93
+ // preambleEvery = 0 means never inject (equivalent to disabled for this agent)
94
+ return message;
95
+ }
96
+
97
+ // Increment counter and check if we should inject preamble
98
+ // Inject on message 1, 1+N, 1+2N, ... where N = preambleEvery
99
+ const count = incrementPreambleCounter(paths, agentName);
100
+ const shouldInject = (count - 1) % preambleEvery === 0;
101
+
102
+ if (!shouldInject) {
103
+ return message;
104
+ }
105
+
82
106
  return `[SYSTEM: ${preamble}]\n\n${message}`;
83
107
  }
84
108
 
@@ -2,9 +2,9 @@
2
2
  // update command - modify agent config
3
3
  // ─────────────────────────────────────────────────────────────
4
4
 
5
- import type { Context } from '../types.js';
5
+ import type { Context, PaneEntry } from '../types.js';
6
6
  import { ExitCodes } from '../exits.js';
7
- import { saveLocalConfig } from '../config.js';
7
+ import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
8
8
 
9
9
  export function cmdUpdate(
10
10
  ctx: Context,
@@ -23,19 +23,30 @@ export function cmdUpdate(
23
23
  exit(ExitCodes.ERROR);
24
24
  }
25
25
 
26
+ // Load existing config to preserve all fields (preamble, deny, etc.)
27
+ const localConfig = loadLocalConfigFile(paths);
28
+
29
+ // Handle edge case where local config was modified externally
30
+ let entry = localConfig[name] as PaneEntry | undefined;
31
+ if (!entry) {
32
+ // Fall back to in-memory paneRegistry if entry is missing
33
+ entry = { ...config.paneRegistry[name] };
34
+ localConfig[name] = entry;
35
+ }
36
+
26
37
  const updates: string[] = [];
27
38
 
28
39
  if (options.pane) {
29
- config.paneRegistry[name].pane = options.pane;
40
+ entry.pane = options.pane;
30
41
  updates.push(`pane → ${options.pane}`);
31
42
  }
32
43
 
33
44
  if (options.remark) {
34
- config.paneRegistry[name].remark = options.remark;
45
+ entry.remark = options.remark;
35
46
  updates.push(`remark updated`);
36
47
  }
37
48
 
38
- saveLocalConfig(paths, config.paneRegistry);
49
+ saveLocalConfigFile(paths, localConfig);
39
50
 
40
51
  if (flags.json) {
41
52
  ui.json({ updated: name, ...options });
@@ -161,16 +161,16 @@ describe('loadConfig', () => {
161
161
  expect(config.defaults.timeout).toBe(180);
162
162
  expect(config.defaults.pollInterval).toBe(1);
163
163
  expect(config.defaults.captureLines).toBe(100);
164
+ expect(config.defaults.hideOrphanTasks).toBe(false);
164
165
  expect(config.agents).toEqual({});
165
166
  expect(config.paneRegistry).toEqual({});
166
167
  });
167
168
 
168
- it('loads and merges global config', () => {
169
+ it('loads and merges global config (mode, preambleMode, defaults only)', () => {
169
170
  const globalConfig = {
170
171
  mode: 'wait',
171
172
  preambleMode: 'disabled',
172
173
  defaults: { timeout: 120 },
173
- agents: { claude: { preamble: 'Be helpful' } },
174
174
  };
175
175
 
176
176
  vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.globalConfig);
@@ -182,7 +182,7 @@ describe('loadConfig', () => {
182
182
  expect(config.preambleMode).toBe('disabled');
183
183
  expect(config.defaults.timeout).toBe(120);
184
184
  expect(config.defaults.pollInterval).toBe(1); // Default preserved
185
- expect(config.agents.claude?.preamble).toBe('Be helpful');
185
+ expect(config.agents).toEqual({}); // No agents from global config
186
186
  });
187
187
 
188
188
  it('loads local pane registry from tmux-team.json', () => {
@@ -201,12 +201,12 @@ describe('loadConfig', () => {
201
201
  expect(config.paneRegistry.codex?.pane).toBe('1.1');
202
202
  });
203
203
 
204
- it('merges both global and local config', () => {
204
+ it('merges both global and local config (agents from local only)', () => {
205
205
  const globalConfig = {
206
- agents: { claude: { preamble: 'Be brief' } },
206
+ mode: 'wait',
207
207
  };
208
208
  const localConfig = {
209
- claude: { pane: '1.0' },
209
+ claude: { pane: '1.0', preamble: 'Be brief' },
210
210
  };
211
211
 
212
212
  vi.mocked(fs.existsSync).mockReturnValue(true);
@@ -218,8 +218,9 @@ describe('loadConfig', () => {
218
218
 
219
219
  const config = loadConfig(mockPaths);
220
220
 
221
- expect(config.agents.claude?.preamble).toBe('Be brief');
222
- expect(config.paneRegistry.claude?.pane).toBe('1.0');
221
+ expect(config.mode).toBe('wait'); // from global
222
+ expect(config.agents.claude?.preamble).toBe('Be brief'); // from local
223
+ expect(config.paneRegistry.claude?.pane).toBe('1.0'); // from local
223
224
  });
224
225
 
225
226
  it('throws ConfigParseError on invalid JSON in config file', () => {
@@ -240,7 +241,180 @@ describe('loadConfig', () => {
240
241
  expect(err).toBeInstanceOf(ConfigParseError);
241
242
  const parseError = err as ConfigParseError;
242
243
  expect(parseError.filePath).toBe(mockPaths.globalConfig);
243
- expect(parseError.cause).toBeDefined();
244
+ expect(parseError.cause).toBeInstanceOf(SyntaxError);
244
245
  }
245
246
  });
247
+
248
+ it('loads local preamble into agents config', () => {
249
+ const localConfig = {
250
+ claude: { pane: '1.0', preamble: 'Be helpful and concise' },
251
+ };
252
+
253
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
254
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
255
+
256
+ const config = loadConfig(mockPaths);
257
+
258
+ expect(config.agents.claude?.preamble).toBe('Be helpful and concise');
259
+ expect(config.paneRegistry.claude?.pane).toBe('1.0');
260
+ });
261
+
262
+ it('loads local deny into agents config', () => {
263
+ const localConfig = {
264
+ claude: { pane: '1.0', deny: ['pm:task:update(status)'] },
265
+ };
266
+
267
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
268
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
269
+
270
+ const config = loadConfig(mockPaths);
271
+
272
+ expect(config.agents.claude?.deny).toEqual(['pm:task:update(status)']);
273
+ });
274
+
275
+ it('loads preamble from local config only', () => {
276
+ const localConfig = {
277
+ claude: { pane: '1.0', preamble: 'Local preamble' },
278
+ };
279
+
280
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
281
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
282
+
283
+ const config = loadConfig(mockPaths);
284
+
285
+ expect(config.agents.claude?.preamble).toBe('Local preamble');
286
+ });
287
+
288
+ it('loads deny from local config only', () => {
289
+ const localConfig = {
290
+ claude: { pane: '1.0', deny: ['pm:task:create'] },
291
+ };
292
+
293
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
294
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
295
+
296
+ const config = loadConfig(mockPaths);
297
+
298
+ expect(config.agents.claude?.deny).toEqual(['pm:task:create']);
299
+ });
300
+
301
+ it('handles local config with both preamble and deny', () => {
302
+ const localConfig = {
303
+ claude: { pane: '1.0', preamble: 'Be helpful', deny: ['pm:task:update(status)'] },
304
+ };
305
+
306
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
307
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
308
+
309
+ const config = loadConfig(mockPaths);
310
+
311
+ expect(config.agents.claude?.preamble).toBe('Be helpful');
312
+ expect(config.agents.claude?.deny).toEqual(['pm:task:update(status)']);
313
+ });
314
+
315
+ it('handles empty preamble in local config', () => {
316
+ const localConfig = {
317
+ claude: { pane: '1.0', preamble: '' },
318
+ };
319
+
320
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
321
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
322
+
323
+ const config = loadConfig(mockPaths);
324
+
325
+ expect(config.agents.claude?.preamble).toBe('');
326
+ });
327
+
328
+ it('handles empty deny array in local config', () => {
329
+ const localConfig = {
330
+ claude: { pane: '1.0', deny: [] },
331
+ };
332
+
333
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
334
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
335
+
336
+ const config = loadConfig(mockPaths);
337
+
338
+ expect(config.agents.claude?.deny).toEqual([]);
339
+ });
340
+
341
+ it('skips entries without pane field in paneRegistry', () => {
342
+ const localConfig = {
343
+ claude: { pane: '1.0' },
344
+ codex: { preamble: 'Preamble only, no pane' },
345
+ };
346
+
347
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
348
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
349
+
350
+ const config = loadConfig(mockPaths);
351
+
352
+ expect(config.paneRegistry.claude?.pane).toBe('1.0');
353
+ expect(config.paneRegistry.codex).toBeUndefined();
354
+ // But preamble should still be merged
355
+ expect(config.agents.codex?.preamble).toBe('Preamble only, no pane');
356
+ });
357
+
358
+ it('ignores agents field in global config (local config is SSOT)', () => {
359
+ // Even if global config has agents, they should be ignored
360
+ const globalConfig = {
361
+ mode: 'wait',
362
+ agents: {
363
+ // This should be ignored
364
+ claude: { preamble: 'Global preamble', deny: ['pm:task:delete'] },
365
+ },
366
+ };
367
+ const localConfig = {
368
+ claude: { pane: '1.0', preamble: 'Local preamble' },
369
+ };
370
+
371
+ vi.mocked(fs.existsSync).mockReturnValue(true);
372
+ vi.mocked(fs.readFileSync).mockImplementation((p) => {
373
+ if (p === mockPaths.globalConfig) return JSON.stringify(globalConfig);
374
+ if (p === mockPaths.localConfig) return JSON.stringify(localConfig);
375
+ return '';
376
+ });
377
+
378
+ const config = loadConfig(mockPaths);
379
+
380
+ // Mode from global should work
381
+ expect(config.mode).toBe('wait');
382
+ // But agents come only from local config
383
+ expect(config.agents.claude?.preamble).toBe('Local preamble');
384
+ expect(config.agents.claude?.deny).toBeUndefined(); // Not from global
385
+ });
386
+
387
+ it('local config defines project-specific agent roles without global pollution', () => {
388
+ // No global config
389
+ const localConfig = {
390
+ claude: {
391
+ pane: '1.0',
392
+ remark: 'Main implementer',
393
+ preamble: 'You implement features. Ask Codex for review.',
394
+ deny: ['pm:task:update(status)', 'pm:milestone:update(status)'],
395
+ },
396
+ codex: {
397
+ pane: '1.1',
398
+ remark: 'Code quality guard',
399
+ preamble: 'You review code. You can update task status.',
400
+ // No deny - codex can do everything
401
+ },
402
+ };
403
+
404
+ vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
405
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
406
+
407
+ const config = loadConfig(mockPaths);
408
+
409
+ // Claude has deny rules
410
+ expect(config.agents.claude?.deny).toEqual([
411
+ 'pm:task:update(status)',
412
+ 'pm:milestone:update(status)',
413
+ ]);
414
+ expect(config.agents.claude?.preamble).toBe('You implement features. Ask Codex for review.');
415
+
416
+ // Codex has no deny rules (full access)
417
+ expect(config.agents.codex?.deny).toBeUndefined();
418
+ expect(config.agents.codex?.preamble).toBe('You review code. You can update task status.');
419
+ });
246
420
  });