cctally 1.11.1 → 1.13.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.
package/bin/cctally CHANGED
@@ -288,6 +288,26 @@ _compute_display_block = _lib_display_tz._compute_display_block
288
288
  format_display_dt = _lib_display_tz.format_display_dt
289
289
  _argparse_tz = _lib_display_tz._argparse_tz
290
290
 
291
+
292
+ def _nonneg_int(raw: str) -> int:
293
+ """argparse `type=` validator for non-negative integer flags (issue #89).
294
+
295
+ Used by ``--debug-samples`` so a negative N is rejected at parse time
296
+ rather than silently coerced inside the helper. Raises
297
+ ``argparse.ArgumentTypeError`` so argparse surfaces the message under
298
+ the standard ``argument <flag>:`` prefix.
299
+ """
300
+ try:
301
+ n = int(raw)
302
+ except ValueError:
303
+ raise argparse.ArgumentTypeError(
304
+ f"must be a non-negative integer, got '{raw}'"
305
+ )
306
+ if n < 0:
307
+ raise argparse.ArgumentTypeError(f"must be >= 0, got {n}")
308
+ return n
309
+
310
+
291
311
  _lib_alerts_payload = _load_sibling("_lib_alerts_payload")
292
312
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
293
313
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
@@ -995,6 +1015,648 @@ def _sum_cost_for_range(
995
1015
  return total
996
1016
 
997
1017
 
1018
+ def _bridge_z_into_tz(args: argparse.Namespace,
1019
+ config: "dict | None" = None) -> None:
1020
+ """Promote ``-z`` / ``--timezone`` onto ``args.tz`` when ``--tz`` is unset.
1021
+
1022
+ Session A (spec §7.2) defines a 4-rung precedence for Claude display tz:
1023
+ ``--tz`` > ``-z --timezone`` > ``config.display.tz`` > host-local. The
1024
+ existing ``resolve_display_tz`` reads only ``args.tz`` and config; the
1025
+ minimal-invasive way to fold in ``-z`` without modifying the shared
1026
+ pure-fn layer is to set ``args.tz`` here when ``--tz`` wasn't
1027
+ supplied. This mutates the namespace and is safe because each cmd_*
1028
+ receives a fresh ``args`` per invocation.
1029
+
1030
+ Internally delegates to :func:`_resolve_claude_tz_name`, which
1031
+ encodes the 4-rung precedence and returns the tz-name string (or
1032
+ ``None`` for host-local fallback). This wires the resolver into the
1033
+ production path so the 7-case test contract (spec §9.2a) is
1034
+ exercised end-to-end, not only by unit tests (Review-A P2-A).
1035
+
1036
+ Validates the bridged rung-2 value via ``_argparse_tz`` so an
1037
+ invalid tz name from ``-z`` surfaces the same canonical error as
1038
+ ``--tz`` (argparse-time validation can't run on the alias because
1039
+ the alias flag is plain string, not ``type=_argparse_tz``). The
1040
+ resolver returns the raw string, so we revalidate here on the path
1041
+ that actually bridges into ``args.tz``.
1042
+
1043
+ Error attribution is rung-aware (#90). A malformed value typed on
1044
+ the command line (rung-2, ``-z``/``--timezone``) is a usage error
1045
+ and hard-fails with the argparse-style message + exit 2. A malformed
1046
+ ``config.display.tz`` (rung-3) is NOT promoted or hard-failed here:
1047
+ it falls through to ``resolve_display_tz`` / ``get_display_tz_pref``
1048
+ downstream, which warn once and default to host-local (exit 0). That
1049
+ restores the pre-Session-A contract and matches how codex commands
1050
+ and the dashboard already treat a malformed persisted tz pref.
1051
+ """
1052
+ # Short-circuit: --tz already set wins all rungs (the resolver
1053
+ # returns args.tz unchanged anyway, but skipping avoids touching
1054
+ # the namespace and re-validating an already-canonical value).
1055
+ if getattr(args, "tz", None) is not None:
1056
+ return
1057
+ resolved = _resolve_claude_tz_name(args, config)
1058
+ if resolved is None:
1059
+ return
1060
+ # ``args.tz`` is None here (short-circuited above), so ``resolved``
1061
+ # came from rung-2 (``-z``/``--timezone``) iff ``args.timezone`` is
1062
+ # set; otherwise it is the rung-3 ``config.display.tz`` value.
1063
+ from_cli_alias = getattr(args, "timezone", None) is not None
1064
+ # Validate via _argparse_tz so an invalid --timezone surfaces the
1065
+ # canonical argparse-style error (matches --tz's type-check).
1066
+ try:
1067
+ canonical = _argparse_tz(resolved)
1068
+ except argparse.ArgumentTypeError as exc:
1069
+ if from_cli_alias:
1070
+ # Rung-2: a value the user typed on the command line. Surface
1071
+ # the same error --tz gives at parse time. We can't call
1072
+ # parser.error from here, so emit-and-raise via SystemExit(2)
1073
+ # for argparse-shape parity.
1074
+ eprint(f"cctally: argument -z/--timezone: {exc}")
1075
+ raise SystemExit(2) from exc
1076
+ # Rung-3: malformed config.display.tz with no CLI tz flag. Don't
1077
+ # mis-attribute the error to -z/--timezone or hard-fail (#90).
1078
+ # Leave args.tz unset; resolve_display_tz / get_display_tz_pref
1079
+ # warn once and default to host-local downstream (exit 0).
1080
+ return
1081
+ args.tz = canonical
1082
+
1083
+
1084
+ def _resolve_claude_tz_name(args: argparse.Namespace,
1085
+ config: "dict | None") -> "str | None":
1086
+ """Resolve display-tz precedence for Claude reporting commands.
1087
+
1088
+ Returns the tz name string (or ``None`` for host-local fallback).
1089
+ Precedence (top wins):
1090
+ 1. ``--tz`` flag (canonical cctally flag).
1091
+ 2. ``-z`` / ``--timezone`` flag (ccusage-codex sharedArgs alias).
1092
+ 3. ``config.display.tz`` (persisted user pref).
1093
+ 4. ``None`` → host-local (existing fallback in ``resolve_display_tz``).
1094
+
1095
+ Spec §7.2 (issue #86 Session A). Mirrors ``_resolve_codex_tz_name``'s
1096
+ structural shape for code-review familiarity, but the rung order
1097
+ diverges: codex's resolver falls back to upstream's ``--timezone``
1098
+ ONLY when neither ``--tz`` nor ``display.tz`` is set, while Claude's
1099
+ Session A contract puts ``-z --timezone`` ahead of ``display.tz``.
1100
+ Both shapes are intentional — the Codex helper preserves drop-in
1101
+ parity with upstream's ``ccusage-codex sharedArgs``; the Claude
1102
+ helper expresses the Session A precedence the spec promises.
1103
+ """
1104
+ tz = getattr(args, "tz", None)
1105
+ if tz is not None:
1106
+ return tz
1107
+ tzname = getattr(args, "timezone", None)
1108
+ if tzname is not None:
1109
+ return tzname
1110
+ display = (config or {}).get("display") or {}
1111
+ cfg_tz = display.get("tz")
1112
+ if cfg_tz:
1113
+ return cfg_tz
1114
+ return None
1115
+
1116
+
1117
+ # ---------------------------------------------------------------------------
1118
+ # Issue #89 — Real --debug diagnostic-sample emission
1119
+ # ---------------------------------------------------------------------------
1120
+ # Spec: docs/superpowers/specs/2026-05-23-issue-89-debug-sample-emission.md
1121
+ # Replaces the §7.6.2 placeholder note with a real ccusage-parity
1122
+ # "Pricing Mismatch Debug Report" on stderr.
1123
+
1124
+ @dataclass
1125
+ class _MismatchModelStat:
1126
+ total: int = 0
1127
+ matches: int = 0
1128
+ mismatches: int = 0
1129
+ avg_percent_diff: float = 0.0
1130
+
1131
+
1132
+ @dataclass
1133
+ class _MismatchSample:
1134
+ file: str
1135
+ timestamp: str
1136
+ model: str
1137
+ original_cost: float
1138
+ calculated_cost: float
1139
+ difference: float
1140
+ percent_diff: float
1141
+ usage: dict
1142
+
1143
+
1144
+ @dataclass
1145
+ class _MismatchStats:
1146
+ command_label: str | None = None
1147
+ total_entries: int = 0
1148
+ entries_with_both: int = 0
1149
+ matches: int = 0
1150
+ mismatches: int = 0
1151
+ model_stats: dict = field(default_factory=dict)
1152
+ discrepancies: list = field(default_factory=list)
1153
+
1154
+
1155
+ def _compute_pricing_mismatch_stats(entries):
1156
+ """Walk ``entries: Iterable[UsageEntry]`` and compute the mismatch stats
1157
+ that ``_render_pricing_mismatch_report`` consumes.
1158
+
1159
+ Mirrors ccusage upstream's ``detectMismatches``
1160
+ (``~/.npm/_npx/.../node_modules/ccusage/dist/debug-DvI5DUKR.js:6-95``):
1161
+
1162
+ - An entry counts toward ``entries_with_both`` iff its ``cost_usd``
1163
+ is not None AND the model has pricing in ``CLAUDE_MODEL_PRICING``.
1164
+ - Threshold: ``percent_diff < 0.1`` is a match; anything else is a
1165
+ mismatch and gets appended to ``discrepancies`` in iteration order.
1166
+ - ``percent_diff`` is ``0.0`` when recorded cost is zero (parity with
1167
+ upstream's divide-by-zero guard).
1168
+ - Per-model ``avg_percent_diff`` updated by streaming mean recurrence
1169
+ to match upstream's per-row accumulation.
1170
+ """
1171
+ stats = _MismatchStats()
1172
+ for entry in entries:
1173
+ # P1.1 (issue #89 review-loop): mirror ccusage upstream's
1174
+ # ``detectMismatches`` precondition filter at debug-DvI5DUKR.js:42
1175
+ # — synthetic entries are excluded from total_entries AND skip the
1176
+ # _resolve_model_pricing call (which would otherwise emit a
1177
+ # ``[cost] unknown model: <synthetic>`` warning and mutate the
1178
+ # module-level _unknown_model_warnings set, suppressing future
1179
+ # legitimate emissions).
1180
+ if entry.model == "<synthetic>":
1181
+ continue
1182
+ stats.total_entries += 1
1183
+ if entry.cost_usd is None:
1184
+ continue
1185
+ if _resolve_model_pricing(entry.model) is None:
1186
+ continue
1187
+ stats.entries_with_both += 1
1188
+ calculated = _calculate_entry_cost(
1189
+ entry.model, entry.usage, mode="calculate",
1190
+ )
1191
+ original = float(entry.cost_usd)
1192
+ difference = abs(original - calculated)
1193
+ percent_diff = (difference / original * 100) if original > 0 else 0.0
1194
+ ms = stats.model_stats.setdefault(entry.model, _MismatchModelStat())
1195
+ ms.total += 1
1196
+ if percent_diff < 0.1:
1197
+ stats.matches += 1
1198
+ ms.matches += 1
1199
+ else:
1200
+ stats.mismatches += 1
1201
+ ms.mismatches += 1
1202
+ stats.discrepancies.append(_MismatchSample(
1203
+ file=os.path.basename(entry.source_path),
1204
+ timestamp=entry.timestamp.isoformat(),
1205
+ model=entry.model,
1206
+ original_cost=original,
1207
+ calculated_cost=calculated,
1208
+ difference=difference,
1209
+ percent_diff=percent_diff,
1210
+ usage=dict(entry.usage),
1211
+ ))
1212
+ # Streaming-mean update for avg_percent_diff (matches upstream).
1213
+ ms.avg_percent_diff = (
1214
+ ms.avg_percent_diff * (ms.total - 1) + percent_diff
1215
+ ) / ms.total
1216
+ return stats
1217
+
1218
+
1219
+ def _render_pricing_mismatch_report(stats, sample_limit):
1220
+ """Return the report as a list of stderr lines (caller prints \\n-joined).
1221
+
1222
+ Matches ccusage upstream's ``printMismatchReport``
1223
+ (debug-DvI5DUKR.js:97-145) including:
1224
+ - Early-return ``"No pricing data found to analyze."`` when
1225
+ ``entries_with_both == 0``.
1226
+ - Model Statistics + Sample Discrepancies sections omitted when
1227
+ ``mismatches == 0``.
1228
+ - Models with ``mismatches == 0`` omitted from Model Statistics.
1229
+ - Sample header prints the requested ``sample_limit`` (not min with
1230
+ discrepancies length).
1231
+ Adds ONE intentional non-upstream line: ``Command: cctally <label>``
1232
+ under the header so the report self-identifies (issue #89 acceptance
1233
+ re: "command in each sample's context").
1234
+ """
1235
+ out = []
1236
+ if stats.entries_with_both == 0:
1237
+ out.append("No pricing data found to analyze.")
1238
+ return out
1239
+
1240
+ match_rate = stats.matches / stats.entries_with_both * 100
1241
+ out.append("")
1242
+ out.append("=== Pricing Mismatch Debug Report ===")
1243
+ if stats.command_label:
1244
+ out.append(f"Command: cctally {stats.command_label}")
1245
+ out.append(f"Total entries processed: {stats.total_entries:,}")
1246
+ out.append(
1247
+ f"Entries with both costUSD and model: {stats.entries_with_both:,}"
1248
+ )
1249
+ out.append(f"Matches (within 0.1%): {stats.matches:,}")
1250
+ out.append(f"Mismatches: {stats.mismatches:,}")
1251
+ out.append(f"Match rate: {match_rate:.2f}%")
1252
+
1253
+ if stats.mismatches > 0 and stats.model_stats:
1254
+ out.append("")
1255
+ out.append("=== Model Statistics ===")
1256
+ sorted_models = sorted(
1257
+ stats.model_stats.items(),
1258
+ key=lambda kv: -kv[1].mismatches,
1259
+ )
1260
+ for model, ms in sorted_models:
1261
+ if ms.mismatches == 0:
1262
+ continue
1263
+ rate = ms.matches / ms.total * 100
1264
+ out.append(f"{model}:")
1265
+ out.append(f" Total entries: {ms.total:,}")
1266
+ out.append(f" Matches: {ms.matches:,} ({rate:.1f}%)")
1267
+ out.append(f" Mismatches: {ms.mismatches:,}")
1268
+ out.append(f" Avg % difference: {ms.avg_percent_diff:.1f}%")
1269
+
1270
+ if stats.discrepancies and sample_limit > 0:
1271
+ out.append("")
1272
+ out.append(f"=== Sample Discrepancies (first {sample_limit}) ===")
1273
+ for d in stats.discrepancies[:sample_limit]:
1274
+ out.append(f"File: {d.file}")
1275
+ out.append(f"Timestamp: {d.timestamp}")
1276
+ out.append(f"Model: {d.model}")
1277
+ out.append(f"Original cost: ${d.original_cost:.6f}")
1278
+ out.append(f"Calculated cost: ${d.calculated_cost:.6f}")
1279
+ out.append(
1280
+ f"Difference: ${d.difference:.6f} ({d.percent_diff:.2f}%)"
1281
+ )
1282
+ out.append(f"Tokens: {json.dumps(d.usage)}")
1283
+ out.append("---")
1284
+ return out
1285
+
1286
+
1287
+ _DEBUG_REPORT_EMITTED = False
1288
+
1289
+
1290
+ def _emit_debug_samples_if_set(
1291
+ args,
1292
+ entries_or_loader,
1293
+ *,
1294
+ command_label: str,
1295
+ ) -> None:
1296
+ """Emit the §7.6.2 normative stderr report exactly once per process
1297
+ when ``args.debug`` is True (issue #89).
1298
+
1299
+ ``entries_or_loader`` is either a list[UsageEntry] (eager) or a
1300
+ zero-arg callable returning list[UsageEntry] (deferred). The deferred
1301
+ form keeps the JSONL scan off the hot path on SQL-backed cmds whose
1302
+ --debug is rare.
1303
+
1304
+ The report mirrors ccusage's ``printMismatchReport`` shape per spec
1305
+ §7.1.2; the one-time-per-process guard ensures a single CLI
1306
+ invocation that composes multiple cmd_* doesn't double-emit. The
1307
+ diff two-window case (``_emit_diff_debug_samples``) bypasses this
1308
+ guard internally for the second window then sets the guard at the
1309
+ end.
1310
+ """
1311
+ global _DEBUG_REPORT_EMITTED
1312
+ if _DEBUG_REPORT_EMITTED:
1313
+ return
1314
+ if not getattr(args, "debug", False):
1315
+ return
1316
+ sample_limit = int(getattr(args, "debug_samples", 5))
1317
+ # Negative values are rejected at argparse parse time via _nonneg_int
1318
+ # (§7.5); the helper assumes sample_limit >= 0.
1319
+
1320
+ # P1.2 (issue #89 review-loop): only the loader call is wrapped — the
1321
+ # pure compute + render functions raising would be a programmer bug,
1322
+ # not a transient. On loader failure we degrade gracefully with a
1323
+ # one-line stderr notice and DO set the guard so a downstream cmd_*
1324
+ # composition doesn't retry (and re-emit) on the same transient.
1325
+ if callable(entries_or_loader):
1326
+ try:
1327
+ entries = entries_or_loader()
1328
+ except (sqlite3.DatabaseError, OSError) as exc:
1329
+ eprint(f"cctally --debug: report unavailable: {exc}")
1330
+ _DEBUG_REPORT_EMITTED = True
1331
+ return
1332
+ else:
1333
+ entries = entries_or_loader
1334
+ stats = _compute_pricing_mismatch_stats(entries)
1335
+ stats.command_label = command_label
1336
+ for line in _render_pricing_mismatch_report(stats, sample_limit):
1337
+ eprint(line)
1338
+ _DEBUG_REPORT_EMITTED = True
1339
+
1340
+
1341
+ # ---------------------------------------------------------------------------
1342
+ # Codex --debug report (issue #92).
1343
+ #
1344
+ # Codex JSONL carries NO recorded costUSD, so the Claude-side "recorded vs
1345
+ # calculated mismatch" framing does not apply. The codex variant (chosen in
1346
+ # #92's design Q&A, option 2; #89 spec §12 precedent) is a "Codex Pricing
1347
+ # Debug Report": totals header (entries processed, models seen, total
1348
+ # computed cost) + a "Sample Top Entries" block of the N highest
1349
+ # computed-cost entries, each tagged ``Recorded cost: (none)``. Reuses the
1350
+ # process-wide ``_DEBUG_REPORT_EMITTED`` guard so one CLI invocation emits a
1351
+ # single debug report regardless of which family it ran.
1352
+ # ---------------------------------------------------------------------------
1353
+
1354
+
1355
+ @dataclass
1356
+ class _CodexCostSample:
1357
+ file: str
1358
+ timestamp: str
1359
+ model: str
1360
+ calculated_cost: float
1361
+ usage: dict
1362
+ is_fallback: bool
1363
+
1364
+
1365
+ @dataclass
1366
+ class _CodexCostStats:
1367
+ command_label: str | None = None
1368
+ total_entries: int = 0
1369
+ total_cost: float = 0.0
1370
+ model_counts: dict = field(default_factory=dict)
1371
+ fallback_models: set = field(default_factory=set)
1372
+ samples: list = field(default_factory=list)
1373
+
1374
+
1375
+ def _compute_codex_cost_stats(entries):
1376
+ """Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
1377
+ per-entry computed-cost samples that ``_render_codex_cost_report``
1378
+ consumes (issue #92).
1379
+
1380
+ Unlike the Claude ``_compute_pricing_mismatch_stats`` there is no
1381
+ recorded cost to diff against, so every entry contributes a sample.
1382
+ Samples are collected for all entries and sorted descending by
1383
+ computed cost; the renderer slices to ``--debug-samples``. (Memory is
1384
+ O(entries); acceptable for typical codex histories and symmetric with
1385
+ the Claude helper, which retains its full discrepancy list.)
1386
+
1387
+ Cost + fallback resolution mirror the live aggregation path:
1388
+ ``_calculate_codex_entry_cost`` (LiteLLM token semantics) and
1389
+ ``_resolve_codex_pricing`` (unknown model → ``gpt-5`` fallback).
1390
+ """
1391
+ stats = _CodexCostStats()
1392
+ for entry in entries:
1393
+ stats.total_entries += 1
1394
+ stats.model_counts[entry.model] = (
1395
+ stats.model_counts.get(entry.model, 0) + 1
1396
+ )
1397
+ _, is_fallback = _resolve_codex_pricing(entry.model)
1398
+ if is_fallback:
1399
+ stats.fallback_models.add(entry.model)
1400
+ cost = _calculate_codex_entry_cost(
1401
+ entry.model,
1402
+ entry.input_tokens,
1403
+ entry.cached_input_tokens,
1404
+ entry.output_tokens,
1405
+ entry.reasoning_output_tokens,
1406
+ )
1407
+ stats.total_cost += cost
1408
+ stats.samples.append(_CodexCostSample(
1409
+ file=os.path.basename(entry.source_path),
1410
+ timestamp=entry.timestamp.isoformat(),
1411
+ model=entry.model,
1412
+ calculated_cost=cost,
1413
+ usage={
1414
+ "input_tokens": entry.input_tokens,
1415
+ "cached_input_tokens": entry.cached_input_tokens,
1416
+ "output_tokens": entry.output_tokens,
1417
+ "reasoning_output_tokens": entry.reasoning_output_tokens,
1418
+ "total_tokens": entry.total_tokens,
1419
+ },
1420
+ is_fallback=is_fallback,
1421
+ ))
1422
+ # Stable sort: equal-cost samples keep iteration order (mirrors the
1423
+ # Claude helper's iteration-order discrepancy list).
1424
+ stats.samples.sort(key=lambda s: -s.calculated_cost)
1425
+ return stats
1426
+
1427
+
1428
+ def _render_codex_cost_report(stats, sample_limit):
1429
+ """Return the codex --debug report as a list of stderr lines (issue #92).
1430
+
1431
+ Structurally parallel to ``_render_pricing_mismatch_report`` but with
1432
+ no match/mismatch framing (codex has no recorded cost):
1433
+
1434
+ - Early-return ``"No Codex usage data found to analyze."`` when
1435
+ ``total_entries == 0``.
1436
+ - Totals header: entries processed, models seen (count desc, ties
1437
+ by name asc; fallback models tagged ``(N, fallback→gpt-5)``),
1438
+ total computed cost.
1439
+ - ``Command: cctally <label>`` self-identifier when set (parity
1440
+ with the Claude report's one non-upstream line).
1441
+ - Sample block omitted when ``sample_limit == 0`` or no samples;
1442
+ header prints the requested ``sample_limit`` (upstream parity).
1443
+ Each sample carries ``Recorded cost: (none)`` and a
1444
+ ``(fallback→gpt-5)`` model-line marker when applicable.
1445
+ """
1446
+ out = []
1447
+ if stats.total_entries == 0:
1448
+ out.append("No Codex usage data found to analyze.")
1449
+ return out
1450
+
1451
+ fallback = CODEX_LEGACY_FALLBACK_MODEL
1452
+ parts = []
1453
+ for model, count in sorted(
1454
+ stats.model_counts.items(), key=lambda kv: (-kv[1], kv[0]),
1455
+ ):
1456
+ if model in stats.fallback_models:
1457
+ parts.append(f"{model} ({count:,}, fallback→{fallback})")
1458
+ else:
1459
+ parts.append(f"{model} ({count:,})")
1460
+
1461
+ out.append("")
1462
+ out.append("=== Codex Pricing Debug Report ===")
1463
+ if stats.command_label:
1464
+ out.append(f"Command: cctally {stats.command_label}")
1465
+ out.append(f"Total entries processed: {stats.total_entries:,}")
1466
+ out.append(f"Models seen: {', '.join(parts)}")
1467
+ out.append(f"Total computed cost: ${stats.total_cost:.6f}")
1468
+
1469
+ if stats.samples and sample_limit > 0:
1470
+ out.append("")
1471
+ out.append(f"=== Sample Top Entries (first {sample_limit}) ===")
1472
+ for s in stats.samples[:sample_limit]:
1473
+ model_line = (
1474
+ f"{s.model} (fallback→{fallback})"
1475
+ if s.is_fallback else s.model
1476
+ )
1477
+ out.append(f"File: {s.file}")
1478
+ out.append(f"Timestamp: {s.timestamp}")
1479
+ out.append(f"Model: {model_line}")
1480
+ out.append("Recorded cost: (none)")
1481
+ out.append(f"Calculated cost: ${s.calculated_cost:.6f}")
1482
+ out.append(f"Tokens: {json.dumps(s.usage)}")
1483
+ out.append("---")
1484
+ return out
1485
+
1486
+
1487
+ def _emit_codex_debug_samples_if_set(
1488
+ args,
1489
+ entries,
1490
+ *,
1491
+ command_label: str,
1492
+ ) -> None:
1493
+ """Emit the codex --debug report once per process when ``args.debug``
1494
+ is True (issue #92).
1495
+
1496
+ ``entries`` is an eager ``list[CodexEntry]`` — each ``cmd_codex_*`` body
1497
+ already loads them via ``get_codex_entries`` before this call, so unlike
1498
+ the Claude helper there is no deferred-loader variant. Shares the
1499
+ process-wide ``_DEBUG_REPORT_EMITTED`` guard with
1500
+ ``_emit_debug_samples_if_set`` so a single CLI invocation emits one
1501
+ report regardless of family.
1502
+ """
1503
+ global _DEBUG_REPORT_EMITTED
1504
+ if _DEBUG_REPORT_EMITTED:
1505
+ return
1506
+ if not getattr(args, "debug", False):
1507
+ return
1508
+ sample_limit = int(getattr(args, "debug_samples", 5))
1509
+ stats = _compute_codex_cost_stats(entries)
1510
+ stats.command_label = command_label
1511
+ for line in _render_codex_cost_report(stats, sample_limit):
1512
+ eprint(line)
1513
+ _DEBUG_REPORT_EMITTED = True
1514
+
1515
+
1516
+ def _usage_entry_from_joined(je) -> "UsageEntry":
1517
+ """Shape a ``_JoinedClaudeEntry`` into a ``UsageEntry`` so the §7.1
1518
+ mismatch helpers can consume both pipelines uniformly (issue #89
1519
+ spec §7.2.1).
1520
+
1521
+ The joined-entry shape already carries ``source_path``, ``cost_usd``,
1522
+ and the per-token integers; this adapter is pure shape conversion
1523
+ with no cache re-read.
1524
+ """
1525
+ return UsageEntry(
1526
+ timestamp=je.timestamp,
1527
+ model=je.model,
1528
+ usage={
1529
+ "input_tokens": je.input_tokens,
1530
+ "output_tokens": je.output_tokens,
1531
+ "cache_creation_input_tokens": je.cache_creation_tokens,
1532
+ "cache_read_input_tokens": je.cache_read_tokens,
1533
+ },
1534
+ cost_usd=je.cost_usd,
1535
+ source_path=je.source_path,
1536
+ )
1537
+
1538
+
1539
+ def _resolve_session_id_for_filter(je) -> str:
1540
+ """Mirror ``_aggregate_claude_sessions``'s session_id resolution
1541
+ (``bin/_lib_aggregators.py:623-627``): use ``je.session_id`` when
1542
+ set, else fall back to the filename stem of ``je.source_path``.
1543
+
1544
+ Used by ``cmd_session``'s ``--id`` filter on the joined-entry list
1545
+ (spec §7.2.1.1) so a rendered session that was assigned its id via
1546
+ the filename-stem fallback isn't silently dropped from the --debug
1547
+ report scope.
1548
+ """
1549
+ if je.session_id is not None:
1550
+ return je.session_id
1551
+ return os.path.splitext(os.path.basename(je.source_path))[0]
1552
+
1553
+
1554
+ def _project_filter_matches(key, project_patterns):
1555
+ """Mirror ``cmd_project``'s ``--project`` predicate
1556
+ (``bin/cctally:4792-4803``): substring match against
1557
+ ``key.display_key`` AND ``key.git_root or key.bucket_path``.
1558
+
1559
+ Used by ``cmd_project``'s --debug report scope so basename-collision
1560
+ suffixes (e.g. ``foo (repos)``) are still selectable by their path
1561
+ segment (spec §7.2.1.2). Do NOT simplify to a raw
1562
+ ``p in (je.project_path or "")`` substring — the report scope would
1563
+ drift from the rendered scope.
1564
+ """
1565
+ if not project_patterns:
1566
+ return True
1567
+ dname = key.display_key.lower()
1568
+ pname = (key.git_root or key.bucket_path or "").lower()
1569
+ return any((p in dname) or (p in pname) for p in project_patterns)
1570
+
1571
+
1572
+ def _emit_diff_debug_samples(args, window_a, window_b) -> None:
1573
+ """Two-window diff report (spec §7.2.2 Pattern D).
1574
+
1575
+ ``cmd_diff`` aggregates two windows; emitting a single union-report
1576
+ would conflate per-window stats. This helper emits two separate
1577
+ reports labeled by window token, then sets ``_DEBUG_REPORT_EMITTED``
1578
+ so a downstream cmd_* composition doesn't double-emit.
1579
+
1580
+ Bypasses ``_emit_debug_samples_if_set``'s one-time guard internally
1581
+ (it would short-circuit the second window).
1582
+ """
1583
+ global _DEBUG_REPORT_EMITTED
1584
+ if _DEBUG_REPORT_EMITTED:
1585
+ return
1586
+ if not getattr(args, "debug", False):
1587
+ return
1588
+ sample_limit = int(getattr(args, "debug_samples", 5))
1589
+ # Sync intent must match `_build_diff_result` (`skip_sync=not args.sync`).
1590
+ # Under `diff --sync --debug` the rendered diff reflects freshly-synced
1591
+ # JSONL while these debug stats, if they skipped sync, would be computed
1592
+ # from the STALE cache — misleading in precisely the stale-cache case
1593
+ # `--sync` exists to fix. So honor `--sync` here too: the first
1594
+ # `get_entries(skip_sync=False)` runs the delta ingest, and every
1595
+ # subsequent read (the second window here + `_build_diff_result`'s own
1596
+ # reads) is a cheap delta no-op (file size/offset unchanged) — no
1597
+ # redundant full walk. Without `--sync`, keep skipping (debug observes
1598
+ # the cache as-is).
1599
+ skip_sync = not bool(getattr(args, "sync", False))
1600
+ try:
1601
+ for window, label_letter, token in (
1602
+ (window_a, "A", getattr(args, "a", "")),
1603
+ (window_b, "B", getattr(args, "b", "")),
1604
+ ):
1605
+ try:
1606
+ # Reuse the SAME half-open window accessor the rendered diff
1607
+ # aggregation uses (`_diff_iter_claude_entries`): `ParsedWindow`
1608
+ # exposes `start_utc`/`end_utc` (NOT `.start`/`.end`), and its
1609
+ # `end_utc` is documented exclusive — the helper trims by 1 µs
1610
+ # before hitting the inclusive-end shared cache reader so the
1611
+ # debug report scopes to exactly the entries the rendered diff
1612
+ # counts. Reading via `get_entries(window.start, window.end)`
1613
+ # both raised AttributeError (wrong field names) and would have
1614
+ # over-counted the exclusive end boundary.
1615
+ entries = list(
1616
+ _diff_iter_claude_entries(window, skip_sync=skip_sync)
1617
+ )
1618
+ except (sqlite3.DatabaseError, OSError) as exc:
1619
+ eprint(
1620
+ f"cctally --debug: window {label_letter} report "
1621
+ f"unavailable: {exc}"
1622
+ )
1623
+ continue
1624
+ # `_diff_iter_claude_entries` yields `_JoinedClaudeEntry`, which
1625
+ # has no `.usage` attribute; adapt to `UsageEntry` before the
1626
+ # mismatch compute, mirroring cmd_project / cmd_session. The stats
1627
+ # helper reads `entry.usage`, so passing raw joined entries here
1628
+ # crashed every priced entry with AttributeError — and the inner
1629
+ # `try/except` only catches DatabaseError/OSError, so the crash
1630
+ # escaped to main()'s generic handler (exit 1, zero diff output).
1631
+ stats = _compute_pricing_mismatch_stats(
1632
+ _usage_entry_from_joined(je) for je in entries
1633
+ )
1634
+ stats.command_label = f"diff (Window {label_letter}: {token})"
1635
+ for line in _render_pricing_mismatch_report(stats, sample_limit):
1636
+ eprint(line)
1637
+ finally:
1638
+ # P1.2 (issue #89 review-loop): set the guard in finally so a
1639
+ # downstream cmd_* composition doesn't double-emit even if one
1640
+ # window raised — the partial output we did emit is enough.
1641
+ _DEBUG_REPORT_EMITTED = True
1642
+
1643
+
1644
+ def _load_claude_config_for_args(args: argparse.Namespace) -> "dict":
1645
+ """Load config honoring the ccusage ``--config <path>`` per-invocation override.
1646
+
1647
+ Thin shim over :func:`load_config` so the 10 in-scope Claude reporting
1648
+ commands (spec §3 T1.6 / issue #88) surface the override behavior
1649
+ uniformly. When ``args.config`` is set, reads from that explicit path
1650
+ only — no first-run create, no writer-lock acquisition, no mutation
1651
+ of the persisted default config at ``_cctally_core.CONFIG_PATH``.
1652
+ Missing / unreadable / non-object-JSON paths raise ``SystemExit(2)``
1653
+ with a clear stderr message (see ``_load_config_from_explicit_path``).
1654
+ When ``args.config`` is unset (or absent), behavior is identical to
1655
+ a bare ``load_config()`` call.
1656
+ """
1657
+ return load_config(getattr(args, "config", None))
1658
+
1659
+
998
1660
  def _resolve_codex_tz_name(args: argparse.Namespace,
999
1661
  config: "dict | None") -> "str | None":
1000
1662
  """Resolve the IANA tz NAME (or None for host-local) used by Codex
@@ -1333,6 +1995,64 @@ def _supports_color_stdout() -> bool:
1333
1995
  return False
1334
1996
 
1335
1997
 
1998
+ def _resolve_color_enabled(args: argparse.Namespace) -> bool:
1999
+ """Resolve effective color-on/off for the 2 real ANSI Claude cmds.
2000
+
2001
+ Spec §7.3 (issue #86 Session A). Precedence (top wins):
2002
+
2003
+ 1. ``args.no_color`` → False (deny-wins; overrides ``FORCE_COLOR``
2004
+ env AND a co-supplied ``--color`` flag).
2005
+ 2. ``args.color`` → True (overrides ``NO_COLOR`` env).
2006
+ 3. ``FORCE_COLOR`` → True.
2007
+ 4. ``NO_COLOR`` → False.
2008
+ 5. ``CI`` env with a TTY stdout → True (CI forces color, but only
2009
+ when stdout is itself a terminal — a piped /
2010
+ redirected stdout under CI stays plain text,
2011
+ issue #100); else ``isatty(stdout)`` with
2012
+ non-dumb TERM → True (the diff/project
2013
+ auto-detect).
2014
+ 6. else → False (incl. a piped stdout under CI).
2015
+
2016
+ The helper is consumed by ``cmd_project`` and ``cmd_diff`` — the only
2017
+ two in-scope Claude cmds whose renderers honor color. The other 8
2018
+ in-scope cmds parse ``--color`` / ``--no-color`` as documented no-op
2019
+ surface (spec §3 T1.5).
2020
+
2021
+ The rung-5 auto-detect keys on ``sys.stdout.isatty()`` ONLY — NOT the
2022
+ shared ``_supports_color_stdout()`` (which is stdout-OR-stderr to match
2023
+ ccusage's picocolors behavior). ``diff``'s pre-Session-A computation was
2024
+ ``sys.stdout.isatty() and not args.no_color`` (stdout-only), and spec
2025
+ §7.3 specifies preserving the *existing* auto-detect on the no-flag
2026
+ rungs. Routing the fallback through the stdout-OR-stderr helper would
2027
+ write ANSI into the pipe under ``cctally diff … | cat`` (stderr is still
2028
+ a TTY) — a backwards-incompatible regression for plain-text consumers.
2029
+ """
2030
+ # Rung 1 — deny-wins: --no-color always disables.
2031
+ if getattr(args, "no_color", False):
2032
+ return False
2033
+ # Rung 2 — --color overrides env (NO_COLOR auto-detect).
2034
+ if getattr(args, "color", False):
2035
+ return True
2036
+ # Rung 3 — FORCE_COLOR env always enables (any value).
2037
+ if "FORCE_COLOR" in os.environ:
2038
+ return True
2039
+ # Rung 4 — NO_COLOR env always disables (any value).
2040
+ if "NO_COLOR" in os.environ:
2041
+ return False
2042
+ # Rung 5 — CI forces color, but ONLY with a TTY stdout; else stdout-only
2043
+ # isatty with non-dumb TERM. Both branches key on sys.stdout.isatty()
2044
+ # (NOT the stdout-OR-stderr _supports_color_stdout) so a piped stdout —
2045
+ # under CI or otherwise — stays uncolored, preserving diff/project's
2046
+ # pre-Session-A plain-text-on-pipe contract (issue #100). CI still wins
2047
+ # over a dumb TERM when stdout is a real terminal (picocolors parity).
2048
+ if sys.stdout.isatty():
2049
+ if "CI" in os.environ:
2050
+ return True
2051
+ return os.environ.get("TERM", "").lower() != "dumb"
2052
+ # Rung 6 — no flag, no env, non-tty stdout (incl. a piped stdout under CI).
2053
+ return False
2054
+
2055
+
1336
2056
  def _style_ansi(text: str, code: str, enabled: bool) -> str:
1337
2057
  if not enabled:
1338
2058
  return text
@@ -1373,6 +2093,7 @@ def _boxed_table(
1373
2093
  aligns: list[str] | None = None,
1374
2094
  *,
1375
2095
  color_header: bool = True,
2096
+ compact: bool = False,
1376
2097
  ) -> str:
1377
2098
  if not headers:
1378
2099
  return ""
@@ -1439,10 +2160,17 @@ def _boxed_table(
1439
2160
  def _dim(s: str) -> str:
1440
2161
  return _style_ansi(s, "90", color_enabled)
1441
2162
 
2163
+ # Issue #91 (Shape B): ``compact`` drops the 1-space cell padding to
2164
+ # 0 on this content-sized table (which has no proportional-width path
2165
+ # to force). Borders and rows both key off ``pad`` so the default
2166
+ # (``pad == 1``) reproduces the prior output byte-for-byte.
2167
+ pad = 0 if compact else 1
2168
+ pad_s = " " * pad
2169
+
1442
2170
  def make_border(left: str, mid: str, right: str) -> str:
1443
2171
  return _dim(
1444
2172
  left
1445
- + mid.join(chars["h"] * (w + 2) for w in widths)
2173
+ + mid.join(chars["h"] * (w + 2 * pad) for w in widths)
1446
2174
  + right
1447
2175
  )
1448
2176
 
@@ -1459,9 +2187,9 @@ def _boxed_table(
1459
2187
  v = _dim(chars["v"])
1460
2188
  return (
1461
2189
  v
1462
- + " "
1463
- + f" {v} ".join(styled_cells)
1464
- + " "
2190
+ + pad_s
2191
+ + f"{pad_s}{v}{pad_s}".join(styled_cells)
2192
+ + pad_s
1465
2193
  + v
1466
2194
  )
1467
2195
 
@@ -1542,6 +2270,7 @@ def _layout_cache_table(
1542
2270
  wide_text_min: int = 15,
1543
2271
  narrow_text_min: int = 12,
1544
2272
  droppable_col_index: int | None = None,
2273
+ compact: bool = False,
1545
2274
  ) -> str:
1546
2275
  """Shared responsive-width table layout for cache-report renderers.
1547
2276
 
@@ -1645,7 +2374,11 @@ def _layout_cache_table(
1645
2374
  term_width = int(os.environ.get("COLUMNS", "120"))
1646
2375
 
1647
2376
  border_overhead = 3 * num_cols + 1
1648
- compact_mode = sum(col_widths) + border_overhead > term_width
2377
+ # Issue #91 (Shape A): the ``compact`` kwarg forces the responsive
2378
+ # scale-down path regardless of terminal width, mirroring
2379
+ # ``_render_project_table`` / ``_render_bucket_table``. Auto-detected
2380
+ # width-overflow continues to trigger the same path as before.
2381
+ compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
1649
2382
 
1650
2383
  if compact_mode:
1651
2384
  available = term_width - border_overhead
@@ -1857,7 +2590,9 @@ def _layout_cache_table(
1857
2590
  return "\n".join(lines)
1858
2591
 
1859
2592
 
1860
- def _render_cache_day_rows(rows: list[CacheRow], title: str) -> str:
2593
+ def _render_cache_day_rows(
2594
+ rows: list[CacheRow], title: str, *, compact: bool = False,
2595
+ ) -> str:
1861
2596
  """Render daily-mode cache report.
1862
2597
 
1863
2598
  Columns: Date, Models, Cache %, Input, Cache Create, Cache Read,
@@ -1986,12 +2721,13 @@ def _render_cache_day_rows(rows: list[CacheRow], title: str) -> str:
1986
2721
  wide_text_min=15,
1987
2722
  narrow_text_min=12,
1988
2723
  droppable_col_index=3, # Input column
2724
+ compact=compact,
1989
2725
  )
1990
2726
 
1991
2727
 
1992
2728
  def _render_cache_session_rows(
1993
2729
  rows: list[CacheRow], title: str,
1994
- *, tz: "ZoneInfo | None" = None,
2730
+ *, tz: "ZoneInfo | None" = None, compact: bool = False,
1995
2731
  ) -> str:
1996
2732
  """Render session-mode cache report.
1997
2733
 
@@ -2135,6 +2871,7 @@ def _render_cache_session_rows(
2135
2871
  wide_text_min=18,
2136
2872
  narrow_text_min=12,
2137
2873
  droppable_col_index=4, # Input column
2874
+ compact=compact,
2138
2875
  )
2139
2876
 
2140
2877
 
@@ -2144,16 +2881,20 @@ def _render_cache_report_table(
2144
2881
  *,
2145
2882
  mode: Literal["day", "session"] = "day",
2146
2883
  tz: "ZoneInfo | None" = None,
2884
+ compact: bool = False,
2147
2885
  ) -> str:
2148
2886
  """Dispatcher: routes to daily or session renderer based on mode.
2149
2887
 
2150
2888
  ``tz`` is the resolved display zone (None = host local). Day-mode
2151
2889
  rows have no clock-instant cells (date strings only) so the parameter
2152
2890
  is currently consumed only by the session-mode renderer.
2891
+
2892
+ ``compact`` (issue #91, Shape A) forces ``_layout_cache_table``'s
2893
+ responsive scale-down branch regardless of terminal width.
2153
2894
  """
2154
2895
  if mode == "session":
2155
- return _render_cache_session_rows(rows, title, tz=tz)
2156
- return _render_cache_day_rows(rows, title)
2896
+ return _render_cache_session_rows(rows, title, tz=tz, compact=compact)
2897
+ return _render_cache_day_rows(rows, title, compact=compact)
2157
2898
 
2158
2899
 
2159
2900
  def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
@@ -3405,17 +4146,19 @@ def _load_recorded_five_hour_windows(
3405
4146
 
3406
4147
  def cmd_blocks(args: argparse.Namespace) -> int:
3407
4148
  """Show usage report grouped by 5-hour session blocks."""
3408
- config = load_config()
4149
+ config = _load_claude_config_for_args(args)
4150
+ _bridge_z_into_tz(args, config)
3409
4151
  tz = resolve_display_tz(args, config)
3410
4152
  args._resolved_tz = tz
3411
4153
 
3412
4154
  now_utc = _command_as_of()
3413
- # Parse --since / --until into datetime range
4155
+ # Parse --since / --until into datetime range. Session A (spec §7.1.1)
4156
+ # routes through the centralized dual-form helper so YYYY-MM-DD also
4157
+ # works and the error message matches the other in-scope cmds.
3414
4158
  if args.since:
3415
4159
  try:
3416
- since_date = dt.datetime.strptime(args.since, "%Y%m%d")
4160
+ since_date = _parse_dual_form_date(args.since, "--since")
3417
4161
  except ValueError:
3418
- eprint(f"Error: --since must be YYYYMMDD format, got '{args.since}'")
3419
4162
  return 1
3420
4163
  range_start = since_date.replace(tzinfo=dt.timezone.utc)
3421
4164
  else:
@@ -3424,9 +4167,8 @@ def cmd_blocks(args: argparse.Namespace) -> int:
3424
4167
 
3425
4168
  if args.until:
3426
4169
  try:
3427
- until_date = dt.datetime.strptime(args.until, "%Y%m%d")
4170
+ until_date = _parse_dual_form_date(args.until, "--until")
3428
4171
  except ValueError:
3429
- eprint(f"Error: --until must be YYYYMMDD format, got '{args.until}'")
3430
4172
  return 1
3431
4173
  # End of that day
3432
4174
  range_end = until_date.replace(
@@ -3439,6 +4181,10 @@ def cmd_blocks(args: argparse.Namespace) -> int:
3439
4181
  # Collect all entries
3440
4182
  all_entries = get_entries(range_start, range_end)
3441
4183
 
4184
+ _emit_debug_samples_if_set(
4185
+ args, all_entries, command_label="blocks",
4186
+ )
4187
+
3442
4188
  # Load recorded 5-hour reset timestamps. Widen both bounds by
3443
4189
  # BLOCK_DURATION: a window covers [R - 5h, R), so a reset R just
3444
4190
  # before ``range_start`` can still anchor entries near it, and a
@@ -3503,8 +4249,13 @@ def cmd_blocks(args: argparse.Namespace) -> int:
3503
4249
  print(_blocks_to_json(blocks))
3504
4250
  return 0
3505
4251
 
3506
- # Table output
3507
- print(_render_blocks_table(blocks, breakdown=args.breakdown, now=now_utc, tz=tz))
4252
+ # Table output. Session A (spec §7.6.1; Review-A P2-B): thread
4253
+ # --compact through so the renderer's scale-down branch fires
4254
+ # regardless of terminal width when the flag is set.
4255
+ print(_render_blocks_table(
4256
+ blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
4257
+ compact=getattr(args, "compact", False),
4258
+ ))
3508
4259
  return 0
3509
4260
 
3510
4261
 
@@ -3607,6 +4358,45 @@ def _maybe_swap_active_block_to_canonical(
3607
4358
  blocks[active_idx] = rebuilt
3608
4359
 
3609
4360
 
4361
+ def _try_dual_form_date(raw: str) -> dt.datetime | None:
4362
+ """Parse ``YYYY-MM-DD`` / ``YYYYMMDD`` WITHOUT emitting an error.
4363
+
4364
+ Returns the parsed naive datetime, or ``None`` when ``raw`` matches
4365
+ neither form. The non-eprinting core of ``_parse_dual_form_date``:
4366
+ callers that have their own fallback for a non-dual-form input must
4367
+ use this, because ``_parse_dual_form_date`` eprints before it raises
4368
+ — so a successful fallback parse would otherwise leak a spurious
4369
+ "must be YYYY-MM-DD" line to stderr (cache-report's ``parse_iso_datetime``
4370
+ second-chance, issue #101).
4371
+ """
4372
+ for fmt in ("%Y-%m-%d", "%Y%m%d"):
4373
+ try:
4374
+ return dt.datetime.strptime(raw, fmt)
4375
+ except ValueError:
4376
+ continue
4377
+ return None
4378
+
4379
+
4380
+ def _parse_dual_form_date(raw: str, flag: str) -> dt.datetime:
4381
+ """Parse a CLI date arg accepting both ``YYYY-MM-DD`` and ``YYYYMMDD``.
4382
+
4383
+ Tries ``%Y-%m-%d`` first (the more readable upstream ccusage / codex
4384
+ sharedArgs preference), then ``%Y%m%d``. On failure, emits a stderr
4385
+ error and raises ``ValueError``. Callers swallow the ``ValueError``
4386
+ and return exit code 1.
4387
+
4388
+ Promoted from ``_resolve_codex_range._parse_date_arg`` so Claude
4389
+ reporting commands can share the dual-form contract (issue #86
4390
+ Session A, spec §7.1.1). Centralizes the error message so the eight
4391
+ in-scope date-taking commands all surface the same diagnostic.
4392
+ """
4393
+ naive = _try_dual_form_date(raw)
4394
+ if naive is not None:
4395
+ return naive
4396
+ eprint(f"Error: {flag} must be YYYY-MM-DD or YYYYMMDD format, got '{raw}'")
4397
+ raise ValueError
4398
+
4399
+
3610
4400
  def _parse_cli_date_range(
3611
4401
  args: argparse.Namespace,
3612
4402
  *,
@@ -3639,14 +4429,11 @@ def _parse_cli_date_range(
3639
4429
  the given date — this avoids DST-boundary drift that would occur if
3640
4430
  we used today's offset (via `datetime.now().astimezone().tzinfo`).
3641
4431
  """
3642
- def _parse_date_arg(raw: str, flag: str) -> dt.datetime:
3643
- for fmt in ("%Y-%m-%d", "%Y%m%d"):
3644
- try:
3645
- return dt.datetime.strptime(raw, fmt)
3646
- except ValueError:
3647
- continue
3648
- eprint(f"Error: {flag} must be YYYY-MM-DD or YYYYMMDD format, got '{raw}'")
3649
- raise ValueError
4432
+ # Local binding to the module-level dual-form helper (spec §7.1.1).
4433
+ # Keeps the diff inside _parse_cli_date_range minimal while sharing
4434
+ # the centralized error message with cmd_blocks, _parse_date_filter,
4435
+ # and _parse_window_arg.
4436
+ _parse_date_arg = _parse_dual_form_date
3650
4437
 
3651
4438
  tz: Any = None
3652
4439
  if tz_name:
@@ -3701,7 +4488,12 @@ def _parse_cli_date_range(
3701
4488
  def cmd_daily(args: argparse.Namespace) -> int:
3702
4489
  """Show usage report grouped by display-timezone date."""
3703
4490
  _share_validate_args(args)
3704
- config = load_config()
4491
+ config = _load_claude_config_for_args(args)
4492
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
4493
+ # existing resolve_display_tz precedence absorbs the new alias. The
4494
+ # canonical --tz still wins (it's set on the namespace before this
4495
+ # bridge fires); when --tz is unset and -z is supplied, use -z.
4496
+ _bridge_z_into_tz(args, config)
3705
4497
  tz = resolve_display_tz(args, config)
3706
4498
  args._resolved_tz = tz
3707
4499
 
@@ -3717,6 +4509,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
3717
4509
  # Collect entries.
3718
4510
  all_entries = get_entries(range_start, range_end)
3719
4511
 
4512
+ _emit_debug_samples_if_set(
4513
+ args, all_entries, command_label="daily",
4514
+ )
4515
+
3720
4516
  # Build the unified daily view (spec §5.1: gap-free; the dashboard
3721
4517
  # heatmap's contiguous-window materialization stays at the dashboard
3722
4518
  # envelope adapter so CLI byte-stability is preserved). Consume
@@ -3775,6 +4571,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
3775
4571
  title_suffix="Daily",
3776
4572
  compact_split_fn=_daily_compact_split,
3777
4573
  breakdown=args.breakdown,
4574
+ compact=getattr(args, "compact", False),
3778
4575
  ))
3779
4576
  return 0
3780
4577
 
@@ -3782,7 +4579,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
3782
4579
  def cmd_monthly(args: argparse.Namespace) -> int:
3783
4580
  """Show usage report grouped by display-timezone calendar month."""
3784
4581
  _share_validate_args(args)
3785
- config = load_config()
4582
+ config = _load_claude_config_for_args(args)
4583
+ _bridge_z_into_tz(args, config)
3786
4584
  tz = resolve_display_tz(args, config)
3787
4585
  args._resolved_tz = tz
3788
4586
 
@@ -3797,6 +4595,10 @@ def cmd_monthly(args: argparse.Namespace) -> int:
3797
4595
 
3798
4596
  all_entries = get_entries(range_start, range_end)
3799
4597
 
4598
+ _emit_debug_samples_if_set(
4599
+ args, all_entries, command_label="monthly",
4600
+ )
4601
+
3800
4602
  # Build the unified monthly view (spec §5.2: drops boundary-spillover
3801
4603
  # bucket; computes delta_cost_pct internally). Consume
3802
4604
  # `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
@@ -3849,6 +4651,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
3849
4651
  title_suffix="Monthly",
3850
4652
  compact_split_fn=_monthly_compact_split,
3851
4653
  breakdown=args.breakdown,
4654
+ compact=getattr(args, "compact", False),
3852
4655
  ))
3853
4656
  return 0
3854
4657
 
@@ -3856,7 +4659,8 @@ def cmd_monthly(args: argparse.Namespace) -> int:
3856
4659
  def cmd_weekly(args: argparse.Namespace) -> int:
3857
4660
  """Show Claude usage grouped by subscription week."""
3858
4661
  _share_validate_args(args)
3859
- config = load_config()
4662
+ config = _load_claude_config_for_args(args)
4663
+ _bridge_z_into_tz(args, config)
3860
4664
  args._resolved_tz = resolve_display_tz(args, config)
3861
4665
 
3862
4666
  now_utc = _command_as_of()
@@ -3869,8 +4673,12 @@ def cmd_weekly(args: argparse.Namespace) -> int:
3869
4673
 
3870
4674
  # Build the subscription-week list spanning the range. Boundaries are
3871
4675
  # anchored in `weekly_usage_snapshots` when available and otherwise
3872
- # extrapolated (see `_compute_subscription_weeks`).
3873
- weeks = _compute_subscription_weeks(conn, range_start, range_end)
4676
+ # extrapolated (see `_compute_subscription_weeks`). Pass the
4677
+ # `--config`-honoring resolved config (issue #88) so the no-snapshot
4678
+ # calendar-week fallback uses the explicit override's `week_start`.
4679
+ weeks = _compute_subscription_weeks(
4680
+ conn, range_start, range_end, config=config,
4681
+ )
3874
4682
 
3875
4683
  # Fetch entries and aggregate.
3876
4684
  # Cover each SubWeek's full [start_ts, end_ts) on the range_start side —
@@ -3889,6 +4697,10 @@ def cmd_weekly(args: argparse.Namespace) -> int:
3889
4697
  fetch_start = range_start
3890
4698
  all_entries = get_entries(fetch_start, range_end)
3891
4699
 
4700
+ _emit_debug_samples_if_set(
4701
+ args, all_entries, command_label="weekly",
4702
+ )
4703
+
3892
4704
  # Bound the usage-snapshot lookup to `<= range_end` so historical
3893
4705
  # `--until <past date>` queries pick the usage% that was current at
3894
4706
  # the end of the requested window rather than the globally latest
@@ -3966,6 +4778,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
3966
4778
  weeks=weeks,
3967
4779
  compact_split_fn=_daily_compact_split,
3968
4780
  breakdown=args.breakdown,
4781
+ compact=getattr(args, "compact", False),
3969
4782
  ))
3970
4783
  return 0
3971
4784
 
@@ -3989,6 +4802,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
3989
4802
  range_start, range_end = range
3990
4803
 
3991
4804
  entries = get_codex_entries(range_start, range_end)
4805
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily")
3992
4806
  # Route through ``build_codex_daily_view`` (issue #58). The View
3993
4807
  # wraps ``_aggregate_codex_daily`` without changing it — preserves
3994
4808
  # LiteLLM token semantics, intentional dedup vs upstream, and
@@ -4048,6 +4862,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
4048
4862
  range_start, range_end = range
4049
4863
 
4050
4864
  entries = get_codex_entries(range_start, range_end)
4865
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly")
4051
4866
  # Route through ``build_codex_monthly_view`` (issue #58).
4052
4867
  view = build_codex_monthly_view(
4053
4868
  entries, now_utc=_command_as_of(), tz_name=tz_name,
@@ -4107,6 +4922,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
4107
4922
  week_start_idx = WEEKDAY_MAP[week_start_name]
4108
4923
 
4109
4924
  entries = get_codex_entries(range_start, range_end)
4925
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly")
4110
4926
  # Route through ``build_codex_weekly_view`` (issue #58).
4111
4927
  view = build_codex_weekly_view(
4112
4928
  entries, now_utc=now_utc, tz_name=tz_name,
@@ -4167,6 +4983,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
4167
4983
  range_start, range_end = range
4168
4984
 
4169
4985
  entries = get_codex_entries(range_start, range_end)
4986
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-session")
4170
4987
  # Route through ``build_codex_session_view`` (issue #58). View rows
4171
4988
  # come descending by last_activity (aggregator default + upstream
4172
4989
  # parity); --order asc reverses.
@@ -4426,7 +5243,10 @@ def _project_sort_key(row: dict, sort_by: str, order: str):
4426
5243
  def cmd_project(args: argparse.Namespace) -> int:
4427
5244
  """Roll entries up by project (git-root) with per-project usage attribution."""
4428
5245
  _share_validate_args(args)
4429
- config = load_config()
5246
+ config = _load_claude_config_for_args(args)
5247
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
5248
+ # existing resolve_display_tz precedence absorbs the new alias.
5249
+ _bridge_z_into_tz(args, config)
4430
5250
  args._resolved_tz = resolve_display_tz(args, config)
4431
5251
 
4432
5252
  # Flag-combination validation (must run before any expensive work).
@@ -4477,7 +5297,7 @@ def cmd_project(args: argparse.Namespace) -> int:
4477
5297
  # false, which would otherwise wrongly fall through to the Monday
4478
5298
  # fallback for non-Monday-reset accounts).
4479
5299
  current_weeks = _compute_subscription_weeks(
4480
- conn, now, now + dt.timedelta(microseconds=1)
5300
+ conn, now, now + dt.timedelta(microseconds=1), config=config,
4481
5301
  )
4482
5302
  if current_weeks:
4483
5303
  cw_start = parse_iso_datetime(
@@ -4497,7 +5317,9 @@ def cmd_project(args: argparse.Namespace) -> int:
4497
5317
  # Pre-compute subscription-week bounds for the query window so each entry
4498
5318
  # can be bucketed onto a canonical subscription-week start_ts. Mirrors
4499
5319
  # `_aggregate_weekly`'s bisect pattern (first-match-wins on overlap).
4500
- subweeks = _compute_subscription_weeks(conn, since_dt, until_dt)
5320
+ subweeks = _compute_subscription_weeks(
5321
+ conn, since_dt, until_dt, config=config,
5322
+ )
4501
5323
  parsed_bounds: list[tuple[dt.datetime, dt.datetime]] = []
4502
5324
  for sw in subweeks:
4503
5325
  s_dt = parse_iso_datetime(sw.start_ts, "week.start_ts").astimezone(dt.timezone.utc)
@@ -4548,7 +5370,45 @@ def cmd_project(args: argparse.Namespace) -> int:
4548
5370
  unknown_entry_count = 0
4549
5371
  missing_sid_count = 0
4550
5372
 
4551
- for entry in get_claude_session_entries(scan_start, scan_end):
5373
+ # Issue #89: materialize the joined-entry iterator once so we can
5374
+ # (a) pre-compute the --debug report's scope (entries passing all
5375
+ # rendered-row filters — user slice + --model + --project) BEFORE
5376
+ # the aggregation loop runs and (b) preserve the existing
5377
+ # aggregation semantics (denominator widened to ALL entries; visible
5378
+ # rows only the post-filter subset). The list is small enough to
5379
+ # hold (entries already in memory via the cache row factory).
5380
+ joined_entries_all = list(get_claude_session_entries(scan_start, scan_end))
5381
+
5382
+ # Build the --debug report dataset: skip synthetic + out-of-window
5383
+ # entries, then apply --model and --project filters (mirroring the
5384
+ # exact predicate at the aggregation loop below). This must match
5385
+ # the rendered scope, NOT the denominator scope.
5386
+ if getattr(args, "debug", False):
5387
+ filtered_for_report = []
5388
+ for je in joined_entries_all:
5389
+ if je.model == "<synthetic>":
5390
+ continue
5391
+ if _week_start_for(je.timestamp) is None:
5392
+ continue
5393
+ if je.timestamp < since_dt or je.timestamp > until_dt:
5394
+ continue
5395
+ if model_patterns:
5396
+ mname = (je.model or "").lower()
5397
+ if not any(p in mname for p in model_patterns):
5398
+ continue
5399
+ key_for_filter = _resolve_project_key(
5400
+ je.project_path, args.group, resolver_cache,
5401
+ )
5402
+ if not _project_filter_matches(key_for_filter, project_patterns):
5403
+ continue
5404
+ filtered_for_report.append(je)
5405
+ _emit_debug_samples_if_set(
5406
+ args,
5407
+ [_usage_entry_from_joined(je) for je in filtered_for_report],
5408
+ command_label="project",
5409
+ )
5410
+
5411
+ for entry in joined_entries_all:
4552
5412
  # Skip synthetic entries (Claude Code internal markers) to match
4553
5413
  # `_aggregate_cache_by_session` / `_aggregate_claude_sessions`.
4554
5414
  if entry.model == "<synthetic>":
@@ -4813,13 +5673,21 @@ def cmd_project(args: argparse.Namespace) -> int:
4813
5673
  eprint("No project usage found in range.")
4814
5674
  return 0
4815
5675
 
5676
+ # Session A (spec §7.3): the new --color flag overrides NO_COLOR
5677
+ # env; --no-color overrides FORCE_COLOR env; deny-wins on the
5678
+ # --color + --no-color clash. _resolve_color_enabled returns the
5679
+ # effective bool; pass it as ``color=`` so the renderer skips its
5680
+ # internal _supports_color_stdout() auto-detect (which would
5681
+ # re-consult NO_COLOR and incorrectly disable color when the user
5682
+ # passed --color under NO_COLOR=1).
4816
5683
  print(_render_project_table(
4817
5684
  sorted_rows,
4818
5685
  title=title,
4819
5686
  breakdown=args.breakdown,
4820
5687
  weeks_missing_snapshot=len(weeks_missing_snapshot),
4821
5688
  weeks_in_range=len(weeks_in_range),
4822
- no_color=args.no_color,
5689
+ color=_resolve_color_enabled(args),
5690
+ compact=args.compact,
4823
5691
  ))
4824
5692
  return 0
4825
5693
 
@@ -4909,7 +5777,10 @@ def cmd_diff(args: argparse.Namespace) -> int:
4909
5777
 
4910
5778
  # Validation already happened via _argparse_tz; resolve now to a ZoneInfo
4911
5779
  # (or None for "local") and derive the IANA name for window resolution.
4912
- config = load_config()
5780
+ config = _load_claude_config_for_args(args)
5781
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
5782
+ # existing resolve_display_tz precedence absorbs the new alias.
5783
+ _bridge_z_into_tz(args, config)
4913
5784
  tz_obj = resolve_display_tz(args, config)
4914
5785
  args._resolved_tz = tz_obj
4915
5786
  tz_name = (tz_obj.key if tz_obj is not None else _local_tz_name())
@@ -4934,16 +5805,12 @@ def cmd_diff(args: argparse.Namespace) -> int:
4934
5805
  print(f"diff: {exc}", file=sys.stderr)
4935
5806
  return 2
4936
5807
 
4937
- threshold = NoiseThreshold(
4938
- show_all=bool(args.show_all),
4939
- user_override=(args.min_delta_usd is not None
4940
- or args.min_delta_pct is not None),
4941
- )
4942
- if args.min_delta_usd is not None:
4943
- threshold = dataclasses.replace(threshold, min_delta_usd=args.min_delta_usd)
4944
- if args.min_delta_pct is not None:
4945
- threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
4946
-
5808
+ # Validate the remaining CLI surface (`--only` / `--with`) BEFORE the
5809
+ # `--debug` emission below. `_emit_diff_debug_samples` prints reports and,
5810
+ # under `--sync`, runs a cache ingest (a local-state mutation). A
5811
+ # fail-fast usage error like `diff ... --only bogus --debug --sync` must
5812
+ # not print unrelated debug output or touch the cache before returning
5813
+ # exit 2 so the validation gate has to precede the debug scan.
4947
5814
  sections_requested = ["overall", "models", "projects", "cache"]
4948
5815
  if args.only is not None:
4949
5816
  sections_requested = [s.strip() for s in args.only.split(",") if s.strip()]
@@ -4970,6 +5837,23 @@ def cmd_diff(args: argparse.Namespace) -> int:
4970
5837
  )
4971
5838
  return 1
4972
5839
 
5840
+ # Issue #89 spec §7.2.2 Pattern D: emit one --debug report per window
5841
+ # before any rendering, with window-A then window-B labels. Runs only
5842
+ # after the validation gate above so a usage error fails fast without
5843
+ # debug output or a cache sync.
5844
+ if getattr(args, "debug", False):
5845
+ _emit_diff_debug_samples(args, window_a, window_b)
5846
+
5847
+ threshold = NoiseThreshold(
5848
+ show_all=bool(args.show_all),
5849
+ user_override=(args.min_delta_usd is not None
5850
+ or args.min_delta_pct is not None),
5851
+ )
5852
+ if args.min_delta_usd is not None:
5853
+ threshold = dataclasses.replace(threshold, min_delta_usd=args.min_delta_usd)
5854
+ if args.min_delta_pct is not None:
5855
+ threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
5856
+
4973
5857
  try:
4974
5858
  result = _build_diff_result(
4975
5859
  window_a, window_b,
@@ -5002,12 +5886,18 @@ def cmd_diff(args: argparse.Namespace) -> int:
5002
5886
  print(_diff_render_json(result, options=options))
5003
5887
  return 0
5004
5888
 
5005
- color = sys.stdout.isatty() and not args.no_color
5889
+ # Session A (spec §7.3): route through the new color resolver so
5890
+ # the bool --color flag overrides NO_COLOR env, --no-color overrides
5891
+ # FORCE_COLOR env, and deny-wins on the --color + --no-color clash.
5892
+ # The old computation (sys.stdout.isatty() and not args.no_color)
5893
+ # only honored --no-color + isatty; the resolver supersedes both
5894
+ # with the full spec §7.3 precedence.
5895
+ color = _resolve_color_enabled(args)
5006
5896
  width = args.width or shutil.get_terminal_size().columns
5007
5897
  width = max(80, min(width, 160))
5008
5898
  print(_diff_render_full_output(
5009
5899
  result, color=color, width=width, raw_aggregates=result.raw_totals,
5010
- tz=tz_obj,
5900
+ tz=tz_obj, compact=args.compact,
5011
5901
  ))
5012
5902
  return 0
5013
5903
 
@@ -5015,7 +5905,8 @@ def cmd_diff(args: argparse.Namespace) -> int:
5015
5905
  def cmd_session(args: argparse.Namespace) -> int:
5016
5906
  """Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
5017
5907
  _share_validate_args(args)
5018
- config = load_config()
5908
+ config = _load_claude_config_for_args(args)
5909
+ _bridge_z_into_tz(args, config)
5019
5910
  tz = resolve_display_tz(args, config)
5020
5911
  args._resolved_tz = tz
5021
5912
 
@@ -5029,6 +5920,27 @@ def cmd_session(args: argparse.Namespace) -> int:
5029
5920
  range_start, range_end = range
5030
5921
 
5031
5922
  entries = get_claude_session_entries(range_start, range_end)
5923
+
5924
+ # Issue #89: --debug report describes the joined-entry list filtered
5925
+ # by --id (post-fallback session_id resolution) when set, matching
5926
+ # the rendered scope of `sessions`.
5927
+ # `is not None`, not truthiness: an explicit empty `--id ''` must still
5928
+ # engage the filter (→ empty render), not silently fall through to
5929
+ # "describe/show all sessions" (code-review finding). Mirrored on the
5930
+ # post-aggregation filter below.
5931
+ if getattr(args, "id", None) is not None:
5932
+ joined_for_report = [
5933
+ je for je in entries
5934
+ if _resolve_session_id_for_filter(je) == args.id
5935
+ ]
5936
+ else:
5937
+ joined_for_report = entries
5938
+ _emit_debug_samples_if_set(
5939
+ args,
5940
+ [_usage_entry_from_joined(je) for je in joined_for_report],
5941
+ command_label="session",
5942
+ )
5943
+
5032
5944
  # Unified view-model kernel (spec §6.5). `limit=None` keeps the
5033
5945
  # full aggregator output — `cctally session` has no `--limit` flag
5034
5946
  # and emits every session in the requested range. `view.aggregated`
@@ -5044,6 +5956,16 @@ def cmd_session(args: argparse.Namespace) -> int:
5044
5956
  )
5045
5957
  sessions = list(view.aggregated)
5046
5958
 
5959
+ # Session A (spec §7.4): exact-string filter on sessionId. Applied
5960
+ # AFTER aggregation (so resume-merged sessions across multiple JSONL
5961
+ # files are matched against their post-merge id) and BEFORE the
5962
+ # `--order asc` reversal and the JSON / share / table render
5963
+ # branches. Unknown id → empty `sessions` list, which falls through
5964
+ # to the existing "no sessions" branch (table: "No Claude session
5965
+ # data found."; JSON: `{"sessions": []}`).
5966
+ if getattr(args, "id", None) is not None: # explicit '' still filters
5967
+ sessions = [s for s in sessions if s.session_id == args.id]
5968
+
5047
5969
  # Shareable-reports gate: --format short-circuits the JSON / table
5048
5970
  # dispatch via `_share_render_and_emit`. The mutex in
5049
5971
  # `_add_share_args` keeps `--format` and `--json` from coexisting.
@@ -5075,8 +5997,17 @@ def cmd_session(args: argparse.Namespace) -> int:
5075
5997
  # sub-rows aren't in the share spec scope). Same convention as
5076
5998
  # cmd_daily / cmd_project.
5077
5999
  display_tz_str = _share_display_tz_label(tz)
6000
+ # Session A (spec §7.4): `_build_session_snapshot` reads
6001
+ # `view.aggregated`, so the `--id` filter applied to the local
6002
+ # `sessions` list above would otherwise be ignored for share
6003
+ # exports (HTML/Markdown/SVG). Hand the builder a view whose
6004
+ # `aggregated` is the filtered list so `--id` is honored across
6005
+ # every output path. The builder reads only `aggregated` (never
6006
+ # `rows` / `total_sessions`), so the parallel-tuple mismatch from
6007
+ # the replace is inert here.
6008
+ share_view = dataclasses.replace(view, aggregated=tuple(sessions))
5078
6009
  snap = _build_session_snapshot(
5079
- view,
6010
+ share_view,
5080
6011
  period_start=range_start,
5081
6012
  period_end=range_end,
5082
6013
  display_tz=display_tz_str,
@@ -5101,10 +6032,14 @@ def cmd_session(args: argparse.Namespace) -> int:
5101
6032
  print("No Claude session data found.")
5102
6033
  return 0
5103
6034
 
6035
+ # Session A (spec §7.6.1; Review-A P2-B): thread --compact through
6036
+ # so the renderer's scale-down branch fires regardless of terminal
6037
+ # width when the flag is set.
5104
6038
  print(_render_claude_session_table(
5105
6039
  sessions,
5106
6040
  breakdown=args.breakdown,
5107
6041
  tz=tz,
6042
+ compact=getattr(args, "compact", False),
5108
6043
  ))
5109
6044
  return 0
5110
6045
 
@@ -7178,19 +8113,19 @@ def _latest_seven_day_and_window(
7178
8113
 
7179
8114
 
7180
8115
  def _parse_date_filter(value: str, flag_name: str) -> str:
7181
- """Parse ``YYYYMMDD`` into a ``YYYY-MM-DD`` ISO date for SQL ``WHERE`` clauses.
8116
+ """Parse ``YYYY-MM-DD`` or ``YYYYMMDD`` into an ISO date for SQL ``WHERE`` clauses.
7182
8117
 
7183
8118
  Used by ``cmd_five_hour_blocks`` ``--since``/``--until``. Mirrors the
7184
- upstream ccusage convention. Raises ``ValueError`` with a subcommand-only
7185
- error prefix on bad input; the caller is responsible for printing to
7186
- stderr and choosing the exit code.
8119
+ upstream ccusage convention. Routes through the centralized
8120
+ ``_parse_dual_form_date`` (spec §7.1.1) so the dual-form contract and
8121
+ error message are shared with cmd_blocks / cmd_daily / etc.
8122
+
8123
+ The helper already eprints its own diagnostic and raises a bare
8124
+ ``ValueError``; we propagate that bare exception so callers can
8125
+ return an exit code without double-printing (Review-A P1-1; mirrors
8126
+ the bare-re-raise pattern used by ``cmd_cache_report``).
7187
8127
  """
7188
- try:
7189
- return dt.datetime.strptime(value, "%Y%m%d").date().isoformat()
7190
- except ValueError as e:
7191
- raise ValueError(
7192
- f"five-hour-blocks: {flag_name} expects YYYYMMDD (got '{value}')"
7193
- ) from e
8128
+ return _parse_dual_form_date(value, flag_name).date().isoformat()
7194
8129
 
7195
8130
 
7196
8131
  def _load_breakdown(
@@ -7219,7 +8154,10 @@ def _load_breakdown(
7219
8154
  def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7220
8155
  """List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
7221
8156
  _share_validate_args(args)
7222
- config = load_config()
8157
+ config = _load_claude_config_for_args(args)
8158
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz before
8159
+ # resolve_display_tz so the new alias precedence lands.
8160
+ _bridge_z_into_tz(args, config)
7223
8161
  args._resolved_tz = resolve_display_tz(args, config)
7224
8162
  # Pin "now" once (CCTALLY_AS_OF for fixture-pinned harnesses; mirrors
7225
8163
  # cmd_five_hour_breakdown). Used by the active-predicate to gate
@@ -7228,6 +8166,9 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7228
8166
  conn = open_db()
7229
8167
  try:
7230
8168
  # Date filter parsing — same convention as cmd_blocks.
8169
+ # _parse_date_filter routes through _parse_dual_form_date, which
8170
+ # eprints its own diagnostic and raises a bare ValueError on bad
8171
+ # input (Review-A P1-1 — dedup stderr by NOT re-emitting here).
7231
8172
  try:
7232
8173
  since_iso = (
7233
8174
  _parse_date_filter(args.since, "--since")
@@ -7237,8 +8178,7 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7237
8178
  _parse_date_filter(args.until, "--until")
7238
8179
  if args.until else None
7239
8180
  )
7240
- except ValueError as e:
7241
- print(str(e), file=sys.stderr)
8181
+ except ValueError:
7242
8182
  return 2
7243
8183
 
7244
8184
  where: list[str] = []
@@ -7266,6 +8206,31 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7266
8206
  params,
7267
8207
  ).fetchall()
7268
8208
 
8209
+ # Issue #89: --debug report scope = the time range spanned by
8210
+ # the rendered block rows. When `rows` is empty, pass an empty
8211
+ # list to short-circuit the loader entirely.
8212
+ if rows:
8213
+ # rows are ORDER BY block_start_at DESC; first row is newest,
8214
+ # last row is oldest. The rendered window is
8215
+ # [oldest_block_start, newest_block_start + BLOCK_DURATION).
8216
+ oldest_start_iso = rows[-1]["block_start_at"]
8217
+ newest_start_iso = rows[0]["block_start_at"]
8218
+ block_window_start = parse_iso_datetime(
8219
+ oldest_start_iso, "block_start_at",
8220
+ )
8221
+ block_window_end = parse_iso_datetime(
8222
+ newest_start_iso, "block_start_at",
8223
+ ) + BLOCK_DURATION
8224
+ _emit_debug_samples_if_set(
8225
+ args,
8226
+ lambda: get_entries(block_window_start, block_window_end),
8227
+ command_label="five-hour-blocks",
8228
+ )
8229
+ else:
8230
+ _emit_debug_samples_if_set(
8231
+ args, [], command_label="five-hour-blocks",
8232
+ )
8233
+
7269
8234
  # Detect truncation: cap applied AND there's at least one older
7270
8235
  # block beyond the cap. Probe with LIMIT 1 OFFSET <cap> over the
7271
8236
  # SAME filter set (none here, but kept symmetric for clarity).
@@ -7868,18 +8833,28 @@ def _resolve_cache_report_window(
7868
8833
  # Full-ISO args carry an explicit time component or tz marker.
7869
8834
  if "T" in raw or "+" in raw or "Z" in raw:
7870
8835
  return parse_iso_datetime(raw, flag)
7871
- # Date-only: try YYYY-MM-DD then YYYYMMDD (matching
7872
- # _parse_cli_date_range's precedent for daily/weekly/monthly).
7873
- for fmt in ("%Y-%m-%d", "%Y%m%d"):
8836
+ # Date-only: route through the centralized dual-form helper
8837
+ # (spec §7.1.1) so YYYY-MM-DD / YYYYMMDD parsing and the error
8838
+ # message stay consistent with cmd_blocks / cmd_daily / etc.
8839
+ naive = _try_dual_form_date(raw)
8840
+ if naive is None:
8841
+ # Second-chance (issue #101): the pre-Session-A code fell
8842
+ # through to parse_iso_datetime here, so space-separated
8843
+ # datetimes (`2026-05-01 12:30:00`) and ISO week-dates
8844
+ # (`2026-W18-1`) — both accepted by datetime.fromisoformat but
8845
+ # rejected by the dual-form parser — kept working. cache-report
8846
+ # is the only date-taking command with this fallthrough; the
8847
+ # other commands accept YYYY-MM-DD / YYYYMMDD only. Returned
8848
+ # verbatim: a full datetime carries its own time component, so
8849
+ # the is_upper_bound end-of-day expansion is NOT applied (it's
8850
+ # for bare date-only forms). On TOTAL failure (neither dual-form
8851
+ # nor ISO), fall back to the centralized dual-form diagnostic
8852
+ # rather than parse_iso_datetime's more generic message.
7874
8853
  try:
7875
- naive = dt.datetime.strptime(raw, fmt)
7876
- break
8854
+ return parse_iso_datetime(raw, flag)
7877
8855
  except ValueError:
7878
- continue
7879
- else:
7880
- # Not a date-only form either — fall back to parse_iso_datetime
7881
- # so callers still get a consistent error message.
7882
- return parse_iso_datetime(raw, flag)
8856
+ _parse_dual_form_date(raw, flag) # eprints + raises ValueError
8857
+ raise # unreachable — the call above always raises here
7883
8858
  if is_upper_bound:
7884
8859
  naive = naive.replace(
7885
8860
  hour=23, minute=59, second=59, microsecond=999999,
@@ -8131,15 +9106,47 @@ def _sort_cache_rows(
8131
9106
 
8132
9107
 
8133
9108
  def cmd_cache_report(args: argparse.Namespace) -> int:
8134
- config = load_config()
9109
+ config = _load_claude_config_for_args(args)
9110
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
9111
+ # existing resolve_display_tz precedence absorbs the new alias. The
9112
+ # canonical --tz still wins (it's set on the namespace before this
9113
+ # bridge fires); when --tz is unset and -z is supplied, use -z.
9114
+ _bridge_z_into_tz(args, config)
8135
9115
  tz = resolve_display_tz(args, config)
8136
9116
  args._resolved_tz = tz
8137
9117
 
8138
9118
  now_utc = _command_as_of()
8139
- since, until = _resolve_cache_report_window(
8140
- args, now_utc=now_utc,
8141
- tz_name=(tz.key if tz is not None else None),
9119
+ # Session A (spec §7.1.1): the dual-form helper eprints its own
9120
+ # diagnostic and raises a bare ValueError; catch it here so main()'s
9121
+ # generic ``Error: {exc}`` fallback doesn't double-print an empty
9122
+ # trailer. Mirrors the catch-and-return-1 shape in cmd_blocks.
9123
+ #
9124
+ # Session A note (Review-A P2-D): cache-report's argparse alias
9125
+ # surface lands in Implementor 2's scope (B-series). The try/except
9126
+ # here only routes _resolve_cache_report_window's bare ValueError
9127
+ # around main()'s generic Error: handler — no parser-level changes.
9128
+ try:
9129
+ since, until = _resolve_cache_report_window(
9130
+ args, now_utc=now_utc,
9131
+ tz_name=(tz.key if tz is not None else None),
9132
+ )
9133
+ except ValueError as exc:
9134
+ # Centralized helper already eprinted on the dual-form path; for
9135
+ # the legacy parse_iso_datetime path (full-ISO mis-format) the
9136
+ # ValueError carries the message, so eprint it.
9137
+ msg = str(exc)
9138
+ if msg:
9139
+ eprint(f"Error: {msg}")
9140
+ return 1
9141
+
9142
+ # Issue #89 Pattern C: deferred loader scoped to the rendered window
9143
+ # (project filter mirrors what the cache-aggregator uses).
9144
+ _emit_debug_samples_if_set(
9145
+ args,
9146
+ lambda: get_entries(since, until, project=args.project),
9147
+ command_label="cache-report",
8142
9148
  )
9149
+
8143
9150
  mode = "session" if getattr(args, "by_session", False) else "day"
8144
9151
  top_key = "sessions" if mode == "session" else "days"
8145
9152
 
@@ -8193,11 +9200,20 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
8193
9200
  return 0
8194
9201
 
8195
9202
  title = _build_cache_report_title(args, mode)
8196
- print(_render_cache_report_table(rows, title, mode=mode, tz=tz))
9203
+ print(_render_cache_report_table(rows, title, mode=mode, tz=tz, compact=args.compact))
8197
9204
  return 0
8198
9205
 
8199
9206
 
8200
9207
  def cmd_range_cost(args: argparse.Namespace) -> int:
9208
+ # Session A (spec §7.2 / §7.6 row note): range-cost has no --tz of
9209
+ # its own — start/end carry their own zone via ISO 8601. Calling the
9210
+ # bridge keeps the alias-surface contract uniform across the 10
9211
+ # in-scope cmds: -z/--timezone lands on args.tz unchanged (no
9212
+ # downstream consumer), so this is a documented no-op for this
9213
+ # command. The bridge still runs _resolve_claude_tz_name so the §9.2a
9214
+ # production-path coverage is exercised here too.
9215
+ config = _load_claude_config_for_args(args)
9216
+ _bridge_z_into_tz(args, config)
8201
9217
  start_dt = parse_iso_datetime(args.start, "--start")
8202
9218
  if args.end:
8203
9219
  end_dt = parse_iso_datetime(args.end, "--end")
@@ -8214,7 +9230,17 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
8214
9230
  last_match: dt.datetime | None = None
8215
9231
  model_buckets: dict[str, dict[str, Any]] = {}
8216
9232
 
8217
- for entry in get_entries(start_dt, end_dt, project=args.project):
9233
+ # Issue #89: keep the loaded list around so the --debug report can
9234
+ # describe the same dataset as the rendered output. Project filter is
9235
+ # applied at the loader (SELECT-time), so the scope is the same.
9236
+ # P2.2 (issue #89 review-loop): get_entries already returns
9237
+ # list[UsageEntry] per bin/_cctally_cache.py:1224 — no list() wrap.
9238
+ entries_list = get_entries(start_dt, end_dt, project=args.project)
9239
+ _emit_debug_samples_if_set(
9240
+ args, entries_list, command_label="range-cost",
9241
+ )
9242
+
9243
+ for entry in entries_list:
8218
9244
  cost = _calculate_entry_cost(
8219
9245
  entry.model, entry.usage, mode=args.mode, cost_usd=entry.cost_usd,
8220
9246
  )
@@ -8319,7 +9345,7 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
8319
9345
  ])
8320
9346
 
8321
9347
  aligns = ["left"] + ["right"] * (len(headers) - 1)
8322
- print(_boxed_table(headers, rows, aligns))
9348
+ print(_boxed_table(headers, rows, aligns, compact=args.compact))
8323
9349
  return 0
8324
9350
 
8325
9351
  # Default: print cost
@@ -8733,6 +9759,110 @@ def _argparse_has_arg(parser, option_string: str) -> bool:
8733
9759
  return False
8734
9760
 
8735
9761
 
9762
+ def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
9763
+ """Attach the Session A ccusage alias surface to a Claude-cmd subparser.
9764
+
9765
+ Sibling to ``_add_codex_shared_args`` (declared inside ``build_parser``)
9766
+ but tailored for Claude commands. Every flag is guarded with
9767
+ ``_argparse_has_arg`` so existing per-parser declarations
9768
+ (cache-report's ``--offline``, project / five-hour-blocks / diff's
9769
+ ``--no-color``) do NOT cause ``argparse.ArgumentError`` — the helper
9770
+ just skips the duplicate. This makes future collisions self-healing
9771
+ when a contributor adds a Session A-managed flag directly on a
9772
+ subparser.
9773
+
9774
+ Args:
9775
+ parser: the subparser to mutate.
9776
+ ansi_emit: ``True`` for project + diff (the 2 real ANSI emitters).
9777
+ ``False`` for the other 8 in-scope cmds. Controls only
9778
+ the ``--color`` help text and whether ``--no-color`` is
9779
+ attempted as a fresh add (when ``ansi_emit=True`` we
9780
+ skip ``--no-color`` entirely — those parsers already
9781
+ declared it themselves).
9782
+
9783
+ Spec §7.1.2 / issue #86 Session A.
9784
+ """
9785
+
9786
+ def _maybe_add(opt: str, *args, **kwargs):
9787
+ if _argparse_has_arg(parser, opt):
9788
+ return
9789
+ parser.add_argument(opt, *args, **kwargs)
9790
+
9791
+ def _maybe_add2(opt1: str, opt2: str, *args, **kwargs):
9792
+ # Two-form add (short + long) — skip if EITHER is present.
9793
+ if _argparse_has_arg(parser, opt1) or _argparse_has_arg(parser, opt2):
9794
+ return
9795
+ parser.add_argument(opt1, opt2, *args, **kwargs)
9796
+
9797
+ _maybe_add2(
9798
+ "-z", "--timezone", default=None, metavar="TZ",
9799
+ help="Alias for --tz (drop-in compat with ccusage). When both "
9800
+ "are supplied, --tz wins.",
9801
+ )
9802
+ _maybe_add2(
9803
+ "-O", "--offline",
9804
+ action=argparse.BooleanOptionalAction, default=False,
9805
+ help="Accepted for ccusage drop-in compat; cctally is always offline.",
9806
+ )
9807
+ _maybe_add(
9808
+ "--compact", action="store_true",
9809
+ help="Force compact table layout regardless of terminal width.",
9810
+ )
9811
+ _maybe_add(
9812
+ "--config", default=None, metavar="PATH",
9813
+ help="Read config from PATH for this invocation only (no "
9814
+ "mutation of the default config at "
9815
+ "~/.local/share/cctally/config.json). Missing or invalid "
9816
+ "PATH errors out with a clear message.",
9817
+ )
9818
+ _maybe_add2(
9819
+ "-d", "--debug", action="store_true",
9820
+ help="Emit a stderr 'Pricing Mismatch Debug Report' "
9821
+ "(totals + per-model stats + sample discrepancies, "
9822
+ "matching ccusage's --debug shape).",
9823
+ )
9824
+ _maybe_add(
9825
+ "--debug-samples", type=_nonneg_int, default=5, metavar="N",
9826
+ help="Cap on sample-discrepancy rows in the --debug report "
9827
+ "(default 5; N=0 suppresses the sample block; "
9828
+ "negatives rejected at parse time).",
9829
+ )
9830
+ _maybe_add(
9831
+ "--single-thread", action="store_true",
9832
+ help="Accepted for ccusage drop-in compat; cctally ingestion "
9833
+ "is already single-threaded via the session-entry cache.",
9834
+ )
9835
+ if ansi_emit:
9836
+ _maybe_add(
9837
+ "--color", action="store_true", default=False,
9838
+ help="Force ANSI color output (overrides NO_COLOR env). When "
9839
+ "neither --color nor --no-color is set, color is auto-"
9840
+ "detected from isatty() and NO_COLOR/FORCE_COLOR env.",
9841
+ )
9842
+ # --no-color already declared on these parsers; do nothing here.
9843
+ else:
9844
+ # No-op-for-compat surface (spec §7.3): these flags parse but do
9845
+ # NOT flow through the color resolver on this command. Color (where
9846
+ # the renderer emits any) follows the auto-detect — isatty() plus
9847
+ # NO_COLOR / FORCE_COLOR env — so the help must NOT claim "no ANSI
9848
+ # is emitted" (daily/monthly/weekly/blocks/session/cache-report DO
9849
+ # emit auto-detected ANSI on a TTY; only the no-color env vars
9850
+ # suppress it). Force/suppress color on the 2 real ANSI commands
9851
+ # (project, diff) instead, or use NO_COLOR=1 / FORCE_COLOR=1.
9852
+ _maybe_add(
9853
+ "--color", action="store_true", default=False,
9854
+ help="Accepted for ccusage drop-in compat; does not control "
9855
+ "this command's color. Color auto-detects from isatty() "
9856
+ "and honors NO_COLOR / FORCE_COLOR env.",
9857
+ )
9858
+ _maybe_add(
9859
+ "--no-color", action="store_true", default=False,
9860
+ help="Accepted for ccusage drop-in compat; does not suppress "
9861
+ "this command's color. Use NO_COLOR=1 env (or pipe stdout) "
9862
+ "to disable auto-detected ANSI.",
9863
+ )
9864
+
9865
+
8736
9866
  def _add_share_args(parser, *, has_status_line: bool = False) -> None:
8737
9867
  """Attach shareable-reports flags + format/json mutex to a subparser.
8738
9868
 
@@ -8887,7 +10017,7 @@ def build_parser() -> argparse.ArgumentParser:
8887
10017
  ),
8888
10018
  )
8889
10019
  p.add_argument(
8890
- "--version",
10020
+ "-v", "--version",
8891
10021
  action="store_true",
8892
10022
  default=argparse.SUPPRESS,
8893
10023
  help="Print cctally version (from CHANGELOG.md latest release header) and exit",
@@ -9472,9 +10602,14 @@ def build_parser() -> argparse.ArgumentParser:
9472
10602
  "Adds SessionId, Last Activity, and Project identity columns.",
9473
10603
  )
9474
10604
  pc.add_argument(
9475
- "--offline",
9476
- action="store_true",
9477
- help="Use cached pricing data in ccusage.",
10605
+ "-O", "--offline",
10606
+ action=argparse.BooleanOptionalAction, default=False,
10607
+ help="Use cached pricing data in ccusage. Session A (spec §7.1.2)"
10608
+ " promotes the existing flag to BooleanOptionalAction + -O"
10609
+ " short form so the ccusage drop-in alias surface (-O,"
10610
+ " --offline, --no-offline) all work on cache-report; the"
10611
+ " behavior under each is unchanged (cctally is always"
10612
+ " offline — args.offline still lands as a bool).",
9478
10613
  )
9479
10614
  pc.add_argument(
9480
10615
  "--project",
@@ -9521,6 +10656,10 @@ def build_parser() -> argparse.ArgumentParser:
9521
10656
  help="Display timezone: local, utc, or IANA name. "
9522
10657
  "Overrides config display.tz for this call.",
9523
10658
  )
10659
+ # Session A (spec §7.6): ansi_emit=False; existing `--offline` is
10660
+ # skipped by the helper's `_argparse_has_arg` guard (the collision
10661
+ # case spec §7.1.2 calls out explicitly).
10662
+ _add_ccusage_alias_args(pc, ansi_emit=False)
9524
10663
  pc.set_defaults(func=cmd_cache_report)
9525
10664
 
9526
10665
  # -- range-cost --
@@ -9575,6 +10714,12 @@ def build_parser() -> argparse.ArgumentParser:
9575
10714
  dest="total_only",
9576
10715
  help="Print numeric USD total only.",
9577
10716
  )
10717
+ # Session A (spec §7.6): ansi_emit=False. range-cost has no --tz of
10718
+ # its own (ISO timestamps carry zone info), but the helper-added
10719
+ # -z/--timezone still lands on the namespace; the bridge promotes
10720
+ # it onto args.tz where the rest of the pipeline treats it as a
10721
+ # documented no-op (cmd_range_cost does not consume args.tz).
10722
+ _add_ccusage_alias_args(rc, ansi_emit=False)
9578
10723
  rc.set_defaults(func=cmd_range_cost)
9579
10724
 
9580
10725
  # -- blocks --
@@ -9619,6 +10764,7 @@ def build_parser() -> argparse.ArgumentParser:
9619
10764
  help="Display timezone: local, utc, or IANA name. "
9620
10765
  "Overrides config display.tz for this call.",
9621
10766
  )
10767
+ _add_ccusage_alias_args(bl, ansi_emit=False)
9622
10768
  bl.set_defaults(func=cmd_blocks)
9623
10769
 
9624
10770
  # -- five-hour-blocks --
@@ -9666,7 +10812,8 @@ def build_parser() -> argparse.ArgumentParser:
9666
10812
  fhb.add_argument(
9667
10813
  "--no-color",
9668
10814
  action="store_true",
9669
- help="Disable ANSI color output (currently a no-op table is plain text).",
10815
+ help="Accepted for ccusage drop-in compat; this command emits "
10816
+ "plain-text output and no ANSI is suppressed.",
9670
10817
  )
9671
10818
  fhb.add_argument(
9672
10819
  "--tz",
@@ -9676,6 +10823,12 @@ def build_parser() -> argparse.ArgumentParser:
9676
10823
  help="Display timezone: local, utc, or IANA name. "
9677
10824
  "Overrides config display.tz for this call.",
9678
10825
  )
10826
+ # Session A (spec §7.6 / §7.6.3): ansi_emit=False. fhb already
10827
+ # declares --no-color (refreshed to no-op text in spec §7.6.3); the
10828
+ # helper's --no-color add is short-circuited by the existing-arg
10829
+ # guard. The helper's --color add lands as a parsed-and-ignored
10830
+ # no-op (the renderer emits plain text).
10831
+ _add_ccusage_alias_args(fhb, ansi_emit=False)
9679
10832
  _add_share_args(fhb)
9680
10833
  fhb.set_defaults(func=cmd_five_hour_blocks)
9681
10834
 
@@ -9747,6 +10900,7 @@ def build_parser() -> argparse.ArgumentParser:
9747
10900
  help="Display timezone: local, utc, or IANA name. "
9748
10901
  "Overrides config display.tz for this call.",
9749
10902
  )
10903
+ _add_ccusage_alias_args(dy, ansi_emit=False)
9750
10904
  _add_share_args(dy)
9751
10905
  dy.set_defaults(func=cmd_daily)
9752
10906
 
@@ -9800,6 +10954,7 @@ def build_parser() -> argparse.ArgumentParser:
9800
10954
  help="Display timezone: local, utc, or IANA name. "
9801
10955
  "Overrides config display.tz for this call.",
9802
10956
  )
10957
+ _add_ccusage_alias_args(mo, ansi_emit=False)
9803
10958
  _add_share_args(mo)
9804
10959
  mo.set_defaults(func=cmd_monthly)
9805
10960
 
@@ -9835,6 +10990,7 @@ def build_parser() -> argparse.ArgumentParser:
9835
10990
  we.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
9836
10991
  help="Display timezone: local, utc, or IANA name. "
9837
10992
  "Overrides config display.tz for this call.")
10993
+ _add_ccusage_alias_args(we, ansi_emit=False)
9838
10994
  _add_share_args(we)
9839
10995
  we.set_defaults(func=cmd_weekly)
9840
10996
 
@@ -9885,6 +11041,22 @@ def build_parser() -> argparse.ArgumentParser:
9885
11041
  "config display.tz for this call. Takes precedence over "
9886
11042
  "upstream's --timezone for drop-in parity.",
9887
11043
  )
11044
+ # Issue #92: codex parity for the #89 --debug surface. Codex JSONL
11045
+ # has no recorded costUSD to diff against, so the report is the
11046
+ # codex variant ("Codex Pricing Debug Report": totals + top-N
11047
+ # highest computed-cost entries), wired via
11048
+ # _emit_codex_debug_samples_if_set in each cmd_codex_* body.
11049
+ parser.add_argument(
11050
+ "-d", "--debug", action="store_true",
11051
+ help="Emit a stderr 'Codex Pricing Debug Report' (totals + "
11052
+ "the N highest computed-cost sample entries).",
11053
+ )
11054
+ parser.add_argument(
11055
+ "--debug-samples", type=_nonneg_int, default=5, metavar="N",
11056
+ help="Cap on top-entry sample rows in the --debug report "
11057
+ "(default 5; N=0 suppresses the sample block; "
11058
+ "negatives rejected at parse time).",
11059
+ )
9888
11060
 
9889
11061
  # -- codex-daily --
9890
11062
  cd = sub.add_parser(
@@ -10039,6 +11211,11 @@ def build_parser() -> argparse.ArgumentParser:
10039
11211
  p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
10040
11212
  help="Display timezone: local, utc, or IANA name. "
10041
11213
  "Overrides config display.tz for this call.")
11214
+ # Session A (spec §7.6): ansi_emit=True. project is one of the two
11215
+ # real ANSI emitters. The helper skips its --no-color add (already
11216
+ # declared at p_project above) and adds the new bool --color flag
11217
+ # whose precedence flows through _resolve_color_enabled (§7.3).
11218
+ _add_ccusage_alias_args(p_project, ansi_emit=True)
10042
11219
  _add_share_args(p_project)
10043
11220
  p_project.set_defaults(func=cmd_project)
10044
11221
 
@@ -10073,6 +11250,14 @@ def build_parser() -> argparse.ArgumentParser:
10073
11250
  diff_p.add_argument("--json", dest="emit_json", action="store_true")
10074
11251
  diff_p.add_argument("--width", type=int, help=argparse.SUPPRESS)
10075
11252
  diff_p.add_argument("--debug-now", action="store_true", help=argparse.SUPPRESS)
11253
+ # Session A (spec §7.6): ansi_emit=True. diff is the other real
11254
+ # ANSI emitter. The helper skips its --no-color add (declared
11255
+ # above) and adds the new bool --color flag wired through
11256
+ # _resolve_color_enabled (§7.3). Note: --debug here collides with
11257
+ # diff's existing `--debug-now` (SUPPRESS'd internal flag) but
11258
+ # `--debug-now` is a different option string; the helper still
11259
+ # adds plain `--debug` cleanly.
11260
+ _add_ccusage_alias_args(diff_p, ansi_emit=True)
10076
11261
  diff_p.set_defaults(func=cmd_diff)
10077
11262
 
10078
11263
  # -- session --
@@ -10111,6 +11296,14 @@ def build_parser() -> argparse.ArgumentParser:
10111
11296
  se.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
10112
11297
  help="Display timezone: local, utc, or IANA name. "
10113
11298
  "Overrides config display.tz for this call.")
11299
+ se.add_argument(
11300
+ "-i", "--id", default=None, metavar="SESSION_ID", dest="id",
11301
+ help="Filter to a single session by exact-string sessionId. "
11302
+ "Match is against the post-resume-merge id (sessions "
11303
+ "resumed across multiple JSONL files collapse to one id). "
11304
+ "Unknown id → exit 0 with the empty-render branch.",
11305
+ )
11306
+ _add_ccusage_alias_args(se, ansi_emit=False)
10114
11307
  _add_share_args(se)
10115
11308
  se.set_defaults(func=cmd_session)
10116
11309