cctally 1.12.0 → 1.14.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
@@ -889,6 +909,17 @@ def _migrate_legacy_data_dir() -> None:
889
909
  Removable in a future major version once early users have been on
890
910
  cctally long enough that the legacy dir is gone everywhere.
891
911
  """
912
+ # Dev-instance isolation (F2): the legacy ccusage-subscription rename is a
913
+ # PROD-only concern. Skip it whenever the data dir was relocated away from
914
+ # the canonical prod path — i.e. dev-checkout auto-detect (DEV_MODE) or an
915
+ # explicit CCTALLY_DATA_DIR override — so a dev run (APP_DIR = cctally-dev)
916
+ # or a per-branch override never hijacks the one-shot move into the wrong
917
+ # dir. "not DEV_MODE and not CCTALLY_DATA_DIR" is exactly the prod-default
918
+ # resolution branch, i.e. APP_DIR == ~/.local/share/cctally. Under the test
919
+ # suppressor DEV_MODE is False and the existing migration tests (which pin
920
+ # APP_DIR directly, no override) still exercise the move.
921
+ if _cctally_core.DEV_MODE or os.environ.get("CCTALLY_DATA_DIR", "").strip():
922
+ return
892
923
  if _cctally_core.APP_DIR.exists():
893
924
  return # already migrated, or fresh install at the new path
894
925
  if not _cctally_core.LEGACY_APP_DIR.exists():
@@ -995,6 +1026,648 @@ def _sum_cost_for_range(
995
1026
  return total
996
1027
 
997
1028
 
1029
+ def _bridge_z_into_tz(args: argparse.Namespace,
1030
+ config: "dict | None" = None) -> None:
1031
+ """Promote ``-z`` / ``--timezone`` onto ``args.tz`` when ``--tz`` is unset.
1032
+
1033
+ Session A (spec §7.2) defines a 4-rung precedence for Claude display tz:
1034
+ ``--tz`` > ``-z --timezone`` > ``config.display.tz`` > host-local. The
1035
+ existing ``resolve_display_tz`` reads only ``args.tz`` and config; the
1036
+ minimal-invasive way to fold in ``-z`` without modifying the shared
1037
+ pure-fn layer is to set ``args.tz`` here when ``--tz`` wasn't
1038
+ supplied. This mutates the namespace and is safe because each cmd_*
1039
+ receives a fresh ``args`` per invocation.
1040
+
1041
+ Internally delegates to :func:`_resolve_claude_tz_name`, which
1042
+ encodes the 4-rung precedence and returns the tz-name string (or
1043
+ ``None`` for host-local fallback). This wires the resolver into the
1044
+ production path so the 7-case test contract (spec §9.2a) is
1045
+ exercised end-to-end, not only by unit tests (Review-A P2-A).
1046
+
1047
+ Validates the bridged rung-2 value via ``_argparse_tz`` so an
1048
+ invalid tz name from ``-z`` surfaces the same canonical error as
1049
+ ``--tz`` (argparse-time validation can't run on the alias because
1050
+ the alias flag is plain string, not ``type=_argparse_tz``). The
1051
+ resolver returns the raw string, so we revalidate here on the path
1052
+ that actually bridges into ``args.tz``.
1053
+
1054
+ Error attribution is rung-aware (#90). A malformed value typed on
1055
+ the command line (rung-2, ``-z``/``--timezone``) is a usage error
1056
+ and hard-fails with the argparse-style message + exit 2. A malformed
1057
+ ``config.display.tz`` (rung-3) is NOT promoted or hard-failed here:
1058
+ it falls through to ``resolve_display_tz`` / ``get_display_tz_pref``
1059
+ downstream, which warn once and default to host-local (exit 0). That
1060
+ restores the pre-Session-A contract and matches how codex commands
1061
+ and the dashboard already treat a malformed persisted tz pref.
1062
+ """
1063
+ # Short-circuit: --tz already set wins all rungs (the resolver
1064
+ # returns args.tz unchanged anyway, but skipping avoids touching
1065
+ # the namespace and re-validating an already-canonical value).
1066
+ if getattr(args, "tz", None) is not None:
1067
+ return
1068
+ resolved = _resolve_claude_tz_name(args, config)
1069
+ if resolved is None:
1070
+ return
1071
+ # ``args.tz`` is None here (short-circuited above), so ``resolved``
1072
+ # came from rung-2 (``-z``/``--timezone``) iff ``args.timezone`` is
1073
+ # set; otherwise it is the rung-3 ``config.display.tz`` value.
1074
+ from_cli_alias = getattr(args, "timezone", None) is not None
1075
+ # Validate via _argparse_tz so an invalid --timezone surfaces the
1076
+ # canonical argparse-style error (matches --tz's type-check).
1077
+ try:
1078
+ canonical = _argparse_tz(resolved)
1079
+ except argparse.ArgumentTypeError as exc:
1080
+ if from_cli_alias:
1081
+ # Rung-2: a value the user typed on the command line. Surface
1082
+ # the same error --tz gives at parse time. We can't call
1083
+ # parser.error from here, so emit-and-raise via SystemExit(2)
1084
+ # for argparse-shape parity.
1085
+ eprint(f"cctally: argument -z/--timezone: {exc}")
1086
+ raise SystemExit(2) from exc
1087
+ # Rung-3: malformed config.display.tz with no CLI tz flag. Don't
1088
+ # mis-attribute the error to -z/--timezone or hard-fail (#90).
1089
+ # Leave args.tz unset; resolve_display_tz / get_display_tz_pref
1090
+ # warn once and default to host-local downstream (exit 0).
1091
+ return
1092
+ args.tz = canonical
1093
+
1094
+
1095
+ def _resolve_claude_tz_name(args: argparse.Namespace,
1096
+ config: "dict | None") -> "str | None":
1097
+ """Resolve display-tz precedence for Claude reporting commands.
1098
+
1099
+ Returns the tz name string (or ``None`` for host-local fallback).
1100
+ Precedence (top wins):
1101
+ 1. ``--tz`` flag (canonical cctally flag).
1102
+ 2. ``-z`` / ``--timezone`` flag (ccusage-codex sharedArgs alias).
1103
+ 3. ``config.display.tz`` (persisted user pref).
1104
+ 4. ``None`` → host-local (existing fallback in ``resolve_display_tz``).
1105
+
1106
+ Spec §7.2 (issue #86 Session A). Mirrors ``_resolve_codex_tz_name``'s
1107
+ structural shape for code-review familiarity, but the rung order
1108
+ diverges: codex's resolver falls back to upstream's ``--timezone``
1109
+ ONLY when neither ``--tz`` nor ``display.tz`` is set, while Claude's
1110
+ Session A contract puts ``-z --timezone`` ahead of ``display.tz``.
1111
+ Both shapes are intentional — the Codex helper preserves drop-in
1112
+ parity with upstream's ``ccusage-codex sharedArgs``; the Claude
1113
+ helper expresses the Session A precedence the spec promises.
1114
+ """
1115
+ tz = getattr(args, "tz", None)
1116
+ if tz is not None:
1117
+ return tz
1118
+ tzname = getattr(args, "timezone", None)
1119
+ if tzname is not None:
1120
+ return tzname
1121
+ display = (config or {}).get("display") or {}
1122
+ cfg_tz = display.get("tz")
1123
+ if cfg_tz:
1124
+ return cfg_tz
1125
+ return None
1126
+
1127
+
1128
+ # ---------------------------------------------------------------------------
1129
+ # Issue #89 — Real --debug diagnostic-sample emission
1130
+ # ---------------------------------------------------------------------------
1131
+ # Spec: docs/superpowers/specs/2026-05-23-issue-89-debug-sample-emission.md
1132
+ # Replaces the §7.6.2 placeholder note with a real ccusage-parity
1133
+ # "Pricing Mismatch Debug Report" on stderr.
1134
+
1135
+ @dataclass
1136
+ class _MismatchModelStat:
1137
+ total: int = 0
1138
+ matches: int = 0
1139
+ mismatches: int = 0
1140
+ avg_percent_diff: float = 0.0
1141
+
1142
+
1143
+ @dataclass
1144
+ class _MismatchSample:
1145
+ file: str
1146
+ timestamp: str
1147
+ model: str
1148
+ original_cost: float
1149
+ calculated_cost: float
1150
+ difference: float
1151
+ percent_diff: float
1152
+ usage: dict
1153
+
1154
+
1155
+ @dataclass
1156
+ class _MismatchStats:
1157
+ command_label: str | None = None
1158
+ total_entries: int = 0
1159
+ entries_with_both: int = 0
1160
+ matches: int = 0
1161
+ mismatches: int = 0
1162
+ model_stats: dict = field(default_factory=dict)
1163
+ discrepancies: list = field(default_factory=list)
1164
+
1165
+
1166
+ def _compute_pricing_mismatch_stats(entries):
1167
+ """Walk ``entries: Iterable[UsageEntry]`` and compute the mismatch stats
1168
+ that ``_render_pricing_mismatch_report`` consumes.
1169
+
1170
+ Mirrors ccusage upstream's ``detectMismatches``
1171
+ (``~/.npm/_npx/.../node_modules/ccusage/dist/debug-DvI5DUKR.js:6-95``):
1172
+
1173
+ - An entry counts toward ``entries_with_both`` iff its ``cost_usd``
1174
+ is not None AND the model has pricing in ``CLAUDE_MODEL_PRICING``.
1175
+ - Threshold: ``percent_diff < 0.1`` is a match; anything else is a
1176
+ mismatch and gets appended to ``discrepancies`` in iteration order.
1177
+ - ``percent_diff`` is ``0.0`` when recorded cost is zero (parity with
1178
+ upstream's divide-by-zero guard).
1179
+ - Per-model ``avg_percent_diff`` updated by streaming mean recurrence
1180
+ to match upstream's per-row accumulation.
1181
+ """
1182
+ stats = _MismatchStats()
1183
+ for entry in entries:
1184
+ # P1.1 (issue #89 review-loop): mirror ccusage upstream's
1185
+ # ``detectMismatches`` precondition filter at debug-DvI5DUKR.js:42
1186
+ # — synthetic entries are excluded from total_entries AND skip the
1187
+ # _resolve_model_pricing call (which would otherwise emit a
1188
+ # ``[cost] unknown model: <synthetic>`` warning and mutate the
1189
+ # module-level _unknown_model_warnings set, suppressing future
1190
+ # legitimate emissions).
1191
+ if entry.model == "<synthetic>":
1192
+ continue
1193
+ stats.total_entries += 1
1194
+ if entry.cost_usd is None:
1195
+ continue
1196
+ if _resolve_model_pricing(entry.model) is None:
1197
+ continue
1198
+ stats.entries_with_both += 1
1199
+ calculated = _calculate_entry_cost(
1200
+ entry.model, entry.usage, mode="calculate",
1201
+ )
1202
+ original = float(entry.cost_usd)
1203
+ difference = abs(original - calculated)
1204
+ percent_diff = (difference / original * 100) if original > 0 else 0.0
1205
+ ms = stats.model_stats.setdefault(entry.model, _MismatchModelStat())
1206
+ ms.total += 1
1207
+ if percent_diff < 0.1:
1208
+ stats.matches += 1
1209
+ ms.matches += 1
1210
+ else:
1211
+ stats.mismatches += 1
1212
+ ms.mismatches += 1
1213
+ stats.discrepancies.append(_MismatchSample(
1214
+ file=os.path.basename(entry.source_path),
1215
+ timestamp=entry.timestamp.isoformat(),
1216
+ model=entry.model,
1217
+ original_cost=original,
1218
+ calculated_cost=calculated,
1219
+ difference=difference,
1220
+ percent_diff=percent_diff,
1221
+ usage=dict(entry.usage),
1222
+ ))
1223
+ # Streaming-mean update for avg_percent_diff (matches upstream).
1224
+ ms.avg_percent_diff = (
1225
+ ms.avg_percent_diff * (ms.total - 1) + percent_diff
1226
+ ) / ms.total
1227
+ return stats
1228
+
1229
+
1230
+ def _render_pricing_mismatch_report(stats, sample_limit):
1231
+ """Return the report as a list of stderr lines (caller prints \\n-joined).
1232
+
1233
+ Matches ccusage upstream's ``printMismatchReport``
1234
+ (debug-DvI5DUKR.js:97-145) including:
1235
+ - Early-return ``"No pricing data found to analyze."`` when
1236
+ ``entries_with_both == 0``.
1237
+ - Model Statistics + Sample Discrepancies sections omitted when
1238
+ ``mismatches == 0``.
1239
+ - Models with ``mismatches == 0`` omitted from Model Statistics.
1240
+ - Sample header prints the requested ``sample_limit`` (not min with
1241
+ discrepancies length).
1242
+ Adds ONE intentional non-upstream line: ``Command: cctally <label>``
1243
+ under the header so the report self-identifies (issue #89 acceptance
1244
+ re: "command in each sample's context").
1245
+ """
1246
+ out = []
1247
+ if stats.entries_with_both == 0:
1248
+ out.append("No pricing data found to analyze.")
1249
+ return out
1250
+
1251
+ match_rate = stats.matches / stats.entries_with_both * 100
1252
+ out.append("")
1253
+ out.append("=== Pricing Mismatch Debug Report ===")
1254
+ if stats.command_label:
1255
+ out.append(f"Command: cctally {stats.command_label}")
1256
+ out.append(f"Total entries processed: {stats.total_entries:,}")
1257
+ out.append(
1258
+ f"Entries with both costUSD and model: {stats.entries_with_both:,}"
1259
+ )
1260
+ out.append(f"Matches (within 0.1%): {stats.matches:,}")
1261
+ out.append(f"Mismatches: {stats.mismatches:,}")
1262
+ out.append(f"Match rate: {match_rate:.2f}%")
1263
+
1264
+ if stats.mismatches > 0 and stats.model_stats:
1265
+ out.append("")
1266
+ out.append("=== Model Statistics ===")
1267
+ sorted_models = sorted(
1268
+ stats.model_stats.items(),
1269
+ key=lambda kv: -kv[1].mismatches,
1270
+ )
1271
+ for model, ms in sorted_models:
1272
+ if ms.mismatches == 0:
1273
+ continue
1274
+ rate = ms.matches / ms.total * 100
1275
+ out.append(f"{model}:")
1276
+ out.append(f" Total entries: {ms.total:,}")
1277
+ out.append(f" Matches: {ms.matches:,} ({rate:.1f}%)")
1278
+ out.append(f" Mismatches: {ms.mismatches:,}")
1279
+ out.append(f" Avg % difference: {ms.avg_percent_diff:.1f}%")
1280
+
1281
+ if stats.discrepancies and sample_limit > 0:
1282
+ out.append("")
1283
+ out.append(f"=== Sample Discrepancies (first {sample_limit}) ===")
1284
+ for d in stats.discrepancies[:sample_limit]:
1285
+ out.append(f"File: {d.file}")
1286
+ out.append(f"Timestamp: {d.timestamp}")
1287
+ out.append(f"Model: {d.model}")
1288
+ out.append(f"Original cost: ${d.original_cost:.6f}")
1289
+ out.append(f"Calculated cost: ${d.calculated_cost:.6f}")
1290
+ out.append(
1291
+ f"Difference: ${d.difference:.6f} ({d.percent_diff:.2f}%)"
1292
+ )
1293
+ out.append(f"Tokens: {json.dumps(d.usage)}")
1294
+ out.append("---")
1295
+ return out
1296
+
1297
+
1298
+ _DEBUG_REPORT_EMITTED = False
1299
+
1300
+
1301
+ def _emit_debug_samples_if_set(
1302
+ args,
1303
+ entries_or_loader,
1304
+ *,
1305
+ command_label: str,
1306
+ ) -> None:
1307
+ """Emit the §7.6.2 normative stderr report exactly once per process
1308
+ when ``args.debug`` is True (issue #89).
1309
+
1310
+ ``entries_or_loader`` is either a list[UsageEntry] (eager) or a
1311
+ zero-arg callable returning list[UsageEntry] (deferred). The deferred
1312
+ form keeps the JSONL scan off the hot path on SQL-backed cmds whose
1313
+ --debug is rare.
1314
+
1315
+ The report mirrors ccusage's ``printMismatchReport`` shape per spec
1316
+ §7.1.2; the one-time-per-process guard ensures a single CLI
1317
+ invocation that composes multiple cmd_* doesn't double-emit. The
1318
+ diff two-window case (``_emit_diff_debug_samples``) bypasses this
1319
+ guard internally for the second window then sets the guard at the
1320
+ end.
1321
+ """
1322
+ global _DEBUG_REPORT_EMITTED
1323
+ if _DEBUG_REPORT_EMITTED:
1324
+ return
1325
+ if not getattr(args, "debug", False):
1326
+ return
1327
+ sample_limit = int(getattr(args, "debug_samples", 5))
1328
+ # Negative values are rejected at argparse parse time via _nonneg_int
1329
+ # (§7.5); the helper assumes sample_limit >= 0.
1330
+
1331
+ # P1.2 (issue #89 review-loop): only the loader call is wrapped — the
1332
+ # pure compute + render functions raising would be a programmer bug,
1333
+ # not a transient. On loader failure we degrade gracefully with a
1334
+ # one-line stderr notice and DO set the guard so a downstream cmd_*
1335
+ # composition doesn't retry (and re-emit) on the same transient.
1336
+ if callable(entries_or_loader):
1337
+ try:
1338
+ entries = entries_or_loader()
1339
+ except (sqlite3.DatabaseError, OSError) as exc:
1340
+ eprint(f"cctally --debug: report unavailable: {exc}")
1341
+ _DEBUG_REPORT_EMITTED = True
1342
+ return
1343
+ else:
1344
+ entries = entries_or_loader
1345
+ stats = _compute_pricing_mismatch_stats(entries)
1346
+ stats.command_label = command_label
1347
+ for line in _render_pricing_mismatch_report(stats, sample_limit):
1348
+ eprint(line)
1349
+ _DEBUG_REPORT_EMITTED = True
1350
+
1351
+
1352
+ # ---------------------------------------------------------------------------
1353
+ # Codex --debug report (issue #92).
1354
+ #
1355
+ # Codex JSONL carries NO recorded costUSD, so the Claude-side "recorded vs
1356
+ # calculated mismatch" framing does not apply. The codex variant (chosen in
1357
+ # #92's design Q&A, option 2; #89 spec §12 precedent) is a "Codex Pricing
1358
+ # Debug Report": totals header (entries processed, models seen, total
1359
+ # computed cost) + a "Sample Top Entries" block of the N highest
1360
+ # computed-cost entries, each tagged ``Recorded cost: (none)``. Reuses the
1361
+ # process-wide ``_DEBUG_REPORT_EMITTED`` guard so one CLI invocation emits a
1362
+ # single debug report regardless of which family it ran.
1363
+ # ---------------------------------------------------------------------------
1364
+
1365
+
1366
+ @dataclass
1367
+ class _CodexCostSample:
1368
+ file: str
1369
+ timestamp: str
1370
+ model: str
1371
+ calculated_cost: float
1372
+ usage: dict
1373
+ is_fallback: bool
1374
+
1375
+
1376
+ @dataclass
1377
+ class _CodexCostStats:
1378
+ command_label: str | None = None
1379
+ total_entries: int = 0
1380
+ total_cost: float = 0.0
1381
+ model_counts: dict = field(default_factory=dict)
1382
+ fallback_models: set = field(default_factory=set)
1383
+ samples: list = field(default_factory=list)
1384
+
1385
+
1386
+ def _compute_codex_cost_stats(entries):
1387
+ """Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
1388
+ per-entry computed-cost samples that ``_render_codex_cost_report``
1389
+ consumes (issue #92).
1390
+
1391
+ Unlike the Claude ``_compute_pricing_mismatch_stats`` there is no
1392
+ recorded cost to diff against, so every entry contributes a sample.
1393
+ Samples are collected for all entries and sorted descending by
1394
+ computed cost; the renderer slices to ``--debug-samples``. (Memory is
1395
+ O(entries); acceptable for typical codex histories and symmetric with
1396
+ the Claude helper, which retains its full discrepancy list.)
1397
+
1398
+ Cost + fallback resolution mirror the live aggregation path:
1399
+ ``_calculate_codex_entry_cost`` (LiteLLM token semantics) and
1400
+ ``_resolve_codex_pricing`` (unknown model → ``gpt-5`` fallback).
1401
+ """
1402
+ stats = _CodexCostStats()
1403
+ for entry in entries:
1404
+ stats.total_entries += 1
1405
+ stats.model_counts[entry.model] = (
1406
+ stats.model_counts.get(entry.model, 0) + 1
1407
+ )
1408
+ _, is_fallback = _resolve_codex_pricing(entry.model)
1409
+ if is_fallback:
1410
+ stats.fallback_models.add(entry.model)
1411
+ cost = _calculate_codex_entry_cost(
1412
+ entry.model,
1413
+ entry.input_tokens,
1414
+ entry.cached_input_tokens,
1415
+ entry.output_tokens,
1416
+ entry.reasoning_output_tokens,
1417
+ )
1418
+ stats.total_cost += cost
1419
+ stats.samples.append(_CodexCostSample(
1420
+ file=os.path.basename(entry.source_path),
1421
+ timestamp=entry.timestamp.isoformat(),
1422
+ model=entry.model,
1423
+ calculated_cost=cost,
1424
+ usage={
1425
+ "input_tokens": entry.input_tokens,
1426
+ "cached_input_tokens": entry.cached_input_tokens,
1427
+ "output_tokens": entry.output_tokens,
1428
+ "reasoning_output_tokens": entry.reasoning_output_tokens,
1429
+ "total_tokens": entry.total_tokens,
1430
+ },
1431
+ is_fallback=is_fallback,
1432
+ ))
1433
+ # Stable sort: equal-cost samples keep iteration order (mirrors the
1434
+ # Claude helper's iteration-order discrepancy list).
1435
+ stats.samples.sort(key=lambda s: -s.calculated_cost)
1436
+ return stats
1437
+
1438
+
1439
+ def _render_codex_cost_report(stats, sample_limit):
1440
+ """Return the codex --debug report as a list of stderr lines (issue #92).
1441
+
1442
+ Structurally parallel to ``_render_pricing_mismatch_report`` but with
1443
+ no match/mismatch framing (codex has no recorded cost):
1444
+
1445
+ - Early-return ``"No Codex usage data found to analyze."`` when
1446
+ ``total_entries == 0``.
1447
+ - Totals header: entries processed, models seen (count desc, ties
1448
+ by name asc; fallback models tagged ``(N, fallback→gpt-5)``),
1449
+ total computed cost.
1450
+ - ``Command: cctally <label>`` self-identifier when set (parity
1451
+ with the Claude report's one non-upstream line).
1452
+ - Sample block omitted when ``sample_limit == 0`` or no samples;
1453
+ header prints the requested ``sample_limit`` (upstream parity).
1454
+ Each sample carries ``Recorded cost: (none)`` and a
1455
+ ``(fallback→gpt-5)`` model-line marker when applicable.
1456
+ """
1457
+ out = []
1458
+ if stats.total_entries == 0:
1459
+ out.append("No Codex usage data found to analyze.")
1460
+ return out
1461
+
1462
+ fallback = CODEX_LEGACY_FALLBACK_MODEL
1463
+ parts = []
1464
+ for model, count in sorted(
1465
+ stats.model_counts.items(), key=lambda kv: (-kv[1], kv[0]),
1466
+ ):
1467
+ if model in stats.fallback_models:
1468
+ parts.append(f"{model} ({count:,}, fallback→{fallback})")
1469
+ else:
1470
+ parts.append(f"{model} ({count:,})")
1471
+
1472
+ out.append("")
1473
+ out.append("=== Codex Pricing Debug Report ===")
1474
+ if stats.command_label:
1475
+ out.append(f"Command: cctally {stats.command_label}")
1476
+ out.append(f"Total entries processed: {stats.total_entries:,}")
1477
+ out.append(f"Models seen: {', '.join(parts)}")
1478
+ out.append(f"Total computed cost: ${stats.total_cost:.6f}")
1479
+
1480
+ if stats.samples and sample_limit > 0:
1481
+ out.append("")
1482
+ out.append(f"=== Sample Top Entries (first {sample_limit}) ===")
1483
+ for s in stats.samples[:sample_limit]:
1484
+ model_line = (
1485
+ f"{s.model} (fallback→{fallback})"
1486
+ if s.is_fallback else s.model
1487
+ )
1488
+ out.append(f"File: {s.file}")
1489
+ out.append(f"Timestamp: {s.timestamp}")
1490
+ out.append(f"Model: {model_line}")
1491
+ out.append("Recorded cost: (none)")
1492
+ out.append(f"Calculated cost: ${s.calculated_cost:.6f}")
1493
+ out.append(f"Tokens: {json.dumps(s.usage)}")
1494
+ out.append("---")
1495
+ return out
1496
+
1497
+
1498
+ def _emit_codex_debug_samples_if_set(
1499
+ args,
1500
+ entries,
1501
+ *,
1502
+ command_label: str,
1503
+ ) -> None:
1504
+ """Emit the codex --debug report once per process when ``args.debug``
1505
+ is True (issue #92).
1506
+
1507
+ ``entries`` is an eager ``list[CodexEntry]`` — each ``cmd_codex_*`` body
1508
+ already loads them via ``get_codex_entries`` before this call, so unlike
1509
+ the Claude helper there is no deferred-loader variant. Shares the
1510
+ process-wide ``_DEBUG_REPORT_EMITTED`` guard with
1511
+ ``_emit_debug_samples_if_set`` so a single CLI invocation emits one
1512
+ report regardless of family.
1513
+ """
1514
+ global _DEBUG_REPORT_EMITTED
1515
+ if _DEBUG_REPORT_EMITTED:
1516
+ return
1517
+ if not getattr(args, "debug", False):
1518
+ return
1519
+ sample_limit = int(getattr(args, "debug_samples", 5))
1520
+ stats = _compute_codex_cost_stats(entries)
1521
+ stats.command_label = command_label
1522
+ for line in _render_codex_cost_report(stats, sample_limit):
1523
+ eprint(line)
1524
+ _DEBUG_REPORT_EMITTED = True
1525
+
1526
+
1527
+ def _usage_entry_from_joined(je) -> "UsageEntry":
1528
+ """Shape a ``_JoinedClaudeEntry`` into a ``UsageEntry`` so the §7.1
1529
+ mismatch helpers can consume both pipelines uniformly (issue #89
1530
+ spec §7.2.1).
1531
+
1532
+ The joined-entry shape already carries ``source_path``, ``cost_usd``,
1533
+ and the per-token integers; this adapter is pure shape conversion
1534
+ with no cache re-read.
1535
+ """
1536
+ return UsageEntry(
1537
+ timestamp=je.timestamp,
1538
+ model=je.model,
1539
+ usage={
1540
+ "input_tokens": je.input_tokens,
1541
+ "output_tokens": je.output_tokens,
1542
+ "cache_creation_input_tokens": je.cache_creation_tokens,
1543
+ "cache_read_input_tokens": je.cache_read_tokens,
1544
+ },
1545
+ cost_usd=je.cost_usd,
1546
+ source_path=je.source_path,
1547
+ )
1548
+
1549
+
1550
+ def _resolve_session_id_for_filter(je) -> str:
1551
+ """Mirror ``_aggregate_claude_sessions``'s session_id resolution
1552
+ (``bin/_lib_aggregators.py:623-627``): use ``je.session_id`` when
1553
+ set, else fall back to the filename stem of ``je.source_path``.
1554
+
1555
+ Used by ``cmd_session``'s ``--id`` filter on the joined-entry list
1556
+ (spec §7.2.1.1) so a rendered session that was assigned its id via
1557
+ the filename-stem fallback isn't silently dropped from the --debug
1558
+ report scope.
1559
+ """
1560
+ if je.session_id is not None:
1561
+ return je.session_id
1562
+ return os.path.splitext(os.path.basename(je.source_path))[0]
1563
+
1564
+
1565
+ def _project_filter_matches(key, project_patterns):
1566
+ """Mirror ``cmd_project``'s ``--project`` predicate
1567
+ (``bin/cctally:4792-4803``): substring match against
1568
+ ``key.display_key`` AND ``key.git_root or key.bucket_path``.
1569
+
1570
+ Used by ``cmd_project``'s --debug report scope so basename-collision
1571
+ suffixes (e.g. ``foo (repos)``) are still selectable by their path
1572
+ segment (spec §7.2.1.2). Do NOT simplify to a raw
1573
+ ``p in (je.project_path or "")`` substring — the report scope would
1574
+ drift from the rendered scope.
1575
+ """
1576
+ if not project_patterns:
1577
+ return True
1578
+ dname = key.display_key.lower()
1579
+ pname = (key.git_root or key.bucket_path or "").lower()
1580
+ return any((p in dname) or (p in pname) for p in project_patterns)
1581
+
1582
+
1583
+ def _emit_diff_debug_samples(args, window_a, window_b) -> None:
1584
+ """Two-window diff report (spec §7.2.2 Pattern D).
1585
+
1586
+ ``cmd_diff`` aggregates two windows; emitting a single union-report
1587
+ would conflate per-window stats. This helper emits two separate
1588
+ reports labeled by window token, then sets ``_DEBUG_REPORT_EMITTED``
1589
+ so a downstream cmd_* composition doesn't double-emit.
1590
+
1591
+ Bypasses ``_emit_debug_samples_if_set``'s one-time guard internally
1592
+ (it would short-circuit the second window).
1593
+ """
1594
+ global _DEBUG_REPORT_EMITTED
1595
+ if _DEBUG_REPORT_EMITTED:
1596
+ return
1597
+ if not getattr(args, "debug", False):
1598
+ return
1599
+ sample_limit = int(getattr(args, "debug_samples", 5))
1600
+ # Sync intent must match `_build_diff_result` (`skip_sync=not args.sync`).
1601
+ # Under `diff --sync --debug` the rendered diff reflects freshly-synced
1602
+ # JSONL while these debug stats, if they skipped sync, would be computed
1603
+ # from the STALE cache — misleading in precisely the stale-cache case
1604
+ # `--sync` exists to fix. So honor `--sync` here too: the first
1605
+ # `get_entries(skip_sync=False)` runs the delta ingest, and every
1606
+ # subsequent read (the second window here + `_build_diff_result`'s own
1607
+ # reads) is a cheap delta no-op (file size/offset unchanged) — no
1608
+ # redundant full walk. Without `--sync`, keep skipping (debug observes
1609
+ # the cache as-is).
1610
+ skip_sync = not bool(getattr(args, "sync", False))
1611
+ try:
1612
+ for window, label_letter, token in (
1613
+ (window_a, "A", getattr(args, "a", "")),
1614
+ (window_b, "B", getattr(args, "b", "")),
1615
+ ):
1616
+ try:
1617
+ # Reuse the SAME half-open window accessor the rendered diff
1618
+ # aggregation uses (`_diff_iter_claude_entries`): `ParsedWindow`
1619
+ # exposes `start_utc`/`end_utc` (NOT `.start`/`.end`), and its
1620
+ # `end_utc` is documented exclusive — the helper trims by 1 µs
1621
+ # before hitting the inclusive-end shared cache reader so the
1622
+ # debug report scopes to exactly the entries the rendered diff
1623
+ # counts. Reading via `get_entries(window.start, window.end)`
1624
+ # both raised AttributeError (wrong field names) and would have
1625
+ # over-counted the exclusive end boundary.
1626
+ entries = list(
1627
+ _diff_iter_claude_entries(window, skip_sync=skip_sync)
1628
+ )
1629
+ except (sqlite3.DatabaseError, OSError) as exc:
1630
+ eprint(
1631
+ f"cctally --debug: window {label_letter} report "
1632
+ f"unavailable: {exc}"
1633
+ )
1634
+ continue
1635
+ # `_diff_iter_claude_entries` yields `_JoinedClaudeEntry`, which
1636
+ # has no `.usage` attribute; adapt to `UsageEntry` before the
1637
+ # mismatch compute, mirroring cmd_project / cmd_session. The stats
1638
+ # helper reads `entry.usage`, so passing raw joined entries here
1639
+ # crashed every priced entry with AttributeError — and the inner
1640
+ # `try/except` only catches DatabaseError/OSError, so the crash
1641
+ # escaped to main()'s generic handler (exit 1, zero diff output).
1642
+ stats = _compute_pricing_mismatch_stats(
1643
+ _usage_entry_from_joined(je) for je in entries
1644
+ )
1645
+ stats.command_label = f"diff (Window {label_letter}: {token})"
1646
+ for line in _render_pricing_mismatch_report(stats, sample_limit):
1647
+ eprint(line)
1648
+ finally:
1649
+ # P1.2 (issue #89 review-loop): set the guard in finally so a
1650
+ # downstream cmd_* composition doesn't double-emit even if one
1651
+ # window raised — the partial output we did emit is enough.
1652
+ _DEBUG_REPORT_EMITTED = True
1653
+
1654
+
1655
+ def _load_claude_config_for_args(args: argparse.Namespace) -> "dict":
1656
+ """Load config honoring the ccusage ``--config <path>`` per-invocation override.
1657
+
1658
+ Thin shim over :func:`load_config` so the 10 in-scope Claude reporting
1659
+ commands (spec §3 T1.6 / issue #88) surface the override behavior
1660
+ uniformly. When ``args.config`` is set, reads from that explicit path
1661
+ only — no first-run create, no writer-lock acquisition, no mutation
1662
+ of the persisted default config at ``_cctally_core.CONFIG_PATH``.
1663
+ Missing / unreadable / non-object-JSON paths raise ``SystemExit(2)``
1664
+ with a clear stderr message (see ``_load_config_from_explicit_path``).
1665
+ When ``args.config`` is unset (or absent), behavior is identical to
1666
+ a bare ``load_config()`` call.
1667
+ """
1668
+ return load_config(getattr(args, "config", None))
1669
+
1670
+
998
1671
  def _resolve_codex_tz_name(args: argparse.Namespace,
999
1672
  config: "dict | None") -> "str | None":
1000
1673
  """Resolve the IANA tz NAME (or None for host-local) used by Codex
@@ -1333,6 +2006,64 @@ def _supports_color_stdout() -> bool:
1333
2006
  return False
1334
2007
 
1335
2008
 
2009
+ def _resolve_color_enabled(args: argparse.Namespace) -> bool:
2010
+ """Resolve effective color-on/off for the 2 real ANSI Claude cmds.
2011
+
2012
+ Spec §7.3 (issue #86 Session A). Precedence (top wins):
2013
+
2014
+ 1. ``args.no_color`` → False (deny-wins; overrides ``FORCE_COLOR``
2015
+ env AND a co-supplied ``--color`` flag).
2016
+ 2. ``args.color`` → True (overrides ``NO_COLOR`` env).
2017
+ 3. ``FORCE_COLOR`` → True.
2018
+ 4. ``NO_COLOR`` → False.
2019
+ 5. ``CI`` env with a TTY stdout → True (CI forces color, but only
2020
+ when stdout is itself a terminal — a piped /
2021
+ redirected stdout under CI stays plain text,
2022
+ issue #100); else ``isatty(stdout)`` with
2023
+ non-dumb TERM → True (the diff/project
2024
+ auto-detect).
2025
+ 6. else → False (incl. a piped stdout under CI).
2026
+
2027
+ The helper is consumed by ``cmd_project`` and ``cmd_diff`` — the only
2028
+ two in-scope Claude cmds whose renderers honor color. The other 8
2029
+ in-scope cmds parse ``--color`` / ``--no-color`` as documented no-op
2030
+ surface (spec §3 T1.5).
2031
+
2032
+ The rung-5 auto-detect keys on ``sys.stdout.isatty()`` ONLY — NOT the
2033
+ shared ``_supports_color_stdout()`` (which is stdout-OR-stderr to match
2034
+ ccusage's picocolors behavior). ``diff``'s pre-Session-A computation was
2035
+ ``sys.stdout.isatty() and not args.no_color`` (stdout-only), and spec
2036
+ §7.3 specifies preserving the *existing* auto-detect on the no-flag
2037
+ rungs. Routing the fallback through the stdout-OR-stderr helper would
2038
+ write ANSI into the pipe under ``cctally diff … | cat`` (stderr is still
2039
+ a TTY) — a backwards-incompatible regression for plain-text consumers.
2040
+ """
2041
+ # Rung 1 — deny-wins: --no-color always disables.
2042
+ if getattr(args, "no_color", False):
2043
+ return False
2044
+ # Rung 2 — --color overrides env (NO_COLOR auto-detect).
2045
+ if getattr(args, "color", False):
2046
+ return True
2047
+ # Rung 3 — FORCE_COLOR env always enables (any value).
2048
+ if "FORCE_COLOR" in os.environ:
2049
+ return True
2050
+ # Rung 4 — NO_COLOR env always disables (any value).
2051
+ if "NO_COLOR" in os.environ:
2052
+ return False
2053
+ # Rung 5 — CI forces color, but ONLY with a TTY stdout; else stdout-only
2054
+ # isatty with non-dumb TERM. Both branches key on sys.stdout.isatty()
2055
+ # (NOT the stdout-OR-stderr _supports_color_stdout) so a piped stdout —
2056
+ # under CI or otherwise — stays uncolored, preserving diff/project's
2057
+ # pre-Session-A plain-text-on-pipe contract (issue #100). CI still wins
2058
+ # over a dumb TERM when stdout is a real terminal (picocolors parity).
2059
+ if sys.stdout.isatty():
2060
+ if "CI" in os.environ:
2061
+ return True
2062
+ return os.environ.get("TERM", "").lower() != "dumb"
2063
+ # Rung 6 — no flag, no env, non-tty stdout (incl. a piped stdout under CI).
2064
+ return False
2065
+
2066
+
1336
2067
  def _style_ansi(text: str, code: str, enabled: bool) -> str:
1337
2068
  if not enabled:
1338
2069
  return text
@@ -1373,6 +2104,7 @@ def _boxed_table(
1373
2104
  aligns: list[str] | None = None,
1374
2105
  *,
1375
2106
  color_header: bool = True,
2107
+ compact: bool = False,
1376
2108
  ) -> str:
1377
2109
  if not headers:
1378
2110
  return ""
@@ -1439,10 +2171,17 @@ def _boxed_table(
1439
2171
  def _dim(s: str) -> str:
1440
2172
  return _style_ansi(s, "90", color_enabled)
1441
2173
 
2174
+ # Issue #91 (Shape B): ``compact`` drops the 1-space cell padding to
2175
+ # 0 on this content-sized table (which has no proportional-width path
2176
+ # to force). Borders and rows both key off ``pad`` so the default
2177
+ # (``pad == 1``) reproduces the prior output byte-for-byte.
2178
+ pad = 0 if compact else 1
2179
+ pad_s = " " * pad
2180
+
1442
2181
  def make_border(left: str, mid: str, right: str) -> str:
1443
2182
  return _dim(
1444
2183
  left
1445
- + mid.join(chars["h"] * (w + 2) for w in widths)
2184
+ + mid.join(chars["h"] * (w + 2 * pad) for w in widths)
1446
2185
  + right
1447
2186
  )
1448
2187
 
@@ -1459,9 +2198,9 @@ def _boxed_table(
1459
2198
  v = _dim(chars["v"])
1460
2199
  return (
1461
2200
  v
1462
- + " "
1463
- + f" {v} ".join(styled_cells)
1464
- + " "
2201
+ + pad_s
2202
+ + f"{pad_s}{v}{pad_s}".join(styled_cells)
2203
+ + pad_s
1465
2204
  + v
1466
2205
  )
1467
2206
 
@@ -1542,6 +2281,7 @@ def _layout_cache_table(
1542
2281
  wide_text_min: int = 15,
1543
2282
  narrow_text_min: int = 12,
1544
2283
  droppable_col_index: int | None = None,
2284
+ compact: bool = False,
1545
2285
  ) -> str:
1546
2286
  """Shared responsive-width table layout for cache-report renderers.
1547
2287
 
@@ -1645,7 +2385,11 @@ def _layout_cache_table(
1645
2385
  term_width = int(os.environ.get("COLUMNS", "120"))
1646
2386
 
1647
2387
  border_overhead = 3 * num_cols + 1
1648
- compact_mode = sum(col_widths) + border_overhead > term_width
2388
+ # Issue #91 (Shape A): the ``compact`` kwarg forces the responsive
2389
+ # scale-down path regardless of terminal width, mirroring
2390
+ # ``_render_project_table`` / ``_render_bucket_table``. Auto-detected
2391
+ # width-overflow continues to trigger the same path as before.
2392
+ compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
1649
2393
 
1650
2394
  if compact_mode:
1651
2395
  available = term_width - border_overhead
@@ -1857,7 +2601,9 @@ def _layout_cache_table(
1857
2601
  return "\n".join(lines)
1858
2602
 
1859
2603
 
1860
- def _render_cache_day_rows(rows: list[CacheRow], title: str) -> str:
2604
+ def _render_cache_day_rows(
2605
+ rows: list[CacheRow], title: str, *, compact: bool = False,
2606
+ ) -> str:
1861
2607
  """Render daily-mode cache report.
1862
2608
 
1863
2609
  Columns: Date, Models, Cache %, Input, Cache Create, Cache Read,
@@ -1986,12 +2732,13 @@ def _render_cache_day_rows(rows: list[CacheRow], title: str) -> str:
1986
2732
  wide_text_min=15,
1987
2733
  narrow_text_min=12,
1988
2734
  droppable_col_index=3, # Input column
2735
+ compact=compact,
1989
2736
  )
1990
2737
 
1991
2738
 
1992
2739
  def _render_cache_session_rows(
1993
2740
  rows: list[CacheRow], title: str,
1994
- *, tz: "ZoneInfo | None" = None,
2741
+ *, tz: "ZoneInfo | None" = None, compact: bool = False,
1995
2742
  ) -> str:
1996
2743
  """Render session-mode cache report.
1997
2744
 
@@ -2135,6 +2882,7 @@ def _render_cache_session_rows(
2135
2882
  wide_text_min=18,
2136
2883
  narrow_text_min=12,
2137
2884
  droppable_col_index=4, # Input column
2885
+ compact=compact,
2138
2886
  )
2139
2887
 
2140
2888
 
@@ -2144,16 +2892,20 @@ def _render_cache_report_table(
2144
2892
  *,
2145
2893
  mode: Literal["day", "session"] = "day",
2146
2894
  tz: "ZoneInfo | None" = None,
2895
+ compact: bool = False,
2147
2896
  ) -> str:
2148
2897
  """Dispatcher: routes to daily or session renderer based on mode.
2149
2898
 
2150
2899
  ``tz`` is the resolved display zone (None = host local). Day-mode
2151
2900
  rows have no clock-instant cells (date strings only) so the parameter
2152
2901
  is currently consumed only by the session-mode renderer.
2902
+
2903
+ ``compact`` (issue #91, Shape A) forces ``_layout_cache_table``'s
2904
+ responsive scale-down branch regardless of terminal width.
2153
2905
  """
2154
2906
  if mode == "session":
2155
- return _render_cache_session_rows(rows, title, tz=tz)
2156
- return _render_cache_day_rows(rows, title)
2907
+ return _render_cache_session_rows(rows, title, tz=tz, compact=compact)
2908
+ return _render_cache_day_rows(rows, title, compact=compact)
2157
2909
 
2158
2910
 
2159
2911
  def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
@@ -2628,7 +3380,16 @@ def _backfill_five_hour_blocks(conn: sqlite3.Connection) -> int:
2628
3380
  now_iso = now_utc_iso()
2629
3381
  now_dt = parse_iso_datetime(now_iso, "now")
2630
3382
 
2631
- conn.execute("BEGIN")
3383
+ # BEGIN IMMEDIATE (not deferred): this transaction's first DML is a
3384
+ # READ (min_row/max_row below), so a plain deferred BEGIN takes a read
3385
+ # snapshot and only tries to upgrade to the write lock at the first
3386
+ # INSERT OR IGNORE. Under concurrent first-run openers, a competing
3387
+ # commit landing between that read and the first write makes the upgrade
3388
+ # fail with SQLITE_BUSY_SNAPSHOT *immediately* — busy_timeout cannot
3389
+ # absorb it, and the whole backfill rolls back. Acquiring the write lock
3390
+ # up front serializes the backfill cleanly behind busy_timeout instead.
3391
+ # See cctally-dev#87.
3392
+ conn.execute("BEGIN IMMEDIATE")
2632
3393
  try:
2633
3394
  for key in keys:
2634
3395
  # MIN-captured row defines the immutable block boundary
@@ -3405,17 +4166,19 @@ def _load_recorded_five_hour_windows(
3405
4166
 
3406
4167
  def cmd_blocks(args: argparse.Namespace) -> int:
3407
4168
  """Show usage report grouped by 5-hour session blocks."""
3408
- config = load_config()
4169
+ config = _load_claude_config_for_args(args)
4170
+ _bridge_z_into_tz(args, config)
3409
4171
  tz = resolve_display_tz(args, config)
3410
4172
  args._resolved_tz = tz
3411
4173
 
3412
4174
  now_utc = _command_as_of()
3413
- # Parse --since / --until into datetime range
4175
+ # Parse --since / --until into datetime range. Session A (spec §7.1.1)
4176
+ # routes through the centralized dual-form helper so YYYY-MM-DD also
4177
+ # works and the error message matches the other in-scope cmds.
3414
4178
  if args.since:
3415
4179
  try:
3416
- since_date = dt.datetime.strptime(args.since, "%Y%m%d")
4180
+ since_date = _parse_dual_form_date(args.since, "--since")
3417
4181
  except ValueError:
3418
- eprint(f"Error: --since must be YYYYMMDD format, got '{args.since}'")
3419
4182
  return 1
3420
4183
  range_start = since_date.replace(tzinfo=dt.timezone.utc)
3421
4184
  else:
@@ -3424,9 +4187,8 @@ def cmd_blocks(args: argparse.Namespace) -> int:
3424
4187
 
3425
4188
  if args.until:
3426
4189
  try:
3427
- until_date = dt.datetime.strptime(args.until, "%Y%m%d")
4190
+ until_date = _parse_dual_form_date(args.until, "--until")
3428
4191
  except ValueError:
3429
- eprint(f"Error: --until must be YYYYMMDD format, got '{args.until}'")
3430
4192
  return 1
3431
4193
  # End of that day
3432
4194
  range_end = until_date.replace(
@@ -3439,6 +4201,10 @@ def cmd_blocks(args: argparse.Namespace) -> int:
3439
4201
  # Collect all entries
3440
4202
  all_entries = get_entries(range_start, range_end)
3441
4203
 
4204
+ _emit_debug_samples_if_set(
4205
+ args, all_entries, command_label="blocks",
4206
+ )
4207
+
3442
4208
  # Load recorded 5-hour reset timestamps. Widen both bounds by
3443
4209
  # BLOCK_DURATION: a window covers [R - 5h, R), so a reset R just
3444
4210
  # before ``range_start`` can still anchor entries near it, and a
@@ -3503,8 +4269,13 @@ def cmd_blocks(args: argparse.Namespace) -> int:
3503
4269
  print(_blocks_to_json(blocks))
3504
4270
  return 0
3505
4271
 
3506
- # Table output
3507
- print(_render_blocks_table(blocks, breakdown=args.breakdown, now=now_utc, tz=tz))
4272
+ # Table output. Session A (spec §7.6.1; Review-A P2-B): thread
4273
+ # --compact through so the renderer's scale-down branch fires
4274
+ # regardless of terminal width when the flag is set.
4275
+ print(_render_blocks_table(
4276
+ blocks, breakdown=args.breakdown, now=now_utc, tz=tz,
4277
+ compact=getattr(args, "compact", False),
4278
+ ))
3508
4279
  return 0
3509
4280
 
3510
4281
 
@@ -3607,6 +4378,45 @@ def _maybe_swap_active_block_to_canonical(
3607
4378
  blocks[active_idx] = rebuilt
3608
4379
 
3609
4380
 
4381
+ def _try_dual_form_date(raw: str) -> dt.datetime | None:
4382
+ """Parse ``YYYY-MM-DD`` / ``YYYYMMDD`` WITHOUT emitting an error.
4383
+
4384
+ Returns the parsed naive datetime, or ``None`` when ``raw`` matches
4385
+ neither form. The non-eprinting core of ``_parse_dual_form_date``:
4386
+ callers that have their own fallback for a non-dual-form input must
4387
+ use this, because ``_parse_dual_form_date`` eprints before it raises
4388
+ — so a successful fallback parse would otherwise leak a spurious
4389
+ "must be YYYY-MM-DD" line to stderr (cache-report's ``parse_iso_datetime``
4390
+ second-chance, issue #101).
4391
+ """
4392
+ for fmt in ("%Y-%m-%d", "%Y%m%d"):
4393
+ try:
4394
+ return dt.datetime.strptime(raw, fmt)
4395
+ except ValueError:
4396
+ continue
4397
+ return None
4398
+
4399
+
4400
+ def _parse_dual_form_date(raw: str, flag: str) -> dt.datetime:
4401
+ """Parse a CLI date arg accepting both ``YYYY-MM-DD`` and ``YYYYMMDD``.
4402
+
4403
+ Tries ``%Y-%m-%d`` first (the more readable upstream ccusage / codex
4404
+ sharedArgs preference), then ``%Y%m%d``. On failure, emits a stderr
4405
+ error and raises ``ValueError``. Callers swallow the ``ValueError``
4406
+ and return exit code 1.
4407
+
4408
+ Promoted from ``_resolve_codex_range._parse_date_arg`` so Claude
4409
+ reporting commands can share the dual-form contract (issue #86
4410
+ Session A, spec §7.1.1). Centralizes the error message so the eight
4411
+ in-scope date-taking commands all surface the same diagnostic.
4412
+ """
4413
+ naive = _try_dual_form_date(raw)
4414
+ if naive is not None:
4415
+ return naive
4416
+ eprint(f"Error: {flag} must be YYYY-MM-DD or YYYYMMDD format, got '{raw}'")
4417
+ raise ValueError
4418
+
4419
+
3610
4420
  def _parse_cli_date_range(
3611
4421
  args: argparse.Namespace,
3612
4422
  *,
@@ -3639,14 +4449,11 @@ def _parse_cli_date_range(
3639
4449
  the given date — this avoids DST-boundary drift that would occur if
3640
4450
  we used today's offset (via `datetime.now().astimezone().tzinfo`).
3641
4451
  """
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
4452
+ # Local binding to the module-level dual-form helper (spec §7.1.1).
4453
+ # Keeps the diff inside _parse_cli_date_range minimal while sharing
4454
+ # the centralized error message with cmd_blocks, _parse_date_filter,
4455
+ # and _parse_window_arg.
4456
+ _parse_date_arg = _parse_dual_form_date
3650
4457
 
3651
4458
  tz: Any = None
3652
4459
  if tz_name:
@@ -3701,7 +4508,12 @@ def _parse_cli_date_range(
3701
4508
  def cmd_daily(args: argparse.Namespace) -> int:
3702
4509
  """Show usage report grouped by display-timezone date."""
3703
4510
  _share_validate_args(args)
3704
- config = load_config()
4511
+ config = _load_claude_config_for_args(args)
4512
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
4513
+ # existing resolve_display_tz precedence absorbs the new alias. The
4514
+ # canonical --tz still wins (it's set on the namespace before this
4515
+ # bridge fires); when --tz is unset and -z is supplied, use -z.
4516
+ _bridge_z_into_tz(args, config)
3705
4517
  tz = resolve_display_tz(args, config)
3706
4518
  args._resolved_tz = tz
3707
4519
 
@@ -3717,6 +4529,10 @@ def cmd_daily(args: argparse.Namespace) -> int:
3717
4529
  # Collect entries.
3718
4530
  all_entries = get_entries(range_start, range_end)
3719
4531
 
4532
+ _emit_debug_samples_if_set(
4533
+ args, all_entries, command_label="daily",
4534
+ )
4535
+
3720
4536
  # Build the unified daily view (spec §5.1: gap-free; the dashboard
3721
4537
  # heatmap's contiguous-window materialization stays at the dashboard
3722
4538
  # envelope adapter so CLI byte-stability is preserved). Consume
@@ -3775,6 +4591,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
3775
4591
  title_suffix="Daily",
3776
4592
  compact_split_fn=_daily_compact_split,
3777
4593
  breakdown=args.breakdown,
4594
+ compact=getattr(args, "compact", False),
3778
4595
  ))
3779
4596
  return 0
3780
4597
 
@@ -3782,7 +4599,8 @@ def cmd_daily(args: argparse.Namespace) -> int:
3782
4599
  def cmd_monthly(args: argparse.Namespace) -> int:
3783
4600
  """Show usage report grouped by display-timezone calendar month."""
3784
4601
  _share_validate_args(args)
3785
- config = load_config()
4602
+ config = _load_claude_config_for_args(args)
4603
+ _bridge_z_into_tz(args, config)
3786
4604
  tz = resolve_display_tz(args, config)
3787
4605
  args._resolved_tz = tz
3788
4606
 
@@ -3797,6 +4615,10 @@ def cmd_monthly(args: argparse.Namespace) -> int:
3797
4615
 
3798
4616
  all_entries = get_entries(range_start, range_end)
3799
4617
 
4618
+ _emit_debug_samples_if_set(
4619
+ args, all_entries, command_label="monthly",
4620
+ )
4621
+
3800
4622
  # Build the unified monthly view (spec §5.2: drops boundary-spillover
3801
4623
  # bucket; computes delta_cost_pct internally). Consume
3802
4624
  # `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
@@ -3849,6 +4671,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
3849
4671
  title_suffix="Monthly",
3850
4672
  compact_split_fn=_monthly_compact_split,
3851
4673
  breakdown=args.breakdown,
4674
+ compact=getattr(args, "compact", False),
3852
4675
  ))
3853
4676
  return 0
3854
4677
 
@@ -3856,7 +4679,8 @@ def cmd_monthly(args: argparse.Namespace) -> int:
3856
4679
  def cmd_weekly(args: argparse.Namespace) -> int:
3857
4680
  """Show Claude usage grouped by subscription week."""
3858
4681
  _share_validate_args(args)
3859
- config = load_config()
4682
+ config = _load_claude_config_for_args(args)
4683
+ _bridge_z_into_tz(args, config)
3860
4684
  args._resolved_tz = resolve_display_tz(args, config)
3861
4685
 
3862
4686
  now_utc = _command_as_of()
@@ -3869,8 +4693,12 @@ def cmd_weekly(args: argparse.Namespace) -> int:
3869
4693
 
3870
4694
  # Build the subscription-week list spanning the range. Boundaries are
3871
4695
  # 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)
4696
+ # extrapolated (see `_compute_subscription_weeks`). Pass the
4697
+ # `--config`-honoring resolved config (issue #88) so the no-snapshot
4698
+ # calendar-week fallback uses the explicit override's `week_start`.
4699
+ weeks = _compute_subscription_weeks(
4700
+ conn, range_start, range_end, config=config,
4701
+ )
3874
4702
 
3875
4703
  # Fetch entries and aggregate.
3876
4704
  # Cover each SubWeek's full [start_ts, end_ts) on the range_start side —
@@ -3889,6 +4717,10 @@ def cmd_weekly(args: argparse.Namespace) -> int:
3889
4717
  fetch_start = range_start
3890
4718
  all_entries = get_entries(fetch_start, range_end)
3891
4719
 
4720
+ _emit_debug_samples_if_set(
4721
+ args, all_entries, command_label="weekly",
4722
+ )
4723
+
3892
4724
  # Bound the usage-snapshot lookup to `<= range_end` so historical
3893
4725
  # `--until <past date>` queries pick the usage% that was current at
3894
4726
  # the end of the requested window rather than the globally latest
@@ -3966,6 +4798,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
3966
4798
  weeks=weeks,
3967
4799
  compact_split_fn=_daily_compact_split,
3968
4800
  breakdown=args.breakdown,
4801
+ compact=getattr(args, "compact", False),
3969
4802
  ))
3970
4803
  return 0
3971
4804
 
@@ -3989,6 +4822,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
3989
4822
  range_start, range_end = range
3990
4823
 
3991
4824
  entries = get_codex_entries(range_start, range_end)
4825
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily")
3992
4826
  # Route through ``build_codex_daily_view`` (issue #58). The View
3993
4827
  # wraps ``_aggregate_codex_daily`` without changing it — preserves
3994
4828
  # LiteLLM token semantics, intentional dedup vs upstream, and
@@ -4048,6 +4882,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
4048
4882
  range_start, range_end = range
4049
4883
 
4050
4884
  entries = get_codex_entries(range_start, range_end)
4885
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly")
4051
4886
  # Route through ``build_codex_monthly_view`` (issue #58).
4052
4887
  view = build_codex_monthly_view(
4053
4888
  entries, now_utc=_command_as_of(), tz_name=tz_name,
@@ -4107,6 +4942,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
4107
4942
  week_start_idx = WEEKDAY_MAP[week_start_name]
4108
4943
 
4109
4944
  entries = get_codex_entries(range_start, range_end)
4945
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly")
4110
4946
  # Route through ``build_codex_weekly_view`` (issue #58).
4111
4947
  view = build_codex_weekly_view(
4112
4948
  entries, now_utc=now_utc, tz_name=tz_name,
@@ -4167,6 +5003,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
4167
5003
  range_start, range_end = range
4168
5004
 
4169
5005
  entries = get_codex_entries(range_start, range_end)
5006
+ _emit_codex_debug_samples_if_set(args, entries, command_label="codex-session")
4170
5007
  # Route through ``build_codex_session_view`` (issue #58). View rows
4171
5008
  # come descending by last_activity (aggregator default + upstream
4172
5009
  # parity); --order asc reverses.
@@ -4426,7 +5263,10 @@ def _project_sort_key(row: dict, sort_by: str, order: str):
4426
5263
  def cmd_project(args: argparse.Namespace) -> int:
4427
5264
  """Roll entries up by project (git-root) with per-project usage attribution."""
4428
5265
  _share_validate_args(args)
4429
- config = load_config()
5266
+ config = _load_claude_config_for_args(args)
5267
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
5268
+ # existing resolve_display_tz precedence absorbs the new alias.
5269
+ _bridge_z_into_tz(args, config)
4430
5270
  args._resolved_tz = resolve_display_tz(args, config)
4431
5271
 
4432
5272
  # Flag-combination validation (must run before any expensive work).
@@ -4477,7 +5317,7 @@ def cmd_project(args: argparse.Namespace) -> int:
4477
5317
  # false, which would otherwise wrongly fall through to the Monday
4478
5318
  # fallback for non-Monday-reset accounts).
4479
5319
  current_weeks = _compute_subscription_weeks(
4480
- conn, now, now + dt.timedelta(microseconds=1)
5320
+ conn, now, now + dt.timedelta(microseconds=1), config=config,
4481
5321
  )
4482
5322
  if current_weeks:
4483
5323
  cw_start = parse_iso_datetime(
@@ -4497,7 +5337,9 @@ def cmd_project(args: argparse.Namespace) -> int:
4497
5337
  # Pre-compute subscription-week bounds for the query window so each entry
4498
5338
  # can be bucketed onto a canonical subscription-week start_ts. Mirrors
4499
5339
  # `_aggregate_weekly`'s bisect pattern (first-match-wins on overlap).
4500
- subweeks = _compute_subscription_weeks(conn, since_dt, until_dt)
5340
+ subweeks = _compute_subscription_weeks(
5341
+ conn, since_dt, until_dt, config=config,
5342
+ )
4501
5343
  parsed_bounds: list[tuple[dt.datetime, dt.datetime]] = []
4502
5344
  for sw in subweeks:
4503
5345
  s_dt = parse_iso_datetime(sw.start_ts, "week.start_ts").astimezone(dt.timezone.utc)
@@ -4548,7 +5390,45 @@ def cmd_project(args: argparse.Namespace) -> int:
4548
5390
  unknown_entry_count = 0
4549
5391
  missing_sid_count = 0
4550
5392
 
4551
- for entry in get_claude_session_entries(scan_start, scan_end):
5393
+ # Issue #89: materialize the joined-entry iterator once so we can
5394
+ # (a) pre-compute the --debug report's scope (entries passing all
5395
+ # rendered-row filters — user slice + --model + --project) BEFORE
5396
+ # the aggregation loop runs and (b) preserve the existing
5397
+ # aggregation semantics (denominator widened to ALL entries; visible
5398
+ # rows only the post-filter subset). The list is small enough to
5399
+ # hold (entries already in memory via the cache row factory).
5400
+ joined_entries_all = list(get_claude_session_entries(scan_start, scan_end))
5401
+
5402
+ # Build the --debug report dataset: skip synthetic + out-of-window
5403
+ # entries, then apply --model and --project filters (mirroring the
5404
+ # exact predicate at the aggregation loop below). This must match
5405
+ # the rendered scope, NOT the denominator scope.
5406
+ if getattr(args, "debug", False):
5407
+ filtered_for_report = []
5408
+ for je in joined_entries_all:
5409
+ if je.model == "<synthetic>":
5410
+ continue
5411
+ if _week_start_for(je.timestamp) is None:
5412
+ continue
5413
+ if je.timestamp < since_dt or je.timestamp > until_dt:
5414
+ continue
5415
+ if model_patterns:
5416
+ mname = (je.model or "").lower()
5417
+ if not any(p in mname for p in model_patterns):
5418
+ continue
5419
+ key_for_filter = _resolve_project_key(
5420
+ je.project_path, args.group, resolver_cache,
5421
+ )
5422
+ if not _project_filter_matches(key_for_filter, project_patterns):
5423
+ continue
5424
+ filtered_for_report.append(je)
5425
+ _emit_debug_samples_if_set(
5426
+ args,
5427
+ [_usage_entry_from_joined(je) for je in filtered_for_report],
5428
+ command_label="project",
5429
+ )
5430
+
5431
+ for entry in joined_entries_all:
4552
5432
  # Skip synthetic entries (Claude Code internal markers) to match
4553
5433
  # `_aggregate_cache_by_session` / `_aggregate_claude_sessions`.
4554
5434
  if entry.model == "<synthetic>":
@@ -4813,13 +5693,21 @@ def cmd_project(args: argparse.Namespace) -> int:
4813
5693
  eprint("No project usage found in range.")
4814
5694
  return 0
4815
5695
 
5696
+ # Session A (spec §7.3): the new --color flag overrides NO_COLOR
5697
+ # env; --no-color overrides FORCE_COLOR env; deny-wins on the
5698
+ # --color + --no-color clash. _resolve_color_enabled returns the
5699
+ # effective bool; pass it as ``color=`` so the renderer skips its
5700
+ # internal _supports_color_stdout() auto-detect (which would
5701
+ # re-consult NO_COLOR and incorrectly disable color when the user
5702
+ # passed --color under NO_COLOR=1).
4816
5703
  print(_render_project_table(
4817
5704
  sorted_rows,
4818
5705
  title=title,
4819
5706
  breakdown=args.breakdown,
4820
5707
  weeks_missing_snapshot=len(weeks_missing_snapshot),
4821
5708
  weeks_in_range=len(weeks_in_range),
4822
- no_color=args.no_color,
5709
+ color=_resolve_color_enabled(args),
5710
+ compact=args.compact,
4823
5711
  ))
4824
5712
  return 0
4825
5713
 
@@ -4909,7 +5797,10 @@ def cmd_diff(args: argparse.Namespace) -> int:
4909
5797
 
4910
5798
  # Validation already happened via _argparse_tz; resolve now to a ZoneInfo
4911
5799
  # (or None for "local") and derive the IANA name for window resolution.
4912
- config = load_config()
5800
+ config = _load_claude_config_for_args(args)
5801
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
5802
+ # existing resolve_display_tz precedence absorbs the new alias.
5803
+ _bridge_z_into_tz(args, config)
4913
5804
  tz_obj = resolve_display_tz(args, config)
4914
5805
  args._resolved_tz = tz_obj
4915
5806
  tz_name = (tz_obj.key if tz_obj is not None else _local_tz_name())
@@ -4934,16 +5825,12 @@ def cmd_diff(args: argparse.Namespace) -> int:
4934
5825
  print(f"diff: {exc}", file=sys.stderr)
4935
5826
  return 2
4936
5827
 
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
-
5828
+ # Validate the remaining CLI surface (`--only` / `--with`) BEFORE the
5829
+ # `--debug` emission below. `_emit_diff_debug_samples` prints reports and,
5830
+ # under `--sync`, runs a cache ingest (a local-state mutation). A
5831
+ # fail-fast usage error like `diff ... --only bogus --debug --sync` must
5832
+ # not print unrelated debug output or touch the cache before returning
5833
+ # exit 2 so the validation gate has to precede the debug scan.
4947
5834
  sections_requested = ["overall", "models", "projects", "cache"]
4948
5835
  if args.only is not None:
4949
5836
  sections_requested = [s.strip() for s in args.only.split(",") if s.strip()]
@@ -4970,6 +5857,23 @@ def cmd_diff(args: argparse.Namespace) -> int:
4970
5857
  )
4971
5858
  return 1
4972
5859
 
5860
+ # Issue #89 spec §7.2.2 Pattern D: emit one --debug report per window
5861
+ # before any rendering, with window-A then window-B labels. Runs only
5862
+ # after the validation gate above so a usage error fails fast without
5863
+ # debug output or a cache sync.
5864
+ if getattr(args, "debug", False):
5865
+ _emit_diff_debug_samples(args, window_a, window_b)
5866
+
5867
+ threshold = NoiseThreshold(
5868
+ show_all=bool(args.show_all),
5869
+ user_override=(args.min_delta_usd is not None
5870
+ or args.min_delta_pct is not None),
5871
+ )
5872
+ if args.min_delta_usd is not None:
5873
+ threshold = dataclasses.replace(threshold, min_delta_usd=args.min_delta_usd)
5874
+ if args.min_delta_pct is not None:
5875
+ threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
5876
+
4973
5877
  try:
4974
5878
  result = _build_diff_result(
4975
5879
  window_a, window_b,
@@ -5002,12 +5906,18 @@ def cmd_diff(args: argparse.Namespace) -> int:
5002
5906
  print(_diff_render_json(result, options=options))
5003
5907
  return 0
5004
5908
 
5005
- color = sys.stdout.isatty() and not args.no_color
5909
+ # Session A (spec §7.3): route through the new color resolver so
5910
+ # the bool --color flag overrides NO_COLOR env, --no-color overrides
5911
+ # FORCE_COLOR env, and deny-wins on the --color + --no-color clash.
5912
+ # The old computation (sys.stdout.isatty() and not args.no_color)
5913
+ # only honored --no-color + isatty; the resolver supersedes both
5914
+ # with the full spec §7.3 precedence.
5915
+ color = _resolve_color_enabled(args)
5006
5916
  width = args.width or shutil.get_terminal_size().columns
5007
5917
  width = max(80, min(width, 160))
5008
5918
  print(_diff_render_full_output(
5009
5919
  result, color=color, width=width, raw_aggregates=result.raw_totals,
5010
- tz=tz_obj,
5920
+ tz=tz_obj, compact=args.compact,
5011
5921
  ))
5012
5922
  return 0
5013
5923
 
@@ -5015,7 +5925,8 @@ def cmd_diff(args: argparse.Namespace) -> int:
5015
5925
  def cmd_session(args: argparse.Namespace) -> int:
5016
5926
  """Show Claude usage grouped by sessionId (merges resumed-across-files sessions)."""
5017
5927
  _share_validate_args(args)
5018
- config = load_config()
5928
+ config = _load_claude_config_for_args(args)
5929
+ _bridge_z_into_tz(args, config)
5019
5930
  tz = resolve_display_tz(args, config)
5020
5931
  args._resolved_tz = tz
5021
5932
 
@@ -5029,6 +5940,27 @@ def cmd_session(args: argparse.Namespace) -> int:
5029
5940
  range_start, range_end = range
5030
5941
 
5031
5942
  entries = get_claude_session_entries(range_start, range_end)
5943
+
5944
+ # Issue #89: --debug report describes the joined-entry list filtered
5945
+ # by --id (post-fallback session_id resolution) when set, matching
5946
+ # the rendered scope of `sessions`.
5947
+ # `is not None`, not truthiness: an explicit empty `--id ''` must still
5948
+ # engage the filter (→ empty render), not silently fall through to
5949
+ # "describe/show all sessions" (code-review finding). Mirrored on the
5950
+ # post-aggregation filter below.
5951
+ if getattr(args, "id", None) is not None:
5952
+ joined_for_report = [
5953
+ je for je in entries
5954
+ if _resolve_session_id_for_filter(je) == args.id
5955
+ ]
5956
+ else:
5957
+ joined_for_report = entries
5958
+ _emit_debug_samples_if_set(
5959
+ args,
5960
+ [_usage_entry_from_joined(je) for je in joined_for_report],
5961
+ command_label="session",
5962
+ )
5963
+
5032
5964
  # Unified view-model kernel (spec §6.5). `limit=None` keeps the
5033
5965
  # full aggregator output — `cctally session` has no `--limit` flag
5034
5966
  # and emits every session in the requested range. `view.aggregated`
@@ -5044,6 +5976,16 @@ def cmd_session(args: argparse.Namespace) -> int:
5044
5976
  )
5045
5977
  sessions = list(view.aggregated)
5046
5978
 
5979
+ # Session A (spec §7.4): exact-string filter on sessionId. Applied
5980
+ # AFTER aggregation (so resume-merged sessions across multiple JSONL
5981
+ # files are matched against their post-merge id) and BEFORE the
5982
+ # `--order asc` reversal and the JSON / share / table render
5983
+ # branches. Unknown id → empty `sessions` list, which falls through
5984
+ # to the existing "no sessions" branch (table: "No Claude session
5985
+ # data found."; JSON: `{"sessions": []}`).
5986
+ if getattr(args, "id", None) is not None: # explicit '' still filters
5987
+ sessions = [s for s in sessions if s.session_id == args.id]
5988
+
5047
5989
  # Shareable-reports gate: --format short-circuits the JSON / table
5048
5990
  # dispatch via `_share_render_and_emit`. The mutex in
5049
5991
  # `_add_share_args` keeps `--format` and `--json` from coexisting.
@@ -5075,8 +6017,17 @@ def cmd_session(args: argparse.Namespace) -> int:
5075
6017
  # sub-rows aren't in the share spec scope). Same convention as
5076
6018
  # cmd_daily / cmd_project.
5077
6019
  display_tz_str = _share_display_tz_label(tz)
6020
+ # Session A (spec §7.4): `_build_session_snapshot` reads
6021
+ # `view.aggregated`, so the `--id` filter applied to the local
6022
+ # `sessions` list above would otherwise be ignored for share
6023
+ # exports (HTML/Markdown/SVG). Hand the builder a view whose
6024
+ # `aggregated` is the filtered list so `--id` is honored across
6025
+ # every output path. The builder reads only `aggregated` (never
6026
+ # `rows` / `total_sessions`), so the parallel-tuple mismatch from
6027
+ # the replace is inert here.
6028
+ share_view = dataclasses.replace(view, aggregated=tuple(sessions))
5078
6029
  snap = _build_session_snapshot(
5079
- view,
6030
+ share_view,
5080
6031
  period_start=range_start,
5081
6032
  period_end=range_end,
5082
6033
  display_tz=display_tz_str,
@@ -5101,10 +6052,14 @@ def cmd_session(args: argparse.Namespace) -> int:
5101
6052
  print("No Claude session data found.")
5102
6053
  return 0
5103
6054
 
6055
+ # Session A (spec §7.6.1; Review-A P2-B): thread --compact through
6056
+ # so the renderer's scale-down branch fires regardless of terminal
6057
+ # width when the flag is set.
5104
6058
  print(_render_claude_session_table(
5105
6059
  sessions,
5106
6060
  breakdown=args.breakdown,
5107
6061
  tz=tz,
6062
+ compact=getattr(args, "compact", False),
5108
6063
  ))
5109
6064
  return 0
5110
6065
 
@@ -7178,19 +8133,19 @@ def _latest_seven_day_and_window(
7178
8133
 
7179
8134
 
7180
8135
  def _parse_date_filter(value: str, flag_name: str) -> str:
7181
- """Parse ``YYYYMMDD`` into a ``YYYY-MM-DD`` ISO date for SQL ``WHERE`` clauses.
8136
+ """Parse ``YYYY-MM-DD`` or ``YYYYMMDD`` into an ISO date for SQL ``WHERE`` clauses.
7182
8137
 
7183
8138
  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.
8139
+ upstream ccusage convention. Routes through the centralized
8140
+ ``_parse_dual_form_date`` (spec §7.1.1) so the dual-form contract and
8141
+ error message are shared with cmd_blocks / cmd_daily / etc.
8142
+
8143
+ The helper already eprints its own diagnostic and raises a bare
8144
+ ``ValueError``; we propagate that bare exception so callers can
8145
+ return an exit code without double-printing (Review-A P1-1; mirrors
8146
+ the bare-re-raise pattern used by ``cmd_cache_report``).
7187
8147
  """
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
8148
+ return _parse_dual_form_date(value, flag_name).date().isoformat()
7194
8149
 
7195
8150
 
7196
8151
  def _load_breakdown(
@@ -7219,7 +8174,10 @@ def _load_breakdown(
7219
8174
  def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7220
8175
  """List API-anchored 5h blocks with rollup totals + 7d-drift columns."""
7221
8176
  _share_validate_args(args)
7222
- config = load_config()
8177
+ config = _load_claude_config_for_args(args)
8178
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz before
8179
+ # resolve_display_tz so the new alias precedence lands.
8180
+ _bridge_z_into_tz(args, config)
7223
8181
  args._resolved_tz = resolve_display_tz(args, config)
7224
8182
  # Pin "now" once (CCTALLY_AS_OF for fixture-pinned harnesses; mirrors
7225
8183
  # cmd_five_hour_breakdown). Used by the active-predicate to gate
@@ -7228,6 +8186,9 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7228
8186
  conn = open_db()
7229
8187
  try:
7230
8188
  # Date filter parsing — same convention as cmd_blocks.
8189
+ # _parse_date_filter routes through _parse_dual_form_date, which
8190
+ # eprints its own diagnostic and raises a bare ValueError on bad
8191
+ # input (Review-A P1-1 — dedup stderr by NOT re-emitting here).
7231
8192
  try:
7232
8193
  since_iso = (
7233
8194
  _parse_date_filter(args.since, "--since")
@@ -7237,8 +8198,7 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7237
8198
  _parse_date_filter(args.until, "--until")
7238
8199
  if args.until else None
7239
8200
  )
7240
- except ValueError as e:
7241
- print(str(e), file=sys.stderr)
8201
+ except ValueError:
7242
8202
  return 2
7243
8203
 
7244
8204
  where: list[str] = []
@@ -7266,6 +8226,31 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
7266
8226
  params,
7267
8227
  ).fetchall()
7268
8228
 
8229
+ # Issue #89: --debug report scope = the time range spanned by
8230
+ # the rendered block rows. When `rows` is empty, pass an empty
8231
+ # list to short-circuit the loader entirely.
8232
+ if rows:
8233
+ # rows are ORDER BY block_start_at DESC; first row is newest,
8234
+ # last row is oldest. The rendered window is
8235
+ # [oldest_block_start, newest_block_start + BLOCK_DURATION).
8236
+ oldest_start_iso = rows[-1]["block_start_at"]
8237
+ newest_start_iso = rows[0]["block_start_at"]
8238
+ block_window_start = parse_iso_datetime(
8239
+ oldest_start_iso, "block_start_at",
8240
+ )
8241
+ block_window_end = parse_iso_datetime(
8242
+ newest_start_iso, "block_start_at",
8243
+ ) + BLOCK_DURATION
8244
+ _emit_debug_samples_if_set(
8245
+ args,
8246
+ lambda: get_entries(block_window_start, block_window_end),
8247
+ command_label="five-hour-blocks",
8248
+ )
8249
+ else:
8250
+ _emit_debug_samples_if_set(
8251
+ args, [], command_label="five-hour-blocks",
8252
+ )
8253
+
7269
8254
  # Detect truncation: cap applied AND there's at least one older
7270
8255
  # block beyond the cap. Probe with LIMIT 1 OFFSET <cap> over the
7271
8256
  # SAME filter set (none here, but kept symmetric for clarity).
@@ -7868,18 +8853,28 @@ def _resolve_cache_report_window(
7868
8853
  # Full-ISO args carry an explicit time component or tz marker.
7869
8854
  if "T" in raw or "+" in raw or "Z" in raw:
7870
8855
  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"):
8856
+ # Date-only: route through the centralized dual-form helper
8857
+ # (spec §7.1.1) so YYYY-MM-DD / YYYYMMDD parsing and the error
8858
+ # message stay consistent with cmd_blocks / cmd_daily / etc.
8859
+ naive = _try_dual_form_date(raw)
8860
+ if naive is None:
8861
+ # Second-chance (issue #101): the pre-Session-A code fell
8862
+ # through to parse_iso_datetime here, so space-separated
8863
+ # datetimes (`2026-05-01 12:30:00`) and ISO week-dates
8864
+ # (`2026-W18-1`) — both accepted by datetime.fromisoformat but
8865
+ # rejected by the dual-form parser — kept working. cache-report
8866
+ # is the only date-taking command with this fallthrough; the
8867
+ # other commands accept YYYY-MM-DD / YYYYMMDD only. Returned
8868
+ # verbatim: a full datetime carries its own time component, so
8869
+ # the is_upper_bound end-of-day expansion is NOT applied (it's
8870
+ # for bare date-only forms). On TOTAL failure (neither dual-form
8871
+ # nor ISO), fall back to the centralized dual-form diagnostic
8872
+ # rather than parse_iso_datetime's more generic message.
7874
8873
  try:
7875
- naive = dt.datetime.strptime(raw, fmt)
7876
- break
8874
+ return parse_iso_datetime(raw, flag)
7877
8875
  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)
8876
+ _parse_dual_form_date(raw, flag) # eprints + raises ValueError
8877
+ raise # unreachable — the call above always raises here
7883
8878
  if is_upper_bound:
7884
8879
  naive = naive.replace(
7885
8880
  hour=23, minute=59, second=59, microsecond=999999,
@@ -8131,15 +9126,47 @@ def _sort_cache_rows(
8131
9126
 
8132
9127
 
8133
9128
  def cmd_cache_report(args: argparse.Namespace) -> int:
8134
- config = load_config()
9129
+ config = _load_claude_config_for_args(args)
9130
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
9131
+ # existing resolve_display_tz precedence absorbs the new alias. The
9132
+ # canonical --tz still wins (it's set on the namespace before this
9133
+ # bridge fires); when --tz is unset and -z is supplied, use -z.
9134
+ _bridge_z_into_tz(args, config)
8135
9135
  tz = resolve_display_tz(args, config)
8136
9136
  args._resolved_tz = tz
8137
9137
 
8138
9138
  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),
9139
+ # Session A (spec §7.1.1): the dual-form helper eprints its own
9140
+ # diagnostic and raises a bare ValueError; catch it here so main()'s
9141
+ # generic ``Error: {exc}`` fallback doesn't double-print an empty
9142
+ # trailer. Mirrors the catch-and-return-1 shape in cmd_blocks.
9143
+ #
9144
+ # Session A note (Review-A P2-D): cache-report's argparse alias
9145
+ # surface lands in Implementor 2's scope (B-series). The try/except
9146
+ # here only routes _resolve_cache_report_window's bare ValueError
9147
+ # around main()'s generic Error: handler — no parser-level changes.
9148
+ try:
9149
+ since, until = _resolve_cache_report_window(
9150
+ args, now_utc=now_utc,
9151
+ tz_name=(tz.key if tz is not None else None),
9152
+ )
9153
+ except ValueError as exc:
9154
+ # Centralized helper already eprinted on the dual-form path; for
9155
+ # the legacy parse_iso_datetime path (full-ISO mis-format) the
9156
+ # ValueError carries the message, so eprint it.
9157
+ msg = str(exc)
9158
+ if msg:
9159
+ eprint(f"Error: {msg}")
9160
+ return 1
9161
+
9162
+ # Issue #89 Pattern C: deferred loader scoped to the rendered window
9163
+ # (project filter mirrors what the cache-aggregator uses).
9164
+ _emit_debug_samples_if_set(
9165
+ args,
9166
+ lambda: get_entries(since, until, project=args.project),
9167
+ command_label="cache-report",
8142
9168
  )
9169
+
8143
9170
  mode = "session" if getattr(args, "by_session", False) else "day"
8144
9171
  top_key = "sessions" if mode == "session" else "days"
8145
9172
 
@@ -8193,11 +9220,20 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
8193
9220
  return 0
8194
9221
 
8195
9222
  title = _build_cache_report_title(args, mode)
8196
- print(_render_cache_report_table(rows, title, mode=mode, tz=tz))
9223
+ print(_render_cache_report_table(rows, title, mode=mode, tz=tz, compact=args.compact))
8197
9224
  return 0
8198
9225
 
8199
9226
 
8200
9227
  def cmd_range_cost(args: argparse.Namespace) -> int:
9228
+ # Session A (spec §7.2 / §7.6 row note): range-cost has no --tz of
9229
+ # its own — start/end carry their own zone via ISO 8601. Calling the
9230
+ # bridge keeps the alias-surface contract uniform across the 10
9231
+ # in-scope cmds: -z/--timezone lands on args.tz unchanged (no
9232
+ # downstream consumer), so this is a documented no-op for this
9233
+ # command. The bridge still runs _resolve_claude_tz_name so the §9.2a
9234
+ # production-path coverage is exercised here too.
9235
+ config = _load_claude_config_for_args(args)
9236
+ _bridge_z_into_tz(args, config)
8201
9237
  start_dt = parse_iso_datetime(args.start, "--start")
8202
9238
  if args.end:
8203
9239
  end_dt = parse_iso_datetime(args.end, "--end")
@@ -8214,7 +9250,17 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
8214
9250
  last_match: dt.datetime | None = None
8215
9251
  model_buckets: dict[str, dict[str, Any]] = {}
8216
9252
 
8217
- for entry in get_entries(start_dt, end_dt, project=args.project):
9253
+ # Issue #89: keep the loaded list around so the --debug report can
9254
+ # describe the same dataset as the rendered output. Project filter is
9255
+ # applied at the loader (SELECT-time), so the scope is the same.
9256
+ # P2.2 (issue #89 review-loop): get_entries already returns
9257
+ # list[UsageEntry] per bin/_cctally_cache.py:1224 — no list() wrap.
9258
+ entries_list = get_entries(start_dt, end_dt, project=args.project)
9259
+ _emit_debug_samples_if_set(
9260
+ args, entries_list, command_label="range-cost",
9261
+ )
9262
+
9263
+ for entry in entries_list:
8218
9264
  cost = _calculate_entry_cost(
8219
9265
  entry.model, entry.usage, mode=args.mode, cost_usd=entry.cost_usd,
8220
9266
  )
@@ -8319,7 +9365,7 @@ def cmd_range_cost(args: argparse.Namespace) -> int:
8319
9365
  ])
8320
9366
 
8321
9367
  aligns = ["left"] + ["right"] * (len(headers) - 1)
8322
- print(_boxed_table(headers, rows, aligns))
9368
+ print(_boxed_table(headers, rows, aligns, compact=args.compact))
8323
9369
  return 0
8324
9370
 
8325
9371
  # Default: print cost
@@ -8689,6 +9735,10 @@ def doctor_gather_state(
8689
9735
  effective_update_reason=effective_update_reason,
8690
9736
  now_utc=now_utc,
8691
9737
  cctally_version=cctally_version,
9738
+ # Dev-instance isolation (§4): which data dir resolved + how.
9739
+ dev_mode=_cctally_core.DEV_MODE,
9740
+ app_dir=str(_cctally_core.APP_DIR),
9741
+ is_dev_checkout=_cctally_core._is_dev_checkout(),
8692
9742
  )
8693
9743
 
8694
9744
 
@@ -8733,6 +9783,110 @@ def _argparse_has_arg(parser, option_string: str) -> bool:
8733
9783
  return False
8734
9784
 
8735
9785
 
9786
+ def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
9787
+ """Attach the Session A ccusage alias surface to a Claude-cmd subparser.
9788
+
9789
+ Sibling to ``_add_codex_shared_args`` (declared inside ``build_parser``)
9790
+ but tailored for Claude commands. Every flag is guarded with
9791
+ ``_argparse_has_arg`` so existing per-parser declarations
9792
+ (cache-report's ``--offline``, project / five-hour-blocks / diff's
9793
+ ``--no-color``) do NOT cause ``argparse.ArgumentError`` — the helper
9794
+ just skips the duplicate. This makes future collisions self-healing
9795
+ when a contributor adds a Session A-managed flag directly on a
9796
+ subparser.
9797
+
9798
+ Args:
9799
+ parser: the subparser to mutate.
9800
+ ansi_emit: ``True`` for project + diff (the 2 real ANSI emitters).
9801
+ ``False`` for the other 8 in-scope cmds. Controls only
9802
+ the ``--color`` help text and whether ``--no-color`` is
9803
+ attempted as a fresh add (when ``ansi_emit=True`` we
9804
+ skip ``--no-color`` entirely — those parsers already
9805
+ declared it themselves).
9806
+
9807
+ Spec §7.1.2 / issue #86 Session A.
9808
+ """
9809
+
9810
+ def _maybe_add(opt: str, *args, **kwargs):
9811
+ if _argparse_has_arg(parser, opt):
9812
+ return
9813
+ parser.add_argument(opt, *args, **kwargs)
9814
+
9815
+ def _maybe_add2(opt1: str, opt2: str, *args, **kwargs):
9816
+ # Two-form add (short + long) — skip if EITHER is present.
9817
+ if _argparse_has_arg(parser, opt1) or _argparse_has_arg(parser, opt2):
9818
+ return
9819
+ parser.add_argument(opt1, opt2, *args, **kwargs)
9820
+
9821
+ _maybe_add2(
9822
+ "-z", "--timezone", default=None, metavar="TZ",
9823
+ help="Alias for --tz (drop-in compat with ccusage). When both "
9824
+ "are supplied, --tz wins.",
9825
+ )
9826
+ _maybe_add2(
9827
+ "-O", "--offline",
9828
+ action=argparse.BooleanOptionalAction, default=False,
9829
+ help="Accepted for ccusage drop-in compat; cctally is always offline.",
9830
+ )
9831
+ _maybe_add(
9832
+ "--compact", action="store_true",
9833
+ help="Force compact table layout regardless of terminal width.",
9834
+ )
9835
+ _maybe_add(
9836
+ "--config", default=None, metavar="PATH",
9837
+ help="Read config from PATH for this invocation only (no "
9838
+ "mutation of the default config at "
9839
+ "~/.local/share/cctally/config.json). Missing or invalid "
9840
+ "PATH errors out with a clear message.",
9841
+ )
9842
+ _maybe_add2(
9843
+ "-d", "--debug", action="store_true",
9844
+ help="Emit a stderr 'Pricing Mismatch Debug Report' "
9845
+ "(totals + per-model stats + sample discrepancies, "
9846
+ "matching ccusage's --debug shape).",
9847
+ )
9848
+ _maybe_add(
9849
+ "--debug-samples", type=_nonneg_int, default=5, metavar="N",
9850
+ help="Cap on sample-discrepancy rows in the --debug report "
9851
+ "(default 5; N=0 suppresses the sample block; "
9852
+ "negatives rejected at parse time).",
9853
+ )
9854
+ _maybe_add(
9855
+ "--single-thread", action="store_true",
9856
+ help="Accepted for ccusage drop-in compat; cctally ingestion "
9857
+ "is already single-threaded via the session-entry cache.",
9858
+ )
9859
+ if ansi_emit:
9860
+ _maybe_add(
9861
+ "--color", action="store_true", default=False,
9862
+ help="Force ANSI color output (overrides NO_COLOR env). When "
9863
+ "neither --color nor --no-color is set, color is auto-"
9864
+ "detected from isatty() and NO_COLOR/FORCE_COLOR env.",
9865
+ )
9866
+ # --no-color already declared on these parsers; do nothing here.
9867
+ else:
9868
+ # No-op-for-compat surface (spec §7.3): these flags parse but do
9869
+ # NOT flow through the color resolver on this command. Color (where
9870
+ # the renderer emits any) follows the auto-detect — isatty() plus
9871
+ # NO_COLOR / FORCE_COLOR env — so the help must NOT claim "no ANSI
9872
+ # is emitted" (daily/monthly/weekly/blocks/session/cache-report DO
9873
+ # emit auto-detected ANSI on a TTY; only the no-color env vars
9874
+ # suppress it). Force/suppress color on the 2 real ANSI commands
9875
+ # (project, diff) instead, or use NO_COLOR=1 / FORCE_COLOR=1.
9876
+ _maybe_add(
9877
+ "--color", action="store_true", default=False,
9878
+ help="Accepted for ccusage drop-in compat; does not control "
9879
+ "this command's color. Color auto-detects from isatty() "
9880
+ "and honors NO_COLOR / FORCE_COLOR env.",
9881
+ )
9882
+ _maybe_add(
9883
+ "--no-color", action="store_true", default=False,
9884
+ help="Accepted for ccusage drop-in compat; does not suppress "
9885
+ "this command's color. Use NO_COLOR=1 env (or pipe stdout) "
9886
+ "to disable auto-detected ANSI.",
9887
+ )
9888
+
9889
+
8736
9890
  def _add_share_args(parser, *, has_status_line: bool = False) -> None:
8737
9891
  """Attach shareable-reports flags + format/json mutex to a subparser.
8738
9892
 
@@ -8887,7 +10041,7 @@ def build_parser() -> argparse.ArgumentParser:
8887
10041
  ),
8888
10042
  )
8889
10043
  p.add_argument(
8890
- "--version",
10044
+ "-v", "--version",
8891
10045
  action="store_true",
8892
10046
  default=argparse.SUPPRESS,
8893
10047
  help="Print cctally version (from CHANGELOG.md latest release header) and exit",
@@ -9472,9 +10626,14 @@ def build_parser() -> argparse.ArgumentParser:
9472
10626
  "Adds SessionId, Last Activity, and Project identity columns.",
9473
10627
  )
9474
10628
  pc.add_argument(
9475
- "--offline",
9476
- action="store_true",
9477
- help="Use cached pricing data in ccusage.",
10629
+ "-O", "--offline",
10630
+ action=argparse.BooleanOptionalAction, default=False,
10631
+ help="Use cached pricing data in ccusage. Session A (spec §7.1.2)"
10632
+ " promotes the existing flag to BooleanOptionalAction + -O"
10633
+ " short form so the ccusage drop-in alias surface (-O,"
10634
+ " --offline, --no-offline) all work on cache-report; the"
10635
+ " behavior under each is unchanged (cctally is always"
10636
+ " offline — args.offline still lands as a bool).",
9478
10637
  )
9479
10638
  pc.add_argument(
9480
10639
  "--project",
@@ -9521,6 +10680,10 @@ def build_parser() -> argparse.ArgumentParser:
9521
10680
  help="Display timezone: local, utc, or IANA name. "
9522
10681
  "Overrides config display.tz for this call.",
9523
10682
  )
10683
+ # Session A (spec §7.6): ansi_emit=False; existing `--offline` is
10684
+ # skipped by the helper's `_argparse_has_arg` guard (the collision
10685
+ # case spec §7.1.2 calls out explicitly).
10686
+ _add_ccusage_alias_args(pc, ansi_emit=False)
9524
10687
  pc.set_defaults(func=cmd_cache_report)
9525
10688
 
9526
10689
  # -- range-cost --
@@ -9575,6 +10738,12 @@ def build_parser() -> argparse.ArgumentParser:
9575
10738
  dest="total_only",
9576
10739
  help="Print numeric USD total only.",
9577
10740
  )
10741
+ # Session A (spec §7.6): ansi_emit=False. range-cost has no --tz of
10742
+ # its own (ISO timestamps carry zone info), but the helper-added
10743
+ # -z/--timezone still lands on the namespace; the bridge promotes
10744
+ # it onto args.tz where the rest of the pipeline treats it as a
10745
+ # documented no-op (cmd_range_cost does not consume args.tz).
10746
+ _add_ccusage_alias_args(rc, ansi_emit=False)
9578
10747
  rc.set_defaults(func=cmd_range_cost)
9579
10748
 
9580
10749
  # -- blocks --
@@ -9619,6 +10788,7 @@ def build_parser() -> argparse.ArgumentParser:
9619
10788
  help="Display timezone: local, utc, or IANA name. "
9620
10789
  "Overrides config display.tz for this call.",
9621
10790
  )
10791
+ _add_ccusage_alias_args(bl, ansi_emit=False)
9622
10792
  bl.set_defaults(func=cmd_blocks)
9623
10793
 
9624
10794
  # -- five-hour-blocks --
@@ -9666,7 +10836,8 @@ def build_parser() -> argparse.ArgumentParser:
9666
10836
  fhb.add_argument(
9667
10837
  "--no-color",
9668
10838
  action="store_true",
9669
- help="Disable ANSI color output (currently a no-op table is plain text).",
10839
+ help="Accepted for ccusage drop-in compat; this command emits "
10840
+ "plain-text output and no ANSI is suppressed.",
9670
10841
  )
9671
10842
  fhb.add_argument(
9672
10843
  "--tz",
@@ -9676,6 +10847,12 @@ def build_parser() -> argparse.ArgumentParser:
9676
10847
  help="Display timezone: local, utc, or IANA name. "
9677
10848
  "Overrides config display.tz for this call.",
9678
10849
  )
10850
+ # Session A (spec §7.6 / §7.6.3): ansi_emit=False. fhb already
10851
+ # declares --no-color (refreshed to no-op text in spec §7.6.3); the
10852
+ # helper's --no-color add is short-circuited by the existing-arg
10853
+ # guard. The helper's --color add lands as a parsed-and-ignored
10854
+ # no-op (the renderer emits plain text).
10855
+ _add_ccusage_alias_args(fhb, ansi_emit=False)
9679
10856
  _add_share_args(fhb)
9680
10857
  fhb.set_defaults(func=cmd_five_hour_blocks)
9681
10858
 
@@ -9747,6 +10924,7 @@ def build_parser() -> argparse.ArgumentParser:
9747
10924
  help="Display timezone: local, utc, or IANA name. "
9748
10925
  "Overrides config display.tz for this call.",
9749
10926
  )
10927
+ _add_ccusage_alias_args(dy, ansi_emit=False)
9750
10928
  _add_share_args(dy)
9751
10929
  dy.set_defaults(func=cmd_daily)
9752
10930
 
@@ -9800,6 +10978,7 @@ def build_parser() -> argparse.ArgumentParser:
9800
10978
  help="Display timezone: local, utc, or IANA name. "
9801
10979
  "Overrides config display.tz for this call.",
9802
10980
  )
10981
+ _add_ccusage_alias_args(mo, ansi_emit=False)
9803
10982
  _add_share_args(mo)
9804
10983
  mo.set_defaults(func=cmd_monthly)
9805
10984
 
@@ -9835,6 +11014,7 @@ def build_parser() -> argparse.ArgumentParser:
9835
11014
  we.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
9836
11015
  help="Display timezone: local, utc, or IANA name. "
9837
11016
  "Overrides config display.tz for this call.")
11017
+ _add_ccusage_alias_args(we, ansi_emit=False)
9838
11018
  _add_share_args(we)
9839
11019
  we.set_defaults(func=cmd_weekly)
9840
11020
 
@@ -9885,6 +11065,22 @@ def build_parser() -> argparse.ArgumentParser:
9885
11065
  "config display.tz for this call. Takes precedence over "
9886
11066
  "upstream's --timezone for drop-in parity.",
9887
11067
  )
11068
+ # Issue #92: codex parity for the #89 --debug surface. Codex JSONL
11069
+ # has no recorded costUSD to diff against, so the report is the
11070
+ # codex variant ("Codex Pricing Debug Report": totals + top-N
11071
+ # highest computed-cost entries), wired via
11072
+ # _emit_codex_debug_samples_if_set in each cmd_codex_* body.
11073
+ parser.add_argument(
11074
+ "-d", "--debug", action="store_true",
11075
+ help="Emit a stderr 'Codex Pricing Debug Report' (totals + "
11076
+ "the N highest computed-cost sample entries).",
11077
+ )
11078
+ parser.add_argument(
11079
+ "--debug-samples", type=_nonneg_int, default=5, metavar="N",
11080
+ help="Cap on top-entry sample rows in the --debug report "
11081
+ "(default 5; N=0 suppresses the sample block; "
11082
+ "negatives rejected at parse time).",
11083
+ )
9888
11084
 
9889
11085
  # -- codex-daily --
9890
11086
  cd = sub.add_parser(
@@ -10039,6 +11235,11 @@ def build_parser() -> argparse.ArgumentParser:
10039
11235
  p_project.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
10040
11236
  help="Display timezone: local, utc, or IANA name. "
10041
11237
  "Overrides config display.tz for this call.")
11238
+ # Session A (spec §7.6): ansi_emit=True. project is one of the two
11239
+ # real ANSI emitters. The helper skips its --no-color add (already
11240
+ # declared at p_project above) and adds the new bool --color flag
11241
+ # whose precedence flows through _resolve_color_enabled (§7.3).
11242
+ _add_ccusage_alias_args(p_project, ansi_emit=True)
10042
11243
  _add_share_args(p_project)
10043
11244
  p_project.set_defaults(func=cmd_project)
10044
11245
 
@@ -10073,6 +11274,14 @@ def build_parser() -> argparse.ArgumentParser:
10073
11274
  diff_p.add_argument("--json", dest="emit_json", action="store_true")
10074
11275
  diff_p.add_argument("--width", type=int, help=argparse.SUPPRESS)
10075
11276
  diff_p.add_argument("--debug-now", action="store_true", help=argparse.SUPPRESS)
11277
+ # Session A (spec §7.6): ansi_emit=True. diff is the other real
11278
+ # ANSI emitter. The helper skips its --no-color add (declared
11279
+ # above) and adds the new bool --color flag wired through
11280
+ # _resolve_color_enabled (§7.3). Note: --debug here collides with
11281
+ # diff's existing `--debug-now` (SUPPRESS'd internal flag) but
11282
+ # `--debug-now` is a different option string; the helper still
11283
+ # adds plain `--debug` cleanly.
11284
+ _add_ccusage_alias_args(diff_p, ansi_emit=True)
10076
11285
  diff_p.set_defaults(func=cmd_diff)
10077
11286
 
10078
11287
  # -- session --
@@ -10111,6 +11320,14 @@ def build_parser() -> argparse.ArgumentParser:
10111
11320
  se.add_argument("--tz", default=None, type=_argparse_tz, metavar="TZ",
10112
11321
  help="Display timezone: local, utc, or IANA name. "
10113
11322
  "Overrides config display.tz for this call.")
11323
+ se.add_argument(
11324
+ "-i", "--id", default=None, metavar="SESSION_ID", dest="id",
11325
+ help="Filter to a single session by exact-string sessionId. "
11326
+ "Match is against the post-resume-merge id (sessions "
11327
+ "resumed across multiple JSONL files collapse to one id). "
11328
+ "Unknown id → exit 0 with the empty-render branch.",
11329
+ )
11330
+ _add_ccusage_alias_args(se, ansi_emit=False)
10114
11331
  _add_share_args(se)
10115
11332
  se.set_defaults(func=cmd_session)
10116
11333
 
@@ -10255,6 +11472,9 @@ def build_parser() -> argparse.ArgumentParser:
10255
11472
  help="Skip confirmations")
10256
11473
  sp.add_argument("--json", action="store_true",
10257
11474
  help="Emit machine-readable output")
11475
+ sp.add_argument("--force-dev", action="store_true", dest="force_dev",
11476
+ help="Allow setup to run from a dev checkout (writes "
11477
+ "dev-pointing hooks into ~/.claude/settings.json)")
10258
11478
  # Legacy bespoke-hook migration flags (install-mode only — see cmd_setup
10259
11479
  # post-parse validation). Spec Section 2 mode×flag matrix.
10260
11480
  mig_group = sp.add_mutually_exclusive_group()
@@ -12302,10 +13522,19 @@ def main(argv: list[str] | None = None) -> int:
12302
13522
  # works without a subcommand (`cctally --version`).
12303
13523
  if getattr(args, "version", False):
12304
13524
  v = _lib_changelog._read_latest_changelog_version()
12305
- if v is None:
12306
- print("cctally unknown")
12307
- else:
12308
- print(f"cctally {v[0]}")
13525
+ base = "cctally unknown" if v is None else f"cctally {v[0]}"
13526
+ # Dev-instance isolation (§4, P3): append the dev marker + resolved
13527
+ # data dir whenever running from a checkout — keyed on
13528
+ # _is_dev_checkout(), NOT DEV_MODE, so the CCTALLY_DATA_DIR
13529
+ # override-on-checkout case (DEV_MODE False) still shows the marker
13530
+ # instead of masquerading as the installed binary. Prod (no .git)
13531
+ # output is unchanged. The override case is labelled distinctly so
13532
+ # the user can tell auto-detect (cctally-dev) from an explicit dir.
13533
+ if _cctally_core.DEV_MODE:
13534
+ base += f" (dev — {_cctally_core.APP_DIR})"
13535
+ elif _cctally_core._is_dev_checkout():
13536
+ base += f" (dev checkout, custom data dir — {_cctally_core.APP_DIR})"
13537
+ print(base)
12309
13538
  return 0
12310
13539
  if not getattr(args, "func", None):
12311
13540
  parser.error("a subcommand is required")