github-issue-tower-defence-management 1.150.0 → 1.151.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +9 -1
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +24 -0
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +13 -1
  6. package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js +12 -0
  8. package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js.map +1 -0
  9. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +60 -13
  10. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  11. package/bin/domain/entities/UnansweredOwnerCall.js +3 -0
  12. package/bin/domain/entities/UnansweredOwnerCall.js.map +1 -0
  13. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +21 -2
  14. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -1
  15. package/package.json +1 -1
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +46 -0
  17. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +104 -1
  18. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +19 -0
  19. package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.test.ts +111 -0
  20. package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.ts +33 -0
  21. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +178 -0
  22. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +106 -17
  23. package/src/domain/entities/UnansweredOwnerCall.ts +4 -0
  24. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +99 -0
  25. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +47 -1
  26. package/src/domain/usecases/intmux/UnansweredOwnerCallSessionKeyContract.test.ts +141 -0
  27. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  28. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +2 -0
  29. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -1
  30. package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts +13 -0
  31. package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts.map +1 -0
  32. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +5 -0
  33. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  34. package/types/domain/entities/UnansweredOwnerCall.d.ts +5 -0
  35. package/types/domain/entities/UnansweredOwnerCall.d.ts.map +1 -0
  36. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +19 -0
  37. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts.map +1 -1
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import { Issue } from '../../../domain/entities/Issue';
5
5
  import { FieldOption, Project } from '../../../domain/entities/Project';
6
6
  import { writeInTmuxByHumanData } from './inTmuxByHumanDataWriter';
7
+ import { toTmuxSessionName } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
7
8
 
8
9
  const ASSIGNEE = 'owner-login';
9
10
  const CONSOLE_BASE_URL = 'https://console.example.test';
@@ -98,22 +99,124 @@ describe('writeInTmuxByHumanData', () => {
98
99
 
99
100
  const file = (name: string): string => path.join(outDir, name);
100
101
 
101
- it('writes the four per-project files and the four index files', () => {
102
+ it('writes the five per-project files and the five index files', () => {
102
103
  writeInTmuxByHumanData(baseParams(outDir));
103
104
  for (const name of [
104
105
  'demo.json',
105
106
  'demo.v2.json',
106
107
  'demo.v3.json',
107
108
  'demo.v4.json',
109
+ 'demo.v5.json',
108
110
  'index.json',
109
111
  'index.v2.json',
110
112
  'index.v3.json',
111
113
  'index.v4.json',
114
+ 'index.v5.json',
112
115
  ]) {
113
116
  expect(fs.existsSync(file(name))).toBe(true);
114
117
  }
115
118
  });
116
119
 
120
+ it('writes the v5 shape carrying the unanswered calls of the session', () => {
121
+ writeInTmuxByHumanData({
122
+ ...baseParams(outDir),
123
+ unansweredCallsByTmuxSessionName: new Map([
124
+ [
125
+ toTmuxSessionName('https://github.com/demo/repo/issues/1'),
126
+ [
127
+ {
128
+ calledAt: '2026-08-13T10:14:00.000Z',
129
+ body: 'Please decide whether to merge the release branch',
130
+ },
131
+ ],
132
+ ],
133
+ ]),
134
+ });
135
+
136
+ expect(readJson(file('demo.v5.json'))).toEqual({
137
+ version: 5,
138
+ overviewUrl: 'https://github.com/orgs/demo/projects/1',
139
+ tdpmConsoleUrl: `${CONSOLE_BASE_URL}/projects/demo?k=${CONSOLE_TOKEN}`,
140
+ newIssueUrl: `https://github.com/demo-org/demo-repo/issues/new?assignees=${ASSIGNEE}`,
141
+ groups: [
142
+ {
143
+ story: 'Story Alpha',
144
+ sessions: [
145
+ {
146
+ name: 'https://github.com/demo/repo/issues/1',
147
+ description: 'Issue 1',
148
+ unansweredCalls: [
149
+ {
150
+ calledAt: '2026-08-13T10:14:00.000Z',
151
+ body: 'Please decide whether to merge the release branch',
152
+ },
153
+ ],
154
+ },
155
+ ],
156
+ },
157
+ ],
158
+ });
159
+ });
160
+
161
+ it('writes an empty unansweredCalls array when no call is supplied', () => {
162
+ writeInTmuxByHumanData(baseParams(outDir));
163
+ expect(readJson(file('demo.v5.json'))).toMatchObject({
164
+ groups: [
165
+ {
166
+ sessions: [
167
+ {
168
+ name: 'https://github.com/demo/repo/issues/1',
169
+ unansweredCalls: [],
170
+ },
171
+ ],
172
+ },
173
+ ],
174
+ });
175
+ });
176
+
177
+ it('points the v5 index entry at the v5 document of each project', () => {
178
+ writeInTmuxByHumanData(baseParams(outDir));
179
+ expect(readJson(file('index.v5.json'))).toEqual({
180
+ version: 5,
181
+ projects: [
182
+ {
183
+ name: 'demo',
184
+ path: `/${path.basename(outDir)}/demo.v5.json?k=${CONSOLE_TOKEN}`,
185
+ },
186
+ ],
187
+ });
188
+ });
189
+
190
+ it('keeps the v4 document free of unanswered call data when calls are supplied', () => {
191
+ writeInTmuxByHumanData({
192
+ ...baseParams(outDir),
193
+ unansweredCallsByTmuxSessionName: new Map([
194
+ [
195
+ toTmuxSessionName('https://github.com/demo/repo/issues/1'),
196
+ [{ calledAt: '2026-08-13T10:14:00.000Z', body: 'first call body' }],
197
+ ],
198
+ ]),
199
+ });
200
+
201
+ expect(readJson(file('demo.v4.json'))).toEqual({
202
+ version: 4,
203
+ overviewUrl: 'https://github.com/orgs/demo/projects/1',
204
+ tdpmConsoleUrl: `${CONSOLE_BASE_URL}/projects/demo?k=${CONSOLE_TOKEN}`,
205
+ newIssueUrl: `https://github.com/demo-org/demo-repo/issues/new?assignees=${ASSIGNEE}`,
206
+ groups: [
207
+ {
208
+ story: 'Story Alpha',
209
+ sessions: [
210
+ {
211
+ name: 'https://github.com/demo/repo/issues/1',
212
+ description: 'Issue 1',
213
+ },
214
+ ],
215
+ },
216
+ ],
217
+ });
218
+ });
219
+
117
220
  it('writes pretty-printed JSON ending with a trailing newline', () => {
118
221
  writeInTmuxByHumanData(baseParams(outDir));
119
222
  const raw = fs.readFileSync(file('demo.json'), 'utf8');
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import type { Issue } from '../../../domain/entities/Issue';
4
4
  import type { Project } from '../../../domain/entities/Project';
5
+ import type { UnansweredOwnerCall } from '../../../domain/entities/UnansweredOwnerCall';
5
6
  import {
6
7
  GenerateInTmuxByHumanDataUseCase,
7
8
  InTmuxByHumanData,
@@ -19,6 +20,7 @@ export type InTmuxByHumanDataWriterParams = {
19
20
  newIssueRepo?: string | null | undefined;
20
21
  project: Project;
21
22
  issues: Issue[];
23
+ unansweredCallsByTmuxSessionName?: Map<string, UnansweredOwnerCall[]>;
22
24
  now: Date;
23
25
  };
24
26
 
@@ -45,6 +47,7 @@ export const writeInTmuxByHumanData = (
45
47
  newIssueRepo,
46
48
  project,
47
49
  issues,
50
+ unansweredCallsByTmuxSessionName,
48
51
  now,
49
52
  } = params;
50
53
  if (!inTmuxDataOutputDir || !pjcode || !assigneeLogin) {
@@ -61,6 +64,9 @@ export const writeInTmuxByHumanData = (
61
64
  newIssueRepo: newIssueRepo ?? undefined,
62
65
  consoleBaseUrl: inTmuxConsoleBaseUrl ?? null,
63
66
  consoleToken: inTmuxConsoleToken ?? null,
67
+ unansweredCallsByTmuxSessionName:
68
+ unansweredCallsByTmuxSessionName ??
69
+ new Map<string, UnansweredOwnerCall[]>(),
64
70
  now,
65
71
  });
66
72
 
@@ -78,6 +84,12 @@ export const writeInTmuxByHumanData = (
78
84
  data.v4,
79
85
  );
80
86
  }
87
+ if (data.v5) {
88
+ writeJsonAtomic(
89
+ path.join(inTmuxDataOutputDir, `${pjcode}.v5.json`),
90
+ data.v5,
91
+ );
92
+ }
81
93
 
82
94
  if (!inTmuxProjectOrder || inTmuxProjectOrder.length === 0) {
83
95
  return;
@@ -105,5 +117,12 @@ export const writeInTmuxByHumanData = (
105
117
  path: `/${outputDirBasename}/${name}.v4.json?k=${inTmuxConsoleToken}`,
106
118
  })),
107
119
  });
120
+ writeJsonAtomic(path.join(inTmuxDataOutputDir, 'index.v5.json'), {
121
+ version: 5,
122
+ projects: presentProjects.map((name) => ({
123
+ name,
124
+ path: `/${outputDirBasename}/${name}.v5.json?k=${inTmuxConsoleToken}`,
125
+ })),
126
+ });
108
127
  }
109
128
  };
@@ -0,0 +1,111 @@
1
+ import { resolveUnansweredOwnerCallsByTmuxSessionName } from './resolveUnansweredOwnerCalls';
2
+ import { UnansweredOwnerCall } from '../../../domain/entities/UnansweredOwnerCall';
3
+ import { InteractiveLiveSession } from '../../../domain/entities/InteractiveLiveSession';
4
+ import { LiveSessionProcessSnapshot } from '../../../domain/entities/LiveSessionProcessSnapshot';
5
+ import { toTmuxSessionName } from '../../../domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase';
6
+
7
+ const ISSUE_URL = 'https://github.com/demo/repo/issues/1';
8
+ const TMUX_SESSION_NAME = toTmuxSessionName(ISSUE_URL);
9
+ const CONFIG_DIR = '/home/agent/.claude';
10
+ const SESSION_ID = 'session-id-1';
11
+
12
+ const snapshotWithSession = (): LiveSessionProcessSnapshot => ({
13
+ sessions: [{ sessionName: TMUX_SESSION_NAME, panePids: [100] }],
14
+ processes: [
15
+ {
16
+ pid: 100,
17
+ ppid: 1,
18
+ commandLine: `claude ${ISSUE_URL}`,
19
+ sessionId: SESSION_ID,
20
+ currentSessionId: SESSION_ID,
21
+ configDir: CONFIG_DIR,
22
+ },
23
+ ],
24
+ });
25
+
26
+ describe('resolveUnansweredOwnerCallsByTmuxSessionName', () => {
27
+ it('asks the call provider for the transcripts of the live interactive sessions and returns what it reports', async () => {
28
+ const call: UnansweredOwnerCall = {
29
+ calledAt: '2026-08-13T10:14:00.000Z',
30
+ body: 'Please decide whether to merge the release branch',
31
+ };
32
+ const requestedTranscriptPaths: Map<string, string>[] = [];
33
+ const resolvedSessions: InteractiveLiveSession[][] = [];
34
+
35
+ const result = await resolveUnansweredOwnerCallsByTmuxSessionName({
36
+ liveSessionProcessSnapshotProvider: {
37
+ getSnapshot: async () => snapshotWithSession(),
38
+ },
39
+ interactiveLiveSessionTranscriptResolver: {
40
+ resolveTranscriptPaths: (sessions) => {
41
+ resolvedSessions.push(sessions);
42
+ return new Map(
43
+ sessions.map((session) => [
44
+ session.sessionName,
45
+ `/transcripts/${session.sessionId}.jsonl`,
46
+ ]),
47
+ );
48
+ },
49
+ },
50
+ unansweredOwnerCallListProvider: {
51
+ listUnansweredOwnerCallsBySessionName: async (
52
+ transcriptPathBySessionName,
53
+ ) => {
54
+ requestedTranscriptPaths.push(transcriptPathBySessionName);
55
+ return new Map([[TMUX_SESSION_NAME, [call]]]);
56
+ },
57
+ },
58
+ });
59
+
60
+ expect(resolvedSessions[0]?.map((session) => session.sessionName)).toEqual([
61
+ TMUX_SESSION_NAME,
62
+ ]);
63
+ expect(requestedTranscriptPaths[0]?.get(TMUX_SESSION_NAME)).toBe(
64
+ `/transcripts/${SESSION_ID}.jsonl`,
65
+ );
66
+ expect(result.get(TMUX_SESSION_NAME)).toEqual([call]);
67
+ });
68
+
69
+ it('keys the returned calls by the tmux session name the reconciler derives from the issue url', async () => {
70
+ const result = await resolveUnansweredOwnerCallsByTmuxSessionName({
71
+ liveSessionProcessSnapshotProvider: {
72
+ getSnapshot: async () => snapshotWithSession(),
73
+ },
74
+ interactiveLiveSessionTranscriptResolver: {
75
+ resolveTranscriptPaths: (sessions) =>
76
+ new Map(
77
+ sessions.map((session) => [session.sessionName, '/transcript']),
78
+ ),
79
+ },
80
+ unansweredOwnerCallListProvider: {
81
+ listUnansweredOwnerCallsBySessionName: async (
82
+ transcriptPathBySessionName,
83
+ ) =>
84
+ new Map(
85
+ [...transcriptPathBySessionName.keys()].map((sessionName) => [
86
+ sessionName,
87
+ [{ calledAt: '2026-08-13T10:14:00.000Z', body: 'body' }],
88
+ ]),
89
+ ),
90
+ },
91
+ });
92
+
93
+ expect([...result.keys()]).toEqual([toTmuxSessionName(ISSUE_URL)]);
94
+ });
95
+
96
+ it('returns nothing when no live session exposes a transcript', async () => {
97
+ const result = await resolveUnansweredOwnerCallsByTmuxSessionName({
98
+ liveSessionProcessSnapshotProvider: {
99
+ getSnapshot: async () => ({ sessions: [], processes: [] }),
100
+ },
101
+ interactiveLiveSessionTranscriptResolver: {
102
+ resolveTranscriptPaths: () => new Map(),
103
+ },
104
+ unansweredOwnerCallListProvider: {
105
+ listUnansweredOwnerCallsBySessionName: async () => new Map(),
106
+ },
107
+ });
108
+
109
+ expect(result.size).toBe(0);
110
+ });
111
+ });
@@ -0,0 +1,33 @@
1
+ import { UnansweredOwnerCall } from '../../../domain/entities/UnansweredOwnerCall';
2
+ import { InteractiveLiveSession } from '../../../domain/entities/InteractiveLiveSession';
3
+ import { ResolveInteractiveLiveSessionsUseCase } from '../../../domain/usecases/ResolveInteractiveLiveSessionsUseCase';
4
+ import { LiveSessionProcessSnapshotProvider } from '../../../domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider';
5
+ import { InteractiveLiveSessionTranscriptResolver } from '../../../domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
6
+
7
+ export type UnansweredOwnerCallListProvider = {
8
+ listUnansweredOwnerCallsBySessionName: (
9
+ transcriptPathBySessionName: Map<string, string>,
10
+ ) => Promise<Map<string, UnansweredOwnerCall[]>>;
11
+ };
12
+
13
+ export type ResolveUnansweredOwnerCallsParams = {
14
+ liveSessionProcessSnapshotProvider: LiveSessionProcessSnapshotProvider;
15
+ interactiveLiveSessionTranscriptResolver: InteractiveLiveSessionTranscriptResolver;
16
+ unansweredOwnerCallListProvider: UnansweredOwnerCallListProvider;
17
+ };
18
+
19
+ export const resolveUnansweredOwnerCallsByTmuxSessionName = async (
20
+ params: ResolveUnansweredOwnerCallsParams,
21
+ ): Promise<Map<string, UnansweredOwnerCall[]>> => {
22
+ const snapshot =
23
+ await params.liveSessionProcessSnapshotProvider.getSnapshot();
24
+ const interactiveSessions: InteractiveLiveSession[] =
25
+ new ResolveInteractiveLiveSessionsUseCase().resolve(snapshot);
26
+ const transcriptPathBySessionName =
27
+ params.interactiveLiveSessionTranscriptResolver.resolveTranscriptPaths(
28
+ interactiveSessions,
29
+ );
30
+ return params.unansweredOwnerCallListProvider.listUnansweredOwnerCallsBySessionName(
31
+ transcriptPathBySessionName,
32
+ );
33
+ };
@@ -820,6 +820,184 @@ describe('TranscriptOwnerCallStatusProvider', () => {
820
820
  Math.floor(Date.parse('2026-06-27T10:20:00.000Z') / 1000),
821
821
  );
822
822
  });
823
+
824
+ describe('listUnansweredOwnerCallsBySessionName', () => {
825
+ const assistantCallSaying = (timestamp: string, text: string): object => ({
826
+ type: 'assistant',
827
+ timestamp,
828
+ message: {
829
+ role: 'assistant',
830
+ stop_reason: 'end_turn',
831
+ content: [{ type: 'text', text: `${text} <<OWNER_CALL>>` }],
832
+ },
833
+ });
834
+
835
+ it('returns the time and the body of an unanswered owner call', async () => {
836
+ const transcriptPath = writeTranscript('workbench.jsonl', [
837
+ ownerReply('2026-06-27T09:00:00.000Z'),
838
+ assistantCallSaying(
839
+ '2026-06-27T10:00:00.000Z',
840
+ 'Please decide whether to merge the release branch',
841
+ ),
842
+ ]);
843
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
844
+
845
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
846
+ new Map([[sessionName, transcriptPath]]),
847
+ );
848
+
849
+ expect(result.get(sessionName)).toEqual([
850
+ {
851
+ calledAt: '2026-06-27T10:00:00.000Z',
852
+ body: 'Please decide whether to merge the release branch <<OWNER_CALL>>',
853
+ },
854
+ ]);
855
+ });
856
+
857
+ it('returns every owner call raised since the last owner reply, oldest first', async () => {
858
+ const transcriptPath = writeTranscript('workbench.jsonl', [
859
+ assistantCallSaying('2026-06-27T08:00:00.000Z', 'answered call'),
860
+ ownerReply('2026-06-27T09:00:00.000Z'),
861
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'first waiting call'),
862
+ assistantCallSaying('2026-06-27T11:30:00.000Z', 'second waiting call'),
863
+ ]);
864
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
865
+
866
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
867
+ new Map([[sessionName, transcriptPath]]),
868
+ );
869
+
870
+ expect(
871
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
872
+ ).toEqual(['2026-06-27T10:00:00.000Z', '2026-06-27T11:30:00.000Z']);
873
+ expect(result.get(sessionName)?.[0].body).toContain('first waiting call');
874
+ expect(result.get(sessionName)?.[1].body).toContain(
875
+ 'second waiting call',
876
+ );
877
+ });
878
+
879
+ it('gives each call of one session a distinct time so the calls stay separable', async () => {
880
+ const transcriptPath = writeTranscript('workbench.jsonl', [
881
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'same text'),
882
+ assistantCallSaying('2026-06-27T11:30:00.000Z', 'same text'),
883
+ ]);
884
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
885
+
886
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
887
+ new Map([[sessionName, transcriptPath]]),
888
+ );
889
+
890
+ const calls = result.get(sessionName) ?? [];
891
+ expect(calls).toHaveLength(2);
892
+ expect(calls[0].body).toBe(calls[1].body);
893
+ expect(calls[0].calledAt).not.toBe(calls[1].calledAt);
894
+ });
895
+
896
+ it('keeps a delivered call listed while the newest call is a candidate the status line has not rendered yet', async () => {
897
+ const transcriptPath = writeTranscript('workbench.jsonl', [
898
+ ownerReply('2026-06-27T09:00:00.000Z'),
899
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'delivered call'),
900
+ assistantWithCandidateMarker('2026-06-27T11:00:00.000Z'),
901
+ ]);
902
+ const markerDirectory = path.join(rootDirectory, 'markers');
903
+ fs.mkdirSync(markerDirectory, { recursive: true });
904
+ const provider = new TranscriptOwnerCallStatusProvider(
905
+ '<<OWNER_CALL>>',
906
+ markerDirectory,
907
+ );
908
+
909
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
910
+ new Map([[sessionName, transcriptPath]]),
911
+ );
912
+
913
+ expect(
914
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
915
+ ).toEqual(['2026-06-27T10:00:00.000Z']);
916
+ });
917
+
918
+ it('omits a call the format gate held and never approved from the listed calls', async () => {
919
+ const transcriptPath = writeTranscript('workbench.jsonl', [
920
+ ownerReply('2026-06-27T09:00:00.000Z'),
921
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'held by the gate'),
922
+ assistantCallSaying('2026-06-27T11:00:00.000Z', 'delivered call'),
923
+ ]);
924
+ const markerDirectory = path.join(rootDirectory, 'markers');
925
+ writeCallGateMarker(
926
+ markerDirectory,
927
+ 'workbench.jsonl',
928
+ '.suppress_ts',
929
+ '2026-06-27T10:00:00.000Z',
930
+ );
931
+ const provider = new TranscriptOwnerCallStatusProvider(
932
+ '<<OWNER_CALL>>',
933
+ markerDirectory,
934
+ );
935
+
936
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
937
+ new Map([[sessionName, transcriptPath]]),
938
+ );
939
+
940
+ expect(
941
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
942
+ ).toEqual(['2026-06-27T11:00:00.000Z']);
943
+ });
944
+
945
+ it('lists the calls oldest first even when the transcript records a later call before an earlier one', async () => {
946
+ const transcriptPath = writeTranscript('workbench.jsonl', [
947
+ assistantCallSaying('2026-06-27T11:00:00.000Z', 'later call'),
948
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'earlier call'),
949
+ ]);
950
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
951
+
952
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
953
+ new Map([[sessionName, transcriptPath]]),
954
+ );
955
+
956
+ expect(
957
+ result.get(sessionName)?.map((ownerCall) => ownerCall.calledAt),
958
+ ).toEqual(['2026-06-27T10:00:00.000Z', '2026-06-27T11:00:00.000Z']);
959
+ });
960
+
961
+ it('returns no entry for a session whose last owner call the owner already answered', async () => {
962
+ const transcriptPath = writeTranscript('workbench.jsonl', [
963
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'please decide'),
964
+ ownerReply('2026-06-27T10:30:00.000Z'),
965
+ ]);
966
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
967
+
968
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
969
+ new Map([[sessionName, transcriptPath]]),
970
+ );
971
+
972
+ expect(result.has(sessionName)).toBe(false);
973
+ });
974
+
975
+ it('returns no entry for a session that raised no owner call', async () => {
976
+ const transcriptPath = writeTranscript('workbench.jsonl', [
977
+ assistantPlain('2026-06-27T10:00:00.000Z'),
978
+ ]);
979
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
980
+
981
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
982
+ new Map([[sessionName, transcriptPath]]),
983
+ );
984
+
985
+ expect(result.has(sessionName)).toBe(false);
986
+ });
987
+
988
+ it('returns nothing when no owner call marker is configured', async () => {
989
+ const transcriptPath = writeTranscript('workbench.jsonl', [
990
+ assistantCallSaying('2026-06-27T10:00:00.000Z', 'please decide'),
991
+ ]);
992
+ const provider = new TranscriptOwnerCallStatusProvider(null);
993
+
994
+ const result = await provider.listUnansweredOwnerCallsBySessionName(
995
+ new Map([[sessionName, transcriptPath]]),
996
+ );
997
+
998
+ expect(result.size).toBe(0);
999
+ });
1000
+ });
823
1001
  });
824
1002
 
825
1003
  describe('ownerCallMarkerFamilyResolve', () => {