github-issue-tower-defence-management 1.83.0 → 1.84.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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +67 -4
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +19 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/consoleListsWriter.js +43 -0
  6. package/bin/adapter/entry-points/handlers/consoleListsWriter.js.map +1 -0
  7. package/bin/adapter/proxy/RateLimitCache.js +27 -2
  8. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  9. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +5 -1
  10. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  11. package/bin/domain/usecases/StartPreparationUseCase.js +6 -6
  12. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  13. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +101 -0
  14. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -0
  15. package/package.json +1 -1
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +18 -0
  17. package/src/adapter/entry-points/handlers/consoleListsWriter.test.ts +167 -0
  18. package/src/adapter/entry-points/handlers/consoleListsWriter.ts +60 -0
  19. package/src/adapter/proxy/RateLimitCache.test.ts +95 -0
  20. package/src/adapter/proxy/RateLimitCache.ts +32 -1
  21. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +43 -0
  22. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -1
  23. package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
  24. package/src/domain/usecases/StartPreparationUseCase.test.ts +343 -0
  25. package/src/domain/usecases/StartPreparationUseCase.ts +6 -6
  26. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +372 -0
  27. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +206 -0
  28. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  29. package/types/adapter/entry-points/handlers/consoleListsWriter.d.ts +13 -0
  30. package/types/adapter/entry-points/handlers/consoleListsWriter.d.ts.map +1 -0
  31. package/types/adapter/proxy/RateLimitCache.d.ts +1 -0
  32. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  33. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  34. package/types/domain/entities/ClaudeTokenUsage.d.ts +1 -0
  35. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  36. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +63 -0
  37. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -0
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GenerateConsoleListsUseCase = void 0;
4
+ const UNKNOWN_STORY_SORT_INDEX = 999999;
5
+ class GenerateConsoleListsUseCase {
6
+ constructor() {
7
+ this.run = (input) => {
8
+ const { project, issues, pjcode, assigneeLogin, generatedAt } = input;
9
+ const storyOptions = project.story ? project.story.stories : [];
10
+ const storyOrder = storyOptions.map((option) => option.name);
11
+ const statusOptions = project.status.statuses;
12
+ const actionableIssues = issues.filter((issue) => this.isActionable(issue, assigneeLogin));
13
+ const buildStatusTab = (selector, excludedStatusNames) => ({
14
+ pjcode,
15
+ generatedAt,
16
+ statusOptions: this.buildFieldOptions(statusOptions, excludedStatusNames),
17
+ storyOrder,
18
+ storyColors: this.buildStoryColorsObject(storyOptions),
19
+ items: this.sortByStoryOrder(actionableIssues
20
+ .filter(selector)
21
+ .map((issue) => this.projectItem(issue)), storyOrder),
22
+ });
23
+ return {
24
+ prs: buildStatusTab((issue) => issue.status !== null &&
25
+ issue.status.toLowerCase() === 'awaiting quality check', ['awaiting quality check', 'done']),
26
+ unread: buildStatusTab((issue) => issue.status !== null && issue.status.toLowerCase() === 'unread', ['unread', 'done']),
27
+ 'failed-preparation': buildStatusTab((issue) => issue.status === 'Failed Preparation', [
28
+ 'failed preparation',
29
+ 'done',
30
+ 'preparation',
31
+ 'icebox',
32
+ 'unread',
33
+ 'in tmux by human',
34
+ ]),
35
+ triage: {
36
+ pjcode,
37
+ generatedAt,
38
+ storyOptions: this.buildFieldOptions(storyOptions, []),
39
+ storyOrder,
40
+ storyColors: this.buildStoryColorsString(storyOptions),
41
+ items: this.sortByStoryOrder(actionableIssues
42
+ .filter((issue) => issue.story !== null &&
43
+ issue.story.toLowerCase().includes('no story'))
44
+ .map((issue) => this.projectItem(issue)), storyOrder),
45
+ },
46
+ };
47
+ };
48
+ this.isActionable = (issue, assigneeLogin) => issue.isClosed === false &&
49
+ issue.assignees.includes(assigneeLogin) &&
50
+ issue.dependedIssueUrls.length === 0 &&
51
+ issue.nextActionDate === null &&
52
+ issue.nextActionHour === null;
53
+ this.projectItem = (issue) => ({
54
+ number: issue.number,
55
+ title: issue.title,
56
+ url: issue.url,
57
+ repo: issue.nameWithOwner,
58
+ nameWithOwner: issue.nameWithOwner,
59
+ projectItemId: issue.itemId,
60
+ itemId: issue.itemId,
61
+ isPr: issue.isPr,
62
+ story: issue.story ?? '',
63
+ labels: issue.labels,
64
+ createdAt: issue.createdAt.toISOString(),
65
+ });
66
+ this.buildFieldOptions = (options, excludedLowerCaseNames) => options
67
+ .filter((option) => !excludedLowerCaseNames.includes(option.name.toLowerCase()))
68
+ .map((option) => ({
69
+ id: option.id,
70
+ name: option.name,
71
+ color: option.color,
72
+ }));
73
+ this.buildStoryColorsObject = (options) => {
74
+ const result = {};
75
+ for (const option of options) {
76
+ result[option.name] = { color: option.color };
77
+ }
78
+ return result;
79
+ };
80
+ this.buildStoryColorsString = (options) => {
81
+ const result = {};
82
+ for (const option of options) {
83
+ result[option.name] = option.color;
84
+ }
85
+ return result;
86
+ };
87
+ this.sortByStoryOrder = (items, storyOrder) => {
88
+ const indexByStory = new Map(storyOrder.map((name, index) => [name, index]));
89
+ return items
90
+ .map((item, position) => ({
91
+ item,
92
+ position,
93
+ sortKey: indexByStory.get(item.story) ?? UNKNOWN_STORY_SORT_INDEX,
94
+ }))
95
+ .sort((a, b) => a.sortKey - b.sortKey || a.position - b.position)
96
+ .map((entry) => entry.item);
97
+ };
98
+ }
99
+ }
100
+ exports.GenerateConsoleListsUseCase = GenerateConsoleListsUseCase;
101
+ //# sourceMappingURL=GenerateConsoleListsUseCase.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GenerateConsoleListsUseCase.js","sourceRoot":"","sources":["../../../../src/domain/usecases/console/GenerateConsoleListsUseCase.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.83.0",
3
+ "version": "1.84.1",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -2,6 +2,7 @@ import YAML from 'yaml';
2
2
  import TYPIA from 'typia';
3
3
  import fs from 'fs';
4
4
  import { writeSituationFile } from './situationFileWriter';
5
+ import { writeConsoleLists } from './consoleListsWriter';
5
6
  import { writeRotationOrderFile } from './rotationOrderFileWriter';
6
7
  import {
7
8
  fetchProjectReadme,
@@ -66,6 +67,7 @@ export class HandleScheduledEventUseCaseHandler {
66
67
  const input: unknown = YAML.parse(configFileContent);
67
68
  type inputType = Parameters<HandleScheduledEventUseCase['run']>[0] & {
68
69
  claudeCodeOauthTokenListJsonPath?: string;
70
+ consoleDataOutputDir?: string;
69
71
  credentials: {
70
72
  manager: {
71
73
  github: {
@@ -365,6 +367,22 @@ export class HandleScheduledEventUseCaseHandler {
365
367
  mergedInput.startPreparation?.preparationProcessCheckCommand ?? null,
366
368
  localCommandRunner: nodeLocalCommandRunner,
367
369
  });
370
+
371
+ try {
372
+ writeConsoleLists({
373
+ consoleDataOutputDir: mergedInput.consoleDataOutputDir ?? null,
374
+ pjcode: input.projectName,
375
+ assigneeLogin: input.manager,
376
+ project: result.project,
377
+ issues: result.issues,
378
+ });
379
+ } catch (error) {
380
+ console.error(
381
+ `Failed to write console lists: ${
382
+ error instanceof Error ? error.message : String(error)
383
+ }`,
384
+ );
385
+ }
368
386
  }
369
387
  return result;
370
388
  };
@@ -0,0 +1,167 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { mock } from 'jest-mock-extended';
5
+ import { Issue } from '../../../domain/entities/Issue';
6
+ import { FieldOption, Project } from '../../../domain/entities/Project';
7
+ import {
8
+ formatConsoleGeneratedAt,
9
+ writeConsoleLists,
10
+ } from './consoleListsWriter';
11
+
12
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
13
+ value !== null && typeof value === 'object' && !Array.isArray(value);
14
+
15
+ const isUnknownArray = (value: unknown): value is unknown[] =>
16
+ Array.isArray(value);
17
+
18
+ const ASSIGNEE = 'owner-login';
19
+
20
+ const option = (
21
+ id: string,
22
+ name: string,
23
+ color: FieldOption['color'],
24
+ ): FieldOption => ({ id, name, color, description: '' });
25
+
26
+ const project: Project = {
27
+ ...mock<Project>(),
28
+ status: {
29
+ name: 'Status',
30
+ fieldId: 'status-field',
31
+ statuses: [
32
+ option('st-unread', 'Unread', 'ORANGE'),
33
+ option('st-aw', 'Awaiting Workspace', 'BLUE'),
34
+ option('st-aqc', 'Awaiting Quality Check', 'GREEN'),
35
+ ],
36
+ },
37
+ story: {
38
+ name: 'story',
39
+ fieldId: 'story-field',
40
+ databaseId: 2,
41
+ stories: [option('s1', 'Story Alpha', 'BLUE')],
42
+ workflowManagementStory: { id: 'wm', name: 'workflow management' },
43
+ },
44
+ };
45
+
46
+ const makeIssue = (overrides: Partial<Issue>): Issue => ({
47
+ ...mock<Issue>(),
48
+ number: 1,
49
+ title: 'Issue 1',
50
+ nameWithOwner: 'demo/repo',
51
+ url: 'https://github.com/demo/repo/issues/1',
52
+ status: null,
53
+ story: null,
54
+ nextActionDate: null,
55
+ nextActionHour: null,
56
+ dependedIssueUrls: [],
57
+ assignees: [ASSIGNEE],
58
+ labels: [],
59
+ body: 'should never be written',
60
+ itemId: 'item-1',
61
+ isPr: false,
62
+ isClosed: false,
63
+ createdAt: new Date('2026-06-13T08:18:45.000Z'),
64
+ ...overrides,
65
+ });
66
+
67
+ describe('writeConsoleLists', () => {
68
+ let outDir: string;
69
+
70
+ beforeEach(() => {
71
+ outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-out-'));
72
+ });
73
+
74
+ afterEach(() => {
75
+ fs.rmSync(outDir, { recursive: true, force: true });
76
+ });
77
+
78
+ const tabFile = (tab: string): string =>
79
+ path.join(outDir, 'demo', tab, 'list.json');
80
+
81
+ it('writes all four tab list.json files', () => {
82
+ writeConsoleLists({
83
+ consoleDataOutputDir: outDir,
84
+ pjcode: 'demo',
85
+ assigneeLogin: ASSIGNEE,
86
+ project,
87
+ issues: [makeIssue({ status: 'Unread' })],
88
+ generatedAt: '2026-06-14T07:22:33Z',
89
+ });
90
+
91
+ for (const tab of ['prs', 'triage', 'unread', 'failed-preparation']) {
92
+ expect(fs.existsSync(tabFile(tab))).toBe(true);
93
+ }
94
+ });
95
+
96
+ it('writes items with no body field', () => {
97
+ writeConsoleLists({
98
+ consoleDataOutputDir: outDir,
99
+ pjcode: 'demo',
100
+ assigneeLogin: ASSIGNEE,
101
+ project,
102
+ issues: [makeIssue({ status: 'Unread' })],
103
+ generatedAt: '2026-06-14T07:22:33Z',
104
+ });
105
+
106
+ const raw: unknown = JSON.parse(fs.readFileSync(tabFile('unread'), 'utf8'));
107
+ expect(isRecord(raw)).toBe(true);
108
+ const items: unknown = isRecord(raw) ? raw.items : undefined;
109
+ expect(isUnknownArray(items)).toBe(true);
110
+ const firstItem: unknown = isUnknownArray(items) ? items[0] : undefined;
111
+ expect(isRecord(firstItem)).toBe(true);
112
+ expect(firstItem).not.toHaveProperty('body');
113
+ });
114
+
115
+ it('does not leave a temp file behind after writing', () => {
116
+ writeConsoleLists({
117
+ consoleDataOutputDir: outDir,
118
+ pjcode: 'demo',
119
+ assigneeLogin: ASSIGNEE,
120
+ project,
121
+ issues: [],
122
+ generatedAt: '2026-06-14T07:22:33Z',
123
+ });
124
+ expect(fs.existsSync(`${tabFile('prs')}.tmp`)).toBe(false);
125
+ });
126
+
127
+ it('is a no-op when consoleDataOutputDir is unset', () => {
128
+ writeConsoleLists({
129
+ consoleDataOutputDir: undefined,
130
+ pjcode: 'demo',
131
+ assigneeLogin: ASSIGNEE,
132
+ project,
133
+ issues: [makeIssue({ status: 'Unread' })],
134
+ });
135
+ expect(fs.readdirSync(outDir)).toHaveLength(0);
136
+ });
137
+
138
+ it('is a no-op when pjcode is unset', () => {
139
+ writeConsoleLists({
140
+ consoleDataOutputDir: outDir,
141
+ pjcode: '',
142
+ assigneeLogin: ASSIGNEE,
143
+ project,
144
+ issues: [makeIssue({ status: 'Unread' })],
145
+ });
146
+ expect(fs.readdirSync(outDir)).toHaveLength(0);
147
+ });
148
+
149
+ it('is a no-op when assigneeLogin is unset', () => {
150
+ writeConsoleLists({
151
+ consoleDataOutputDir: outDir,
152
+ pjcode: 'demo',
153
+ assigneeLogin: null,
154
+ project,
155
+ issues: [makeIssue({ status: 'Unread' })],
156
+ });
157
+ expect(fs.readdirSync(outDir)).toHaveLength(0);
158
+ });
159
+ });
160
+
161
+ describe('formatConsoleGeneratedAt', () => {
162
+ it('strips milliseconds and keeps the trailing Z', () => {
163
+ expect(formatConsoleGeneratedAt(new Date('2026-06-14T07:22:33.456Z'))).toBe(
164
+ '2026-06-14T07:22:33Z',
165
+ );
166
+ });
167
+ });
@@ -0,0 +1,60 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type { Issue } from '../../../domain/entities/Issue';
4
+ import type { Project } from '../../../domain/entities/Project';
5
+ import {
6
+ ConsoleLists,
7
+ ConsoleTabName,
8
+ GenerateConsoleListsUseCase,
9
+ } from '../../../domain/usecases/console/GenerateConsoleListsUseCase';
10
+
11
+ export type ConsoleListsWriterParams = {
12
+ consoleDataOutputDir: string | null | undefined;
13
+ pjcode: string | null | undefined;
14
+ assigneeLogin: string | null | undefined;
15
+ project: Project;
16
+ issues: Issue[];
17
+ generatedAt?: string;
18
+ };
19
+
20
+ const CONSOLE_TAB_NAMES: ConsoleTabName[] = [
21
+ 'prs',
22
+ 'triage',
23
+ 'unread',
24
+ 'failed-preparation',
25
+ ];
26
+
27
+ export const formatConsoleGeneratedAt = (date: Date): string =>
28
+ date.toISOString().replace(/\.\d{3}Z$/, 'Z');
29
+
30
+ const writeJsonAtomic = (filePath: string, data: unknown): void => {
31
+ const dir = path.dirname(filePath);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const tmpPath = `${filePath}.tmp`;
34
+ fs.writeFileSync(tmpPath, JSON.stringify(data));
35
+ fs.renameSync(tmpPath, filePath);
36
+ };
37
+
38
+ export const writeConsoleLists = (params: ConsoleListsWriterParams): void => {
39
+ const { consoleDataOutputDir, pjcode, assigneeLogin } = params;
40
+ if (!consoleDataOutputDir || !pjcode || !assigneeLogin) {
41
+ return;
42
+ }
43
+
44
+ const generatedAt =
45
+ params.generatedAt ?? formatConsoleGeneratedAt(new Date());
46
+ const lists: ConsoleLists = new GenerateConsoleListsUseCase().run({
47
+ project: params.project,
48
+ issues: params.issues,
49
+ pjcode,
50
+ assigneeLogin,
51
+ generatedAt,
52
+ });
53
+
54
+ for (const tab of CONSOLE_TAB_NAMES) {
55
+ writeJsonAtomic(
56
+ path.join(consoleDataOutputDir, pjcode, tab, 'list.json'),
57
+ lists[tab],
58
+ );
59
+ }
60
+ };
@@ -8,6 +8,7 @@ import {
8
8
  HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
9
9
  HEADERLESS_429_MAX_COOLDOWN_SECONDS,
10
10
  parseModelRateLimitsFromBody,
11
+ parseModelRateLimitsFromHeaders,
11
12
  readRateLimit,
12
13
  writeModelRateLimit,
13
14
  writeRateLimit,
@@ -647,4 +648,98 @@ describe('RateLimitCache', () => {
647
648
  expect(readRateLimit(token)).toBeNull();
648
649
  });
649
650
  });
651
+
652
+ describe('parseModelRateLimitsFromHeaders', () => {
653
+ it('should extract a rejected seven_day_sonnet limit from the per-model unified headers', () => {
654
+ expect(
655
+ parseModelRateLimitsFromHeaders({
656
+ 'anthropic-ratelimit-unified-7d_sonnet-status': 'rejected',
657
+ 'anthropic-ratelimit-unified-7d_sonnet-reset': '1779642000',
658
+ }),
659
+ ).toEqual({
660
+ seven_day_sonnet: { rejected: true, resetsAt: 1779642000 },
661
+ });
662
+ });
663
+
664
+ it('should extract both seven_day_sonnet and seven_day_opus limits with their statuses', () => {
665
+ expect(
666
+ parseModelRateLimitsFromHeaders({
667
+ 'anthropic-ratelimit-unified-7d_sonnet-status': 'rejected',
668
+ 'anthropic-ratelimit-unified-7d_sonnet-reset': '1779642000',
669
+ 'anthropic-ratelimit-unified-7d_opus-status': 'allowed',
670
+ 'anthropic-ratelimit-unified-7d_opus-reset': '1779700000',
671
+ }),
672
+ ).toEqual({
673
+ seven_day_sonnet: { rejected: true, resetsAt: 1779642000 },
674
+ seven_day_opus: { rejected: false, resetsAt: 1779700000 },
675
+ });
676
+ });
677
+
678
+ it('should return an empty map when no per-model headers are present', () => {
679
+ expect(
680
+ parseModelRateLimitsFromHeaders({
681
+ 'anthropic-ratelimit-unified-status': 'allowed',
682
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
683
+ }),
684
+ ).toEqual({});
685
+ });
686
+
687
+ it('should default resetsAt to 0 when the per-model reset header is missing', () => {
688
+ expect(
689
+ parseModelRateLimitsFromHeaders({
690
+ 'anthropic-ratelimit-unified-7d_opus-status': 'rejected',
691
+ }),
692
+ ).toEqual({
693
+ seven_day_opus: { rejected: true, resetsAt: 0 },
694
+ });
695
+ });
696
+ });
697
+
698
+ describe('readRateLimit per-model header population', () => {
699
+ it('should populate seven_day_sonnet from the per-model headers when the body carried no rate_limit event', () => {
700
+ const token = 'header-only-sonnet-token';
701
+ writeRateLimit(token, {
702
+ 'anthropic-ratelimit-unified-status': 'rejected',
703
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
704
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
705
+ 'anthropic-ratelimit-unified-5h-utilization': '53',
706
+ 'anthropic-ratelimit-unified-7d-status': 'allowed_warning',
707
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
708
+ 'anthropic-ratelimit-unified-7d-utilization': '88',
709
+ 'anthropic-ratelimit-unified-7d_sonnet-status': 'rejected',
710
+ 'anthropic-ratelimit-unified-7d_sonnet-reset': '1779642000',
711
+ 'anthropic-ratelimit-unified-7d_opus-status': 'allowed',
712
+ 'anthropic-ratelimit-unified-7d_opus-reset': '1779700000',
713
+ });
714
+ const snapshot = readRateLimit(token);
715
+ expect(snapshot?.modelWeeklyLimits).toEqual({
716
+ seven_day_sonnet: { rejected: true, resetsAt: 1779642000 },
717
+ seven_day_opus: { rejected: false, resetsAt: 1779700000 },
718
+ });
719
+ expect(snapshot?.unifiedRejected).toBe(true);
720
+ expect(snapshot?.fiveHourRejected).toBe(false);
721
+ });
722
+
723
+ it('should let a body-derived model limit override the per-model header value for the same claim', () => {
724
+ const token = 'body-overrides-header-token';
725
+ writeRateLimit(token, {
726
+ 'anthropic-ratelimit-unified-status': 'allowed',
727
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
728
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
729
+ 'anthropic-ratelimit-unified-5h-utilization': '10',
730
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
731
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
732
+ 'anthropic-ratelimit-unified-7d-utilization': '5',
733
+ 'anthropic-ratelimit-unified-7d_sonnet-status': 'allowed',
734
+ 'anthropic-ratelimit-unified-7d_sonnet-reset': '1700200000',
735
+ });
736
+ writeModelRateLimit(token, {
737
+ seven_day_sonnet: { rejected: true, resetsAt: 1779642000 },
738
+ });
739
+ const snapshot = readRateLimit(token);
740
+ expect(snapshot?.modelWeeklyLimits).toEqual({
741
+ seven_day_sonnet: { rejected: true, resetsAt: 1779642000 },
742
+ });
743
+ });
744
+ });
650
745
  });
@@ -194,6 +194,34 @@ export const parseModelRateLimitsFromBody = (
194
194
  return result;
195
195
  };
196
196
 
197
+ const HEADER_CLAIM_TO_LIMIT_TYPE: Record<string, string> = {
198
+ '7d_sonnet': 'seven_day_sonnet',
199
+ '7d_opus': 'seven_day_opus',
200
+ };
201
+
202
+ export const parseModelRateLimitsFromHeaders = (
203
+ headers: Record<string, string>,
204
+ ): Record<string, ModelWeeklyLimit> => {
205
+ const result: Record<string, ModelWeeklyLimit> = {};
206
+ for (const [headerClaim, limitType] of Object.entries(
207
+ HEADER_CLAIM_TO_LIMIT_TYPE,
208
+ )) {
209
+ const status = headers[`anthropic-ratelimit-unified-${headerClaim}-status`];
210
+ const resetRaw =
211
+ headers[`anthropic-ratelimit-unified-${headerClaim}-reset`];
212
+ if (status === undefined) continue;
213
+ const resetsAt =
214
+ resetRaw !== undefined && Number.isFinite(Number(resetRaw))
215
+ ? Number(resetRaw)
216
+ : 0;
217
+ result[limitType] = {
218
+ rejected: status === 'rejected',
219
+ resetsAt,
220
+ };
221
+ }
222
+ return result;
223
+ };
224
+
197
225
  export const readRateLimit = (token: string): RateLimitSnapshot | null => {
198
226
  const filePath = cachePathForToken(token);
199
227
  if (!fs.existsSync(filePath)) return null;
@@ -240,7 +268,10 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
240
268
  unifiedRejected,
241
269
  fiveHourRejected,
242
270
  sevenDayRejected,
243
- modelWeeklyLimits: readModelWeeklyLimits(parsed),
271
+ modelWeeklyLimits: {
272
+ ...parseModelRateLimitsFromHeaders(headers),
273
+ ...readModelWeeklyLimits(parsed),
274
+ },
244
275
  lastUpdatedEpoch,
245
276
  blockedUntilEpoch,
246
277
  };
@@ -109,6 +109,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
109
109
  sevenDayUtilization: 0,
110
110
  blocked: false,
111
111
  rejected: false,
112
+ fiveHourRejected: false,
112
113
  blockedUntilEpoch: 0,
113
114
  modelWeeklyLimits: {
114
115
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -121,6 +122,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
121
122
  sevenDayUtilization: 0,
122
123
  blocked: false,
123
124
  rejected: false,
125
+ fiveHourRejected: false,
124
126
  blockedUntilEpoch: 0,
125
127
  modelWeeklyLimits: {},
126
128
  },
@@ -155,6 +157,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
155
157
  sevenDayUtilization: 0,
156
158
  blocked: true,
157
159
  rejected: false,
160
+ fiveHourRejected: false,
158
161
  blockedUntilEpoch: 0,
159
162
  modelWeeklyLimits: {
160
163
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -191,6 +194,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
191
194
  sevenDayUtilization: 0,
192
195
  blocked: false,
193
196
  rejected: true,
197
+ fiveHourRejected: true,
194
198
  blockedUntilEpoch: 0,
195
199
  modelWeeklyLimits: {
196
200
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -227,6 +231,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
227
231
  sevenDayUtilization: 30,
228
232
  blocked: false,
229
233
  rejected: false,
234
+ fiveHourRejected: false,
230
235
  blockedUntilEpoch: 0,
231
236
  modelWeeklyLimits: {
232
237
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -263,6 +268,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
263
268
  sevenDayUtilization: 0,
264
269
  blocked: false,
265
270
  rejected: false,
271
+ fiveHourRejected: false,
266
272
  blockedUntilEpoch: 0,
267
273
  modelWeeklyLimits: {
268
274
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -299,6 +305,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
299
305
  sevenDayUtilization: 0,
300
306
  blocked: false,
301
307
  rejected: false,
308
+ fiveHourRejected: false,
302
309
  blockedUntilEpoch: 0,
303
310
  modelWeeklyLimits: {
304
311
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -335,6 +342,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
335
342
  sevenDayUtilization: 0,
336
343
  blocked: false,
337
344
  rejected: false,
345
+ fiveHourRejected: false,
338
346
  blockedUntilEpoch: 0,
339
347
  modelWeeklyLimits: {},
340
348
  },
@@ -369,6 +377,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
369
377
  sevenDayUtilization: 0,
370
378
  blocked: false,
371
379
  rejected: true,
380
+ fiveHourRejected: true,
372
381
  blockedUntilEpoch: 0,
373
382
  modelWeeklyLimits: {
374
383
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -405,6 +414,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
405
414
  sevenDayUtilization: 100,
406
415
  blocked: false,
407
416
  rejected: true,
417
+ fiveHourRejected: false,
408
418
  blockedUntilEpoch: 0,
409
419
  modelWeeklyLimits: {
410
420
  seven_day: { rejected: true, resetsAt: futureReset },
@@ -441,6 +451,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
441
451
  sevenDayUtilization: 0,
442
452
  blocked: false,
443
453
  rejected: false,
454
+ fiveHourRejected: false,
444
455
  blockedUntilEpoch: 0,
445
456
  modelWeeklyLimits: {
446
457
  seven_day: { rejected: false, resetsAt: futureReset },
@@ -466,6 +477,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
466
477
  sevenDayUtilization: 0,
467
478
  blocked: false,
468
479
  rejected: false,
480
+ fiveHourRejected: false,
469
481
  blockedUntilEpoch: 0,
470
482
  modelWeeklyLimits: {},
471
483
  },
@@ -502,6 +514,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
502
514
  sevenDayUtilization: 10,
503
515
  blocked: false,
504
516
  rejected: false,
517
+ fiveHourRejected: false,
505
518
  blockedUntilEpoch: 0,
506
519
  modelWeeklyLimits: {
507
520
  seven_day_sonnet: { rejected: true, resetsAt: futureReset },
@@ -540,6 +553,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
540
553
  sevenDayUtilization: 10,
541
554
  blocked: false,
542
555
  rejected: false,
556
+ fiveHourRejected: false,
543
557
  blockedUntilEpoch: 0,
544
558
  modelWeeklyLimits: {
545
559
  seven_day_sonnet: { rejected: false, resetsAt: pastReset },
@@ -682,6 +696,35 @@ describe('ProxyClaudeTokenUsageRepository', () => {
682
696
  });
683
697
  });
684
698
 
699
+ it('should bridge a rejected generic seven_day window even when a per-model weekly limit is already present', async () => {
700
+ const opusResetsAt = futureReset + 2000;
701
+ mockLoadTokenEntries.mockReturnValue([
702
+ { name: 'alice', token: 'token-a' },
703
+ ]);
704
+ mockReadRateLimit.mockReturnValue({
705
+ fiveHourUtilization: 10,
706
+ fiveHourReset: futureReset,
707
+ sevenDayUtilization: 100,
708
+ sevenDayReset: futureReset,
709
+ blocked: false,
710
+ rejected: true,
711
+ unifiedRejected: false,
712
+ fiveHourRejected: false,
713
+ sevenDayRejected: true,
714
+ modelWeeklyLimits: {
715
+ seven_day_opus: { rejected: false, resetsAt: opusResetsAt },
716
+ },
717
+ });
718
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
719
+
720
+ const result = await repository.getAvailableTokenUsages();
721
+
722
+ expect(result[0].modelWeeklyLimits).toEqual({
723
+ seven_day_opus: { rejected: false, resetsAt: opusResetsAt },
724
+ seven_day: { rejected: true, resetsAt: futureReset },
725
+ });
726
+ });
727
+
685
728
  it('should not bridge sevenDayReset when the 7d reset has already passed', async () => {
686
729
  mockLoadTokenEntries.mockReturnValue([
687
730
  { name: 'alice', token: 'token-a' },