cctally 1.116.1 → 1.117.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +263 -17
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +44 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +446 -22
  15. package/bin/_cctally_forecast.py +302 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_milestone_history.py +379 -91
  18. package/bin/_cctally_oauth_samples.py +185 -7
  19. package/bin/_cctally_parser.py +38 -0
  20. package/bin/_cctally_project.py +34 -8
  21. package/bin/_cctally_quarantine.py +821 -343
  22. package/bin/_cctally_quota.py +69 -4
  23. package/bin/_cctally_quota_calibration.py +677 -3
  24. package/bin/_cctally_quota_model.py +927 -127
  25. package/bin/_cctally_record.py +922 -445
  26. package/bin/_cctally_rederive.py +119 -15
  27. package/bin/_cctally_statusline.py +1048 -176
  28. package/bin/_cctally_store.py +544 -110
  29. package/bin/_cctally_tui.py +455 -42
  30. package/bin/_cctally_update.py +77 -14
  31. package/bin/_lib_account_protocol.py +227 -19
  32. package/bin/_lib_claude_attribution.py +421 -0
  33. package/bin/_lib_clone_copy.py +30 -0
  34. package/bin/_lib_codex_conversation_query.py +45 -9
  35. package/bin/_lib_codex_conversation_watch.py +72 -13
  36. package/bin/_lib_codex_rollout_container.py +234 -19
  37. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  38. package/bin/_lib_credit.py +22 -13
  39. package/bin/_lib_dashboard_sources.py +257 -18
  40. package/bin/_lib_detector_access_registry.py +1335 -166
  41. package/bin/_lib_doctor.py +276 -92
  42. package/bin/_lib_ingest_frontier.py +269 -36
  43. package/bin/_lib_journal.py +273 -22
  44. package/bin/_lib_jsonl.py +12 -0
  45. package/bin/_lib_meter_rate_change.py +72 -0
  46. package/bin/_lib_outcome_quarantine.py +917 -554
  47. package/bin/_lib_provider_activity.py +24 -6
  48. package/bin/_lib_quota_calibration.py +57 -3
  49. package/bin/_lib_quota_copy.py +7 -2
  50. package/bin/_lib_quota_lineage.py +613 -0
  51. package/bin/_lib_quota_model.py +464 -33
  52. package/bin/_lib_rate_change_delivery.py +16 -0
  53. package/bin/_lib_reclaim_planner.py +6 -4
  54. package/bin/_lib_rederive.py +2046 -21
  55. package/bin/_lib_selector_state.py +131 -3
  56. package/bin/_lib_snapshot_cache.py +8 -0
  57. package/bin/_lib_stale_proof.py +113 -4
  58. package/bin/_lib_statusline.py +72 -3
  59. package/bin/_lib_statusline_candidates.py +246 -42
  60. package/bin/_lib_tick_stats.py +10 -5
  61. package/bin/_lib_write_io.py +419 -6
  62. package/bin/cctally +28 -11
  63. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
  64. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
  65. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
  66. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
  67. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
  68. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
  69. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
  70. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  71. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
  72. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  73. package/dashboard/static/dashboard.html +3 -3
  74. package/package.json +5 -1
  75. package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
  76. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -353,11 +353,12 @@ class DoctorState:
353
353
  # summary (a deep run substitutes its own whole-journal recomputation).
354
354
  # None / available=False is reported as unavailable, never as zero.
355
355
  legacy_writer: Optional[dict] = None
356
- # #825/#832 (spec section 8, F16): the outcome quarantine, read from the
357
- # validated index. `{"available": bool, "triggered": bool, "accounts":
358
- # [{"account", "accountLabel", "accountingPaused", "controlPaused",
359
- # "accountingSince", "controlSince"}]}`. None / available=False is
360
- # reported as unavailable, never as OK.
356
+ # #948 (spec 7.1): the per-account, per-limit outcome quarantine, read
357
+ # from the validated index. `{"available": bool, "triggered": bool,
358
+ # "generation", "cause", "inventoryEpisode", "inventoryPath"?, "accounts":
359
+ # [{"account", "accountLabel", "cause", "paused", "axes": {axis: {"state",
360
+ # "waitingFor", "notBefore", "since", "recoveredAt", "revocation"}}}]}`.
361
+ # None / available=False is reported as unavailable, never as OK.
361
362
  outcome_quarantine: Optional[dict] = None
362
363
  # Beta-channel (spec 2026-07-21 §3): the configured RELEASE channel
363
364
  # (stable|beta) that `cctally update` tracks — DISTINCT from the preview
@@ -494,6 +495,10 @@ class CheckResult:
494
495
  summary: str
495
496
  remediation: Optional[str]
496
497
  details: dict
498
+ # An OK result's remediation is a text-only hint unless the check opts in
499
+ # here: #948's seven-day recovered note must also reach `--json` and the
500
+ # dashboard modal (spec 7.2), while every other OK hint stays text-only.
501
+ remediation_on_ok: bool = False
497
502
 
498
503
 
499
504
  @dataclasses.dataclass(frozen=True)
@@ -1054,7 +1059,7 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
1054
1059
  # current index as a mismatch; keep this in lockstep with core
1055
1060
  # (#496 S5b §6.1). It stays a literal because this kernel is pure and
1056
1061
  # must not import `_cctally_core`.
1057
- epoch = 1019
1062
+ epoch = 1023
1058
1063
  mismatch = uv > legacy_head and uv != epoch
1059
1064
  return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
1060
1065
  "mismatch": mismatch}
@@ -4212,91 +4217,179 @@ def _legacy_writer_verdict_result(verdict, *, count: int,
4212
4217
 
4213
4218
 
4214
4219
  #: Spec revision 5 (section 8), operator-owned wording, verbatim: an
4215
- #: incomplete legacy spool inventory, and the sentence added when the
4216
- #: validated index holds its durable `inventory-incomplete` marker.
4220
+ #: incomplete legacy spool inventory. #948 (spec 7.3, operator-approved
4221
+ #: 2026-11-01): the sentence added while the validated index shows the
4222
+ #: inventory episode open.
4217
4223
  LEGACY_SPOOL_INVENTORY_TEXT = (
4218
4224
  "cctally cannot read {path} ({operation}: {error}), one of the "
4219
4225
  "status-line files an older cctally release used. Until it can, cctally "
4220
4226
  "cannot tell whether an older process changed it. Check the file's "
4221
4227
  "permissions and the disk.")
4222
4228
  LEGACY_SPOOL_INVENTORY_PAUSED_TEXT = (
4223
- "Automatic credit and reset detection is paused because of this, and "
4224
- "making the file readable again does not end the pause (see "
4229
+ "Automatic credit and reset detection is paused because of this. Once "
4230
+ "the file is readable again it resumes on its own (see "
4225
4231
  "`accounting.outcome_quarantine`); usage recording continues.")
4226
4232
 
4227
4233
 
4228
- #: Operator-approved wording (#825/#832 spec section 8), verbatim.
4229
- OUTCOME_QUARANTINE_ACCOUNTING_TEXT = (
4230
- "Automatic credit and reset detection is paused for {account} since "
4231
- "{time} because an older cctally process wrote data after the upgrade. It "
4232
- "resumes only after cctally verifies a clean reading for this account, "
4233
- "typically the first one after an idle period spanning a weekly reset; "
4234
- "there is no guaranteed time. If your usage was reset in place meanwhile, "
4235
- "record it with `cctally record-credit`.")
4236
- OUTCOME_QUARANTINE_CONTROL_TEXT = (
4237
- "Status-line drop detection also waits for a successful usage refresh for "
4238
- "this account.")
4239
- #: Spec revision 3 (section 8; operator decision dec-2b5bcf910816), verbatim:
4240
- #: an account paused by an unresolved spool marker. It replaces the "resumes
4241
- #: only after..." sentence and already names drop detection, so no control
4242
- #: sentence is appended.
4243
- OUTCOME_QUARANTINE_SPOOL_MARKER_TEXT = (
4244
- "Automatic credit, reset and status-line drop detection is paused for "
4245
- "{account} since {time} because an older cctally process wrote "
4246
- "status-line data after the upgrade. This version of cctally cannot yet "
4247
- "confirm a safe point to resume, so the pause does not end on its own; "
4248
- "usage recording continues. If your usage was reset in place meanwhile, "
4249
- "record it with `cctally record-credit`.")
4250
- #: Spec revision 5 (section 8), operator-owned wording, verbatim: an account
4251
- #: paused by a marker an incomplete legacy spool inventory left.
4252
- OUTCOME_QUARANTINE_INVENTORY_MARKER_TEXT = (
4253
- "Automatic credit, reset and status-line drop detection is paused for "
4254
- "{account} since {time} because cctally could not read the status-line "
4255
- "files an older cctally release used, so it cannot rule out that an "
4256
- "older process changed them. This version of cctally cannot yet confirm "
4257
- "a safe point to resume, so the pause does not end on its own; usage "
4258
- "recording continues. If your usage was reset in place meanwhile, record "
4259
- "it with `cctally record-credit`.")
4260
- #: Operator decision dec-5d2a9c2b004a (escalation esc-8a386c207966): the
4261
- #: standalone sentence for an account missing only its control certificate.
4262
- #: The approved control sentence above opens with "also", so it reads only as
4263
- #: an addendum to the accounting one.
4264
- OUTCOME_QUARANTINE_CONTROL_ONLY_TEXT = (
4265
- "Status-line drop detection is paused for {account} since {time}. It "
4266
- "resumes after a successful usage refresh for this account.")
4267
-
4268
-
4269
- def _outcome_quarantine_message(row: dict) -> str:
4270
- """One account's paused outcome classes, in the approved wording. A
4271
- missing control certificate appends its sentence to the accounting one;
4272
- alone, it gets its own operator-approved standalone sentence."""
4234
+ #: #948 (spec 7.3; operator decision D6, 2026-11-01: "Per-limit detail",
4235
+ #: "Note for 7 days"), verbatim. One paused account: the sentence, with the
4236
+ #: cause clause of its trigger, then one line per limit.
4237
+ OUTCOME_QUARANTINE_PAUSED_TEXT = (
4238
+ "Automatic credit, early-reset and status-line drop detection is paused "
4239
+ "for {account} since {time} because {reason}. It resumes on its own, "
4240
+ "separately for each limit. Usage recording continues; no command is "
4241
+ "needed. If your usage was reset in place meanwhile, you can record it "
4242
+ "with `cctally record-credit`.")
4243
+ OUTCOME_QUARANTINE_PAUSE_REASONS = {
4244
+ "legacy-spool-activity": (
4245
+ "an older cctally process wrote status-line data after the upgrade"),
4246
+ "inventory-incomplete": (
4247
+ "cctally could not read the status-line files an older cctally "
4248
+ "release used"),
4249
+ "excluded-write": "an older cctally process wrote data after the upgrade",
4250
+ }
4251
+ #: The OK note for seven days after an account's last limit recovered.
4252
+ OUTCOME_QUARANTINE_RECOVERED_TEXT = (
4253
+ "Automatic detection resumed for {account} on {time} after a pause "
4254
+ "{reason}.")
4255
+ OUTCOME_QUARANTINE_RECOVERED_REASONS = {
4256
+ "inventory-incomplete": (
4257
+ "while cctally could not read the status-line files an older cctally "
4258
+ "release used"),
4259
+ }
4260
+ OUTCOME_QUARANTINE_RECOVERED_REASON = "caused by an older cctally process"
4261
+ #: The per-limit lines: limit name, then its state.
4262
+ OUTCOME_QUARANTINE_LIMITS = (("fiveHour", "five-hour"), ("sevenDay", "weekly"))
4263
+ OUTCOME_QUARANTINE_RESUMED_LINE = "{limit}: resumed {time}"
4264
+ OUTCOME_QUARANTINE_WAITING_LINES = {
4265
+ "inventory": "{limit}: paused - waiting until cctally can read {path}",
4266
+ "expiry": "{limit}: paused - waiting for an automatic usage refresh",
4267
+ "usage-refresh": "{limit}: paused - waiting for an automatic usage refresh",
4268
+ }
4269
+ OUTCOME_QUARANTINE_FRESH_PERIOD_LINES = {
4270
+ "fiveHour": "{limit}: paused - waiting for the next five-hour window",
4271
+ "sevenDay": "{limit}: paused - waiting for the next weekly period",
4272
+ }
4273
+ OUTCOME_QUARANTINE_NOT_BEFORE = {
4274
+ "fiveHour": " (not before {time})",
4275
+ "sevenDay": " (not before {date})",
4276
+ }
4277
+ #: How long an account's recovered note stays on the OK result (spec 7.2).
4278
+ OUTCOME_QUARANTINE_NOTE_DAYS = 7
4279
+
4280
+
4281
+ def _quarantine_instant(text) -> "Optional[dt.datetime]":
4282
+ if not text:
4283
+ return None
4284
+ try:
4285
+ value = dt.datetime.fromisoformat(str(text).replace("Z", "+00:00"))
4286
+ except ValueError:
4287
+ return None
4288
+ return value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)
4289
+
4290
+
4291
+ def _quarantine_time(text) -> str:
4292
+ value = _quarantine_instant(text)
4293
+ return _iso_z(value) if value is not None else "an unknown time"
4294
+
4295
+
4296
+ def _outcome_quarantine_limit_line(axis: str, limit: str, doc: dict, *,
4297
+ now: dt.datetime,
4298
+ inventory_path: "Optional[str]") -> str:
4299
+ if doc.get("state") != "paused":
4300
+ return OUTCOME_QUARANTINE_RESUMED_LINE.format(
4301
+ limit=limit, time=_quarantine_time(doc.get("recoveredAt")))
4302
+ waiting = doc.get("waitingFor")
4303
+ if waiting in OUTCOME_QUARANTINE_WAITING_LINES:
4304
+ return OUTCOME_QUARANTINE_WAITING_LINES[waiting].format(
4305
+ limit=limit, path=inventory_path or "the status-line files")
4306
+ line = OUTCOME_QUARANTINE_FRESH_PERIOD_LINES[axis].format(limit=limit)
4307
+ bound = _quarantine_instant(doc.get("notBefore"))
4308
+ if bound is not None and bound > now:
4309
+ # Spec 7.1: never an estimate, omitted when null or already past.
4310
+ line += OUTCOME_QUARANTINE_NOT_BEFORE[axis].format(
4311
+ time=_iso_z(bound), date=bound.astimezone(dt.timezone.utc).date().isoformat())
4312
+ return line
4313
+
4314
+
4315
+ def _outcome_quarantine_paused(row: dict) -> bool:
4316
+ return any((row.get("axes") or {}).get(axis, {}).get("state") == "paused"
4317
+ for axis, _limit in OUTCOME_QUARANTINE_LIMITS)
4318
+
4319
+
4320
+ def _outcome_quarantine_recovered_at(row: dict) -> "Optional[dt.datetime]":
4321
+ """When the account's last limit recovered, or None while one is paused
4322
+ or none recovered."""
4323
+ if _outcome_quarantine_paused(row):
4324
+ return None
4325
+ instants = [_quarantine_instant(doc.get("recoveredAt"))
4326
+ for doc in (row.get("axes") or {}).values()]
4327
+ instants = [value for value in instants if value is not None]
4328
+ return max(instants) if instants else None
4329
+
4330
+
4331
+ def _outcome_quarantine_message(row: dict, *, now: dt.datetime,
4332
+ inventory_path: "Optional[str]" = None
4333
+ ) -> "Optional[str]":
4334
+ """One account's operator-approved text (spec 7.3): the paused sentence
4335
+ and its per-limit lines while any limit is paused; the recovered note
4336
+ for seven days after its last limit recovered; else None."""
4273
4337
  label = row.get("accountLabel") or row.get("account")
4274
- if row.get("unresolvedSpoolMarker"):
4275
- text = (OUTCOME_QUARANTINE_INVENTORY_MARKER_TEXT
4276
- if row.get("spoolMarkerCause") == "inventory-incomplete"
4277
- else OUTCOME_QUARANTINE_SPOOL_MARKER_TEXT)
4278
- return text.format(
4279
- account=label, time=row.get("accountingSince") or "an unknown time")
4280
- if row.get("accountingPaused"):
4281
- text = OUTCOME_QUARANTINE_ACCOUNTING_TEXT.format(
4282
- account=label, time=row.get("accountingSince") or "an unknown time")
4283
- if row.get("controlPaused"):
4284
- text += " " + OUTCOME_QUARANTINE_CONTROL_TEXT
4285
- return text
4286
- return OUTCOME_QUARANTINE_CONTROL_ONLY_TEXT.format(
4287
- account=label, time=row.get("controlSince") or "an unknown time")
4338
+ cause = row.get("cause")
4339
+ axes = row.get("axes") or {}
4340
+ if _outcome_quarantine_paused(row):
4341
+ since = [doc.get("since") for doc in axes.values()
4342
+ if doc.get("state") == "paused" and doc.get("since")]
4343
+ since_at = min((v for v in (_quarantine_instant(t) for t in since)
4344
+ if v is not None), default=None)
4345
+ text = OUTCOME_QUARANTINE_PAUSED_TEXT.format(
4346
+ account=label,
4347
+ time=_iso_z(since_at) if since_at is not None else "an unknown time",
4348
+ reason=OUTCOME_QUARANTINE_PAUSE_REASONS.get(
4349
+ cause, OUTCOME_QUARANTINE_PAUSE_REASONS["legacy-spool-activity"]))
4350
+ lines = [_outcome_quarantine_limit_line(
4351
+ axis, limit, axes.get(axis) or {}, now=now,
4352
+ inventory_path=inventory_path)
4353
+ for axis, limit in OUTCOME_QUARANTINE_LIMITS]
4354
+ return "\n".join([text] + lines)
4355
+ recovered = _outcome_quarantine_recovered_at(row)
4356
+ if recovered is None or now - recovered >= dt.timedelta(
4357
+ days=OUTCOME_QUARANTINE_NOTE_DAYS):
4358
+ return None
4359
+ return OUTCOME_QUARANTINE_RECOVERED_TEXT.format(
4360
+ account=label, time=_iso_z(recovered),
4361
+ reason=OUTCOME_QUARANTINE_RECOVERED_REASONS.get(
4362
+ cause, OUTCOME_QUARANTINE_RECOVERED_REASON))
4363
+
4364
+
4365
+ #: #948 review F7: doctor lists an outcome-quarantine account only when it
4366
+ #: was seen in use (a reading or a registry observation) within this many
4367
+ #: days (operator decision 2026-11-02).
4368
+ _OUTCOME_QUARANTINE_ACTIVE_DAYS = 7
4369
+
4370
+
4371
+ def _parse_iso_utc(text: "str | None") -> "dt.datetime | None":
4372
+ if not text:
4373
+ return None
4374
+ try:
4375
+ moment = dt.datetime.fromisoformat(str(text).replace("Z", "+00:00"))
4376
+ except ValueError:
4377
+ return None
4378
+ return moment if moment.tzinfo else moment.replace(tzinfo=dt.timezone.utc)
4288
4379
 
4289
4380
 
4290
4381
  def _check_accounting_outcome_quarantine(s: DoctorState) -> CheckResult:
4291
- """#825/#832 (spec section 8, F16, operator Q9): after a post-cutover
4292
- legacy write or legacy spool change, automatic outcomes are withheld per
4293
- account until certified recovery. OK when no account is quarantined; WARN
4294
- naming per account what is paused and since when; unavailable — WARN,
4295
- never OK — on an unavailable or unvalidated index. Separate from
4296
- `journal.legacy_writer`: seven quiet days do not mean outcomes resumed.
4297
- Never claims when recovery will happen. While a spool marker is
4298
- unresolved (spec revision 3) every row uses the marker wording and the
4299
- details carry the markers."""
4382
+ """#948 (spec 7): automatic outcomes and status-line drop detection are
4383
+ paused per account and per limit after a post-upgrade legacy write, a
4384
+ legacy spool change or an unreadable legacy spool, and each limit
4385
+ resumes on its own. WARN while any limit of any account is paused,
4386
+ naming per limit what it waits for; OK otherwise, carrying the recovered
4387
+ note for seven days after an account's last limit recovered; unavailable
4388
+ — WARN, never OK — on an unavailable or unvalidated index. Separate from
4389
+ `journal.legacy_writer`. Never estimates when recovery will happen: a
4390
+ "not before" bound is a guaranteed lower bound, omitted when null or
4391
+ past. ``details.inventoryEpisode`` names the latest inventory episode.
4392
+ An account idle for seven days is not listed (operator decision F7)."""
4300
4393
  data = s.outcome_quarantine
4301
4394
  title = "Outcome quarantine"
4302
4395
  if not data or not data.get("available"):
@@ -4307,25 +4400,47 @@ def _check_accounting_outcome_quarantine(s: DoctorState) -> CheckResult:
4307
4400
  "up to date, then run `cctally doctor` again."),
4308
4401
  details={"available": False, "accounts": []},
4309
4402
  )
4403
+ now = s.now_utc or dt.datetime.now(dt.timezone.utc)
4404
+ if now.tzinfo is None:
4405
+ now = now.replace(tzinfo=dt.timezone.utc)
4406
+ # Operator decision F7 (2026-11-02, "Hide after 7 idle days"): an account
4407
+ # not seen in use in the last seven days is left out. Its limits stay
4408
+ # paused and resume on their own once it is used again, when it is
4409
+ # listed again; leaving it in would keep this leg WARN indefinitely.
4410
+ active_since = now - dt.timedelta(days=_OUTCOME_QUARANTINE_ACTIVE_DAYS)
4310
4411
  rows = []
4311
4412
  for row in data.get("accounts") or []:
4312
- rows.append({**row, "message": _outcome_quarantine_message(row)})
4413
+ last_seen = _parse_iso_utc(row.get("lastSeenAt"))
4414
+ if last_seen is None or last_seen < active_since:
4415
+ continue
4416
+ rows.append({**row, "message": _outcome_quarantine_message(
4417
+ row, now=now, inventory_path=data.get("inventoryPath"))})
4313
4418
  details = {"available": True, "triggered": bool(data.get("triggered")),
4314
4419
  "accounts": rows}
4315
- if data.get("unresolvedSpoolMarkers"):
4316
- details["unresolvedSpoolMarkers"] = list(data["unresolvedSpoolMarkers"])
4317
- if not rows:
4420
+ if details["triggered"]:
4421
+ # Spec 7.1: the trigger's cause and the latest inventory episode
4422
+ # (`inventoryEpisode` replaces `unresolvedSpoolMarkers`).
4423
+ details.update(cause=data.get("cause"),
4424
+ inventoryEpisode=data.get("inventoryEpisode"))
4425
+ paused = [row for row in rows if _outcome_quarantine_paused(row)]
4426
+ if paused:
4318
4427
  return CheckResult(
4319
- id="accounting.outcome_quarantine", title=title, severity="ok",
4320
- summary=("no account is quarantined" if data.get("triggered")
4321
- else "no legacy write or spool change since the upgrade"),
4322
- remediation=None, details=details,
4428
+ id="accounting.outcome_quarantine", title=title, severity="warn",
4429
+ summary=f"automatic outcomes paused for {len(paused)} account(s)",
4430
+ remediation="\n".join(row["message"] for row in paused),
4431
+ details=details,
4323
4432
  )
4433
+ notes = [row["message"] for row in rows if row.get("message")]
4434
+ if notes:
4435
+ summary = f"automatic detection resumed for {len(notes)} account(s)"
4436
+ elif data.get("triggered"):
4437
+ summary = "no account is quarantined"
4438
+ else:
4439
+ summary = "no legacy write or spool change since the upgrade"
4324
4440
  return CheckResult(
4325
- id="accounting.outcome_quarantine", title=title, severity="warn",
4326
- summary=(f"automatic outcomes paused for {len(rows)} account(s)"),
4327
- remediation=" ".join(row["message"] for row in rows),
4328
- details=details,
4441
+ id="accounting.outcome_quarantine", title=title, severity="ok",
4442
+ summary=summary, remediation="\n".join(notes) or None,
4443
+ details=details, remediation_on_ok=True,
4329
4444
  )
4330
4445
 
4331
4446
 
@@ -4691,6 +4806,64 @@ def _check_accounts_attribution(s: DoctorState) -> CheckResult:
4691
4806
  )
4692
4807
 
4693
4808
 
4809
+ _CLAUDE_COST_ATTRIBUTION_REMEDIATION = (
4810
+ "Keep the cctally status line or hooks installed so the active account "
4811
+ "is recorded as Claude writes. cctally cache-sync --rebuild keeps these "
4812
+ "decisions; it does not change them.")
4813
+ _CLAUDE_COST_EXPORT_REMEDIATION = (
4814
+ "Check that the cctally journal directory is writable; the export "
4815
+ "retries on the next cctally command.")
4816
+
4817
+
4818
+ def _check_accounts_claude_cost_attribution(s: DoctorState) -> CheckResult:
4819
+ """#944 spec 5.9 (U2, U3, U4'): how the Claude cost decided in the last 7
4820
+ days was attributed, and the one-time export of existing stamps. WARN
4821
+ when the export is stalled, or when a decorated Claude provider (more
4822
+ than one real account, R8) had a recent fallback that picked a real
4823
+ account; otherwise OK with the fallback count stated."""
4824
+ st = s.accounts_state or {}
4825
+ d = dict(st.get("claude_cost_attribution") or {})
4826
+ m = int(d.get("totalRows") or 0)
4827
+ n = int(d.get("fallbackRows") or 0)
4828
+ real = int(d.get("fallbackRealAccountRows") or 0)
4829
+ pres = d.get("preservation") or {}
4830
+ state = pres.get("state")
4831
+ saved = int(pres.get("saved") or 0)
4832
+ total = int(pres.get("total") or 0)
4833
+ sentences = []
4834
+ if state == "stalled":
4835
+ sentences.append(
4836
+ "Recording existing Claude cost attribution is stalled at "
4837
+ f"{saved} of {total} rows: the journal could not be written")
4838
+ elif state == "running":
4839
+ sentences.append(
4840
+ "Recording existing Claude cost attribution "
4841
+ f"({saved} of {total} rows saved)")
4842
+ if n:
4843
+ sentences.append(
4844
+ f"{n} of {m} Claude cost rows decided in the last 7 days had no "
4845
+ "write-time account evidence and took the account active at "
4846
+ "ingest; this does not mean they are wrong")
4847
+ else:
4848
+ sentences.append(
4849
+ "Claude cost decided in the last 7 days was attributed from "
4850
+ f"write-time account evidence ({m} rows)")
4851
+ decorated = int((st.get("by_provider") or {}).get("claude") or 0) > 1
4852
+ stalled = state == "stalled"
4853
+ fallback_warn = decorated and real > 0
4854
+ remediation = [text for text, fired in (
4855
+ (_CLAUDE_COST_EXPORT_REMEDIATION, stalled),
4856
+ (_CLAUDE_COST_ATTRIBUTION_REMEDIATION, fallback_warn)) if fired]
4857
+ return CheckResult(
4858
+ id="accounts.claude_cost_attribution",
4859
+ title="Claude cost attribution",
4860
+ severity="warn" if remediation else "ok",
4861
+ summary="; ".join(sentences),
4862
+ remediation=" ".join(remediation) if remediation else None,
4863
+ details=d,
4864
+ )
4865
+
4866
+
4694
4867
  def _check_accounts_codex_reset_anchors(s: DoctorState) -> CheckResult:
4695
4868
  """Surface Codex quota rows that landed after migration 032 with no anchor."""
4696
4869
  st = s.accounts_state or {}
@@ -4863,6 +5036,8 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
4863
5036
  ("accounts.registry", "_check_accounts_registry"),
4864
5037
  ("accounts.freshness", "_check_accounts_freshness"),
4865
5038
  ("accounts.attribution", "_check_accounts_attribution"),
5039
+ ("accounts.claude_cost_attribution",
5040
+ "_check_accounts_claude_cost_attribution"),
4866
5041
  ("accounts.codex_reset_anchors", "_check_accounts_codex_reset_anchors"),
4867
5042
  ("accounts.codex_window_attribution",
4868
5043
  "_check_accounts_codex_window_attribution"),
@@ -4925,6 +5100,9 @@ _QUOTA_CALIBRATION_WARN: dict = {
4925
5100
  "the stored calibration was fitted by an earlier algorithm",
4926
5101
  "calibration-malformed": "the stored calibration could not be parsed",
4927
5102
  "calibration-unreadable": "the stored calibration could not be read",
5103
+ # #1015 spec 5.4 (U3): a correction changed the fit's inputs.
5104
+ "recalibration-needed":
5105
+ "the stored calibration was fitted before a correction to its inputs",
4928
5106
  }
4929
5107
 
4930
5108
  #: The rejections that are ordinary rather than a problem. An
@@ -4935,6 +5113,10 @@ _QUOTA_CALIBRATION_OK: dict = {
4935
5113
  "calibration-absent": "no calibration fitted yet",
4936
5114
  "calibration-detection-only":
4937
5115
  "a metering-rate change is fitted but not yet predictive",
5116
+ # #1015: transient (a maintenance owner holds the index, or it is not
5117
+ # published yet); the calibration is withheld, not reported broken.
5118
+ "calibration-lineage-unavailable":
5119
+ "the calibration's correction lineage could not be verified",
4938
5120
  }
4939
5121
 
4940
5122
 
@@ -5321,7 +5503,7 @@ def _serialize_check(c: CheckResult) -> dict:
5321
5503
  "summary": c.summary,
5322
5504
  "details": c.details,
5323
5505
  }
5324
- if c.severity != "ok" and c.remediation:
5506
+ if c.remediation and (c.severity != "ok" or c.remediation_on_ok):
5325
5507
  out["remediation"] = c.remediation
5326
5508
  return out
5327
5509
 
@@ -5430,7 +5612,9 @@ def render_text(report: DoctorReport, *, quiet: bool = False, verbose: bool = Fa
5430
5612
  glyph = _GLYPH.get(c.severity, "?")
5431
5613
  lines.append(f" {glyph} {c.title:<24s} {c.summary}")
5432
5614
  if c.remediation:
5433
- lines.append(f" → {c.remediation}")
5615
+ # A multi-line remediation (#948: one line per limit) keeps
5616
+ # its continuation lines under the arrow.
5617
+ lines.append(" → " + c.remediation.replace("\n", "\n "))
5434
5618
  if verbose and c.details:
5435
5619
  lines.append(" details:")
5436
5620
  for k, v in c.details.items():