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.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +57 -3
- package/bin/_cctally_cache.py +5720 -885
- package/bin/_cctally_claude_attribution.py +997 -0
- package/bin/_cctally_completion.py +101 -28
- package/bin/_cctally_core.py +235 -12
- package/bin/_cctally_dashboard.py +263 -17
- package/bin/_cctally_dashboard_conversation.py +107 -21
- package/bin/_cctally_dashboard_envelope.py +44 -0
- package/bin/_cctally_db.py +928 -80
- package/bin/_cctally_diagnosis_sources.py +78 -13
- package/bin/_cctally_doctor.py +211 -86
- package/bin/_cctally_five_hour.py +446 -22
- package/bin/_cctally_forecast.py +302 -6
- package/bin/_cctally_journal.py +2418 -158
- package/bin/_cctally_milestone_history.py +379 -91
- package/bin/_cctally_oauth_samples.py +185 -7
- package/bin/_cctally_parser.py +38 -0
- package/bin/_cctally_project.py +34 -8
- package/bin/_cctally_quarantine.py +821 -343
- package/bin/_cctally_quota.py +69 -4
- package/bin/_cctally_quota_calibration.py +677 -3
- package/bin/_cctally_quota_model.py +927 -127
- package/bin/_cctally_record.py +922 -445
- package/bin/_cctally_rederive.py +119 -15
- package/bin/_cctally_statusline.py +1048 -176
- package/bin/_cctally_store.py +544 -110
- package/bin/_cctally_tui.py +455 -42
- package/bin/_cctally_update.py +77 -14
- package/bin/_lib_account_protocol.py +227 -19
- package/bin/_lib_claude_attribution.py +421 -0
- package/bin/_lib_clone_copy.py +30 -0
- package/bin/_lib_codex_conversation_query.py +45 -9
- package/bin/_lib_codex_conversation_watch.py +72 -13
- package/bin/_lib_codex_rollout_container.py +234 -19
- package/bin/_lib_codex_worker_lifecycle.py +374 -0
- package/bin/_lib_credit.py +22 -13
- package/bin/_lib_dashboard_sources.py +257 -18
- package/bin/_lib_detector_access_registry.py +1335 -166
- package/bin/_lib_doctor.py +276 -92
- package/bin/_lib_ingest_frontier.py +269 -36
- package/bin/_lib_journal.py +273 -22
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_meter_rate_change.py +72 -0
- package/bin/_lib_outcome_quarantine.py +917 -554
- package/bin/_lib_provider_activity.py +24 -6
- package/bin/_lib_quota_calibration.py +57 -3
- package/bin/_lib_quota_copy.py +7 -2
- package/bin/_lib_quota_lineage.py +613 -0
- package/bin/_lib_quota_model.py +464 -33
- package/bin/_lib_rate_change_delivery.py +16 -0
- package/bin/_lib_reclaim_planner.py +6 -4
- package/bin/_lib_rederive.py +2046 -21
- package/bin/_lib_selector_state.py +131 -3
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_stale_proof.py +113 -4
- package/bin/_lib_statusline.py +72 -3
- package/bin/_lib_statusline_candidates.py +246 -42
- package/bin/_lib_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +419 -6
- package/bin/cctally +28 -11
- package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
- package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
- package/dashboard/static/assets/index-D34eYhAc.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
- package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +5 -1
- package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
- package/dashboard/static/assets/index-BKITk1CT.css +0 -1
package/bin/_lib_doctor.py
CHANGED
|
@@ -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
|
-
# #
|
|
357
|
-
# validated index. `{"available": bool, "triggered": bool,
|
|
358
|
-
#
|
|
359
|
-
# "
|
|
360
|
-
#
|
|
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 =
|
|
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
|
|
4216
|
-
#:
|
|
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
|
|
4224
|
-
"
|
|
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
|
-
#:
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
"
|
|
4233
|
-
"
|
|
4234
|
-
"
|
|
4235
|
-
"
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
"
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
"
|
|
4248
|
-
"
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
"
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
"
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
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
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
text =
|
|
4282
|
-
account=label,
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
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
|
-
"""#
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
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
|
-
|
|
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
|
|
4316
|
-
|
|
4317
|
-
|
|
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="
|
|
4320
|
-
summary=
|
|
4321
|
-
|
|
4322
|
-
|
|
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="
|
|
4326
|
-
summary=
|
|
4327
|
-
|
|
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"
|
|
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
|
-
|
|
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():
|