github-issue-tower-defence-management 1.148.25 → 1.149.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 (63) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -4
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -0
  6. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  7. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js +126 -0
  8. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js.map +1 -0
  9. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  10. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  11. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +5 -3
  12. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  13. package/bin/domain/entities/KevReportWatermark.js +3 -0
  14. package/bin/domain/entities/KevReportWatermark.js.map +1 -0
  15. package/bin/domain/usecases/DailySecurityScanUseCase.js +56 -7
  16. package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
  17. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -0
  18. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  19. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +12 -11
  20. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  21. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js +3 -0
  22. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
  25. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +35 -0
  26. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +7 -0
  27. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.test.ts +253 -0
  28. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.ts +130 -0
  29. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +2 -0
  30. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  31. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +3 -0
  32. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +159 -0
  33. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +7 -1
  34. package/src/domain/entities/KevReportWatermark.ts +4 -0
  35. package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
  36. package/src/domain/usecases/DailySecurityScanUseCase.test.ts +210 -0
  37. package/src/domain/usecases/DailySecurityScanUseCase.ts +84 -7
  38. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +58 -1
  39. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +21 -0
  40. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +146 -8
  41. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +19 -10
  42. package/src/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.ts +11 -0
  43. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +3 -0
  44. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  45. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +2 -0
  46. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  47. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts +10 -0
  48. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts.map +1 -0
  49. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  50. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  51. package/types/domain/entities/KevReportWatermark.d.ts +5 -0
  52. package/types/domain/entities/KevReportWatermark.d.ts.map +1 -0
  53. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
  54. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  55. package/types/domain/usecases/DailySecurityScanUseCase.d.ts +3 -1
  56. package/types/domain/usecases/DailySecurityScanUseCase.d.ts.map +1 -1
  57. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -0
  58. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  59. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  60. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts +15 -0
  61. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts.map +1 -0
  62. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +1 -0
  63. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -2,6 +2,7 @@ import { DailySecurityScanUseCase } from './DailySecurityScanUseCase';
2
2
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
3
3
  import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
4
  import { HttpRepository } from './adapter-interfaces/HttpRepository';
5
+ import { KevReportWatermarkRepository } from './adapter-interfaces/KevReportWatermarkRepository';
5
6
  import { mock } from 'jest-mock-extended';
6
7
 
7
8
  const KEV_CATALOG_URL =
@@ -12,16 +13,21 @@ describe('DailySecurityScanUseCase', () => {
12
13
  const mockLocalCommandRunner = mock<LocalCommandRunner>();
13
14
  const mockIssueRepository = mock<IssueRepository>();
14
15
  const mockHttpRepository = mock<HttpRepository>();
16
+ const mockKevReportWatermarkRepository =
17
+ mock<KevReportWatermarkRepository>();
18
+ mockKevReportWatermarkRepository.load.mockResolvedValue({ type: 'absent' });
15
19
  const useCase = new DailySecurityScanUseCase(
16
20
  mockLocalCommandRunner,
17
21
  mockIssueRepository,
18
22
  mockHttpRepository,
23
+ mockKevReportWatermarkRepository,
19
24
  );
20
25
  return {
21
26
  useCase,
22
27
  mockLocalCommandRunner,
23
28
  mockIssueRepository,
24
29
  mockHttpRepository,
30
+ mockKevReportWatermarkRepository,
25
31
  };
26
32
  };
27
33
 
@@ -1238,5 +1244,209 @@ describe('DailySecurityScanUseCase', () => {
1238
1244
  );
1239
1245
  expect(grepCalls).toHaveLength(0);
1240
1246
  });
1247
+
1248
+ const scannedExampleLibrary = osvScanOutput([
1249
+ {
1250
+ name: 'example-library',
1251
+ version: '1.2.3',
1252
+ ecosystem: 'npm',
1253
+ id: 'GHSA-1111-2222-3333',
1254
+ aliases: ['CVE-2024-0001', 'CVE-2024-0002', 'CVE-2024-0003'],
1255
+ summary: 'Example Library Deserialization',
1256
+ },
1257
+ ]);
1258
+
1259
+ const kevCatalogOf = (
1260
+ vulnerabilities: { cveID: string; dateAdded: string }[],
1261
+ ) => ({
1262
+ vulnerabilities: vulnerabilities.map((vulnerability) => ({
1263
+ cveID: vulnerability.cveID,
1264
+ vendorProject: 'Example',
1265
+ product: 'ExampleLibrary',
1266
+ vulnerabilityName: `${vulnerability.cveID} Deserialization`,
1267
+ dateAdded: vulnerability.dateAdded,
1268
+ })),
1269
+ });
1270
+
1271
+ it('records every addition it considered in the watermark after the report issue is created', async () => {
1272
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1273
+ buildScanEnvironment(
1274
+ scannedExampleLibrary,
1275
+ kevCatalogOf([
1276
+ { cveID: 'CVE-2024-0001', dateAdded: '2024-01-01' },
1277
+ { cveID: 'CVE-2024-0002', dateAdded: '2024-01-02' },
1278
+ ]),
1279
+ );
1280
+
1281
+ await runWithKevReporting(useCase);
1282
+
1283
+ expect(
1284
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1285
+ ).toHaveLength(1);
1286
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1287
+ [
1288
+ {
1289
+ lastReportedDateAdded: '2024-01-02',
1290
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002'],
1291
+ },
1292
+ ],
1293
+ ]);
1294
+ });
1295
+
1296
+ it('does not report an addition that the stored watermark already covers', async () => {
1297
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1298
+ buildScanEnvironment(
1299
+ scannedExampleLibrary,
1300
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1301
+ );
1302
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1303
+ type: 'stored',
1304
+ watermark: {
1305
+ lastReportedDateAdded: '2024-01-02',
1306
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0001'],
1307
+ },
1308
+ });
1309
+
1310
+ await runWithKevReporting(useCase);
1311
+
1312
+ expect(
1313
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1314
+ ).toHaveLength(0);
1315
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1316
+ });
1317
+
1318
+ it('reports an addition sharing the stored watermark date that was not reported before', async () => {
1319
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1320
+ buildScanEnvironment(
1321
+ scannedExampleLibrary,
1322
+ kevCatalogOf([
1323
+ { cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' },
1324
+ { cveID: 'CVE-2024-0002', dateAdded: '2024-01-02' },
1325
+ ]),
1326
+ );
1327
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1328
+ type: 'stored',
1329
+ watermark: {
1330
+ lastReportedDateAdded: '2024-01-02',
1331
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0001'],
1332
+ },
1333
+ });
1334
+
1335
+ await runWithKevReporting(useCase);
1336
+
1337
+ const kevCalls = kevReportCalls(
1338
+ mockIssueRepository.createNewIssue.mock.calls,
1339
+ );
1340
+ expect(kevCalls).toHaveLength(1);
1341
+ expect(kevCalls[0][3]).toContain('CVE-2024-0002');
1342
+ expect(kevCalls[0][3]).not.toContain('CVE-2024-0001');
1343
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1344
+ [
1345
+ {
1346
+ lastReportedDateAdded: '2024-01-02',
1347
+ reportedCveIdsOnLastReportedDateAdded: [
1348
+ 'CVE-2024-0001',
1349
+ 'CVE-2024-0002',
1350
+ ],
1351
+ },
1352
+ ],
1353
+ ]);
1354
+ });
1355
+
1356
+ it('does not select an addition dated strictly before the stored watermark date', async () => {
1357
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1358
+ buildScanEnvironment(
1359
+ scannedExampleLibrary,
1360
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-01' }]),
1361
+ );
1362
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1363
+ type: 'stored',
1364
+ watermark: {
1365
+ lastReportedDateAdded: '2024-01-02',
1366
+ reportedCveIdsOnLastReportedDateAdded: [],
1367
+ },
1368
+ });
1369
+
1370
+ await runWithKevReporting(useCase);
1371
+
1372
+ expect(
1373
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1374
+ ).toHaveLength(0);
1375
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1376
+ });
1377
+
1378
+ it('advances the watermark over an addition that affects no scanned package so it is not weighed again', async () => {
1379
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1380
+ buildScanEnvironment(
1381
+ scannedExampleLibrary,
1382
+ kevCatalogOf([{ cveID: 'CVE-2024-9999', dateAdded: '2024-01-02' }]),
1383
+ );
1384
+
1385
+ await runWithKevReporting(useCase);
1386
+
1387
+ expect(
1388
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1389
+ ).toHaveLength(0);
1390
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1391
+ [
1392
+ {
1393
+ lastReportedDateAdded: '2024-01-02',
1394
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-9999'],
1395
+ },
1396
+ ],
1397
+ ]);
1398
+ });
1399
+
1400
+ it('skips the report and leaves the watermark untouched when the stored watermark is unreadable', async () => {
1401
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1402
+ buildScanEnvironment(
1403
+ scannedExampleLibrary,
1404
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1405
+ );
1406
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1407
+ type: 'unreadable',
1408
+ reason: 'the stored watermark file holds malformed JSON',
1409
+ });
1410
+ const errorSpy = jest
1411
+ .spyOn(console, 'error')
1412
+ .mockImplementation(() => {});
1413
+
1414
+ await runWithKevReporting(useCase);
1415
+
1416
+ expect(
1417
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1418
+ ).toHaveLength(0);
1419
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1420
+ expect(errorSpy).toHaveBeenCalledWith(
1421
+ expect.stringContaining(
1422
+ 'the stored watermark file holds malformed JSON',
1423
+ ),
1424
+ );
1425
+ errorSpy.mockRestore();
1426
+ });
1427
+
1428
+ it('creates the report issue and logs when the watermark write fails, so the same additions are weighed again', async () => {
1429
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1430
+ buildScanEnvironment(
1431
+ scannedExampleLibrary,
1432
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1433
+ );
1434
+ mockKevReportWatermarkRepository.save.mockRejectedValue(
1435
+ new Error('disk is read-only'),
1436
+ );
1437
+ const errorSpy = jest
1438
+ .spyOn(console, 'error')
1439
+ .mockImplementation(() => {});
1440
+
1441
+ await runWithKevReporting(useCase);
1442
+
1443
+ expect(
1444
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1445
+ ).toHaveLength(1);
1446
+ expect(errorSpy).toHaveBeenCalledWith(
1447
+ expect.stringContaining('disk is read-only'),
1448
+ );
1449
+ errorSpy.mockRestore();
1450
+ });
1241
1451
  });
1242
1452
  });
@@ -1,6 +1,8 @@
1
1
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
2
2
  import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
3
  import { HttpRepository } from './adapter-interfaces/HttpRepository';
4
+ import { KevReportWatermarkRepository } from './adapter-interfaces/KevReportWatermarkRepository';
5
+ import { KevReportWatermark } from '../entities/KevReportWatermark';
4
6
  import { Member } from '../entities/Member';
5
7
 
6
8
  export type DailySecurityScanConfig = {
@@ -122,6 +124,40 @@ const renderScannerFindings = (
122
124
  const KEV_CATALOG_URL =
123
125
  'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
124
126
 
127
+ const dayBeforeUtcYmd = (date: Date): string => {
128
+ const dayBefore = new Date(date);
129
+ dayBefore.setUTCDate(dayBefore.getUTCDate() - 1);
130
+ return dayBefore.toISOString().slice(0, 10);
131
+ };
132
+
133
+ const advanceWatermark = (
134
+ storedWatermark: KevReportWatermark | null,
135
+ reportedVulnerabilities: KevVulnerability[],
136
+ ): KevReportWatermark => {
137
+ const lastReportedDateAdded = reportedVulnerabilities.reduce(
138
+ (latest, vulnerability) =>
139
+ vulnerability.dateAdded > latest ? vulnerability.dateAdded : latest,
140
+ storedWatermark === null ? '' : storedWatermark.lastReportedDateAdded,
141
+ );
142
+ const reportedCveIdsOnLastReportedDateAdded = new Set<string>(
143
+ storedWatermark !== null &&
144
+ storedWatermark.lastReportedDateAdded === lastReportedDateAdded
145
+ ? storedWatermark.reportedCveIdsOnLastReportedDateAdded
146
+ : [],
147
+ );
148
+ for (const vulnerability of reportedVulnerabilities) {
149
+ if (vulnerability.dateAdded === lastReportedDateAdded) {
150
+ reportedCveIdsOnLastReportedDateAdded.add(vulnerability.cveID);
151
+ }
152
+ }
153
+ return {
154
+ lastReportedDateAdded,
155
+ reportedCveIdsOnLastReportedDateAdded: Array.from(
156
+ reportedCveIdsOnLastReportedDateAdded,
157
+ ),
158
+ };
159
+ };
160
+
125
161
  export class DailySecurityScanUseCase {
126
162
  constructor(
127
163
  readonly localCommandRunner: LocalCommandRunner,
@@ -130,6 +166,7 @@ export class DailySecurityScanUseCase {
130
166
  'createNewIssue' | 'searchIssue' | 'createCommentByUrl'
131
167
  >,
132
168
  readonly httpRepository: HttpRepository,
169
+ readonly kevReportWatermarkRepository: KevReportWatermarkRepository,
133
170
  ) {}
134
171
 
135
172
  run = async (input: {
@@ -344,9 +381,26 @@ export class DailySecurityScanUseCase {
344
381
  return;
345
382
  }
346
383
 
347
- const yesterday = new Date(lastTargetDate);
348
- yesterday.setUTCDate(yesterday.getUTCDate() - 1);
349
- const yesterdayYmd = yesterday.toISOString().slice(0, 10);
384
+ const watermarkLoadResult = await this.kevReportWatermarkRepository.load();
385
+ if (watermarkLoadResult.type === 'unreadable') {
386
+ console.error(
387
+ `Skipping the CISA KEV report for this run because ${watermarkLoadResult.reason}. The stored watermark is left unchanged and no issue was created; reporting resumes once the stored watermark is readable again.`,
388
+ );
389
+ return;
390
+ }
391
+ const storedWatermark =
392
+ watermarkLoadResult.type === 'stored'
393
+ ? watermarkLoadResult.watermark
394
+ : null;
395
+ const reportBoundaryDateAdded =
396
+ storedWatermark === null
397
+ ? dayBeforeUtcYmd(lastTargetDate)
398
+ : storedWatermark.lastReportedDateAdded;
399
+ const alreadyReportedCveIdsOnBoundary = new Set<string>(
400
+ storedWatermark === null
401
+ ? []
402
+ : storedWatermark.reportedCveIdsOnLastReportedDateAdded,
403
+ );
350
404
 
351
405
  const kevJson = await this.httpRepository.get(KEV_CATALOG_URL);
352
406
  const parsedKev: unknown = JSON.parse(kevJson);
@@ -356,10 +410,30 @@ export class DailySecurityScanUseCase {
356
410
  );
357
411
  }
358
412
 
359
- const newKevEntries = parsedKev.vulnerabilities.filter(
360
- (vulnerability) => vulnerability.dateAdded >= yesterdayYmd,
413
+ const unreportedVulnerabilities = parsedKev.vulnerabilities.filter(
414
+ (vulnerability) =>
415
+ vulnerability.dateAdded > reportBoundaryDateAdded ||
416
+ (vulnerability.dateAdded === reportBoundaryDateAdded &&
417
+ !alreadyReportedCveIdsOnBoundary.has(vulnerability.cveID)),
418
+ );
419
+ if (unreportedVulnerabilities.length === 0) {
420
+ return;
421
+ }
422
+ const advancedWatermark = advanceWatermark(
423
+ storedWatermark,
424
+ unreportedVulnerabilities,
361
425
  );
362
- const affectingKevEntries = newKevEntries
426
+ const saveAdvancedWatermark = async (): Promise<void> => {
427
+ try {
428
+ await this.kevReportWatermarkRepository.save(advancedWatermark);
429
+ } catch (error) {
430
+ console.error(
431
+ `The CISA KEV additions considered in this run were not recorded because the KEV report watermark file did not advance to ${advancedWatermark.lastReportedDateAdded}: ${String(error)}. Those additions will be considered again on the next run, and the rest of the scheduled work continues.`,
432
+ );
433
+ }
434
+ };
435
+
436
+ const affectingKevEntries = unreportedVulnerabilities
363
437
  .map((vulnerability) => ({
364
438
  vulnerability,
365
439
  affectedPackages: scannedVulnerablePackages.filter((scannedPackage) =>
@@ -368,13 +442,14 @@ export class DailySecurityScanUseCase {
368
442
  }))
369
443
  .filter((entry) => entry.affectedPackages.length > 0);
370
444
  if (affectingKevEntries.length === 0) {
445
+ await saveAdvancedWatermark();
371
446
  return;
372
447
  }
373
448
 
374
449
  await this.issueRepository.createNewIssue(
375
450
  org,
376
451
  config.kevReportRepo,
377
- `CISA KEV new additions since ${yesterdayYmd}`,
452
+ `CISA KEV new additions since ${reportBoundaryDateAdded}`,
378
453
  affectingKevEntries
379
454
  .map((entry) =>
380
455
  [
@@ -389,5 +464,7 @@ export class DailySecurityScanUseCase {
389
464
  [manager],
390
465
  [],
391
466
  );
467
+
468
+ await saveAdvancedWatermark();
392
469
  };
393
470
  }
@@ -15,6 +15,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
15
15
  silentSeconds: 360,
16
16
  runningSeconds: 1200,
17
17
  waitingOnExternalProcess: false,
18
+ finishedResultUnconsumed: false,
18
19
  };
19
20
  const section = composer.composeSubAgentSection({
20
21
  idleSubAgents: [subAgent],
@@ -23,6 +24,45 @@ describe('DefaultSilentSessionMessageComposer', () => {
23
24
  expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
24
25
  });
25
26
 
27
+ it('embeds the reminder sentinel in the unconsumed-result section', () => {
28
+ const section = composer.composeSubAgentUnconsumedResultSection([
29
+ {
30
+ label: 'agent-aaabbbbcccc30001',
31
+ silentSeconds: 5220,
32
+ runningSeconds: 5400,
33
+ waitingOnExternalProcess: false,
34
+ finishedResultUnconsumed: true,
35
+ },
36
+ ]);
37
+ expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
38
+ });
39
+
40
+ it('names every finished sub-agent and the whole minutes its result has stayed unconsumed', () => {
41
+ const section = composer.composeSubAgentUnconsumedResultSection([
42
+ {
43
+ label: 'agent-aaabbbbcccc30001',
44
+ silentSeconds: 5220,
45
+ runningSeconds: 5400,
46
+ waitingOnExternalProcess: false,
47
+ finishedResultUnconsumed: true,
48
+ },
49
+ {
50
+ label: 'agent-aaabbbbcccc30002',
51
+ silentSeconds: 359,
52
+ runningSeconds: 900,
53
+ waitingOnExternalProcess: false,
54
+ finishedResultUnconsumed: true,
55
+ },
56
+ ]);
57
+ expect(section).toContain('This is an automated status check.');
58
+ expect(section).toContain(
59
+ 'agent-aaabbbbcccc30001: result unconsumed for 87m',
60
+ );
61
+ expect(section).toContain(
62
+ 'agent-aaabbbbcccc30002: result unconsumed for 5m',
63
+ );
64
+ });
65
+
26
66
  it('renders the main-stalled message as a neutral automated status notice with the silent minutes substituted', () => {
27
67
  const section = composer.composeMainStalledSection(600);
28
68
  expect(section).toContain('This is an automated status check.');
@@ -148,6 +188,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
148
188
  silentSeconds: 360,
149
189
  runningSeconds: 1200,
150
190
  waitingOnExternalProcess: false,
191
+ finishedResultUnconsumed: false,
151
192
  };
152
193
  const section = composer.composeSubAgentSection({
153
194
  idleSubAgents: [subAgent],
@@ -258,6 +299,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
258
299
  silentSeconds: 360,
259
300
  runningSeconds: 60,
260
301
  waitingOnExternalProcess: false,
302
+ finishedResultUnconsumed: false,
261
303
  },
262
304
  ],
263
305
  longRunningSubAgents: [],
@@ -277,6 +319,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
277
319
  silentSeconds: 360,
278
320
  runningSeconds: 60,
279
321
  waitingOnExternalProcess: false,
322
+ finishedResultUnconsumed: false,
280
323
  },
281
324
  ],
282
325
  longRunningSubAgents: [],
@@ -296,6 +339,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
296
339
  silentSeconds: 360,
297
340
  runningSeconds: 60,
298
341
  waitingOnExternalProcess: false,
342
+ finishedResultUnconsumed: false,
299
343
  },
300
344
  ],
301
345
  longRunningSubAgents: [],
@@ -317,6 +361,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
317
361
  silentSeconds: 30,
318
362
  runningSeconds: 1200,
319
363
  waitingOnExternalProcess: false,
364
+ finishedResultUnconsumed: false,
320
365
  },
321
366
  ],
322
367
  });
@@ -340,6 +385,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
340
385
  silentSeconds: 30,
341
386
  runningSeconds: 1200,
342
387
  waitingOnExternalProcess: false,
388
+ finishedResultUnconsumed: false,
343
389
  },
344
390
  ],
345
391
  });
@@ -353,6 +399,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
353
399
  silentSeconds: 360,
354
400
  runningSeconds: 1200,
355
401
  waitingOnExternalProcess: false,
402
+ finishedResultUnconsumed: false,
356
403
  };
357
404
  const section = composer.composeSubAgentSection({
358
405
  idleSubAgents: [subAgent],
@@ -374,6 +421,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
374
421
  silentSeconds: 360,
375
422
  runningSeconds: 60,
376
423
  waitingOnExternalProcess: false,
424
+ finishedResultUnconsumed: false,
377
425
  },
378
426
  ],
379
427
  longRunningSubAgents: [
@@ -382,6 +430,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
382
430
  silentSeconds: 10,
383
431
  runningSeconds: 960,
384
432
  waitingOnExternalProcess: false,
433
+ finishedResultUnconsumed: false,
385
434
  },
386
435
  ],
387
436
  });
@@ -411,6 +460,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
411
460
  silentSeconds: 360,
412
461
  runningSeconds: 1200,
413
462
  waitingOnExternalProcess: false,
463
+ finishedResultUnconsumed: false,
414
464
  };
415
465
  const composedDefaultTexts = [
416
466
  composer.composeMainStalledSection(600),
@@ -419,6 +469,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
419
469
  idleSubAgents: [subAgent],
420
470
  longRunningSubAgents: [subAgent],
421
471
  }),
472
+ composer.composeSubAgentUnconsumedResultSection([subAgent]),
422
473
  ];
423
474
  for (const text of composedDefaultTexts) {
424
475
  for (const pattern of flaggedPatterns) {
@@ -435,6 +486,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
435
486
  silentSeconds: 360,
436
487
  runningSeconds: 1200,
437
488
  waitingOnExternalProcess: false,
489
+ finishedResultUnconsumed: false,
438
490
  };
439
491
  const composedDefaultTexts = [
440
492
  composer.composeMainStalledSection(600),
@@ -443,6 +495,7 @@ describe('DefaultSilentSessionMessageComposer', () => {
443
495
  idleSubAgents: [subAgent],
444
496
  longRunningSubAgents: [subAgent],
445
497
  }),
498
+ composer.composeSubAgentUnconsumedResultSection([subAgent]),
446
499
  ];
447
500
  for (const text of composedDefaultTexts) {
448
501
  expect(text).not.toContain('<');
@@ -462,12 +515,16 @@ describe('DefaultSilentSessionMessageComposer', () => {
462
515
  silentSeconds: 360,
463
516
  runningSeconds: 1200,
464
517
  waitingOnExternalProcess: false,
518
+ finishedResultUnconsumed: false,
465
519
  };
466
520
  const subSection = composer.composeSubAgentSection({
467
521
  idleSubAgents: [subAgent],
468
522
  longRunningSubAgents: [subAgent],
469
523
  });
470
- const combined = `${mainSection}\n${subSection}`.toLowerCase();
524
+ const unconsumedResultSection =
525
+ composer.composeSubAgentUnconsumedResultSection([subAgent]);
526
+ const combined =
527
+ `${mainSection}\n${subSection}\n${unconsumedResultSection}`.toLowerCase();
471
528
  expect(combined).not.toContain('claude');
472
529
  expect(combined).not.toContain('take ownership');
473
530
  expect(combined).not.toContain('/home/');
@@ -42,6 +42,22 @@ const composeLongRunningSubAgentSection = (
42
42
  ].join('\n');
43
43
  };
44
44
 
45
+ const composeUnconsumedResultSubAgentSection = (
46
+ unconsumedResultSubAgents: SubAgentActivity[],
47
+ ): string => {
48
+ const lines = unconsumedResultSubAgents.map(
49
+ (subAgent) =>
50
+ `- ${subAgent.label}: result unconsumed for ${formatMinutes(
51
+ subAgent.silentSeconds,
52
+ )}`,
53
+ );
54
+ return [
55
+ `${SILENT_SESSION_REMINDER_SENTINEL} This is an automated status check. The following sub-process(es) have finished and their results have been waiting unread for about the minutes shown:`,
56
+ ...lines,
57
+ 'For each one: please read its result, act on it, and then mark the sub-process as finished so it is no longer tracked as running. If you have already acted on the result, marking it finished is all that remains.',
58
+ ].join('\n');
59
+ };
60
+
45
61
  export const composeOwnerCallFormatGuidance = (): string => {
46
62
  return 'Please share it through a new owner-call in the format documented for this session, written to be self-contained so the owner can understand the situation from that single message.';
47
63
  };
@@ -98,4 +114,9 @@ export class DefaultSilentSessionMessageComposer implements SilentSessionMessage
98
114
  }
99
115
  return sections.join('\n\n');
100
116
  };
117
+
118
+ composeSubAgentUnconsumedResultSection = (
119
+ unconsumedResultSubAgents: SubAgentActivity[],
120
+ ): string =>
121
+ composeUnconsumedResultSubAgentSection(unconsumedResultSubAgents);
101
122
  }