loki-mode 9.54.0 → 9.54.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 (112) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/autonomy/completion-council.sh +10 -1
  4. package/autonomy/lib/proof-generator.py +36 -5
  5. package/autonomy/run.sh +234 -31
  6. package/dashboard/__init__.py +1 -1
  7. package/dashboard/api_v2.py +4 -0
  8. package/dashboard/audit.py +9 -1
  9. package/dashboard/auth.py +16 -4
  10. package/dashboard/registry.py +34 -12
  11. package/dashboard/rigour_integration.py +25 -17
  12. package/dashboard/server.py +252 -104
  13. package/dashboard/static/cost.html +55 -26
  14. package/dashboard/static/index.html +475 -1544
  15. package/dashboard/static/proofs.html +1 -1
  16. package/docs/INSTALLATION.md +1 -1
  17. package/docs/air-gapped.md +7 -2
  18. package/docs/image-provenance.md +4 -2
  19. package/docs/v10/BACKLOG.md +41 -2
  20. package/docs/v10/BOARD.md +105 -0
  21. package/docs/v10/DECISIONS.md +71 -0
  22. package/docs/v10/PROGRESS.md +43 -0
  23. package/docs/v10/SWARM-PROMPT.md +98 -0
  24. package/docs/v10/SWARM.md +237 -0
  25. package/loki-ts/dist/loki.js +2 -2
  26. package/mcp/__init__.py +1 -1
  27. package/package.json +1 -1
  28. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  29. package/providers/aider.sh +8 -0
  30. package/providers/cline.sh +8 -0
  31. package/providers/codex.sh +9 -0
  32. package/providers/opencode.sh +9 -0
  33. package/web-app/dist/assets/AdminPage-CZrBkzRF.js +32 -0
  34. package/web-app/dist/assets/Avatar-gQIp6FBI.js +11 -0
  35. package/web-app/dist/assets/{Badge-BF3mhT57.js → Badge-D2_fBTFY.js} +1 -1
  36. package/web-app/dist/assets/{Button-BDp9aJ9q.js → Button-D5qDd2ef.js} +1 -1
  37. package/web-app/dist/assets/CockpitPage-DVMk2RUK.js +47 -0
  38. package/web-app/dist/assets/ComparePage-NMLyE1xL.js +1 -0
  39. package/web-app/dist/assets/{ErrorBoundary-ut3wrUmu.js → ErrorBoundary-BRABI-BT.js} +1 -1
  40. package/web-app/dist/assets/EvidenceReceiptPanel-D2LNKqUx.js +1 -0
  41. package/web-app/dist/assets/GitHubIssuesPanel-BC5GzKUN.js +7 -0
  42. package/web-app/dist/assets/{GitHubPRsPanel-DurZgSaj.js → GitHubPRsPanel-BSlmoVTS.js} +3 -3
  43. package/web-app/dist/assets/HomePage-D_CDMoen.js +12 -0
  44. package/web-app/dist/assets/{LoginPage-HeRGJeZy.js → LoginPage-BBNA_N9t.js} +1 -1
  45. package/web-app/dist/assets/{MagicPage-CUKrqsZs.js → MagicPage-CnFRZCMx.js} +1 -1
  46. package/web-app/dist/assets/MetricsPage-CPfLMRlh.js +6 -0
  47. package/web-app/dist/assets/{NotFoundPage-BZDZ-B77.js → NotFoundPage-lA0XSGTz.js} +1 -1
  48. package/web-app/dist/assets/ProjectPage-CNAPbr9z.js +256 -0
  49. package/web-app/dist/assets/ProjectsPage-CMtm8goN.js +6 -0
  50. package/web-app/dist/assets/SettingsPage-BhIjVMYE.js +10 -0
  51. package/web-app/dist/assets/{ShowcasePage-BGS2jTGH.js → ShowcasePage-BndtGeYo.js} +1 -1
  52. package/web-app/dist/assets/SystemSettingsPage-euXI-KXE.js +1 -0
  53. package/web-app/dist/assets/TeamsPage-ClUKecun.js +11 -0
  54. package/web-app/dist/assets/TemplatesPage-BWHWVk0L.js +29 -0
  55. package/web-app/dist/assets/{TerminalOutput-togzv9Zl.js → TerminalOutput-Cv3N3Szf.js} +1 -1
  56. package/web-app/dist/assets/{activity-Dreyb_Kr.js → activity-dEhz-bXR.js} +1 -1
  57. package/web-app/dist/assets/{bell-DWH0drES.js → bell-B3XUMq1t.js} +1 -1
  58. package/web-app/dist/assets/{bot-78MoCuDl.js → bot-X-ssNlwR.js} +1 -1
  59. package/web-app/dist/assets/{check-ikfylPst.js → check-C_9Nx7kb.js} +1 -1
  60. package/web-app/dist/assets/{chevron-left-DXUqkd8J.js → chevron-left-tvNswo3A.js} +1 -1
  61. package/web-app/dist/assets/{circle-alert-Da74VO8P.js → circle-alert-CSB88nEs.js} +1 -1
  62. package/web-app/dist/assets/{clock-mlGCFq8l.js → clock-C8p2e4NI.js} +1 -1
  63. package/web-app/dist/assets/{cloud-B_Y5ZrIO.js → cloud-CKp2ZvhG.js} +1 -1
  64. package/web-app/dist/assets/{code-xml-Bbom7YWV.js → code-xml-dY70LCHD.js} +1 -1
  65. package/web-app/dist/assets/{database-CLcofiVp.js → database-DObn5fmn.js} +1 -1
  66. package/web-app/dist/assets/{dollar-sign-C5mkjXIM.js → dollar-sign-DSu6n3K3.js} +1 -1
  67. package/web-app/dist/assets/{file-code-corner-CVrkaOQP.js → file-code-corner-DpCnS73z.js} +1 -1
  68. package/web-app/dist/assets/{file-plus-BV9240gX.js → file-plus-D3IPKCf1.js} +1 -1
  69. package/web-app/dist/assets/{globe-aZ0Zso4I.js → globe-C9QXkgH_.js} +1 -1
  70. package/web-app/dist/assets/{hammer-EgOQNukG.js → hammer-D_FtWe1y.js} +1 -1
  71. package/web-app/dist/assets/index-CI54rara.css +1 -0
  72. package/web-app/dist/assets/{index-DvZ0Kk2x.js → index-Cz7q1bvC.js} +2 -2
  73. package/web-app/dist/assets/{layers-BOVKmepE.js → layers-DsW7jnJX.js} +1 -1
  74. package/web-app/dist/assets/{loader-circle-0K8yLgHl.js → loader-circle-B3HspjKR.js} +1 -1
  75. package/web-app/dist/assets/{lock-XzlDMy8M.js → lock-DSI29pct.js} +1 -1
  76. package/web-app/dist/assets/{package-BtXTKusI.js → package-CLNxtA2x.js} +1 -1
  77. package/web-app/dist/assets/{plus-PxeiD8ju.js → plus-DyI4WC4_.js} +1 -1
  78. package/web-app/dist/assets/{refresh-cw-CjqzYczt.js → refresh-cw-D2dv0xA0.js} +1 -1
  79. package/web-app/dist/assets/{rotate-ccw-CY0os4DH.js → rotate-ccw-CBvihA-a.js} +1 -1
  80. package/web-app/dist/assets/scroll-text-DvoOvAl_.js +6 -0
  81. package/web-app/dist/assets/{server-pIIs9vNp.js → server-DenWvSUt.js} +1 -1
  82. package/web-app/dist/assets/{shield-alert-CsDqKXwA.js → shield-alert-40cX_FvG.js} +1 -1
  83. package/web-app/dist/assets/{thumbs-up-CAh3HVzH.js → thumbs-up-BrjZ6QuV.js} +12 -7
  84. package/web-app/dist/assets/trash-2-C6evwkEH.js +16 -0
  85. package/web-app/dist/assets/{trending-down-DrtBd_C9.js → trending-down-CfbZis3O.js} +1 -1
  86. package/web-app/dist/assets/{trending-up-D-DbCHXm.js → trending-up-DWuEK3WC.js} +1 -1
  87. package/web-app/dist/assets/{upload-D4l6DCOt.js → upload-B9DFBcPG.js} +1 -1
  88. package/web-app/dist/assets/{usePolling-D0aoTX80.js → usePolling-BryVC-T1.js} +1 -1
  89. package/web-app/dist/assets/{user-BQf_XJO_.js → user-DMRXwZiw.js} +1 -1
  90. package/web-app/dist/index.html +2 -2
  91. package/web-app/server.py +122 -47
  92. package/web-app/dist/assets/AdminPage-y6_b2Xo4.js +0 -32
  93. package/web-app/dist/assets/Avatar-BFPkXhIJ.js +0 -6
  94. package/web-app/dist/assets/CockpitPage-5TKICCoA.js +0 -47
  95. package/web-app/dist/assets/ComparePage-BSuNsUHy.js +0 -1
  96. package/web-app/dist/assets/EvidenceReceiptPanel-CcaBwEI-.js +0 -1
  97. package/web-app/dist/assets/GitHubIssuesPanel-DJeYgzAa.js +0 -12
  98. package/web-app/dist/assets/HomePage-Bs451LAy.js +0 -28
  99. package/web-app/dist/assets/MetricsPage-DvY1csgz.js +0 -6
  100. package/web-app/dist/assets/ProjectPage-CqHkEZkl.js +0 -257
  101. package/web-app/dist/assets/ProjectsPage-CLvCK1Y8.js +0 -6
  102. package/web-app/dist/assets/SettingsPage-DP7OmdwP.js +0 -10
  103. package/web-app/dist/assets/SystemSettingsPage-P_rlLh0x.js +0 -1
  104. package/web-app/dist/assets/TeamsPage-DxtBDcBC.js +0 -11
  105. package/web-app/dist/assets/TemplatesPage-5e-faH3W.js +0 -29
  106. package/web-app/dist/assets/folder-open-D_6yV78G.js +0 -11
  107. package/web-app/dist/assets/index-BSgmPwC0.css +0 -1
  108. package/web-app/dist/assets/lightbulb-DdASgqOG.js +0 -6
  109. package/web-app/dist/assets/mail-COu670_W.js +0 -6
  110. package/web-app/dist/assets/minus-bQpUt9ff.js +0 -6
  111. package/web-app/dist/assets/save-v8qep5cY.js +0 -6
  112. package/web-app/dist/assets/trash-2-Dt6urTyY.js +0 -6
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v9.54.0
6
+ # Loki Mode v9.54.2
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -470,4 +470,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
470
470
 
471
471
  ---
472
472
 
473
- **v9.54.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
473
+ **v9.54.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~410 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 9.54.0
1
+ 9.54.2
@@ -1948,6 +1948,15 @@ runner = d.get('runner', 'none')
1948
1948
  # an unrecorded outcome is not a pass. None falls to INCONCLUSIVE below.
1949
1949
  passed = d.get('pass')
1950
1950
  status = d.get('status', '')
1951
+ # BACKLOG 89: a recorded failure count above zero is a failure whatever "pass"
1952
+ # says, as the Bun gate reads it (quality_gates.ts artifactCount): a numeric
1953
+ # failed_count wins, the legacy numeric failed is the fallback, and anything
1954
+ # else (null, missing, a bool) is unmeasured, never a failure and never 0.
1955
+ def _count(v):
1956
+ return v if isinstance(v, (int, float)) and not isinstance(v, bool) else None
1957
+ failed_n = _count(d.get('failed_count'))
1958
+ if failed_n is None:
1959
+ failed_n = _count(d.get('failed'))
1951
1960
  #82 (zero-test-file hardening): a runner that ran but executed ZERO real tests
1952
1961
  # (node --test on a *.test.js with no test() calls; jest --passWithNoTests with
1953
1962
  # no suites) records pass:"inconclusive" + status:"no_tests_run" -- a mini
@@ -1958,7 +1967,7 @@ status = d.get('status', '')
1958
1967
  # Only the boolean True passes as affirmative; only the boolean False blocks.
1959
1968
  if runner == 'none':
1960
1969
  print('PASS:none:true')
1961
- elif passed is False:
1970
+ elif passed is False or (failed_n or 0) > 0:
1962
1971
  print('FAIL:%s:false' % runner)
1963
1972
  elif status == 'no_tests_run' or passed is not True:
1964
1973
  # Both are INCONCLUSIVE; NO_PASS names the second: no boolean pass was
@@ -319,6 +319,32 @@ def _is_exogenous(gate):
319
319
  return _gate_provenance(gate.get("name")) == "exogenous"
320
320
 
321
321
 
322
+ def _test_results_fresh(quality_dir):
323
+ """BACKLOG 82: the gates' freshness rule (ensure_completion_test_evidence,
324
+ Bun testResultsFresh). enforce_test_coverage writes unit-tests.pass and
325
+ test-results.json together with .test-results.iter; they are this
326
+ iteration's evidence only when that marker holds this iteration's number.
327
+ Both loop callers always pass the iteration as ITERATION_COUNT (run.sh
328
+ generate_proof_of_run, Bun runner/proof.ts); a set but empty value is not
329
+ fresh.
330
+ ponytail: ITERATION_COUNT absent altogether means a hand run outside any
331
+ loop, with no iteration to compare against, so the pre-82 reading applies
332
+ there (Bun's gate calls that stale). Tighten when a caller exists that
333
+ omits it."""
334
+ raw = os.environ.get("ITERATION_COUNT")
335
+ if raw is None:
336
+ return True
337
+ iteration = raw.strip()
338
+ if not iteration:
339
+ return False
340
+ try:
341
+ with open(os.path.join(quality_dir, ".test-results.iter"), "r",
342
+ encoding="utf-8") as handle:
343
+ return handle.read(64).strip() == iteration
344
+ except OSError:
345
+ return False
346
+
347
+
322
348
  def _collect_quality_gates(loki_dir):
323
349
  gates_raw = _read_json(
324
350
  os.path.join(loki_dir, "state", "quality-gates.json"), default=None
@@ -351,6 +377,8 @@ def _collect_quality_gates(loki_dir):
351
377
  # never an LLM opinion. Only fills gates the aggregate did not already cover.
352
378
  if not gates:
353
379
  quality_dir = os.path.join(loki_dir, "quality")
380
+ tests_json = os.path.join(quality_dir, "test-results.json")
381
+ tests_fresh = _test_results_fresh(quality_dir)
354
382
  seen = set()
355
383
  # (gate name in proof, marker/result filename stem under quality/)
356
384
  markers = [
@@ -361,7 +389,12 @@ def _collect_quality_gates(loki_dir):
361
389
  pass_marker = os.path.join(quality_dir, stem + ".pass")
362
390
  result_json = os.path.join(quality_dir, stem + ".json")
363
391
  status = None
364
- if os.path.exists(pass_marker):
392
+ if gate_name == "unit_tests" and not tests_fresh:
393
+ # BACKLOG 82: a test result another iteration wrote is not
394
+ # this iteration's evidence, pass or fail. Name the gap.
395
+ if any(os.path.exists(p) for p in (pass_marker, result_json, tests_json)):
396
+ status = "not_run"
397
+ elif os.path.exists(pass_marker):
365
398
  status = "passed"
366
399
  elif os.path.exists(result_json):
367
400
  rj = _read_json(result_json, default=None)
@@ -390,10 +423,8 @@ def _collect_quality_gates(loki_dir):
390
423
  passed += 1
391
424
  # test-results.json carries the suite outcome even when no .pass marker
392
425
  # (e.g. {status:"verified"} = tests ran and passed).
393
- if "unit_tests" not in seen:
394
- tr = _read_json(
395
- os.path.join(quality_dir, "test-results.json"), default=None
396
- )
426
+ if "unit_tests" not in seen and tests_fresh:
427
+ tr = _read_json(tests_json, default=None)
397
428
  if isinstance(tr, dict):
398
429
  st = str(tr.get("status") or "")
399
430
  if st in ("verified", "pass", "passed"):
package/autonomy/run.sh CHANGED
@@ -8765,10 +8765,20 @@ start_resource_monitor() {
8765
8765
  # Initial check
8766
8766
  check_system_resources
8767
8767
 
8768
- # Background monitoring loop
8768
+ # Background monitoring loop. BACKLOG 22: a bare `sleep N` inside this
8769
+ # subshell survives a TERM to the subshell -- caught traps do not carry
8770
+ # into a foreground child, so the sleep reparents to init and outlives the
8771
+ # run for up to RESOURCE_CHECK_INTERVAL seconds. Backgrounding the sleep
8772
+ # and recording its PID lets a TERM trap kill it directly; `wait` returns
8773
+ # immediately once the trap fires, so shutdown is not delayed either.
8769
8774
  (
8775
+ _loki_rm_sleep_pid=""
8776
+ trap '[ -n "$_loki_rm_sleep_pid" ] && kill "$_loki_rm_sleep_pid" 2>/dev/null; exit 0' TERM
8770
8777
  while true; do
8771
- sleep "$RESOURCE_CHECK_INTERVAL"
8778
+ sleep "$RESOURCE_CHECK_INTERVAL" &
8779
+ _loki_rm_sleep_pid=$!
8780
+ wait "$_loki_rm_sleep_pid"
8781
+ _loki_rm_sleep_pid=""
8772
8782
  check_system_resources
8773
8783
  done
8774
8784
  ) &
@@ -9091,8 +9101,10 @@ setup_agent_branch() {
9091
9101
  [ -f .loki/.gitignore ] || printf '*\n' > .loki/.gitignore 2>/dev/null || true
9092
9102
 
9093
9103
  # Capture the ref Loki was run from. Detached HEAD yields the literal "HEAD".
9104
+ # symbolic-ref also names an unborn branch (fresh `git init`), where
9105
+ # rev-parse printed "HEAD" and failed, recording "HEAD" twice as the base.
9094
9106
  local cur=""
9095
- cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
9107
+ cur="$(git symbolic-ref --short -q HEAD 2>/dev/null || echo HEAD)"
9096
9108
 
9097
9109
  # Detached HEAD: do NOT branch, do NOT fabricate a base (LOCK A2/A6).
9098
9110
  if [ "$cur" = "HEAD" ]; then
@@ -9135,15 +9147,13 @@ setup_agent_branch() {
9135
9147
  fi
9136
9148
  fi
9137
9149
 
9138
- # Fresh run: persist the base branch (fresh-run-only) BEFORE branching, then
9139
- # mint and check out the feature branch (LOCK A2).
9150
+ # Fresh run: mint and check out the feature branch, then persist its base
9151
+ # (fresh-run-only, LOCK A2).
9140
9152
  local timestamp
9141
9153
  timestamp=$(date +%s)
9142
9154
  local branch_name="loki/session-${timestamp}-$$"
9143
9155
 
9144
9156
  mkdir -p .loki/state 2>/dev/null || true
9145
- # Persist the base only once per run tree; never overwrite an existing base.
9146
- [ ! -s .loki/state/base-branch.txt ] && printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null
9147
9157
 
9148
9158
  log_info "Branch protection enabled - creating agent branch: $branch_name (base: $cur)"
9149
9159
 
@@ -9172,6 +9182,15 @@ setup_agent_branch() {
9172
9182
  rm -f .loki/state/session-created.z 2>/dev/null
9173
9183
  if [ "$snap_ok" = 1 ]; then _LOKI_SNAPSHOT_THIS_RUN=1; fi
9174
9184
 
9185
+ # Every mint (a refused resume included) records its own base and the
9186
+ # commit it starts at (the empty tree when unborn); the resume paths above
9187
+ # keep both. A base left by an earlier session can name a branch deleted
9188
+ # since, and _loki_untrack_agent_committed_user_files then could not find
9189
+ # the user files the agent commits.
9190
+ printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null
9191
+ { git rev-parse --verify -q HEAD || git hash-object -t tree /dev/null; } \
9192
+ > .loki/state/session-start-sha 2>/dev/null
9193
+
9175
9194
  # Store the branch name for later use (PR creation, cleanup)
9176
9195
  printf '%s\n' "$branch_name" > .loki/state/agent-branch.txt 2>/dev/null
9177
9196
 
@@ -9367,6 +9386,101 @@ fi
9367
9386
  if ! type _commit_scan_secret_file >/dev/null 2>&1; then _commit_scan_secret_file() { return 1; }; fi
9368
9387
  if ! type _commit_path_looks_secret >/dev/null 2>&1; then _commit_path_looks_secret() { return 1; }; fi
9369
9388
 
9389
+ # _loki_covered_paths <paths> <snapshot> <top> <out>
9390
+ # Atomically write <out>: the NUL-delimited entries of <paths> that <snapshot>
9391
+ # covers (the entry itself or a parent "dir/" entry, as workspace_diff._covered)
9392
+ # and that still exist under <top>. Non-zero on a read or write failure. -E and
9393
+ # no cwd on sys.path (D7): the cwd is the agent's repo.
9394
+ _loki_covered_paths() {
9395
+ python3 -E -c 'import sys
9396
+ sys.path[:] = [p for p in sys.path if p not in ("", ".")]
9397
+ import os
9398
+ paths, snap, top, out = sys.argv[1:5]
9399
+ def entries(path):
9400
+ with open(path, "rb") as fh:
9401
+ return [p for p in fh.read().split(b"\0") if p]
9402
+ held = set(entries(snap))
9403
+ def covered(path):
9404
+ if path in held:
9405
+ return True
9406
+ cut = path.find(b"/")
9407
+ while cut != -1:
9408
+ if path[:cut + 1] in held:
9409
+ return True
9410
+ cut = path.find(b"/", cut + 1)
9411
+ return False
9412
+ # A file gone from disk stays tracked: untracking it would leave its bytes
9413
+ # only in history.
9414
+ top = os.fsencode(top)
9415
+ hits = [p for p in entries(paths) if covered(p) and os.path.lexists(os.path.join(top, p))]
9416
+ with open(out + ".tmp", "wb") as fh:
9417
+ fh.write(b"".join(p + b"\0" for p in hits))
9418
+ os.replace(out + ".tmp", out)' "$@" 2>/dev/null
9419
+ }
9420
+
9421
+ # _loki_untrack_agent_committed_user_files <branch> (BACKLOG 74)
9422
+ # An agent that rewrites .gitignore and runs `git add -A && git commit` itself
9423
+ # puts the user's untracked or ignored files on the session branch, and a
9424
+ # checkout of the base then deletes them from disk. Files ADDED on the branch
9425
+ # since it forked from the recorded base that the pre-existing snapshot covers
9426
+ # are removed from the index (they stay on disk) and that removal is committed
9427
+ # on its own, before the session commit: it adds no content, and no later exit
9428
+ # (a secret abort, a failed unstage) can put the files back on the branch tip.
9429
+ # Records this session's removals in .loki/state/agent-committed-user-files.z
9430
+ # and warns that the branch history still holds them. The fork is the merge
9431
+ # base with the recorded base branch, else the session's recorded start commit
9432
+ # (.loki/state/session-start-sha). Non-zero when the removal could not be
9433
+ # committed, or when neither fork resolves or the check fails (the tip may hold
9434
+ # user files): the caller then makes no session commit, whose `git add -A`
9435
+ # would stage the files again.
9436
+ _loki_untrack_agent_committed_user_files() {
9437
+ local snap=".loki/state/preexisting-untracked.z" rec=".loki/state/agent-committed-user-files.z"
9438
+ local base="" fork="" start="" top="" names=""
9439
+ local unchecked="Before switching branches, run git rm --cached on each of your files the agent committed and commit, or a checkout of the base deletes them. Left uncommitted: no session commit."
9440
+ [ -s "$snap" ] || return 0
9441
+ # No commit on the branch yet: the agent committed nothing.
9442
+ git rev-parse --verify -q HEAD >/dev/null 2>&1 || return 0
9443
+ [ -s .loki/state/base-branch.txt ] && base="$(cat .loki/state/base-branch.txt 2>/dev/null)"
9444
+ if [ -n "$base" ]; then fork="$(git merge-base HEAD "$base" 2>/dev/null)" || fork=""; fi
9445
+ if [ -z "$fork" ] && [ -s .loki/state/session-start-sha ]; then
9446
+ start="$(cat .loki/state/session-start-sha 2>/dev/null)"
9447
+ # A commit on this branch, or the empty tree of an unborn mint.
9448
+ if git merge-base --is-ancestor "$start" HEAD 2>/dev/null \
9449
+ || [ "$(git cat-file -t "$start" 2>/dev/null)" = tree ]; then
9450
+ fork="$start"
9451
+ fi
9452
+ fi
9453
+ if [ -z "$fork" ]; then
9454
+ log_warn "Could not check whether the agent committed your pre-existing untracked files on $1: base branch '${base:-unknown}' not found and the recorded session start commit is missing or not on this branch. ${unchecked}"
9455
+ return 1
9456
+ fi
9457
+ # --no-renames: a user file swept in beside a deleted tracked file must
9458
+ # read as added, not as a rename of that file.
9459
+ if ! top="$(git rev-parse --show-toplevel 2>/dev/null)" \
9460
+ || ! git -C "$top" diff --name-only -z --no-renames --diff-filter=A "$fork" HEAD > "$rec.added" 2>/dev/null \
9461
+ || ! _loki_covered_paths "$rec.added" "$snap" "$top" "$rec.new"; then
9462
+ rm -f "$rec.added" "$rec.new" "$rec.new.tmp"
9463
+ log_warn "Could not check whether the agent committed your pre-existing untracked files on $1. ${unchecked}"
9464
+ return 1
9465
+ fi
9466
+ rm -f "$rec.added"
9467
+ if [ ! -s "$rec.new" ]; then
9468
+ rm -f "$rec.new"
9469
+ return 0
9470
+ fi
9471
+ mv -f "$rec.new" "$rec"
9472
+ names="$(_loki_nul_names "$rec")"
9473
+ git reset -q >/dev/null 2>&1 || true
9474
+ if ! git -C "$top" update-index -z --force-remove --stdin < "$rec" >/dev/null 2>&1 \
9475
+ || ! git commit -q -m "Loki Mode: untrack pre-existing user files the agent committed" >/dev/null 2>&1; then
9476
+ log_warn "The agent committed your pre-existing files on $1 and Loki could not remove them from the branch: ${names}. They are on disk. Before switching branches, run git rm --cached on each and commit, or a checkout of the base deletes them. Left uncommitted: no session commit."
9477
+ return 1
9478
+ fi
9479
+ log_warn "The agent committed your pre-existing untracked or ignored files on $1: ${names}. Loki removed them from the branch tip; they stay on disk, untracked. The branch history still holds them: do not push $1 as-is. To drop them from its history: git reset --soft ${fork} && git commit"
9480
+ audit_agent_action "git_untrack_user_files" "Removed pre-existing user files the agent committed" "files=${names}" || true
9481
+ return 0
9482
+ }
9483
+
9370
9484
  commit_session_changes() {
9371
9485
  # Squash the session's work into one honest session-end commit on the agent
9372
9486
  # branch (LOCK A3/A4/A8). Commit-always (incl. failed runs) so the user is
@@ -9401,6 +9515,25 @@ commit_session_changes() {
9401
9515
  ;;
9402
9516
  esac
9403
9517
 
9518
+ # Commit nothing unless THIS run took a current snapshot (BACKLOG 90). The
9519
+ # LOCK A1 opt-out takes none, so it never commits, even on a leftover
9520
+ # loki/session-* branch whose agent-branch.txt and snapshot belong to an
9521
+ # earlier session: that stale snapshot would sweep in a file the user made
9522
+ # since, and a checkout of the base would delete it. Checked before any
9523
+ # staging, so the opt-out leaves the user's index alone.
9524
+ if [ "${_LOKI_SNAPSHOT_THIS_RUN:-0}" != 1 ]; then
9525
+ if [ "${LOKI_BRANCH_PROTECTION:-true}" != "true" ]; then
9526
+ log_info "Branch protection off (LOKI_BRANCH_PROTECTION=${LOKI_BRANCH_PROTECTION}): no session commit; the work stays uncommitted on ${cur}"
9527
+ elif [ -f "$PWD/.loki/state/preexisting-untracked.failed" ]; then
9528
+ log_warn "Left uncommitted: could not record your pre-existing untracked files at session start (the snapshot needs git 2.18+, excluding them needs 2.25+). Review and commit manually."
9529
+ else
9530
+ log_warn "Left uncommitted: this run recorded no snapshot of your pre-existing untracked files, so it makes no session commit. Review and commit manually."
9531
+ fi
9532
+ return 0
9533
+ fi
9534
+
9535
+ _loki_untrack_agent_committed_user_files "$cur" || return 0
9536
+
9404
9537
  # Stage everything except .loki/ runtime state and a secret-path denylist.
9405
9538
  # These excludes are defense-in-depth ONLY for the top-level cases git
9406
9539
  # pathspec handles cleanly. We deliberately do NOT add nested globs like
@@ -9426,12 +9559,8 @@ commit_session_changes() {
9426
9559
  # Agent-created files stay staged. Skip an empty snapshot: an
9427
9560
  # empty --pathspec-from-file resets the WHOLE index. No snapshot (older
9428
9561
  # session): behave as before. If the unstage fails (git < 2.25), commit
9429
- # nothing rather than sweep the user's files in.
9430
- if [ -f "$PWD/.loki/state/preexisting-untracked.failed" ]; then
9431
- git reset -q >/dev/null 2>&1 || true
9432
- log_warn "Left uncommitted: could not record your pre-existing untracked files at session start (the snapshot needs git 2.18+, excluding them needs 2.25+). Review and commit manually."
9433
- return 0
9434
- fi
9562
+ # nothing rather than sweep the user's files in. A failed snapshot never
9563
+ # gets here (the snapshot guard above returns first).
9435
9564
  local preexisting="$PWD/.loki/state/preexisting-untracked.z" top=""
9436
9565
  if [ -s "$preexisting" ]; then
9437
9566
  if ! { top="$(git rev-parse --show-toplevel 2>/dev/null)" \
@@ -12089,6 +12218,12 @@ ZT_MATCHES_EOF
12089
12218
  }
12090
12219
 
12091
12220
  enforce_test_coverage() {
12221
+ # Every suite this gate launches (pytest, unittest, the coverage pass, a
12222
+ # package script that runs Python) runs inside the user's repo: no
12223
+ # __pycache__/*.pyc there, so none can land in the session commit when the
12224
+ # repo does not ignore them (BACKLOG 85). Bytecode caching only; the same
12225
+ # tests run. Scoped to this call.
12226
+ local -x PYTHONDONTWRITEBYTECODE=1
12092
12227
  local loki_dir="${TARGET_DIR:-.}/.loki"
12093
12228
  local quality_dir="$loki_dir/quality"
12094
12229
  mkdir -p "$quality_dir" "$loki_dir/signals"
@@ -12118,7 +12253,9 @@ enforce_test_coverage() {
12118
12253
  # that is the legitimate case they were written for (a package that ships
12119
12254
  # a runner but no npm script).
12120
12255
  local _declared_test_script=""
12121
- _declared_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -c "
12256
+ # BACKLOG 81 (D7): -E plus the sys.path filter, so the agent's repo (the
12257
+ # cwd) cannot supply json or sitecustomize to this reader.
12258
+ _declared_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
12122
12259
  import json, os, sys
12123
12260
  try:
12124
12261
  with open(os.environ['_LOKI_PKG']) as f:
@@ -12238,7 +12375,8 @@ sys.stdout.write(t.strip())
12238
12375
  # npm test), so the grep only ever picked the LABEL, never
12239
12376
  # what ran. This corrects the evidence, not the execution.
12240
12377
  local _ws_script
12241
- _ws_script=$(_LOKI_PKG="$pkg_json" python3 -c "
12378
+ # BACKLOG 81 (D7): same guard as the root reader above.
12379
+ _ws_script=$(_LOKI_PKG="$pkg_json" python3 -E -c "import sys; sys.path[:] = [p for p in sys.path if p not in ('', '.')]
12242
12380
  import json, os, sys
12243
12381
  try:
12244
12382
  with open(os.environ['_LOKI_PKG']) as f:
@@ -12558,6 +12696,9 @@ TREOF
12558
12696
  # run instead of re-running the suite. Single source of truth for "tests
12559
12697
  # ran this iteration", set on every return path that writes results.
12560
12698
  printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
12699
+ # BACKLOG 91: non-blocking, but nothing was measured: the loop's
12700
+ # stage_complete event reads this and says not_run, not pass.
12701
+ _LOKI_TEST_SUITE_STATUS=not_run
12561
12702
  return 0
12562
12703
  fi
12563
12704
 
@@ -12569,6 +12710,49 @@ TREOF
12569
12710
  # first, then delete every remaining control char (\000-\037, incl. ESC).
12570
12711
  details=$(echo "$details" | tr '"' "'" | tr '\n' ' ' | tr -d '\000-\037' | head -c 500)
12571
12712
 
12713
+ # BACKLOG 73: a runner that exits 0 while its own summary reports failures
12714
+ # (`jest || true`, a script that swallows the status) failed. The failure
12715
+ # count comes from the runner's summary lines in the FULL output (the details
12716
+ # tail can drop them: the jest branch keeps 3 lines). The highest count
12717
+ # across summaries wins, so output that concatenates several runs (npm test
12718
+ # --workspaces, `jest; vitest`) cannot hide a failed run behind a later
12719
+ # green one. Shapes: jest "Tests: 1 failed, ...", vitest "Tests 1 failed |
12720
+ # ...", pytest "=== 1 failed, 2 passed in 0.1s ===" (or its -q line), node
12721
+ # TAP "# fail 1" and the spec reporter's "<info sign> fail 1", counted only
12722
+ # right after the matching pass line (node TAP echoes a test's own stdout
12723
+ # as "# ..." lines, which must not count), mocha "1 failing", go "--- FAIL:"
12724
+ # lines. A read summary with no failures is a measured 0. With no recognised
12725
+ # summary the old best-effort parse of the tail still applies (null when it
12726
+ # finds nothing). Either way a recorded count above zero is a failure: the
12727
+ # council evidence gate and the Bun gate both read failed_count > 0 as one.
12728
+ # LC_ALL=C: bytes, so any runner output parses; [ \t] not [[:space:]] for
12729
+ # old mawk.
12730
+ local _tr_passed_n _tr_failed_n _tr_summary_red=false
12731
+ _tr_failed_n=$(printf '%s\n' "${output:-}" | LC_ALL=C sed "s/$(printf '\033')\[[0-9;]*[A-Za-z]//g" | LC_ALL=C awk '
12732
+ function upd(n) { if (n > f) f = n; seen = 1 }
12733
+ /^Tests:[ \t]/ || /^[ \t]*Tests[ \t]+[0-9]/ ||
12734
+ /^=+ .*[0-9]+ (passed|failed)/ || /^[0-9]+ (passed|failed).* in [0-9.]+s/ {
12735
+ n = 0; if (match($0, /[0-9]+ failed/)) n = substr($0, RSTART, RLENGTH) + 0; upd(n) }
12736
+ /^[^ A-Za-z0-9]+ fail [0-9]+[ \t]*$/ { if (prev ~ /^[^ A-Za-z0-9]+ pass [0-9]+[ \t]*$/) upd($3 + 0) }
12737
+ /^[ \t]*[0-9]+ passing \(/ { upd(0) }
12738
+ /^[ \t]*[0-9]+ failing[ \t]*$/ { upd($1 + 0) }
12739
+ /^--- FAIL: / { go++ }
12740
+ { prev = $0 }
12741
+ END { if (go > f) { f = go; seen = 1 } if (seen) print f + 0 }')
12742
+ _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
12743
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
12744
+ # node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
12745
+ # "N passed"/"N failed" pattern above does not match. Fall back to those.
12746
+ [ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
12747
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
12748
+ [ -n "$_tr_passed_n" ] || _tr_passed_n=null
12749
+ [ -n "$_tr_failed_n" ] || _tr_failed_n=null
12750
+ if [ "$test_passed" = "true" ] && [ "$_tr_failed_n" != "null" ] && [ "$_tr_failed_n" -gt 0 ] 2>/dev/null; then
12751
+ test_passed=false
12752
+ _tr_summary_red=true
12753
+ log_warn "Test suite gate: $test_runner exited 0 but its summary reports $_tr_failed_n failed -- recording a failure"
12754
+ fi
12755
+
12572
12756
  # Evidence Receipt provenance (v7.85.0): record the deterministic FACTS a
12573
12757
  # non-forgeable receipt needs -- the command that ran, its exit code, and a
12574
12758
  # status enum -- alongside the legacy pass/runner/min_coverage keys the
@@ -12586,6 +12770,8 @@ TREOF
12586
12770
  *) _tr_cmd="$test_runner" ;;
12587
12771
  esac
12588
12772
  if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi
12773
+ # BACKLOG 73: the runner itself exited 0; record that fact, not a made-up 1.
12774
+ if [ "$_tr_summary_red" = "true" ]; then _tr_exit=0; fi
12589
12775
 
12590
12776
  # #82 (zero-test-file hardening): a runner that EXITED 0 but executed ZERO
12591
12777
  # real tests is a mini fake-green -- it records "verified" while proving
@@ -12607,17 +12793,6 @@ TREOF
12607
12793
  log_warn "Verification gap: $test_runner exited 0 but ran ZERO tests -- recording inconclusive (no_tests_run), not verified"
12608
12794
  fi
12609
12795
 
12610
- # Best-effort pass/fail counts from the summary text (null when not found).
12611
- local _tr_passed_n _tr_failed_n
12612
- _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
12613
- _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
12614
- # node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
12615
- # "N passed"/"N failed" pattern above does not match. Fall back to those.
12616
- [ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
12617
- [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
12618
- [ -n "$_tr_passed_n" ] || _tr_passed_n=null
12619
- [ -n "$_tr_failed_n" ] || _tr_failed_n=null
12620
-
12621
12796
  # verification_gap is "none" whenever a real runner executed AND ran tests:
12622
12797
  # the suite ran, so there is no docs-without-execution / source-without-tests
12623
12798
  # gap. The #82 zero-test case is the exception -- a runner ran but executed
@@ -12787,9 +12962,11 @@ os.replace(tmp, out)
12787
12962
  if [ "$_tr_zero_tests" = "true" ]; then
12788
12963
  rm -f "$quality_dir/unit-tests.pass" 2>/dev/null || true
12789
12964
  log_warn "Test suite gate: $test_runner ran zero tests -- inconclusive (not passed, not failed)"
12965
+ _LOKI_TEST_SUITE_STATUS=not_run # BACKLOG 91, see the no-runner return
12790
12966
  else
12791
12967
  touch "$quality_dir/unit-tests.pass"
12792
12968
  log_info "Test suite gate: $test_runner passed"
12969
+ _LOKI_TEST_SUITE_STATUS=pass
12793
12970
  fi
12794
12971
  # Coverage block is distinct from tests-red: tests passed, but enforced
12795
12972
  # coverage is below threshold. Return nonzero to gate WITHOUT writing the
@@ -24587,8 +24764,12 @@ EOF
24587
24764
  local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
24588
24765
  # F49: isolate HOME so the project's suite cannot pollute the
24589
24766
  # user's real home when it execs the generated app.
24767
+ _LOKI_TEST_SUITE_STATUS=""
24590
24768
  if _loki_with_app_sandbox enforce_test_coverage; then
24591
24769
  clear_gate_failure "test_coverage"
24770
+ # BACKLOG 91: a zero-test or no-runner run returns 0 without
24771
+ # measuring anything; its stage status is not_run, not pass.
24772
+ if [ "$_LOKI_TEST_SUITE_STATUS" = "not_run" ]; then _stg_ok=not_run; fi
24592
24773
  else
24593
24774
  _stg_ok=fail
24594
24775
  local tc_count
@@ -25688,11 +25869,33 @@ kill_provider_child() {
25688
25869
  fi
25689
25870
  kill -TERM "$child_pid" 2>/dev/null && killed=1
25690
25871
  done
25691
- # Also kill provider leaf processes by name in case they were reparented.
25692
- local proc
25693
- for proc in claude codex aider cline; do
25694
- pkill -TERM -f "^${proc}( |$)" 2>/dev/null && killed=1
25695
- done
25872
+ # Also kill provider leaf processes in case they were reparented (to init,
25873
+ # once their immediate parent -- a shell or the provider CLI itself --
25874
+ # exited). A reparented process changes PARENT, not PROCESS GROUP: unless
25875
+ # a process explicitly calls setpgid/setsid, it keeps the pgid it was
25876
+ # launched into, which for every invoke_* call above is this shell's own
25877
+ # pgid ($$). Scope the sweep to that pgid, matched by process name, rather
25878
+ # than a bare `pkill -f "^claude( |$)"` -- that matched the FULL COMMAND
25879
+ # LINE of every "claude", "codex", "aider" or "cline" process on the whole
25880
+ # machine, with no scoping at all, and killed unrelated Claude Code
25881
+ # sessions in other terminals the moment ANY loki-mode run finished,
25882
+ # double-Ctrl-C'd, or Ctrl-C'd in perpetual mode (build prompt D14 and
25883
+ # `feedback-pkill-f-substring-killed-the-session`). Reproduced: a decoy
25884
+ # `claude` process in a DIFFERENT process group survived; one launched
25885
+ # inside this shell's job (matching how a real reparented leaf would sit)
25886
+ # was still caught.
25887
+ local proc my_pgid
25888
+ my_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
25889
+ if [ -n "$my_pgid" ]; then
25890
+ for proc in claude codex aider cline; do
25891
+ local leaf_pid leaf_pgid
25892
+ for leaf_pid in $(pgrep -f "^${proc}( |$)" 2>/dev/null); do
25893
+ leaf_pgid="$(ps -o pgid= -p "$leaf_pid" 2>/dev/null | tr -d ' ')"
25894
+ [ -n "$leaf_pgid" ] && [ "$leaf_pgid" = "$my_pgid" ] || continue
25895
+ kill -TERM "$leaf_pid" 2>/dev/null && killed=1
25896
+ done
25897
+ done
25898
+ fi
25696
25899
 
25697
25900
  # Brief wait for graceful exit (max ~2s).
25698
25901
  local i=0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.54.0"
10
+ __version__ = "9.54.2"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -894,6 +894,10 @@ async def verify_audit_integrity():
894
894
 
895
895
  return {
896
896
  "valid": aggregate["valid"],
897
+ # verified/status: whether anything was checked (valid stays True for
898
+ # an empty chain); see audit.verify_all_logs_in_dir.
899
+ "verified": aggregate["verified"],
900
+ "status": aggregate["status"],
897
901
  "files_checked": aggregate["files_checked"],
898
902
  "files_skipped": aggregate.get("files_skipped", 0),
899
903
  "entries_checked": aggregate.get("entries_checked", 0),
@@ -657,7 +657,8 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
657
657
  if not audit_dir.exists():
658
658
  return {"valid": True, "files_checked": 0, "files_skipped": 0,
659
659
  "entries_checked": 0, "first_tampered_file": None,
660
- "first_tampered_line": None, "genesis_file": None}
660
+ "first_tampered_line": None, "genesis_file": None,
661
+ "verified": False, "status": "nothing_checked"}
661
662
  # v7.7.15 council fix (Opus 2): rotated files have name shape
662
663
  # `audit-YYYY-MM-DD.HHMMSS.jsonl` (from `_rotate_logs_if_needed` at
663
664
  # line 167). Lexicographic sort puts `audit-2026-05-04.123456.jsonl`
@@ -689,8 +690,13 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
689
690
  "first_tampered_file": str(log_file),
690
691
  "first_tampered_line": result.get("first_tampered_line"),
691
692
  "genesis_file": genesis_file,
693
+ "verified": False,
694
+ "status": "tampered",
692
695
  }
693
696
  prev_hash = result.get("last_hash", prev_hash)
697
+ # `valid` means "no break found" and stays True for an empty chain (the
698
+ # cross-chain verifier reads it). `verified` and `status` say whether
699
+ # anything was actually checked: zero files checked is not a verified chain.
694
700
  return {
695
701
  "valid": True,
696
702
  "files_checked": files_checked,
@@ -699,6 +705,8 @@ def verify_all_logs_in_dir(audit_dir) -> dict:
699
705
  "first_tampered_file": None,
700
706
  "first_tampered_line": None,
701
707
  "genesis_file": genesis_file,
708
+ "verified": files_checked > 0,
709
+ "status": "verified" if files_checked > 0 else "nothing_checked",
702
710
  }
703
711
 
704
712
 
package/dashboard/auth.py CHANGED
@@ -343,6 +343,18 @@ def list_tokens(include_revoked: bool = False) -> list[dict]:
343
343
  return result
344
344
 
345
345
 
346
+ def _deadline_passed(value) -> bool:
347
+ """True when an ISO-8601 deadline is in the past, or cannot be read.
348
+
349
+ Fails closed: an unparseable value, or a naive timestamp that cannot be
350
+ ordered against an aware "now", is treated as passed.
351
+ """
352
+ try:
353
+ return datetime.now(timezone.utc) > datetime.fromisoformat(str(value))
354
+ except (TypeError, ValueError):
355
+ return True
356
+
357
+
346
358
  def validate_token(raw_token: str) -> Optional[dict]:
347
359
  """
348
360
  Validate a raw token.
@@ -372,10 +384,10 @@ def validate_token(raw_token: str) -> Optional[dict]:
372
384
  if matched_token.get("revoked"):
373
385
  return None
374
386
 
375
- # Check expiration
376
- if matched_token.get("expires_at"):
377
- expires = datetime.fromisoformat(matched_token["expires_at"])
378
- if datetime.now(timezone.utc) > expires:
387
+ # Check expiration, and the grace period of a rotated-out key
388
+ # (api_keys.rotate_key sets rotation_expires_at on the old key).
389
+ for field in ("expires_at", "rotation_expires_at"):
390
+ if matched_token.get(field) and _deadline_passed(matched_token[field]):
379
391
  return None
380
392
 
381
393
  # Update last used