cctally 1.50.0 → 1.51.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.
@@ -170,6 +170,7 @@ from _cctally_core import (
170
170
  compute_week_bounds,
171
171
  parse_date_str,
172
172
  _canonicalize_optional_iso,
173
+ _reset_aware_floor,
173
174
  make_week_ref,
174
175
  _get_alerts_config,
175
176
  _AlertsConfigError,
@@ -2174,6 +2175,78 @@ def _read_reset_zero_marker():
2174
2175
  return (week_start_date, cur_end_canon, baseline_pct, first_zero_iso)
2175
2176
 
2176
2177
 
2178
+ @dataclass(frozen=True)
2179
+ class CreditPlan:
2180
+ week_start_date: str
2181
+ week_start_at: str
2182
+ week_end_at: str
2183
+ cur_end_canon: str
2184
+ from_pct: float
2185
+ from_source: str # "hwm" | "explicit" | "prior_credit"
2186
+ to_pct: float
2187
+ effective_iso: str # weekly_credit_floors.effective_at_utc (floored to hour)
2188
+ captured_iso: str # synthetic snapshot captured_at_utc (un-floored), 'Z'
2189
+
2190
+
2191
+ def _parse_credit_at(value, now):
2192
+ """Parse --at as an aware UTC datetime; default to `now`. Naive => UTC
2193
+ (mirrors bin/_cctally_five_hour.py:88), NOT parse_iso_datetime (host-local)."""
2194
+ if value is None:
2195
+ return now
2196
+ try:
2197
+ parsed = dt.datetime.fromisoformat(value)
2198
+ except ValueError as e:
2199
+ raise ValueError(f"--at: {e}") from e
2200
+ if parsed.tzinfo is None:
2201
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
2202
+ return parsed.astimezone(dt.timezone.utc)
2203
+
2204
+
2205
+ def _build_credit_plan(*, week_start_date, week_start_at, week_end_at,
2206
+ from_pct, from_source, to_pct, at_dt, now,
2207
+ effective_override=None):
2208
+ """Validate inputs and build a CreditPlan. Pure (no DB/file I/O).
2209
+ Raises ValueError(msg) on any violation — caller maps to exit 2.
2210
+
2211
+ `effective_override` (an ISO string) is the completion-path reuse of an
2212
+ EXISTING `weekly_credit_floors.effective_at_utc` (spec §4a): when present,
2213
+ the plan's effective is that value rather than `floor_to_hour(at)`, so a
2214
+ rerun of a half-applied credit at a later wall-clock keeps the original
2215
+ floor moment and never leaks a stale pre-credit replay into the floored
2216
+ MAX. The synthetic snapshot's captured timestamp stays the un-floored
2217
+ `at` either way."""
2218
+ to_pct = _normalize_percent(to_pct)
2219
+ from_pct = _normalize_percent(from_pct)
2220
+ if not (0.0 <= to_pct <= 100.0) or not (0.0 <= from_pct <= 100.0):
2221
+ raise ValueError("--to/--from must be in [0, 100]")
2222
+ if to_pct >= from_pct:
2223
+ raise ValueError(f"not a credit: to >= from ({to_pct} >= {from_pct})")
2224
+ ws = parse_iso_datetime(week_start_at, "week_start_at")
2225
+ we = parse_iso_datetime(week_end_at, "week_end_at")
2226
+ if not (ws <= at_dt < we):
2227
+ raise ValueError(f"--at {at_dt.isoformat()} is outside the week window")
2228
+ if at_dt > now:
2229
+ raise ValueError("--at is in the future")
2230
+ if effective_override is not None:
2231
+ effective_iso = parse_iso_datetime(
2232
+ effective_override, "effective_override"
2233
+ ).astimezone(dt.timezone.utc).isoformat(timespec="seconds")
2234
+ else:
2235
+ effective_iso = _floor_to_hour(at_dt).isoformat(timespec="seconds")
2236
+ cur_end_canon = _canonicalize_optional_iso(week_end_at, "record-credit.week_end")
2237
+ return CreditPlan(
2238
+ week_start_date=week_start_date,
2239
+ week_start_at=week_start_at,
2240
+ week_end_at=week_end_at,
2241
+ cur_end_canon=cur_end_canon,
2242
+ from_pct=from_pct,
2243
+ from_source=from_source,
2244
+ to_pct=to_pct,
2245
+ effective_iso=effective_iso,
2246
+ captured_iso=at_dt.isoformat(timespec="seconds").replace("+00:00", "Z"),
2247
+ )
2248
+
2249
+
2177
2250
  def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2178
2251
  *, observed_pre_credit_pct, effective_dt):
2179
2252
  """Emit/refresh the in-place weekly-credit artifacts (issue #19 + #128).
@@ -2238,6 +2311,422 @@ def _fire_in_place_credit(conn, week_start_date, cur_end_canon, weekly_percent,
2238
2311
  eprint(f"[record-usage] post-credit cleanup failed: {exc}")
2239
2312
 
2240
2313
 
2314
+ def _resolve_reset_aware_hwm(conn, week_start_date, week_start_at, week_end_at):
2315
+ """The floored MAX(weekly_percent) the statusline _hwm_clamp computes:
2316
+ MAX over snapshots captured at/after the latest in-week clamp floor. The
2317
+ floor is the latest effective across BOTH `week_reset_events` and
2318
+ `weekly_credit_floors` (`_reset_aware_floor`) so a manual partial credit
2319
+ (record-credit M2, #209) lowers the resolved HWM without re-anchoring the
2320
+ week — used both as the `--from` default and as the assertion source of
2321
+ truth in the record-credit tests."""
2322
+ floor_iso = _reset_aware_floor(conn, week_start_date, week_start_at, week_end_at)
2323
+ if floor_iso is not None:
2324
+ row = conn.execute(
2325
+ "SELECT MAX(weekly_percent) FROM weekly_usage_snapshots "
2326
+ " WHERE week_start_date = ? AND unixepoch(captured_at_utc) >= unixepoch(?)",
2327
+ (week_start_date, floor_iso),
2328
+ ).fetchone()
2329
+ else:
2330
+ row = conn.execute(
2331
+ "SELECT MAX(weekly_percent) FROM weekly_usage_snapshots "
2332
+ " WHERE week_start_date = ?",
2333
+ (week_start_date,),
2334
+ ).fetchone()
2335
+ return None if not row or row[0] is None else float(row[0])
2336
+
2337
+
2338
+ def _insert_credit_snapshot(conn, plan, *, five_hour=(None, None, None)):
2339
+ """Insert the post-credit snapshot at plan.to_pct, tagged source='record-credit'."""
2340
+ fhp, fhr, fhk = five_hour
2341
+ # Normalize effective to a +00:00 UTC spelling in the payload (matches the
2342
+ # stored weekly_credit_floors.effective_at_utc on a non-UTC host).
2343
+ effective_utc = parse_iso_datetime(
2344
+ plan.effective_iso, "snapshot.effective"
2345
+ ).astimezone(dt.timezone.utc).isoformat(timespec="seconds")
2346
+ payload = json.dumps(
2347
+ {"kind": "record-credit", "from": plan.from_pct, "to": plan.to_pct,
2348
+ "effective": effective_utc},
2349
+ separators=(",", ":"),
2350
+ )
2351
+ conn.execute(
2352
+ "INSERT INTO weekly_usage_snapshots "
2353
+ "(captured_at_utc, week_start_date, week_end_date, week_start_at, "
2354
+ " week_end_at, weekly_percent, page_url, source, payload_json, "
2355
+ " five_hour_percent, five_hour_resets_at, five_hour_window_key) "
2356
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
2357
+ (plan.captured_iso, plan.week_start_date,
2358
+ parse_iso_datetime(plan.week_end_at, "we").date().isoformat(),
2359
+ plan.week_start_at, plan.week_end_at, plan.to_pct, None,
2360
+ "record-credit", payload, fhp, fhr, fhk),
2361
+ )
2362
+ conn.commit()
2363
+ return conn.total_changes
2364
+
2365
+
2366
+ def _resolve_prior_5h(conn, at_dt):
2367
+ """Return the most-recent snapshot's (five_hour_percent, five_hour_resets_at,
2368
+ five_hour_window_key) iff that 5h window is still active (resets_at > at_dt),
2369
+ else (None, None, None) — so the synthetic row doesn't blank the live 5h
2370
+ display, and never inflates the 5h HWM (copies an already-<=MAX value)."""
2371
+ row = conn.execute(
2372
+ "SELECT five_hour_percent, five_hour_resets_at, five_hour_window_key "
2373
+ "FROM weekly_usage_snapshots "
2374
+ "WHERE five_hour_resets_at IS NOT NULL AND five_hour_window_key IS NOT NULL "
2375
+ "ORDER BY unixepoch(captured_at_utc) DESC, id DESC LIMIT 1").fetchone()
2376
+ if row is None:
2377
+ return (None, None, None)
2378
+ try:
2379
+ if parse_iso_datetime(row[1], "prior.5h_resets") > at_dt:
2380
+ return (row[0], row[1], int(row[2]))
2381
+ except ValueError:
2382
+ pass
2383
+ return (None, None, None)
2384
+
2385
+
2386
+ def _apply_credit(conn, plan, *, five_hour=(None, None, None)):
2387
+ """Apply the M2 same-window partial-credit artifacts (record-credit, #209,
2388
+ spec §4). Unlike the >=25pp auto-credit path (`_fire_in_place_credit`), this
2389
+ writes NO `week_reset_events` row — the window-resolution code never sees a
2390
+ credit, so the week is NOT re-anchored. It only lowers the clamp floor.
2391
+
2392
+ Side-effect ordering mirrors `_fire_in_place_credit`'s discipline: the
2393
+ INSERT OR IGNORE of the floor row is dedup-gated by UNIQUE(week_start_date,
2394
+ effective_at_utc), but the hwm force-write, stale-replay DELETE, and
2395
+ synthetic-snapshot INSERT run UNCONDITIONALLY so a rerun finishes a crash-
2396
+ half-applied credit (memory: project_dedup_must_not_gate_side_effects). All
2397
+ are individually idempotent (file overwrite; DELETE on a stable predicate;
2398
+ the synthetic snapshot is re-INSERTed only after `_force_clear_credit` or on
2399
+ the completion path where none exists yet).
2400
+
2401
+ `plan.effective_iso` is `floor_to_hour(at)`. parse_iso_datetime returns a
2402
+ host-local-offset aware datetime; convert to UTC so `effective_at_utc`
2403
+ persists with a +00:00 spelling, not a host offset, in the `*_utc` column.
2404
+ On the completion / --force re-apply path the CALLER passes a `plan` whose
2405
+ `effective_iso` is the EXISTING floor row's `effective_at_utc` (NOT a fresh
2406
+ floor_to_hour(now)) — spec §4a completion-effective reuse."""
2407
+ c = _cctally()
2408
+ effective_dt = parse_iso_datetime(plan.effective_iso, "effective").astimezone(dt.timezone.utc)
2409
+ effective_iso = effective_dt.isoformat(timespec="seconds")
2410
+ pre_credit = float(plan.from_pct)
2411
+
2412
+ # 4a. INSERT the credit floor (no week_reset_events row — the whole point).
2413
+ conn.execute(
2414
+ "INSERT OR IGNORE INTO weekly_credit_floors "
2415
+ "(week_start_date, effective_at_utc, observed_pre_credit_pct, applied_at_utc) "
2416
+ "VALUES (?, ?, ?, ?)",
2417
+ (plan.week_start_date, effective_iso, pre_credit, now_utc_iso()),
2418
+ )
2419
+ conn.commit()
2420
+
2421
+ # 4b. Force-write hwm-7d so the external statusline render reflects the
2422
+ # post-credit value (the normal write-site monotonic guard would refuse to
2423
+ # decrease the file).
2424
+ try:
2425
+ (_cctally_core.APP_DIR / "hwm-7d").write_text(
2426
+ f"{plan.week_start_date} {plan.to_pct}\n"
2427
+ )
2428
+ except OSError:
2429
+ pass
2430
+
2431
+ # 4c. Stale-replay DELETE: drop pre-credit-valued replays that land at/after
2432
+ # the floor (the gotcha_statusline_replay_race_after_credit defense; same
2433
+ # 1.0pp band as the auto path). unixepoch() on both sides for offset safety.
2434
+ try:
2435
+ conn.execute(
2436
+ "DELETE FROM weekly_usage_snapshots "
2437
+ "WHERE week_start_date = ? "
2438
+ " AND unixepoch(captured_at_utc) >= unixepoch(?) "
2439
+ " AND ABS(weekly_percent - ?) < 1.0",
2440
+ (plan.week_start_date, effective_iso, pre_credit),
2441
+ )
2442
+ conn.commit()
2443
+ except sqlite3.DatabaseError as exc:
2444
+ eprint(f"[record-credit] post-credit cleanup failed: {exc}")
2445
+
2446
+ # 4d. INSERT the synthetic post-credit snapshot at plan.to_pct.
2447
+ c._insert_credit_snapshot(conn, plan, five_hour=five_hour)
2448
+
2449
+ # 4e. Clear a stale same-week reset-zero marker so the next record-usage
2450
+ # tick can't confirm a phantom reset-to-zero off it.
2451
+ _clear_reset_zero_marker()
2452
+
2453
+
2454
+ def _force_clear_credit(conn, week_start_date):
2455
+ """--force scope (M2, spec §4): delete ONLY this week's command-owned
2456
+ synthetic snapshots (`source='record-credit'`) + its `weekly_credit_floors`
2457
+ row(s). Never touches real status-line snapshots, and never
2458
+ `week_reset_events` / `percent_milestones` — a partial credit writes
2459
+ neither (no event row -> no segmentation; no milestones)."""
2460
+ conn.execute("DELETE FROM weekly_usage_snapshots "
2461
+ " WHERE week_start_date=? AND source='record-credit'", (week_start_date,))
2462
+ conn.execute("DELETE FROM weekly_credit_floors "
2463
+ " WHERE week_start_date=?", (week_start_date,))
2464
+ conn.commit()
2465
+
2466
+
2467
+ def _count_stale_replays(conn, plan):
2468
+ """Count the pre-credit replay rows the _apply_credit stale-replay DELETE
2469
+ (step 4c, inline) will touch (captured at/after the credit moment within a
2470
+ 1.0pp band of from), for the preview / --json `staleReplaysDeleted` field.
2471
+ Read-only."""
2472
+ row = conn.execute(
2473
+ "SELECT COUNT(*) FROM weekly_usage_snapshots "
2474
+ " WHERE week_start_date = ? "
2475
+ " AND unixepoch(captured_at_utc) >= unixepoch(?) "
2476
+ " AND ABS(weekly_percent - ?) < 1.0",
2477
+ (plan.week_start_date, plan.effective_iso, float(plan.from_pct)),
2478
+ ).fetchone()
2479
+ return int(row[0]) if row and row[0] is not None else 0
2480
+
2481
+
2482
+ def _credit_preview_text(plan, *, stale_replays, dry_run):
2483
+ """Human preview (spec §5). Shown before the confirm prompt and as the
2484
+ whole body under --dry-run."""
2485
+ eff_dt = parse_iso_datetime(plan.effective_iso, "effective").astimezone(dt.timezone.utc)
2486
+ cap_dt = parse_iso_datetime(plan.captured_iso, "captured").astimezone(dt.timezone.utc)
2487
+ we_dt = parse_iso_datetime(plan.week_end_at, "week_end").astimezone(dt.timezone.utc)
2488
+ src = {
2489
+ "hwm": "current HWM",
2490
+ "explicit": "explicit",
2491
+ "prior_credit": "prior credit",
2492
+ }.get(plan.from_source, plan.from_source)
2493
+ lines = [
2494
+ "record-credit — weekly in-place credit",
2495
+ f" week: {plan.week_start_date} -> "
2496
+ f"{we_dt.strftime('%Y-%m-%d %H:%M')} UTC",
2497
+ f" from -> to: {plan.from_pct:g}% -> {plan.to_pct:g}% (from: {src})",
2498
+ f" effective: {eff_dt.strftime('%Y-%m-%d %H:%M')} UTC "
2499
+ f"(floored from {cap_dt.strftime('%Y-%m-%d %H:%M')})",
2500
+ " writes:",
2501
+ f" + weekly_credit_floors (effective={plan.effective_iso}, "
2502
+ f"pre_credit={plan.from_pct:g})",
2503
+ f" ~ hwm-7d {plan.from_pct:g} -> {plan.to_pct:g}",
2504
+ f" - stale replays {stale_replays} rows",
2505
+ f" + snapshot captured={plan.captured_iso}, "
2506
+ f"weekly_percent={plan.to_pct:g}",
2507
+ " note: same week — no window re-anchor (no week_reset_events row)",
2508
+ ]
2509
+ if dry_run:
2510
+ lines.append(" (dry-run — nothing written)")
2511
+ return "\n".join(lines)
2512
+
2513
+
2514
+ def _credit_json(plan, *, applied, dry_run, forced, stale_replays, hwm_before):
2515
+ """The --json envelope (schemaVersion 1, spec §5); all datetimes …Z."""
2516
+ def _z(iso):
2517
+ return parse_iso_datetime(iso, "z").astimezone(
2518
+ dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
2519
+ return {
2520
+ "schemaVersion": 1,
2521
+ "applied": applied,
2522
+ "dryRun": dry_run,
2523
+ "forced": forced,
2524
+ "week": {
2525
+ "weekStartDate": plan.week_start_date,
2526
+ "weekStartAt": _z(plan.week_start_at),
2527
+ "weekEndAt": _z(plan.week_end_at),
2528
+ },
2529
+ "credit": {
2530
+ "fromPct": plan.from_pct,
2531
+ "toPct": plan.to_pct,
2532
+ "fromSource": plan.from_source,
2533
+ "effectiveAtUtc": _z(plan.effective_iso),
2534
+ },
2535
+ "actions": {
2536
+ "creditFloorInserted": applied,
2537
+ "hwm7dBefore": hwm_before,
2538
+ "hwm7dAfter": plan.to_pct if applied else hwm_before,
2539
+ "staleReplaysDeleted": stale_replays,
2540
+ "postCreditSnapshotInserted": applied,
2541
+ },
2542
+ }
2543
+
2544
+
2545
+ def cmd_record_credit(args) -> int:
2546
+ now = _command_as_of()
2547
+ try:
2548
+ at_dt = _parse_credit_at(getattr(args, "at", None), now)
2549
+ except ValueError as e:
2550
+ eprint(f"record-credit: {e}")
2551
+ return 2
2552
+ conn = None
2553
+ try:
2554
+ conn = open_db()
2555
+ # 1. Resolve the week.
2556
+ if getattr(args, "week", None):
2557
+ week_start_date = args.week
2558
+ ws_at, we_at = _get_canonical_boundary_for_date(conn, week_start_date)
2559
+ if not ws_at or not we_at:
2560
+ eprint(f"record-credit: no snapshot for --week {week_start_date}")
2561
+ return 2
2562
+ else:
2563
+ fetched = _fetch_current_week_snapshots(conn, at_dt)
2564
+ if fetched is None:
2565
+ eprint("record-credit: no snapshot week contains --at; pass --week")
2566
+ return 2
2567
+ ws_at, we_at, _samples = fetched
2568
+ ws_at = ws_at if isinstance(ws_at, str) else ws_at.isoformat(timespec="seconds")
2569
+ we_at = we_at if isinstance(we_at, str) else we_at.isoformat(timespec="seconds")
2570
+ week_start_date = parse_iso_datetime(ws_at, "ws_at").date().isoformat()
2571
+
2572
+ # Resolve any existing credit FLOOR for this week up front — needed
2573
+ # both for the --from default fallback (a half-applied credit empties
2574
+ # the post-credit segment, so the reset-aware HWM reads NULL) and the
2575
+ # apply-time completion/refuse/force branch (M2 keys on
2576
+ # weekly_credit_floors, NOT week_reset_events — a partial credit never
2577
+ # writes a reset-event row). Latest floor wins (a --force re-apply at a
2578
+ # new effective leaves the old row only until _force_clear_credit
2579
+ # deletes it; pick the newest defensively).
2580
+ existing = conn.execute(
2581
+ "SELECT id, effective_at_utc, observed_pre_credit_pct "
2582
+ "FROM weekly_credit_floors WHERE week_start_date=? "
2583
+ "ORDER BY unixepoch(effective_at_utc) DESC, id DESC LIMIT 1",
2584
+ (week_start_date,)).fetchone()
2585
+
2586
+ # 2. Resolve --from default.
2587
+ if getattr(args, "from_pct", None) is not None:
2588
+ from_pct, from_source = float(args.from_pct), "explicit"
2589
+ elif existing is not None and existing[2] is not None:
2590
+ # A credit floor already exists for this week (completion or
2591
+ # --force re-record). Its recorded observed_pre_credit_pct is the
2592
+ # AUTHENTIC pre-credit baseline; prefer it over the reset-aware
2593
+ # HWM. The post-credit segment's MAX(weekly_percent) would
2594
+ # otherwise pick up the post-credit value (31) or a later real
2595
+ # status-line reading, mis-deriving the baseline and causing the
2596
+ # stale-replay DELETE to (wrongly) match real history. fromSource
2597
+ # is 'prior_credit' (spec §5).
2598
+ from_pct, from_source = float(existing[2]), "prior_credit"
2599
+ else:
2600
+ hwm = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at)
2601
+ if hwm is None:
2602
+ eprint("record-credit: no usage history for the week; pass --from")
2603
+ return 2
2604
+ from_pct, from_source = hwm, "hwm"
2605
+
2606
+ is_force = getattr(args, "force", False)
2607
+
2608
+ # 2a. Classify the existing-floor state (M2, spec §4/§5). A
2609
+ # `weekly_credit_floors` row may be:
2610
+ # - half-applied (floor row present, NO command-owned snapshot
2611
+ # at/after its effective): a crash between 4a and 4d. A plain
2612
+ # rerun FINISHES it, reusing the existing effective_at_utc (NOT
2613
+ # a fresh floor_to_hour(now)) so no stale [old,new) replay leaks
2614
+ # into the floored MAX (spec §4a completion-effective reuse).
2615
+ # - fully applied (floor row + command-owned snapshot): refuse by
2616
+ # default; --force clears + re-records at a fresh effective.
2617
+ is_completion = False
2618
+ if existing is not None and not is_force:
2619
+ owned = conn.execute(
2620
+ "SELECT 1 FROM weekly_usage_snapshots "
2621
+ " WHERE week_start_date=? AND source='record-credit' "
2622
+ " AND unixepoch(captured_at_utc) >= unixepoch(?) LIMIT 1",
2623
+ (week_start_date, existing[1])).fetchone()
2624
+ is_completion = owned is None
2625
+
2626
+ # The effective the plan should carry: a half-applied completion reuses
2627
+ # the EXISTING floor row's effective; a first credit / --force re-apply
2628
+ # uses floor_to_hour(at) (computed inside _build_credit_plan).
2629
+ reuse_effective = existing[1] if is_completion else None
2630
+
2631
+ # 3. Validate + build plan.
2632
+ try:
2633
+ plan = _build_credit_plan(
2634
+ week_start_date=week_start_date, week_start_at=ws_at,
2635
+ week_end_at=we_at, from_pct=from_pct, from_source=from_source,
2636
+ to_pct=args.to, at_dt=at_dt, now=now,
2637
+ effective_override=reuse_effective,
2638
+ )
2639
+ except ValueError as e:
2640
+ eprint(f"record-credit: {e}")
2641
+ return 2
2642
+
2643
+ # 4. Output + confirm matrix (spec §5).
2644
+ is_json = getattr(args, "json", False)
2645
+ is_dry = getattr(args, "dry_run", False)
2646
+ is_yes = getattr(args, "yes", False)
2647
+ stale_replays = _count_stale_replays(conn, plan)
2648
+ hwm_before = _resolve_reset_aware_hwm(conn, week_start_date, ws_at, we_at)
2649
+ if hwm_before is None:
2650
+ hwm_before = plan.from_pct
2651
+
2652
+ # --dry-run: preview only, write nothing, exit 0 (TTY or not,
2653
+ # with/without --json).
2654
+ if is_dry:
2655
+ if is_json:
2656
+ print(json.dumps(_credit_json(
2657
+ plan, applied=False, dry_run=True, forced=False,
2658
+ stale_replays=stale_replays, hwm_before=hwm_before)))
2659
+ else:
2660
+ print(_credit_preview_text(plan, stale_replays=stale_replays,
2661
+ dry_run=True))
2662
+ return 0
2663
+
2664
+ # --json (not dry-run) must be paired with --yes; never prompts.
2665
+ if is_json and not is_yes:
2666
+ eprint("record-credit: --json requires --yes or --dry-run")
2667
+ return 2
2668
+
2669
+ # No --yes: prompt (TTY) or refuse (non-TTY).
2670
+ if not is_yes:
2671
+ if not sys.stdin.isatty():
2672
+ eprint("record-credit: stdin not a TTY: pass --yes to apply "
2673
+ "or --dry-run to preview")
2674
+ return 2
2675
+ print(_credit_preview_text(plan, stale_replays=stale_replays,
2676
+ dry_run=False))
2677
+ try:
2678
+ reply = input("Proceed? [y/N] ")
2679
+ except EOFError:
2680
+ reply = ""
2681
+ if reply.strip().lower() not in ("y", "yes"):
2682
+ print("aborted — nothing written")
2683
+ return 0
2684
+
2685
+ # 4a. Existing-floor handling (M2; state classified at step 2a).
2686
+ # _apply_credit commits the floor row + cleanup BEFORE the synthetic
2687
+ # snapshot, so a crash in between leaves a half-applied credit
2688
+ # (floor row, no command-owned snapshot). `existing` /
2689
+ # `is_completion` were resolved up front (above) keyed on
2690
+ # week_start_date / weekly_credit_floors.
2691
+ forced = False
2692
+ if existing is not None and not is_force:
2693
+ if not is_completion:
2694
+ # Fully applied -> refuse by default.
2695
+ eprint(f"record-credit: a credit is already recorded for this "
2696
+ f"week (effective={existing[1]}, pre_credit={existing[2]}); "
2697
+ f"pass --force to re-record")
2698
+ return 2
2699
+ # else: half-applied -> fall through (completion path; the plan
2700
+ # reuses the existing effective and the apply steps are idempotent).
2701
+ elif existing is not None and is_force:
2702
+ _force_clear_credit(conn, plan.week_start_date)
2703
+ forced = True
2704
+
2705
+ five_hour = _resolve_prior_5h(conn, at_dt)
2706
+ _apply_credit(conn, plan, five_hour=five_hour)
2707
+
2708
+ if is_json:
2709
+ print(json.dumps(_credit_json(
2710
+ plan, applied=True, dry_run=False, forced=forced,
2711
+ stale_replays=stale_replays, hwm_before=hwm_before)))
2712
+ else:
2713
+ print(f"record-credit: applied — week {plan.week_start_date} "
2714
+ f"{plan.from_pct:g}% -> {plan.to_pct:g}% "
2715
+ f"(effective {plan.effective_iso})")
2716
+ return 0
2717
+ except sqlite3.DatabaseError as e:
2718
+ # Documented exit 3 (docs/commands/record-credit.md "3 — a database
2719
+ # error"). The inner ValueError->2 / EOFError paths return before
2720
+ # reaching here, so this only catches genuine DB failures from
2721
+ # open_db() through _apply_credit/output. Plain-text on stderr,
2722
+ # matching the record-credit: <msg> convention of the validation paths.
2723
+ eprint(f"record-credit: {e}")
2724
+ return 3
2725
+ finally:
2726
+ if conn is not None:
2727
+ conn.close()
2728
+
2729
+
2241
2730
  def cmd_record_usage(args: argparse.Namespace) -> int:
2242
2731
  """Record usage data from Claude Code status line rate_limits."""
2243
2732
  c = _cctally()
@@ -2758,38 +3247,43 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
2758
3247
  eprint(f"[record-usage] reset-event detection failed: {exc}")
2759
3248
 
2760
3249
  # 7-day usage is monotonically non-decreasing within a billing week
2761
- # — UNTIL Anthropic issues an in-place weekly credit. When a
2762
- # week_reset_events row exists for THIS week_end_at, the MAX query
2763
- # filters to samples captured at-or-after the segment's
2764
- # effective_reset_at_utc so a fresh post-credit OAuth value (e.g.
2765
- # 2%) lands instead of being held back by stale pre-credit history
2766
- # (e.g. 67%). When no event row exists, COALESCE defaults to
2767
- # epoch-zero so the filter is a no-op and legacy clamp behavior
2768
- # is preserved byte-identically.
3250
+ # — UNTIL an in-place weekly credit lowers it. There are TWO credit
3251
+ # shapes and BOTH must floor this clamp, or a real post-credit tick
3252
+ # is suppressed and never stored:
3253
+ # (a) an Anthropic mid-week reset / >=25pp auto-credit writes a
3254
+ # `week_reset_events` row (it also re-anchors the window); and
3255
+ # (b) a manual `record-credit` partial credit writes a
3256
+ # `weekly_credit_floors` row WITHOUT re-anchoring the week
3257
+ # (record-credit M2, #209).
3258
+ # `_reset_aware_floor` returns the LATEST in-week effective across
3259
+ # both legs. The MAX query then filters to samples captured at-or-
3260
+ # after that floor, so a fresh post-credit OAuth value (e.g. 37%
3261
+ # after a 46->31 credit) lands instead of being held back by stale
3262
+ # pre-credit history (46%). Without the credit-floor leg, the 37%
3263
+ # tick is `round(37,1) < round(46,1)` -> should_insert=False ->
3264
+ # never stored, cascading to every latest-snapshot surface
3265
+ # (the M2 linchpin; spec §4a, test S13).
3266
+ # When neither leg has a row, the floor is None -> '1970-...' epoch-
3267
+ # zero default -> the filter is a no-op and legacy clamp behavior is
3268
+ # preserved byte-identically.
2769
3269
  # NB: comparison wrapped with ``unixepoch()`` on BOTH sides.
2770
- # ``captured_at_utc`` is stored with `Z` suffix, but
2771
- # ``effective_reset_at_utc`` may have a non-UTC offset on
2772
- # historical backfill rows written before Bug 3 was fixed
2773
- # (parse_iso_datetime returned host-local). Lex string compare
2774
- # on mixed offsets silently mis-orders moments for non-UTC
2775
- # hosts (CLAUDE.md gotcha: 5h-block cross-reset flag — "all
2776
- # comparisons go through unixepoch(), NOT lex
2777
- # BETWEEN/`<`/`>`"). Same rule applies here.
3270
+ # ``captured_at_utc`` is stored with `Z` suffix, but the floor may
3271
+ # carry a non-UTC / +00:00 offset spelling. Lex string compare on
3272
+ # mixed offsets silently mis-orders moments for non-UTC hosts
3273
+ # (CLAUDE.md gotcha: 5h-block cross-reset flag — "all comparisons go
3274
+ # through unixepoch(), NOT lex BETWEEN/`<`/`>`"). Same rule here, and
3275
+ # inside `_reset_aware_floor`'s own ORDER BY.
3276
+ clamp_floor_iso = _reset_aware_floor(
3277
+ conn, week_start_date, week_start_at, week_end_at,
3278
+ ) or "1970-01-01T00:00:00Z"
2778
3279
  max_row = conn.execute(
2779
3280
  """
2780
3281
  SELECT MAX(weekly_percent) AS v
2781
3282
  FROM weekly_usage_snapshots
2782
3283
  WHERE week_start_date = ?
2783
- AND unixepoch(captured_at_utc) >= unixepoch(COALESCE(
2784
- (SELECT effective_reset_at_utc
2785
- FROM week_reset_events
2786
- WHERE new_week_end_at = ?
2787
- ORDER BY id DESC
2788
- LIMIT 1),
2789
- '1970-01-01T00:00:00Z'
2790
- ))
3284
+ AND unixepoch(captured_at_utc) >= unixepoch(?)
2791
3285
  """,
2792
- (week_start_date, week_end_at),
3286
+ (week_start_date, clamp_floor_iso),
2793
3287
  ).fetchone()
2794
3288
  if max_row and max_row["v"] is not None and round(weekly_percent, 1) < round(float(max_row["v"]), 1):
2795
3289
  should_insert = False
@@ -500,20 +500,22 @@ def _build_statusline_injections(warn_once):
500
500
  # (_apply_reset_events_to_subweeks: post-reset window
501
501
  # start_ts := effective_reset_at_utc) by flooring the
502
502
  # MAX to snapshots captured at/after the latest reset
503
- # effective WITHIN this window. unixepoch() on both
504
- # sides — reset rows carry mixed offset spellings
505
- # (+00:00 / +03:00) while captured_at_utc uses 'Z', so a
506
- # lexical compare would misorder them (same rule as the
507
- # 5h-block cross-reset flag).
508
- floor_row = conn.execute(
509
- "SELECT MAX(effective_reset_at_utc) "
510
- "FROM week_reset_events "
511
- "WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?) "
512
- " AND unixepoch(effective_reset_at_utc) < unixepoch(?)",
513
- (week_start_dt.isoformat(), week_end_dt.isoformat()),
514
- ).fetchone()
515
- floor_iso = (
516
- floor_row[0] if floor_row and floor_row[0] else None
503
+ # effective WITHIN this window.
504
+ #
505
+ # The floor is the LATEST in-week effective across BOTH
506
+ # `week_reset_events` (Anthropic resets / >=25pp auto-
507
+ # credits) and `weekly_credit_floors` (manual `record-
508
+ # credit` partial credits — record-credit M2, #209): a
509
+ # partial credit lowers the clamp floor WITHOUT re-
510
+ # anchoring the week, so without the credit-floor leg the
511
+ # statusline would re-clamp the post-credit 31% back UP to
512
+ # the stale pre-credit 46% peak. `_reset_aware_floor`
513
+ # unions both legs with `unixepoch()` ordering (mixed
514
+ # Z / +00:00 offset spellings; lexical MAX would misorder
515
+ # them — same rule as the 5h-block cross-reset flag).
516
+ floor_iso = c._reset_aware_floor(
517
+ conn, week_start_date,
518
+ week_start_dt.isoformat(), week_end_dt.isoformat(),
517
519
  )
518
520
  if floor_iso is not None:
519
521
  row = conn.execute(
package/bin/cctally CHANGED
@@ -212,6 +212,7 @@ WeekRef = _cctally_core.WeekRef
212
212
  _canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
213
213
  make_week_ref = _cctally_core.make_week_ref
214
214
  _get_latest_row_for_week = _cctally_core._get_latest_row_for_week
215
+ _reset_aware_floor = _cctally_core._reset_aware_floor
215
216
  get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
216
217
 
217
218
  # === Path constants — re-exported from _cctally_core ================
@@ -634,6 +635,9 @@ cmd_sync_week = _cctally_sync_week.cmd_sync_week
634
635
  # module-private (no external caller).
635
636
  _cctally_project = _load_sibling("_cctally_project")
636
637
  cmd_project = _cctally_project.cmd_project
638
+ # Re-exported so the record-credit tests can assert `project`'s per-week MAX
639
+ # is reset-aware-floored (S15, #209).
640
+ _load_week_snapshots = _cctally_project._load_week_snapshots
637
641
  # Shared per-project cost compute (#19/#121, spec §7.1). Re-exported so the
638
642
  # per-project budget display (cmd_budget) and the Task 3 firing path reach it
639
643
  # via the cctally namespace.
@@ -840,6 +844,22 @@ _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projectio
840
844
  _compute_block_totals = _cctally_record._compute_block_totals
841
845
  maybe_update_five_hour_block = _cctally_record.maybe_update_five_hour_block
842
846
  cmd_record_usage = _cctally_record.cmd_record_usage
847
+ CreditPlan = _cctally_record.CreditPlan
848
+ _parse_credit_at = _cctally_record._parse_credit_at
849
+ _build_credit_plan = _cctally_record._build_credit_plan
850
+ _resolve_reset_aware_hwm = _cctally_record._resolve_reset_aware_hwm
851
+ _insert_credit_snapshot = _cctally_record._insert_credit_snapshot
852
+ _resolve_prior_5h = _cctally_record._resolve_prior_5h
853
+ _apply_credit = _cctally_record._apply_credit
854
+ _fire_in_place_credit = _cctally_record._fire_in_place_credit
855
+ _force_clear_credit = _cctally_record._force_clear_credit
856
+ _count_stale_replays = _cctally_record._count_stale_replays
857
+ _credit_preview_text = _cctally_record._credit_preview_text
858
+ _credit_json = _cctally_record._credit_json
859
+ _arm_reset_zero_marker = _cctally_record._arm_reset_zero_marker
860
+ _read_reset_zero_marker = _cctally_record._read_reset_zero_marker
861
+ _clear_reset_zero_marker = _cctally_record._clear_reset_zero_marker
862
+ cmd_record_credit = _cctally_record.cmd_record_credit
843
863
  _hook_tick_log_line = _cctally_record._hook_tick_log_line
844
864
  _hook_tick_log_rotate_if_needed = _cctally_record._hook_tick_log_rotate_if_needed
845
865
  _hook_tick_throttle_age_seconds = _cctally_record._hook_tick_throttle_age_seconds