jules-orchestrator-kit 0.38.2 → 0.41.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.
@@ -0,0 +1,579 @@
1
+ import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
2
+ import { resolve, relative, join, sep } from "node:path";
3
+ import { gzipSync } from "node:zlib";
4
+ import { checkAssetIntegrity } from "./asset-integrity.mjs";
5
+ import { checkRulesBudget } from "./rules-budget.mjs";
6
+
7
+ /**
8
+ * Normalizes path to POSIX slashes.
9
+ * @param {string} p
10
+ * @returns {string}
11
+ */
12
+ export function normalizePosix(p = "") {
13
+ return p.split(sep).join("/").replace(/\\/g, "/");
14
+ }
15
+
16
+ /**
17
+ * Formats byte count into human-readable string.
18
+ * @param {number} bytes
19
+ * @returns {string}
20
+ */
21
+ export function formatBytes(bytes = 0) {
22
+ if (!Number.isFinite(bytes) || bytes === 0) return "0 B";
23
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
24
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
25
+ const val = (bytes / Math.pow(1024, i)).toFixed(2);
26
+ return `${val.replace(/\.00$/, "")} ${units[i]}`;
27
+ }
28
+
29
+ /**
30
+ * Resolves byte limit from config options (maxBytes, maxKb, maxMb, maxGb).
31
+ * @param {object} opts
32
+ * @returns {number | null}
33
+ */
34
+ export function resolveBytesLimit(opts = {}) {
35
+ if (typeof opts.maxBytes === "number" && Number.isFinite(opts.maxBytes)) {
36
+ return opts.maxBytes;
37
+ }
38
+ if (typeof opts.maxKb === "number" && Number.isFinite(opts.maxKb)) {
39
+ return opts.maxKb * 1024;
40
+ }
41
+ if (typeof opts.maxMb === "number" && Number.isFinite(opts.maxMb)) {
42
+ return opts.maxMb * 1024 * 1024;
43
+ }
44
+ if (typeof opts.maxGb === "number" && Number.isFinite(opts.maxGb)) {
45
+ return opts.maxGb * 1024 * 1024 * 1024;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Simple zero-dependency glob/pattern matcher supporting * and **.
52
+ * @param {string} pathStr
53
+ * @param {string} pattern
54
+ * @returns {boolean}
55
+ */
56
+ export function matchesGlob(pathStr, pattern) {
57
+ if (!pattern || pattern === "*") return true;
58
+ const normalizedPath = normalizePosix(pathStr).replace(/^\.\//, "");
59
+ const normalizedPattern = normalizePosix(pattern).replace(/^\.\//, "");
60
+
61
+ if (normalizedPath === normalizedPattern) return true;
62
+
63
+ // Convert simple glob pattern to RegExp
64
+ let regexStr = "^";
65
+ let i = 0;
66
+ while (i < normalizedPattern.length) {
67
+ const c = normalizedPattern[i];
68
+ if (c === "*" && normalizedPattern[i + 1] === "*") {
69
+ if (normalizedPattern[i + 2] === "/") {
70
+ regexStr += "(?:.*/)?";
71
+ i += 3;
72
+ continue;
73
+ } else {
74
+ regexStr += ".*";
75
+ i += 2;
76
+ continue;
77
+ }
78
+ } else if (c === "*") {
79
+ regexStr += "[^/]*";
80
+ i++;
81
+ } else if (c === "?") {
82
+ regexStr += "[^/]";
83
+ i++;
84
+ } else if (["\\", ".", "+", "^", "$", "{", "}", "(", ")", "|", "[", "]"].includes(c)) {
85
+ regexStr += "\\" + c;
86
+ i++;
87
+ } else {
88
+ regexStr += c;
89
+ i++;
90
+ }
91
+ }
92
+ regexStr += "$";
93
+
94
+ try {
95
+ const re = new RegExp(regexStr);
96
+ return re.test(normalizedPath);
97
+ } catch (_) {
98
+ return normalizedPath.includes(normalizedPattern);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Asserts total byte size of a directory.
104
+ */
105
+ export function assertDirSize(config = {}, root = process.cwd()) {
106
+ const targetRel = config.path || config.dir || config.target || ".";
107
+ const targetAbs = resolve(root, targetRel);
108
+ const limitBytes = resolveBytesLimit(config);
109
+ const useGzip = Boolean(config.gzip);
110
+
111
+ if (!existsSync(targetAbs)) {
112
+ return {
113
+ ok: false,
114
+ measuredBytes: 0,
115
+ limitBytes,
116
+ exceededBy: 0,
117
+ fileCount: 0,
118
+ gzip: useGzip,
119
+ diagnostics: [`Target directory does not exist: '${targetRel}'`],
120
+ metrics: { measuredBytes: 0, limitBytes, fileCount: 0, gzip: useGzip },
121
+ };
122
+ }
123
+
124
+ const stat = statSync(targetAbs);
125
+ if (!stat.isDirectory()) {
126
+ return {
127
+ ok: false,
128
+ measuredBytes: 0,
129
+ limitBytes,
130
+ exceededBy: 0,
131
+ fileCount: 0,
132
+ gzip: useGzip,
133
+ diagnostics: [`Path '${targetRel}' is a file, not a directory. Use 'assert:file-size' instead.`],
134
+ metrics: { measuredBytes: 0, limitBytes, fileCount: 0, gzip: useGzip },
135
+ };
136
+ }
137
+
138
+ const includes = Array.isArray(config.include) ? config.include : config.include ? [config.include] : [];
139
+ const excludes = Array.isArray(config.exclude) ? config.exclude : config.exclude ? [config.exclude] : ["node_modules/**", ".git/**"];
140
+
141
+ let totalBytes = 0;
142
+ let fileCount = 0;
143
+
144
+ function scan(currentDir) {
145
+ let entries = [];
146
+ try {
147
+ entries = readdirSync(currentDir, { withFileTypes: true });
148
+ } catch (_) {
149
+ return;
150
+ }
151
+
152
+ for (const entry of entries) {
153
+ const fullPath = join(currentDir, entry.name);
154
+ const relToTarget = normalizePosix(relative(targetAbs, fullPath));
155
+ const relToRoot = normalizePosix(relative(root, fullPath));
156
+
157
+ if (entry.isDirectory()) {
158
+ const isExcluded = excludes.some((pat) => matchesGlob(relToTarget, pat) || matchesGlob(relToRoot, pat) || entry.name === "node_modules" || entry.name === ".git");
159
+ if (!isExcluded) {
160
+ scan(fullPath);
161
+ }
162
+ } else if (entry.isFile()) {
163
+ const isExcluded = excludes.some((pat) => matchesGlob(relToTarget, pat) || matchesGlob(relToRoot, pat));
164
+ if (isExcluded) continue;
165
+
166
+ if (includes.length > 0) {
167
+ const isIncluded = includes.some((pat) => matchesGlob(relToTarget, pat) || matchesGlob(relToRoot, pat));
168
+ if (!isIncluded) continue;
169
+ }
170
+
171
+ try {
172
+ if (useGzip) {
173
+ const buf = readFileSync(fullPath);
174
+ const gzipped = gzipSync(buf);
175
+ totalBytes += gzipped.length;
176
+ } else {
177
+ const fileStat = statSync(fullPath);
178
+ totalBytes += fileStat.size;
179
+ }
180
+ fileCount++;
181
+ } catch (_) {}
182
+ }
183
+ }
184
+ }
185
+
186
+ scan(targetAbs);
187
+
188
+ const exceededBy = limitBytes !== null ? Math.max(0, totalBytes - limitBytes) : 0;
189
+ const ok = limitBytes !== null ? totalBytes <= limitBytes : true;
190
+ const diagnostics = [];
191
+
192
+ if (!ok) {
193
+ const formattedMeasured = formatBytes(totalBytes);
194
+ const formattedLimit = formatBytes(limitBytes);
195
+ const formattedExceeded = formatBytes(exceededBy);
196
+ diagnostics.push(
197
+ `Directory '${targetRel}' (${formattedMeasured}${useGzip ? " gzipped" : ""}) exceeds limit of ${formattedLimit} by ${formattedExceeded}.`
198
+ );
199
+ }
200
+
201
+ return {
202
+ ok,
203
+ measuredBytes: totalBytes,
204
+ limitBytes,
205
+ exceededBy,
206
+ fileCount,
207
+ gzip: useGzip,
208
+ diagnostics,
209
+ metrics: {
210
+ measuredBytes: totalBytes,
211
+ limitBytes,
212
+ exceededBy,
213
+ fileCount,
214
+ gzip: useGzip,
215
+ },
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Asserts size of a single file.
221
+ */
222
+ export function assertFileSize(config = {}, root = process.cwd()) {
223
+ const targetRel = config.path || config.file || config.target;
224
+ if (!targetRel) {
225
+ return {
226
+ ok: false,
227
+ measuredBytes: 0,
228
+ limitBytes: null,
229
+ exceededBy: 0,
230
+ gzip: false,
231
+ diagnostics: ["'assert:file-size' requires a 'path' or 'file' parameter."],
232
+ metrics: { measuredBytes: 0, limitBytes: null },
233
+ };
234
+ }
235
+
236
+ const targetAbs = resolve(root, targetRel);
237
+ const limitBytes = resolveBytesLimit(config);
238
+ const useGzip = Boolean(config.gzip);
239
+
240
+ if (!existsSync(targetAbs)) {
241
+ return {
242
+ ok: false,
243
+ measuredBytes: 0,
244
+ limitBytes,
245
+ exceededBy: 0,
246
+ gzip: useGzip,
247
+ diagnostics: [`Target file does not exist: '${targetRel}'`],
248
+ metrics: { measuredBytes: 0, limitBytes, gzip: useGzip },
249
+ };
250
+ }
251
+
252
+ let measuredBytes = 0;
253
+ try {
254
+ if (useGzip) {
255
+ const buf = readFileSync(targetAbs);
256
+ measuredBytes = gzipSync(buf).length;
257
+ } else {
258
+ measuredBytes = statSync(targetAbs).size;
259
+ }
260
+ } catch (err) {
261
+ return {
262
+ ok: false,
263
+ measuredBytes: 0,
264
+ limitBytes,
265
+ exceededBy: 0,
266
+ gzip: useGzip,
267
+ diagnostics: [`Failed to read file '${targetRel}': ${err.message}`],
268
+ metrics: { measuredBytes: 0, limitBytes, gzip: useGzip },
269
+ };
270
+ }
271
+
272
+ const exceededBy = limitBytes !== null ? Math.max(0, measuredBytes - limitBytes) : 0;
273
+ const ok = limitBytes !== null ? measuredBytes <= limitBytes : true;
274
+ const diagnostics = [];
275
+
276
+ if (!ok) {
277
+ const formattedMeasured = formatBytes(measuredBytes);
278
+ const formattedLimit = formatBytes(limitBytes);
279
+ const formattedExceeded = formatBytes(exceededBy);
280
+ diagnostics.push(
281
+ `File '${targetRel}' (${formattedMeasured}${useGzip ? " gzipped" : ""}) exceeds limit of ${formattedLimit} by ${formattedExceeded}.`
282
+ );
283
+ }
284
+
285
+ return {
286
+ ok,
287
+ measuredBytes,
288
+ limitBytes,
289
+ exceededBy,
290
+ gzip: useGzip,
291
+ diagnostics,
292
+ metrics: {
293
+ measuredBytes,
294
+ limitBytes,
295
+ exceededBy,
296
+ gzip: useGzip,
297
+ },
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Asserts that files matching targets contain no forbidden patterns or banned terms.
303
+ */
304
+ export function assertFilePatterns(config = {}, root = process.cwd()) {
305
+ const targets = Array.isArray(config.targets)
306
+ ? config.targets
307
+ : Array.isArray(config.files)
308
+ ? config.files
309
+ : config.targets
310
+ ? [config.targets]
311
+ : config.files
312
+ ? [config.files]
313
+ : config.path
314
+ ? [config.path]
315
+ : ["src/**/*"];
316
+
317
+ let patterns = [];
318
+ if (Array.isArray(config.patterns)) {
319
+ patterns.push(...config.patterns);
320
+ }
321
+ if (Array.isArray(config.forbiddenRegex)) {
322
+ patterns.push(...config.forbiddenRegex);
323
+ }
324
+ if (Array.isArray(config.bannedWords)) {
325
+ patterns.push(...config.bannedWords);
326
+ }
327
+
328
+ if (config.patternsFile) {
329
+ const patFileAbs = resolve(root, config.patternsFile);
330
+ if (existsSync(patFileAbs)) {
331
+ try {
332
+ const raw = readFileSync(patFileAbs, "utf-8");
333
+ const parsed = JSON.parse(raw);
334
+ if (Array.isArray(parsed)) {
335
+ patterns.push(...parsed);
336
+ } else if (parsed && Array.isArray(parsed.patterns)) {
337
+ patterns.push(...parsed.patterns);
338
+ }
339
+ } catch (err) {
340
+ return {
341
+ ok: false,
342
+ matchCount: 0,
343
+ matches: [],
344
+ diagnostics: [`Failed to parse patternsFile '${config.patternsFile}': ${err.message}`],
345
+ metrics: { filesScanned: 0, matchCount: 0 },
346
+ };
347
+ }
348
+ } else {
349
+ return {
350
+ ok: false,
351
+ matchCount: 0,
352
+ matches: [],
353
+ diagnostics: [`patternsFile not found: '${config.patternsFile}'`],
354
+ metrics: { filesScanned: 0, matchCount: 0 },
355
+ };
356
+ }
357
+ }
358
+
359
+ if (patterns.length === 0) {
360
+ return {
361
+ ok: true,
362
+ matchCount: 0,
363
+ matches: [],
364
+ diagnostics: [],
365
+ metrics: { filesScanned: 0, matchCount: 0 },
366
+ };
367
+ }
368
+
369
+ const compiledPatterns = patterns.map((p) => {
370
+ if (p instanceof RegExp) return { raw: p.source, re: p };
371
+ const str = String(p);
372
+ try {
373
+ return { raw: str, re: new RegExp(str, "g") };
374
+ } catch (_) {
375
+ const escaped = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
376
+ return { raw: str, re: new RegExp(escaped, "g") };
377
+ }
378
+ });
379
+
380
+ const candidateFiles = [];
381
+
382
+ function collectFiles(dir) {
383
+ let entries = [];
384
+ try {
385
+ entries = readdirSync(dir, { withFileTypes: true });
386
+ } catch (_) {
387
+ return;
388
+ }
389
+
390
+ for (const entry of entries) {
391
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
392
+ const fullPath = join(dir, entry.name);
393
+ const relPath = normalizePosix(relative(root, fullPath));
394
+
395
+ if (entry.isDirectory()) {
396
+ collectFiles(fullPath);
397
+ } else if (entry.isFile()) {
398
+ const matchesAnyTarget = targets.some((tgt) => matchesGlob(relPath, tgt));
399
+ if (matchesAnyTarget) {
400
+ candidateFiles.push(relPath);
401
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ collectFiles(root);
407
+
408
+ const matches = [];
409
+
410
+ for (const relFile of candidateFiles) {
411
+ const absFile = resolve(root, relFile);
412
+ let content = "";
413
+ try {
414
+ content = readFileSync(absFile, "utf-8");
415
+ } catch (_) {
416
+ continue;
417
+ }
418
+
419
+ const lines = content.split("\n");
420
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
421
+ const line = lines[lineIdx];
422
+ for (const pat of compiledPatterns) {
423
+ pat.re.lastIndex = 0;
424
+ if (pat.re.test(line)) {
425
+ matches.push({
426
+ file: relFile,
427
+ line: lineIdx + 1,
428
+ pattern: pat.raw,
429
+ snippet: line.trim().slice(0, 120),
430
+ });
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ const ok = matches.length === 0;
437
+ const diagnostics = matches.map(
438
+ (m) => `Forbidden pattern '${m.pattern}' found in ${m.file}:${m.line}: "${m.snippet}"`
439
+ );
440
+
441
+ return {
442
+ ok,
443
+ matchCount: matches.length,
444
+ matches,
445
+ diagnostics,
446
+ metrics: {
447
+ filesScanned: candidateFiles.length,
448
+ matchCount: matches.length,
449
+ },
450
+ };
451
+ }
452
+
453
+ /**
454
+ * Asserts file existence or absence.
455
+ */
456
+ export function assertFileExists(config = {}, root = process.cwd()) {
457
+ const targetRel = config.path || config.file || config.target;
458
+ const mustExist = config.mustExist !== false && config.exists !== false;
459
+
460
+ if (!targetRel) {
461
+ return {
462
+ ok: false,
463
+ exists: false,
464
+ diagnostics: ["'assert:exists' requires a 'path' parameter."],
465
+ metrics: {},
466
+ };
467
+ }
468
+
469
+ const targetAbs = resolve(root, targetRel);
470
+ const exists = existsSync(targetAbs);
471
+ const ok = mustExist ? exists : !exists;
472
+ const diagnostics = [];
473
+
474
+ if (!ok) {
475
+ diagnostics.push(
476
+ mustExist
477
+ ? `Required file or directory '${targetRel}' does not exist.`
478
+ : `Path '${targetRel}' exists but was asserted to not exist.`
479
+ );
480
+ }
481
+
482
+ return {
483
+ ok,
484
+ exists,
485
+ diagnostics,
486
+ metrics: { path: targetRel, exists, mustExist },
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Dispatches and executes an assertion primitive.
492
+ */
493
+ export function runAssertion(stage = {}, root = process.cwd()) {
494
+ const startTime = Date.now();
495
+ const rawType = stage.assert || stage.type || stage.kind || "";
496
+ const assertionType = String(rawType).toLowerCase().replace(/^assert:/, "").trim();
497
+
498
+ let res;
499
+ switch (assertionType) {
500
+ case "dir-size":
501
+ case "dir_size":
502
+ case "directory-size":
503
+ res = assertDirSize(stage, root);
504
+ break;
505
+
506
+ case "file-size":
507
+ case "file_size":
508
+ res = assertFileSize(stage, root);
509
+ break;
510
+
511
+ case "file-patterns":
512
+ case "file_patterns":
513
+ case "patterns":
514
+ case "pattern":
515
+ case "no-forbidden-terms":
516
+ case "forbidden-patterns":
517
+ res = assertFilePatterns(stage, root);
518
+ break;
519
+
520
+ case "asset-integrity":
521
+ case "asset_integrity":
522
+ case "assets": {
523
+ const targetDir = stage.path || stage.dir || stage.searchDir || "public";
524
+ const checkRes = checkAssetIntegrity(resolve(root, targetDir));
525
+ const diag = (checkRes.corruptedFiles || []).map((f) => `Corrupted asset: ${f.path} (${f.reason})`);
526
+ res = {
527
+ ok: checkRes.ok,
528
+ diagnostics: diag,
529
+ metrics: { checkedCount: checkRes.checkedCount, corruptedCount: checkRes.corruptedFiles?.length || 0 },
530
+ };
531
+ break;
532
+ }
533
+
534
+ case "rules-budget":
535
+ case "rules_budget": {
536
+ const budgetRes = checkRulesBudget(root, stage);
537
+ const diag = (budgetRes.violations || []).map((v) => `${v.path}: ${v.reason}`);
538
+ res = {
539
+ ok: budgetRes.ok,
540
+ diagnostics: diag,
541
+ metrics: { violationCount: budgetRes.violations?.length || 0 },
542
+ };
543
+ break;
544
+ }
545
+
546
+ case "exists":
547
+ case "file-exists":
548
+ case "not-exists": {
549
+ const cfg = { ...stage, mustExist: assertionType !== "not-exists" };
550
+ res = assertFileExists(cfg, root);
551
+ break;
552
+ }
553
+
554
+ default:
555
+ res = {
556
+ ok: false,
557
+ diagnostics: [`Unknown assertion primitive type: '${rawType}'`],
558
+ metrics: {},
559
+ };
560
+ }
561
+
562
+ const durationMs = Date.now() - startTime;
563
+ const metrics = { ...(res.metrics || {}), durationMs };
564
+ const ok = Boolean(res.ok);
565
+ const status = ok ? 0 : 1;
566
+
567
+ const stdout = ok ? `Assertion '${assertionType}' PASSED (${durationMs}ms)` : "";
568
+ const stderr = !ok ? (res.diagnostics || []).join("\n") || `Assertion '${assertionType}' FAILED` : "";
569
+
570
+ return {
571
+ ok,
572
+ status,
573
+ assertionType,
574
+ stdout,
575
+ stderr,
576
+ diagnostics: res.diagnostics || [],
577
+ metrics,
578
+ };
579
+ }
package/src/budget.mjs CHANGED
@@ -1,15 +1,56 @@
1
1
  import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { userInfo } from "node:os";
3
5
  import { resolveRoot } from "./config.mjs";
4
6
  import {
5
7
  getStateDir,
6
8
  ensureDir,
7
9
  appendLedger,
8
- checkDailyBudget,
9
10
  scanBudgetWindow,
10
11
  ROLLING_WINDOW_MS,
11
12
  } from "./state.mjs";
12
13
 
14
+ /**
15
+ * Resolves ambient developer identity with zero dependencies and strict PII protection.
16
+ * Hierarchy: CLI flag -> GITHUB_ACTOR -> Git config user.email (stripped of domain) -> OS username -> anonymous-local
17
+ * @param {string|null} [cliOverride]
18
+ * @returns {string}
19
+ */
20
+ export function resolveAmbientIdentity(cliOverride = null) {
21
+ const sanitize = (str) =>
22
+ (str || "")
23
+ .toString()
24
+ .trim()
25
+ .toLowerCase()
26
+ .split("@")[0] // Strip email domain to prevent PII leakage
27
+ .replace(/[^a-z0-9_.-]/g, ""); // Remove unsafe injection/special chars
28
+
29
+ if (cliOverride) {
30
+ const cleaned = sanitize(cliOverride);
31
+ if (cleaned) return cleaned;
32
+ }
33
+
34
+ if (process.env.GITHUB_ACTOR) {
35
+ const actor = sanitize(process.env.GITHUB_ACTOR);
36
+ if (actor) return `ci-${actor}`;
37
+ }
38
+
39
+ try {
40
+ const gitEmail = execSync("git config user.email", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
41
+ const cleaned = sanitize(gitEmail);
42
+ if (cleaned) return cleaned;
43
+ } catch (_) {}
44
+
45
+ try {
46
+ const osUser = process.env.USER || process.env.USERNAME || userInfo().username;
47
+ const cleaned = sanitize(osUser);
48
+ if (cleaned) return `${cleaned}-local`;
49
+ } catch (_) {}
50
+
51
+ return "anonymous-local";
52
+ }
53
+
13
54
  /**
14
55
  * Where the observed quota ceiling lives, outside the ledger so it survives
15
56
  * rotation.
@@ -347,21 +388,22 @@ export function releaseOpenReservations(opts = {}) {
347
388
  */
348
389
  export function budgetStatus(config, root = resolveRoot()) {
349
390
  const resolved = resolveDailyLimit(config, root);
350
- const check = checkDailyBudget(root, resolved.limit);
391
+ const scan = scanBudgetWindow(root);
351
392
  return {
352
- used: check.used,
393
+ used: scan.used,
353
394
  limit: resolved.limit,
354
- remaining: check.remaining,
395
+ remaining: Math.max(0, resolved.limit - scan.used),
396
+ byUser: scan.byUser || {},
355
397
  tier: config?.tier || "unknown",
356
398
  source: resolved.source,
357
399
  certain: resolved.certain,
358
400
  note: resolved.note,
359
401
  // The count is a rolling 24h window, not a calendar day — surfaced so a
360
402
  // caller reporting "used today" cannot quietly mean something else.
361
- windowStart: check.windowStart || "",
403
+ windowStart: scan.windowStart || "",
362
404
  windowHours: ROLLING_WINDOW_MS / 3600000,
363
405
  // Only a limit we actually know may stop a dispatch.
364
406
  enforced: resolved.certain,
365
- exhausted: check.used >= resolved.limit,
407
+ exhausted: scan.used >= resolved.limit,
366
408
  };
367
409
  }