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.
- package/CHANGELOG.md +19 -0
- package/README.md +9 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +32 -9
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js +13 -1
- package/bin/adapter/entry-points/handlers/inTmuxByHumanDataWriter.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +2 -10
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js +12 -0
- package/bin/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.js.map +1 -0
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +72 -14
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
- package/bin/domain/entities/UnansweredOwnerCall.js +3 -0
- package/bin/domain/entities/UnansweredOwnerCall.js.map +1 -0
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js +21 -2
- package/bin/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +58 -14
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.test.ts +104 -1
- package/src/adapter/entry-points/handlers/inTmuxByHumanDataWriter.ts +19 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +36 -3
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +3 -22
- package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.test.ts +111 -0
- package/src/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.ts +33 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.singleScan.test.ts +88 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +178 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +122 -22
- package/src/domain/entities/UnansweredOwnerCall.ts +4 -0
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.test.ts +99 -0
- package/src/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.ts +47 -1
- package/src/domain/usecases/intmux/UnansweredOwnerCallSessionKeyContract.test.ts +141 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts +2 -0
- package/types/adapter/entry-points/handlers/inTmuxByHumanDataWriter.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +2 -2
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts +13 -0
- package/types/adapter/entry-points/handlers/resolveUnansweredOwnerCalls.d.ts.map +1 -0
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +7 -0
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/entities/UnansweredOwnerCall.d.ts +5 -0
- package/types/domain/entities/UnansweredOwnerCall.d.ts.map +1 -0
- package/types/domain/usecases/intmux/GenerateInTmuxByHumanDataUseCase.d.ts +19 -0
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
+
lastOwnerCall.candidateOnly &&
|
|
254
344
|
!this.isCandidateCallDelivered(transcriptPath, lastOwnerCallEpochMs)
|
|
255
345
|
) {
|
|
256
346
|
return null;
|
|
257
347
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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,
|
|
@@ -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
|
});
|