github-issue-tower-defence-management 1.150.0 → 1.151.1

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 (44) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +9 -1
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +32 -9
  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/notifySilentTmuxSessions.js +2 -10
  8. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js +12 -0
  10. package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js.map +1 -0
  11. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +72 -14
  12. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  13. package/bin/domain/entities/UnansweredOwnerCall.js +3 -0
  14. package/bin/domain/entities/UnansweredOwnerCall.js.map +1 -0
  15. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +21 -2
  16. package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +58 -14
  19. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +104 -1
  20. package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +19 -0
  21. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +36 -3
  22. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +3 -22
  23. package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.test.ts +111 -0
  24. package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.ts +33 -0
  25. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.singleScan.test.ts +88 -0
  26. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +178 -0
  27. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +122 -22
  28. package/src/domain/entities/UnansweredOwnerCall.ts +4 -0
  29. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +99 -0
  30. package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +47 -1
  31. package/src/domain/usecases/intmux/UnansweredOwnerCallSessionKeyContract.test.ts +141 -0
  32. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  33. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +2 -0
  34. package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -1
  35. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +2 -2
  36. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  37. package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts +13 -0
  38. package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts.map +1 -0
  39. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +7 -0
  40. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  41. package/types/domain/entities/UnansweredOwnerCall.d.ts +5 -0
  42. package/types/domain/entities/UnansweredOwnerCall.d.ts.map +1 -0
  43. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +19 -0
  44. package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts.map +1 -1
@@ -0,0 +1,88 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { TranscriptOwnerCallStatusProvider } from './TranscriptOwnerCallStatusProvider';
5
+
6
+ describe('TranscriptOwnerCallStatusProvider single transcript scan', () => {
7
+ let rootDirectory: string;
8
+
9
+ beforeEach(() => {
10
+ rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'single-scan-'));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(rootDirectory, { force: true, recursive: true });
15
+ });
16
+
17
+ const writeTranscript = (fileName: string, lines: object[]): string => {
18
+ const filePath = path.join(rootDirectory, fileName);
19
+ fs.writeFileSync(
20
+ filePath,
21
+ lines.map((line) => JSON.stringify(line)).join('\n'),
22
+ 'utf8',
23
+ );
24
+ return filePath;
25
+ };
26
+
27
+ const assistantWithMarker = (timestamp: string): object => ({
28
+ type: 'assistant',
29
+ timestamp,
30
+ message: {
31
+ role: 'assistant',
32
+ stop_reason: 'end_turn',
33
+ content: [
34
+ { type: 'text', text: 'Waiting <<OWNER_CALL>> please decide.' },
35
+ ],
36
+ },
37
+ });
38
+
39
+ const epochSecondsOf = (timestamp: string): number =>
40
+ Math.floor(Date.parse(timestamp) / 1000);
41
+
42
+ it('serves the reminder path and the listing path from one read of the transcript', async () => {
43
+ const transcriptPath = writeTranscript('session.jsonl', [
44
+ assistantWithMarker('2026-08-13T10:00:00.000Z'),
45
+ ]);
46
+ const transcriptPathBySessionName = new Map([['session', transcriptPath]]);
47
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
48
+
49
+ const calls = await provider.listUnansweredOwnerCallsBySessionName(
50
+ transcriptPathBySessionName,
51
+ );
52
+
53
+ writeTranscript('session.jsonl', [
54
+ assistantWithMarker('2026-08-13T11:00:00.000Z'),
55
+ ]);
56
+
57
+ const epochSeconds =
58
+ await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
59
+ transcriptPathBySessionName,
60
+ );
61
+
62
+ expect(calls.get('session')).toEqual([
63
+ {
64
+ calledAt: '2026-08-13T10:00:00.000Z',
65
+ body: 'Waiting <<OWNER_CALL>> please decide.',
66
+ },
67
+ ]);
68
+ expect(epochSeconds.get('session')).toBe(
69
+ epochSecondsOf('2026-08-13T10:00:00.000Z'),
70
+ );
71
+ });
72
+
73
+ it('scans a transcript that only the reminder path asked for', async () => {
74
+ const transcriptPath = writeTranscript('reminder-only.jsonl', [
75
+ assistantWithMarker('2026-08-13T12:00:00.000Z'),
76
+ ]);
77
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
78
+
79
+ const epochSeconds =
80
+ await provider.listUnansweredOwnerCallEpochSecondsBySessionName(
81
+ new Map([['session', transcriptPath]]),
82
+ );
83
+
84
+ expect(epochSeconds.get('session')).toBe(
85
+ epochSecondsOf('2026-08-13T12:00:00.000Z'),
86
+ );
87
+ });
88
+ });
@@ -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', () => {
@@ -1,8 +1,20 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
4
+ import { UnansweredOwnerCall } from '../../domain/entities/UnansweredOwnerCall';
4
5
  import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
5
6
 
7
+ type TranscriptOwnerCall = {
8
+ epochMs: number;
9
+ body: string;
10
+ candidateOnly: boolean;
11
+ };
12
+
13
+ type TranscriptOwnerCallScan = {
14
+ ownerCalls: TranscriptOwnerCall[];
15
+ lastOwnerReplyEpochMs: number | null;
16
+ };
17
+
6
18
  const isRecord = (value: unknown): value is Record<string, unknown> =>
7
19
  typeof value === 'object' && value !== null;
8
20
 
@@ -146,6 +158,11 @@ const isCandidateOwnerCallMarker = (marker: string): boolean =>
146
158
  export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvider {
147
159
  private readonly ownerCallMarkerFamily: string[];
148
160
 
161
+ private readonly transcriptScanByPath = new Map<
162
+ string,
163
+ TranscriptOwnerCallScan | null
164
+ >();
165
+
149
166
  constructor(
150
167
  ownerCallMarker: string | null,
151
168
  private readonly ownerReplyMarkerDirectory: string | null = null,
@@ -167,10 +184,8 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
167
184
  return unansweredOwnerCallEpochSecondsBySessionName;
168
185
  }
169
186
  for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
170
- const unansweredOwnerCallEpochMs = this.findUnansweredOwnerCallEpochMs(
171
- transcriptPath,
172
- this.ownerCallMarkerFamily,
173
- );
187
+ const unansweredOwnerCallEpochMs =
188
+ this.findUnansweredOwnerCallEpochMs(transcriptPath);
174
189
  if (unansweredOwnerCallEpochMs !== null) {
175
190
  unansweredOwnerCallEpochSecondsBySessionName.set(
176
191
  sessionName,
@@ -181,18 +196,67 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
181
196
  return unansweredOwnerCallEpochSecondsBySessionName;
182
197
  };
183
198
 
184
- private findUnansweredOwnerCallEpochMs = (
199
+ listUnansweredOwnerCallsBySessionName = async (
200
+ transcriptPathBySessionName: Map<string, string>,
201
+ ): Promise<Map<string, UnansweredOwnerCall[]>> => {
202
+ const unansweredOwnerCallsBySessionName = new Map<
203
+ string,
204
+ UnansweredOwnerCall[]
205
+ >();
206
+ if (this.ownerCallMarkerFamily.length === 0) {
207
+ return unansweredOwnerCallsBySessionName;
208
+ }
209
+ for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
210
+ const transcript = this.scanTranscript(transcriptPath);
211
+ if (transcript === null) {
212
+ continue;
213
+ }
214
+ const answeredBeforeEpochMs = this.resolveReplyEpochMs(
215
+ transcriptPath,
216
+ transcript.lastOwnerReplyEpochMs,
217
+ );
218
+ const unansweredCalls = transcript.ownerCalls
219
+ .filter(
220
+ (ownerCall) =>
221
+ (answeredBeforeEpochMs === null ||
222
+ ownerCall.epochMs > answeredBeforeEpochMs) &&
223
+ this.isCallDeliveredToOwner(transcriptPath, ownerCall),
224
+ )
225
+ .sort((oneCall, otherCall) => oneCall.epochMs - otherCall.epochMs)
226
+ .map((ownerCall) => ({
227
+ calledAt: new Date(ownerCall.epochMs).toISOString(),
228
+ body: ownerCall.body,
229
+ }));
230
+ if (unansweredCalls.length > 0) {
231
+ unansweredOwnerCallsBySessionName.set(sessionName, unansweredCalls);
232
+ }
233
+ }
234
+ return unansweredOwnerCallsBySessionName;
235
+ };
236
+
237
+ private scanTranscript = (
185
238
  transcriptPath: string,
186
- markerFamily: string[],
187
- ): number | null => {
239
+ ): TranscriptOwnerCallScan | null => {
240
+ const alreadyScanned = this.transcriptScanByPath.get(transcriptPath);
241
+ if (alreadyScanned !== undefined) {
242
+ return alreadyScanned;
243
+ }
244
+ const scan = this.readTranscriptScan(transcriptPath);
245
+ this.transcriptScanByPath.set(transcriptPath, scan);
246
+ return scan;
247
+ };
248
+
249
+ private readTranscriptScan = (
250
+ transcriptPath: string,
251
+ ): TranscriptOwnerCallScan | null => {
252
+ const markerFamily = this.ownerCallMarkerFamily;
188
253
  let content: string;
189
254
  try {
190
255
  content = fs.readFileSync(transcriptPath, 'utf8');
191
256
  } catch {
192
257
  return null;
193
258
  }
194
- let lastOwnerCallEpochMs: number | null = null;
195
- let lastOwnerCallIsCandidateOnly = false;
259
+ const ownerCalls: TranscriptOwnerCall[] = [];
196
260
  let lastOwnerReplyEpochMs: number | null = null;
197
261
  for (const line of content.split('\n')) {
198
262
  const trimmed = line.trim();
@@ -221,10 +285,11 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
221
285
  assistantText.includes(marker),
222
286
  );
223
287
  if (matchedMarkers.length > 0) {
224
- lastOwnerCallEpochMs = epochMs;
225
- lastOwnerCallIsCandidateOnly = matchedMarkers.every(
226
- isCandidateOwnerCallMarker,
227
- );
288
+ ownerCalls.push({
289
+ epochMs,
290
+ body: assistantText,
291
+ candidateOnly: matchedMarkers.every(isCandidateOwnerCallMarker),
292
+ });
228
293
  }
229
294
  }
230
295
  if (
@@ -241,27 +306,49 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
241
306
  : lastOwnerReplyEpochMs;
242
307
  }
243
308
  }
244
- if (lastOwnerCallEpochMs === null) {
309
+ return { ownerCalls, lastOwnerReplyEpochMs };
310
+ };
311
+
312
+ private resolveReplyEpochMs = (
313
+ transcriptPath: string,
314
+ lastOwnerReplyEpochMs: number | null,
315
+ ): number | null => {
316
+ const markerReplyEpochMs = this.readOwnerReplyMarkerEpochMs(transcriptPath);
317
+ return markerReplyEpochMs !== null &&
318
+ (lastOwnerReplyEpochMs === null ||
319
+ markerReplyEpochMs > lastOwnerReplyEpochMs)
320
+ ? markerReplyEpochMs
321
+ : lastOwnerReplyEpochMs;
322
+ };
323
+
324
+ private findUnansweredOwnerCallEpochMs = (
325
+ transcriptPath: string,
326
+ ): number | null => {
327
+ const transcript = this.scanTranscript(transcriptPath);
328
+ if (transcript === null) {
329
+ return null;
330
+ }
331
+ const lastOwnerCall =
332
+ transcript.ownerCalls[transcript.ownerCalls.length - 1] ?? null;
333
+ if (lastOwnerCall === null) {
245
334
  return null;
246
335
  }
336
+ const lastOwnerCallEpochMs = lastOwnerCall.epochMs;
247
337
  if (
248
338
  this.isCallSuppressedUndelivered(transcriptPath, lastOwnerCallEpochMs)
249
339
  ) {
250
340
  return null;
251
341
  }
252
342
  if (
253
- lastOwnerCallIsCandidateOnly &&
343
+ lastOwnerCall.candidateOnly &&
254
344
  !this.isCandidateCallDelivered(transcriptPath, lastOwnerCallEpochMs)
255
345
  ) {
256
346
  return null;
257
347
  }
258
- const markerReplyEpochMs = this.readOwnerReplyMarkerEpochMs(transcriptPath);
259
- const resolvedReplyEpochMs =
260
- markerReplyEpochMs !== null &&
261
- (lastOwnerReplyEpochMs === null ||
262
- markerReplyEpochMs > lastOwnerReplyEpochMs)
263
- ? markerReplyEpochMs
264
- : lastOwnerReplyEpochMs;
348
+ const resolvedReplyEpochMs = this.resolveReplyEpochMs(
349
+ transcriptPath,
350
+ transcript.lastOwnerReplyEpochMs,
351
+ );
265
352
  return resolvedReplyEpochMs === null ||
266
353
  lastOwnerCallEpochMs > resolvedReplyEpochMs
267
354
  ? lastOwnerCallEpochMs
@@ -285,6 +372,19 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
285
372
  // on the owner would silence the session's stall reminder for good — the session would keep its
286
373
  // task and never be woken again. Such a call is therefore not an outstanding owner call here. A
287
374
  // delivered call, and a newer call the suppression marker does not name, are untouched.
375
+ private isCallDeliveredToOwner = (
376
+ transcriptPath: string,
377
+ ownerCall: TranscriptOwnerCall,
378
+ ): boolean => {
379
+ if (this.isCallSuppressedUndelivered(transcriptPath, ownerCall.epochMs)) {
380
+ return false;
381
+ }
382
+ return (
383
+ !ownerCall.candidateOnly ||
384
+ this.isCandidateCallDelivered(transcriptPath, ownerCall.epochMs)
385
+ );
386
+ };
387
+
288
388
  private isCallSuppressedUndelivered = (
289
389
  transcriptPath: string,
290
390
  ownerCallEpochMs: number,
@@ -0,0 +1,4 @@
1
+ export type UnansweredOwnerCall = {
2
+ calledAt: string;
3
+ body: string;
4
+ };
@@ -1,6 +1,8 @@
1
1
  import { Issue } from '../../entities/Issue';
2
2
  import { FieldOption, Project } from '../../entities/Project';
3
3
  import { GenerateInTmuxByHumanDataUseCase } from './GenerateInTmuxByHumanDataUseCase';
4
+ import { UnansweredOwnerCall } from '../../entities/UnansweredOwnerCall';
5
+ import { toTmuxSessionName } from './InTmuxByHumanSessionReconcileUseCase';
4
6
 
5
7
  const ASSIGNEE = 'owner-login';
6
8
  const CONSOLE_BASE_URL = 'https://console.example.test';
@@ -98,6 +100,7 @@ describe('GenerateInTmuxByHumanDataUseCase', () => {
98
100
  consoleBaseUrl?: string | null;
99
101
  consoleToken?: string | null;
100
102
  newIssueRepo?: string;
103
+ unansweredCallsByTmuxSessionName?: Map<string, UnansweredOwnerCall[]>;
101
104
  } = {},
102
105
  ) =>
103
106
  usecase.run({
@@ -116,6 +119,9 @@ describe('GenerateInTmuxByHumanDataUseCase', () => {
116
119
  overrides.consoleToken === undefined
117
120
  ? CONSOLE_TOKEN
118
121
  : overrides.consoleToken,
122
+ unansweredCallsByTmuxSessionName:
123
+ overrides.unansweredCallsByTmuxSessionName ??
124
+ new Map<string, UnansweredOwnerCall[]>(),
119
125
  now: NOW,
120
126
  });
121
127
 
@@ -333,4 +339,97 @@ describe('GenerateInTmuxByHumanDataUseCase', () => {
333
339
  expect(result.v3).not.toBeNull();
334
340
  });
335
341
  });
342
+
343
+ describe('v5 document', () => {
344
+ const callsFor = (
345
+ issueUrl: string,
346
+ calls: UnansweredOwnerCall[],
347
+ ): Map<string, UnansweredOwnerCall[]> =>
348
+ new Map([[toTmuxSessionName(issueUrl), calls]]);
349
+
350
+ it('builds version 5 with key order version, overviewUrl, tdpmConsoleUrl, newIssueUrl, groups', () => {
351
+ const result = run([makeIssue({ story: 'Story Alpha' })]);
352
+ expect(result.v5).not.toBeNull();
353
+ expect(Object.keys(result.v5 ?? {})).toEqual([
354
+ 'version',
355
+ 'overviewUrl',
356
+ 'tdpmConsoleUrl',
357
+ 'newIssueUrl',
358
+ 'groups',
359
+ ]);
360
+ expect(result.v5?.version).toBe(5);
361
+ });
362
+
363
+ it('gives a session with no unanswered call an empty unansweredCalls array', () => {
364
+ const result = run([makeIssue({ story: 'Story Alpha' })]);
365
+ expect(result.v5?.groups).toEqual([
366
+ {
367
+ story: 'Story Alpha',
368
+ sessions: [
369
+ {
370
+ name: 'https://github.com/demo/repo/issues/1',
371
+ description: 'Issue 1',
372
+ unansweredCalls: [],
373
+ },
374
+ ],
375
+ },
376
+ ]);
377
+ });
378
+
379
+ it('carries the unanswered calls of a session looked up by the tmux session name the reconciler derives from the issue url', () => {
380
+ const issue = makeIssue({ story: 'Story Alpha' });
381
+ const call: UnansweredOwnerCall = {
382
+ calledAt: '2026-08-13T10:14:00.000Z',
383
+ body: 'Please decide whether to merge the release branch',
384
+ };
385
+
386
+ const result = run([issue], {
387
+ unansweredCallsByTmuxSessionName: callsFor(issue.url, [call]),
388
+ });
389
+
390
+ expect(result.v5?.groups[0].sessions[0].unansweredCalls).toEqual([call]);
391
+ });
392
+
393
+ it('carries every unanswered call of one session in call order with its own time and body', () => {
394
+ const issue = makeIssue({ story: 'Story Alpha' });
395
+ const calls: UnansweredOwnerCall[] = [
396
+ { calledAt: '2026-08-13T10:14:00.000Z', body: 'first call body' },
397
+ { calledAt: '2026-08-13T10:41:30.000Z', body: 'second call body' },
398
+ ];
399
+
400
+ const result = run([issue], {
401
+ unansweredCallsByTmuxSessionName: callsFor(issue.url, calls),
402
+ });
403
+
404
+ expect(result.v5?.groups[0].sessions[0].unansweredCalls).toEqual(calls);
405
+ expect(
406
+ result.v5?.groups[0].sessions[0].unansweredCalls.map(
407
+ (unansweredCall) => unansweredCall.calledAt,
408
+ ),
409
+ ).toEqual(['2026-08-13T10:14:00.000Z', '2026-08-13T10:41:30.000Z']);
410
+ });
411
+
412
+ it('leaves the version 4 document free of unanswered call data', () => {
413
+ const issue = makeIssue({ story: 'Story Alpha' });
414
+
415
+ const result = run([issue], {
416
+ unansweredCallsByTmuxSessionName: callsFor(issue.url, [
417
+ { calledAt: '2026-08-13T10:14:00.000Z', body: 'first call body' },
418
+ ]),
419
+ });
420
+
421
+ expect(result.v4?.groups[0].sessions[0]).toEqual({
422
+ name: 'https://github.com/demo/repo/issues/1',
423
+ description: 'Issue 1',
424
+ });
425
+ });
426
+
427
+ it('is null when the console token is unset while v3 is still produced', () => {
428
+ const result = run([makeIssue({ story: 'Story Alpha' })], {
429
+ consoleToken: null,
430
+ });
431
+ expect(result.v5).toBeNull();
432
+ expect(result.v3).not.toBeNull();
433
+ });
434
+ });
336
435
  });