cctally 1.91.0 → 1.92.1

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 (42) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -2
  3. package/bin/_cctally_cache.py +903 -74
  4. package/bin/_cctally_config.py +57 -0
  5. package/bin/_cctally_core.py +94 -14
  6. package/bin/_cctally_dashboard.py +217 -19
  7. package/bin/_cctally_dashboard_conversation.py +170 -20
  8. package/bin/_cctally_dashboard_envelope.py +2 -0
  9. package/bin/_cctally_db.py +481 -19
  10. package/bin/_cctally_doctor.py +18 -1
  11. package/bin/_cctally_journal.py +1156 -21
  12. package/bin/_cctally_journal_repair.py +6 -0
  13. package/bin/_cctally_parser.py +26 -0
  14. package/bin/_cctally_quota.py +171 -55
  15. package/bin/_cctally_record.py +13 -1
  16. package/bin/_cctally_rederive.py +4 -0
  17. package/bin/_cctally_statusline.py +6 -6
  18. package/bin/_cctally_store.py +1061 -40
  19. package/bin/_cctally_transcript.py +32 -2
  20. package/bin/_cctally_tui.py +54 -6
  21. package/bin/_lib_cache_report.py +8 -3
  22. package/bin/_lib_codex_conversation.py +851 -81
  23. package/bin/_lib_codex_conversation_query.py +2031 -96
  24. package/bin/_lib_codex_find_projection.py +517 -0
  25. package/bin/_lib_codex_harness_preamble.py +176 -0
  26. package/bin/_lib_codex_hooks.py +5 -3
  27. package/bin/_lib_codex_js_scan.py +254 -0
  28. package/bin/_lib_codex_landmarks.py +309 -0
  29. package/bin/_lib_codex_title_clean.py +116 -0
  30. package/bin/_lib_conversation_dispatch.py +168 -22
  31. package/bin/_lib_conversation_query.py +62 -2
  32. package/bin/_lib_conversation_watch.py +4 -2
  33. package/bin/_lib_doctor.py +64 -0
  34. package/bin/_lib_quota_alert_axes.py +31 -34
  35. package/bin/_lib_stats_damage.py +523 -0
  36. package/bin/_lib_stats_publish.py +243 -0
  37. package/bin/cctally +17 -3
  38. package/dashboard/static/assets/index-Dat-mza6.js +97 -0
  39. package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
  40. package/dashboard/static/dashboard.html +2 -2
  41. package/package.json +8 -1
  42. package/dashboard/static/assets/index-CILAoEja.js +0 -90
@@ -307,6 +307,7 @@ ALLOWED_CONFIG_KEYS = (
307
307
  "dashboard.expose_transcripts",
308
308
  "dashboard.cache_failure_markers",
309
309
  "dashboard.live_tail",
310
+ "dashboard.lan_auth",
310
311
  "update.check.enabled",
311
312
  "update.check.ttl_hours",
312
313
  "update.channel",
@@ -1028,6 +1029,22 @@ def _config_known_value(config: dict, key: str) -> "object":
1028
1029
  except ValueError:
1029
1030
  return True
1030
1031
  return True
1032
+ if key == "dashboard.lan_auth":
1033
+ # Boolean opt-OUT (issue #282). Default TRUE — non-loopback dashboard
1034
+ # runs require their per-run access token unless the user explicitly
1035
+ # disables the gate. Invalid hand edits fail safe to authentication ON.
1036
+ block = config.get("dashboard") if isinstance(config, dict) else None
1037
+ if not isinstance(block, dict):
1038
+ block = {}
1039
+ stored = block.get("lan_auth")
1040
+ if stored is None:
1041
+ return True
1042
+ if isinstance(stored, bool):
1043
+ return stored
1044
+ # The config command normalizes accepted text spellings before write.
1045
+ # A persisted string is therefore a hand edit, not an explicit opt-out;
1046
+ # accept only the JSON boolean false at this security boundary.
1047
+ return True
1031
1048
  if key == "telemetry.enabled":
1032
1049
  # Boolean opt-OUT (anonymous install-count telemetry, spec 2026-07-07).
1033
1050
  # Default TRUE — absence is ON. A hand-edited junk value surfaces the
@@ -1623,6 +1640,34 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
1623
1640
  else:
1624
1641
  print(f"dashboard.live_tail={'true' if canonical else 'false'}")
1625
1642
  return 0
1643
+ if key == "dashboard.lan_auth":
1644
+ try:
1645
+ canonical = c._normalize_alerts_enabled_value(raw)
1646
+ except ValueError:
1647
+ print(
1648
+ f"cctally: invalid boolean value for dashboard.lan_auth: "
1649
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
1650
+ file=sys.stderr,
1651
+ )
1652
+ return 2
1653
+ with config_writer_lock():
1654
+ config = _load_config_unlocked()
1655
+ existing = config.get("dashboard")
1656
+ if existing is not None and not isinstance(existing, dict):
1657
+ print(
1658
+ "cctally: dashboard config error: dashboard must be an object",
1659
+ file=sys.stderr,
1660
+ )
1661
+ return 2
1662
+ block = dict(existing or {})
1663
+ block["lan_auth"] = canonical
1664
+ config["dashboard"] = block
1665
+ save_config(config)
1666
+ if getattr(args, "emit_json", False):
1667
+ print(json.dumps({"dashboard": {"lan_auth": canonical}}, indent=2))
1668
+ else:
1669
+ print(f"dashboard.lan_auth={'true' if canonical else 'false'}")
1670
+ return 0
1626
1671
  if key == "telemetry.enabled":
1627
1672
  # Anonymous install-count telemetry opt-out (spec 2026-07-07). Mirror
1628
1673
  # dashboard.live_tail exactly: validate the bool first, then
@@ -2237,6 +2282,18 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
2237
2282
  save_config(config)
2238
2283
  # idempotent: silent on missing key
2239
2284
  return 0
2285
+ if key == "dashboard.lan_auth":
2286
+ # Restart-only opt-out. Removing the leaf restores the safe True
2287
+ # default while preserving all sibling dashboard preferences.
2288
+ with config_writer_lock():
2289
+ config = _load_config_unlocked()
2290
+ block = config.get("dashboard")
2291
+ if isinstance(block, dict) and "lan_auth" in block:
2292
+ del block["lan_auth"]
2293
+ if not block:
2294
+ config.pop("dashboard", None)
2295
+ save_config(config)
2296
+ return 0
2240
2297
  if key == "telemetry.enabled":
2241
2298
  # Mirror the dashboard.live_tail unset branch: drop only the enabled
2242
2299
  # leaf; if the telemetry block ends up empty, drop the parent too.
@@ -364,9 +364,49 @@ STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16777216
364
364
  # full pass is measured against. Same mechanical reason as 1005 — an
365
365
  # epoch-current open returns before any schema work — so it is a second bump
366
366
  # rather than an amendment to the first.
367
- STATS_INDEX_EPOCH = 1006
367
+ # 1006 -> 1007 (#460): scheduled quota-alert ownership. Adds the per-root
368
+ # future-capture schedule that lets a matured boundary widen to its owning root
369
+ # instead of deferring forever on a quiet hook-only install.
370
+ # 1007 -> 1008 (#496 S3): in-place transactional publication. Adds
371
+ # `stats_publication_stamp`, the publication identity written inside the
372
+ # publication transaction. It replaces the marker's `scratchPath` crash
373
+ # discriminator, which an in-place publish inverts because it attaches the
374
+ # scratch read-only and leaves it on disk whether the transaction committed or
375
+ # rolled back. A stats schema change is an epoch bump and never a migration;
376
+ # the 13-migration registry stays frozen. Each install pays one rebuild on
377
+ # upgrade, deferred to the background worker by #453.
378
+ STATS_INDEX_EPOCH = 1008
368
379
  LEGACY_STATS_HEAD = 13
369
380
 
381
+ #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
382
+ #: publication transaction exists: a replacement index was published and then
383
+ #: failed validation on a fresh connection. The existing corrupt-stats text
384
+ #: says the database was "Not auto-recreated", which would be false here, so
385
+ #: this path gets its own wording. It does not alter the heal message or any
386
+ #: other corruption path.
387
+ STATS_PUBLICATION_FAILED_MSG = (
388
+ "stats.db published a rebuilt index that then FAILED validation, so the "
389
+ "live index is known bad and cctally refuses to use it. path: {path}. "
390
+ "The rebuild record naming the failing check is at {record}. The damaged "
391
+ "predecessor was preserved under quarantine/ with a forensics bundle in "
392
+ "logs/. Recovery: run `cctally db rebuild --db stats`."
393
+ )
394
+
395
+ #: #496 S3. The text above describes PHYSICAL replacement, which is now the
396
+ #: fallback. In-place publication drops the live generation and installs the
397
+ #: scratch's inside one transaction, so it preserves nothing and allocates no
398
+ #: quarantine directory — and the sentence about a preserved predecessor would
399
+ #: send a user whose index is already known bad to a directory that does not
400
+ #: exist. Selected by the mechanism the publication marker records.
401
+ STATS_PUBLICATION_FAILED_IN_PLACE_MSG = (
402
+ "stats.db published a rebuilt index that then FAILED validation, so the "
403
+ "live index is known bad and cctally refuses to use it. path: {path}. "
404
+ "The rebuild record naming the failing check is at {record}. This index "
405
+ "was published in place, so no copy of the previous index was kept; every "
406
+ "row it holds is derived from the append-only journal, which the "
407
+ "publication did not touch. Recovery: run `cctally db rebuild --db stats`."
408
+ )
409
+
370
410
 
371
411
  # === Telemetry constants (non-path; see spec 2026-07-07) =============
372
412
  #
@@ -705,18 +745,27 @@ def ensure_dirs() -> None:
705
745
  # before returning does not, and must not — see `stats_open_guarded`):
706
746
  # bin/_cctally_journal.py _acquire_maintenance_{shared,exclusive} / _release
707
747
  # bin/_cctally_store.py _heal_flock_blocking, reached through
708
- # _acquire_stats_maintenance_reentrant by the heal
709
- # hook and the epoch resolver
748
+ # _acquire_stats_maintenance_reentrant by the epoch
749
+ # resolver
750
+ # bin/_cctally_store.py _acquire_stats_maintenance_for_heal, the corruption
751
+ # heal's ownership-first BOUNDED acquire (#496 S3)
710
752
  # bin/_cctally_db.py cmd_db_rebuild, _acquire_db_admin_writer_flocks
711
753
  # (db skip / db unskip), _cmd_db_repair_exclusive,
712
754
  # _vacuum_one_db
713
755
  # bin/_cctally_rederive.py _rederive_locks
756
+ # bin/_cctally_store.py stats_open_guarded's interrupted-rebuild-recovery
757
+ # branch, which upgrades to EXCLUSIVE and then calls
758
+ # rebuild_stats_index (#496 S3)
714
759
  # Adding another acquisition site without noting it here reintroduces the hang.
715
760
  #
716
- # The opener (`_cctally_store.stats_open_guarded`) takes the lock SHARED and
717
- # releases it before handing the connection back, so it deliberately does NOT
718
- # note a hold — but it DOES consult `holds_stats_maintenance()` to skip the
719
- # acquire entirely when this context already owns the exclusive side.
761
+ # The opener (`_cctally_store.stats_open_guarded`) takes the lock SHARED around
762
+ # an ordinary open and releases it before handing the connection back, so that
763
+ # acquire deliberately does NOT note a hold — but it DOES consult
764
+ # `holds_stats_maintenance()` to skip the acquire entirely when this context
765
+ # already owns the exclusive side. Its interrupted-rebuild-recovery branch is
766
+ # the exception: that one upgrades to EXCLUSIVE and holds it across a rebuild,
767
+ # whose in-place publisher opens the destination through `stats_open_guarded`
768
+ # again, so it notes the hold like every other exclusive site.
720
769
 
721
770
  _STATS_MAINTENANCE_HELD = contextvars.ContextVar(
722
771
  "cctally_stats_maintenance_held", default=0
@@ -1369,6 +1418,9 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1369
1418
  -- that are not functions of window dirtiness — a delivery-gate
1370
1419
  -- transition, and a future-clocked observation that becomes eligible
1371
1420
  -- when wall time passes it with no row mutation at all.
1421
+ -- `next_evaluation_by_root_json` owns that scalar minimum: one earliest
1422
+ -- future capture per root, so a due hook tick can reconcile only the
1423
+ -- roots whose instants matured.
1372
1424
  --
1373
1425
  -- `last_full_pass_at` is the periodic verification's deadline. Two
1374
1426
  -- cases a scoped sweep structurally cannot see — a block whose physical
@@ -1386,6 +1438,7 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1386
1438
  alerts_enabled INTEGER,
1387
1439
  next_evaluation_at_utc TEXT,
1388
1440
  last_full_pass_at TEXT,
1441
+ next_evaluation_by_root_json TEXT NOT NULL DEFAULT '{}',
1389
1442
  PRIMARY KEY(source)
1390
1443
  );
1391
1444
 
@@ -1417,14 +1470,19 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1417
1470
  }
1418
1471
  if "account_key" not in _proj_cols:
1419
1472
  conn.execute("DROP TABLE quota_projection_state")
1473
+ # SQLite stores `sqlite_schema.sql` verbatim apart from stripping
1474
+ # `IF NOT EXISTS`, and `_stats_schema_fingerprint` hashes that text, so
1475
+ # this body must stay character-for-character equal to the fresh-path
1476
+ # definition above (#496 S1 F18).
1420
1477
  conn.execute(
1421
- "CREATE TABLE quota_projection_state ("
1422
- " source_root_key TEXT NOT NULL,"
1423
- " account_key TEXT NOT NULL DEFAULT 'unattributed',"
1424
- " generation TEXT NOT NULL,"
1425
- " physical_signature TEXT NOT NULL,"
1426
- " completed_at_utc TEXT NOT NULL,"
1427
- " PRIMARY KEY(source_root_key, account_key))"
1478
+ "CREATE TABLE quota_projection_state (\n"
1479
+ " source_root_key TEXT NOT NULL,\n"
1480
+ " account_key TEXT NOT NULL DEFAULT 'unattributed',\n"
1481
+ " generation TEXT NOT NULL,\n"
1482
+ " physical_signature TEXT NOT NULL,\n"
1483
+ " completed_at_utc TEXT NOT NULL,\n"
1484
+ " PRIMARY KEY(source_root_key, account_key)\n"
1485
+ " )"
1428
1486
  )
1429
1487
  if add_column_if_missing is not None:
1430
1488
  for _tbl in ("quota_window_blocks", "quota_percent_milestones",
@@ -1445,6 +1503,11 @@ def _apply_quota_projection_schema(conn: sqlite3.Connection) -> None:
1445
1503
  # no-op over the table it already has.
1446
1504
  add_column_if_missing(
1447
1505
  conn, "quota_projection_ledger_state", "last_full_pass_at", "TEXT")
1506
+ # Epoch 1007 / #460: same legacy-cutover seam. Current-epoch indexes
1507
+ # rebuild; a legacy index cuts over in place and needs the column added.
1508
+ add_column_if_missing(
1509
+ conn, "quota_projection_ledger_state",
1510
+ "next_evaluation_by_root_json", "TEXT NOT NULL DEFAULT '{}'")
1448
1511
 
1449
1512
 
1450
1513
  def open_db(*, _target_path=None) -> sqlite3.Connection:
@@ -2444,6 +2507,23 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2444
2507
  "kind TEXT NOT NULL, "
2445
2508
  "violation_json TEXT NOT NULL)"
2446
2509
  )
2510
+ # In-place publication identity (#496 S3 §5). An in-place publish
2511
+ # attaches the scratch read-only and detaches it, so the scratch
2512
+ # survives commit and rollback identically and the publication marker's
2513
+ # `scratchPath` proxy inverts. This row is written INSIDE the
2514
+ # publication transaction, so it commits atomically with the content
2515
+ # and the `user_version` it describes and a crash before the commit
2516
+ # rolls it back. The opener compares it against the marker's
2517
+ # `recordPath` and knows without inference whether that publication
2518
+ # committed. Holds at most one row; single-row-ness is deliberately not
2519
+ # enforced structurally, because a duplicated row is one of the states
2520
+ # that must resolve INDETERMINATE rather than be made impossible.
2521
+ conn.execute(
2522
+ "CREATE TABLE IF NOT EXISTS stats_publication_stamp ("
2523
+ "record_path TEXT NOT NULL, "
2524
+ "started_at_utc TEXT NOT NULL, "
2525
+ "stamped_at_utc TEXT NOT NULL)"
2526
+ )
2447
2527
 
2448
2528
  # §6.2 backfill gate (Task 8): stamp the one-shot marker AFTER the three
2449
2529
  # open-time backfills ran, so the next open skips them (and their probes)