loki-mode 9.8.0 → 9.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +19 -14
  2. package/SKILL.md +3 -2
  3. package/VERSION +1 -1
  4. package/autonomy/loki +122 -1
  5. package/autonomy/run.sh +49 -2
  6. package/dashboard/__init__.py +1 -1
  7. package/dashboard/api_evidence.py +411 -0
  8. package/dashboard/api_operator.py +283 -0
  9. package/dashboard/api_phases.py +262 -0
  10. package/dashboard/api_releases.py +242 -0
  11. package/dashboard/api_runs.py +477 -0
  12. package/dashboard/api_tests.py +444 -0
  13. package/dashboard/api_v2.py +47 -1
  14. package/dashboard/server.py +54 -0
  15. package/dashboard/static/index.html +246 -135
  16. package/docs/ARCHITECTURE-OVERVIEW.md +5 -3
  17. package/docs/CAPABILITY-BACKLOG.md +53 -0
  18. package/docs/COMPARISON.md +2 -2
  19. package/docs/COMPETITIVE-ANALYSIS.md +1 -1
  20. package/docs/COMPETITIVE-SCORECARD.md +422 -0
  21. package/docs/DASHBOARD-9.12-EVIDENCE.md +97 -0
  22. package/docs/DASHBOARD-ARCHITECTURE.md +423 -0
  23. package/docs/DEMOS.md +21 -23
  24. package/docs/HANDOFF-2026-08-03.md +439 -0
  25. package/docs/INSTALLATION.md +17 -10
  26. package/docs/OUTCOME-FRONTIER.md +536 -0
  27. package/docs/PROMPT-ABLATION-RESULT.md +97 -0
  28. package/docs/TOOLS.md +800 -0
  29. package/docs/alternative-installations.md +2 -3
  30. package/docs/audit-logging.md +44 -35
  31. package/docs/authentication.md +13 -2
  32. package/docs/authorization.md +87 -81
  33. package/docs/git-workflow.md +6 -3
  34. package/docs/metrics.md +15 -16
  35. package/docs/network-security.md +16 -13
  36. package/docs/openclaw-integration.md +36 -556
  37. package/docs/show-hn-post.md +2 -2
  38. package/docs/siem-integration.md +39 -36
  39. package/loki-ts/dist/loki.js +18 -18
  40. package/mcp/__init__.py +1 -1
  41. package/package.json +2 -2
  42. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  43. package/references/confidence-routing.md +18 -1
  44. package/references/invariant-checks.md +13 -8
  45. package/references/magic-rarv-integration.md +0 -1
  46. package/references/multi-provider.md +27 -5
  47. package/skills/healing.md +4 -2
  48. package/tools/audit-docs.py +488 -0
  49. package/tools/baseline-pin.py +19 -1
  50. package/tools/calibration-audit.py +523 -0
  51. package/tools/ci-gate.py +19 -1
  52. package/tools/cost-forecast.py +344 -0
  53. package/tools/cost-guard.py +19 -1
  54. package/tools/cost-history.py +19 -1
  55. package/tools/cost-per-outcome.py +394 -0
  56. package/tools/estimate-run.py +19 -1
  57. package/tools/evidence-freshness.py +307 -0
  58. package/tools/gate-init.py +19 -1
  59. package/tools/gate-report.py +19 -1
  60. package/tools/gate-simulate.py +570 -0
  61. package/tools/gate-trend.py +354 -0
  62. package/tools/model-advisor.py +52 -1
  63. package/tools/policy-load.py +19 -1
  64. package/tools/prompt-cost.py +363 -0
  65. package/tools/prompt-diff.py +448 -0
  66. package/tools/prompt-lint.py +448 -0
  67. package/tools/receipt-bundle.py +72 -2
  68. package/tools/receipt-diff.py +19 -1
  69. package/tools/receipt-find.py +19 -1
  70. package/tools/receipt-stats.py +380 -0
  71. package/tools/receipt-timeline.py +478 -0
  72. package/tools/receipt-verify-batch.py +291 -0
  73. package/tools/run-replay.py +19 -1
  74. package/tools/signing-status.py +19 -1
  75. package/tools/token-guard.py +19 -1
  76. package/tools/token-tax.py +375 -0
  77. package/tools/tool-index.py +19 -1
  78. package/tools/verification-tax.py +277 -0
  79. package/tools/verify-chain.py +361 -0
@@ -0,0 +1,262 @@
1
+ """Read-only phase history for a run, derived from real `phase_change` events.
2
+
3
+ WHY THIS EXISTS. dashboard-ui/components/loki-session-timeline.js SYNTHESIZED
4
+ its timeline. It took a scalar status (uptime, current phase, iteration count),
5
+ rotated a fixed list `['planning','building','testing','reviewing']`, and gave
6
+ each invented segment a randomized duration:
7
+
8
+ const duration = segmentDuration * (0.8 + Math.random() * 0.4);
9
+
10
+ An operator read plausible phase boundaries for work that never happened. The
11
+ runtime records the real thing -- autonomy/run.sh:6562 emits
12
+
13
+ emit_event_json "phase_change" "from=$LAST_KNOWN_PHASE" \\
14
+ "to=$current_phase" "iteration=$ITERATION_COUNT"
15
+
16
+ and emit_event_json (autonomy/run.sh:2435) appends to .loki/events.jsonl with a
17
+ UTC timestamp. So the fix is to READ the record, not to render UNKNOWN.
18
+
19
+ THE FILE IS events.jsonl, NOT metrics/trust-events.jsonl. Those are two
20
+ different append-only logs. api_runs.py documents the trust log and finds no
21
+ phase_change in it; looking there and concluding the data is absent is the
22
+ available wrong turn.
23
+
24
+ WHAT A phase_change EVENT CAN AND CANNOT TELL US
25
+ ------------------------------------------------
26
+ Each record is {"timestamp": <utc>, "type": "phase_change",
27
+ "data": {"from":..., "to":..., "iteration":...}}. It marks an INSTANT, not an
28
+ interval -- there is no duration field anywhere. So a segment's end is the NEXT
29
+ event's timestamp, and the final `to` phase is still running.
30
+
31
+ Two boundaries are therefore genuinely unmeasured, and this module refuses to
32
+ invent either:
33
+
34
+ THE FIRST SEGMENT'S START. The emitter only fires when LAST_KNOWN_PHASE is
35
+ non-empty AND changed, so the run's opening phase has no recorded start.
36
+ status.uptime_seconds is PROCESS uptime, not phase-0 start; subtracting it
37
+ would be fabrication with extra steps. The first event's `from` phase is
38
+ reported separately as `leading_phase` with start=None, never as a segment
39
+ with a made-up start.
40
+
41
+ THE LAST SEGMENT'S END. The trailing phase is ongoing. Its `end` is None and
42
+ `ongoing` is True; the caller anchors it to `checked_at` (supplied here, as
43
+ api_evidence.receipts_report already does) rather than to a client clock.
44
+
45
+ SAMPLED, NOT EXHAUSTIVE. The emitter lives in a polled monitor loop, so a phase
46
+ shorter than one poll interval never produced an event and is absent from this
47
+ history. `sampled: True` says so in the envelope. Absence of a segment is not
48
+ evidence the phase did not occur.
49
+
50
+ PHASE NAMES ARE PASSED THROUGH VERBATIM. The runtime writes UPPERCASE values
51
+ (BOOTSTRAP, BUILDING, COMPLETED, FAILED -- autonomy/run.sh:6229, :25537,
52
+ :25645, :25653) via _advance_current_phase, and the set is open: any string a
53
+ caller passes to that function becomes a phase. Mapping an unrecognized name
54
+ onto a known label is how "BOOTSTRAP" renders as "Idle", which is the same
55
+ class of lie as the simulation. Normalization is the renderer's problem and it
56
+ must fall back to the ACTUAL string.
57
+
58
+ THE HONESTY RULE, as elsewhere in this package: an empty result carries a
59
+ `reason`, an unmeasured value is None and never 0, and `source` names the real
60
+ path read so any row can be audited. A missing events.jsonl and an unreadable
61
+ one produce DIFFERENT reasons.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import json
67
+ import os
68
+ import time
69
+ from typing import Any, Optional
70
+
71
+ # Imported, not restated: a second copy of the freshness/UNKNOWN convention is
72
+ # how the honesty rule drifts (see api_runs.py's module docstring).
73
+ from .api_runs import UNKNOWN, _freshness, _mtime, _p
74
+
75
+ __all__ = ["phase_history", "UNKNOWN"]
76
+
77
+ _EVENTS = "events.jsonl"
78
+ _SOURCE_PATHS = (".loki/events.jsonl",)
79
+
80
+ # events.jsonl is append-only and unbounded. Read at most the trailing slice,
81
+ # matching the 10MB cap dashboard/server.py:_read_events already applies to the
82
+ # same file, so a long-running workspace cannot pin the dashboard's memory.
83
+ _MAX_BYTES = 10 * 1024 * 1024
84
+
85
+ # ponytail: a phase_change event is ~120 bytes, so the byte cap alone bounds
86
+ # this well below any render budget. No event-count cap on top of it.
87
+
88
+
89
+ def _iter_lines(path: str):
90
+ """Trailing slice of an append-only log, newest-truncation-safe.
91
+
92
+ Seeking mid-file lands inside a record, so the first partial line after a
93
+ seek is discarded. Raises on an unreadable file: the caller distinguishes
94
+ "absent" from "could not read", and swallowing the error here would erase
95
+ that difference.
96
+ """
97
+ size = os.path.getsize(path)
98
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
99
+ if size > _MAX_BYTES:
100
+ fh.seek(size - _MAX_BYTES)
101
+ fh.readline() # discard the partial record
102
+ for line in fh:
103
+ yield line
104
+
105
+
106
+ def _parse_ts(raw: Any) -> Optional[float]:
107
+ """UTC ISO-8601 (`date -u +%Y-%m-%dT%H:%M:%SZ`) to epoch seconds.
108
+
109
+ None on anything unparseable -- an event whose time cannot be read cannot
110
+ anchor a segment, and guessing a time would place a measured phase at a
111
+ fabricated moment.
112
+ """
113
+ if not isinstance(raw, str) or not raw:
114
+ return None
115
+ txt = raw.strip()
116
+ if txt.endswith("Z"):
117
+ txt = txt[:-1] + "+00:00"
118
+ try:
119
+ from datetime import datetime
120
+ return datetime.fromisoformat(txt).timestamp()
121
+ except (ValueError, TypeError):
122
+ return None
123
+
124
+
125
+ def _phase_change_events(path: str) -> list:
126
+ """Every well-formed phase_change record, oldest first.
127
+
128
+ A malformed line is skipped rather than fatal: events.jsonl is appended to
129
+ by concurrent shell writers, so a torn final line is normal operation and
130
+ must not blank out the history behind it.
131
+ """
132
+ out = []
133
+ for line in _iter_lines(path):
134
+ line = line.strip()
135
+ if not line or "phase_change" not in line:
136
+ continue # cheap prefilter; the log is mostly other event types
137
+ try:
138
+ rec = json.loads(line)
139
+ except (json.JSONDecodeError, ValueError):
140
+ continue
141
+ if not isinstance(rec, dict) or rec.get("type") != "phase_change":
142
+ continue
143
+ ts = _parse_ts(rec.get("timestamp"))
144
+ if ts is None:
145
+ continue
146
+ data = rec.get("data")
147
+ if not isinstance(data, dict):
148
+ continue
149
+ out.append((ts, data))
150
+ out.sort(key=lambda pair: pair[0])
151
+ return out
152
+
153
+
154
+ def _iteration(data: dict) -> Optional[int]:
155
+ """The iteration number, or None. Never 0 as a stand-in for absent."""
156
+ raw = data.get("iteration")
157
+ if isinstance(raw, bool):
158
+ return UNKNOWN
159
+ if isinstance(raw, int):
160
+ return raw
161
+ if isinstance(raw, str) and raw.strip().lstrip("-").isdigit():
162
+ return int(raw.strip())
163
+ return UNKNOWN
164
+
165
+
166
+ def _envelope(segments, leading, reason, freshness, checked_at, sampled=True):
167
+ return {
168
+ "segments": segments,
169
+ "leading_phase": leading,
170
+ "source": list(_SOURCE_PATHS),
171
+ "freshness_s": freshness,
172
+ "reason": reason,
173
+ "checked_at": checked_at,
174
+ "sampled": sampled,
175
+ }
176
+
177
+
178
+ def phase_history(loki_dir: str, now: Optional[float] = None) -> dict:
179
+ """Measured phase segments for the current run.
180
+
181
+ Returns an envelope::
182
+
183
+ {"segments": [{"phase", "start", "end", "ongoing", "iteration"}, ...],
184
+ "leading_phase": {"phase", "start": None, "iteration"} | None,
185
+ "source": [...], "freshness_s": int|None, "reason": str|None,
186
+ "checked_at": float, "sampled": True}
187
+
188
+ `segments` is empty with a populated `reason` in every no-data case, and
189
+ the reasons are distinct so the UI can say WHY rather than showing one
190
+ blank state for four different situations.
191
+ """
192
+ now = time.time() if now is None else now
193
+ path = _p(loki_dir, _EVENTS)
194
+ fresh = _freshness([_mtime(path)], now=now)
195
+
196
+ if not os.path.exists(path):
197
+ return _envelope(
198
+ [], None,
199
+ "no phase history: %s does not exist (the run has not started, or "
200
+ "this workspace predates event logging)" % _SOURCE_PATHS[0],
201
+ fresh, now)
202
+
203
+ try:
204
+ events = _phase_change_events(path)
205
+ except OSError as exc:
206
+ # NOT the same as "no events". A dashboard that renders an unreadable
207
+ # log identically to an empty one reports healthy on a broken disk.
208
+ return _envelope(
209
+ [], None,
210
+ "could not read phase history: %s is unreadable (%s)"
211
+ % (_SOURCE_PATHS[0], exc),
212
+ fresh, now)
213
+
214
+ if not events:
215
+ return _envelope(
216
+ [], None,
217
+ "phase history not recorded: %s exists but contains no "
218
+ "phase_change events (the run never changed phase, or it predates "
219
+ "phase_change emission)" % _SOURCE_PATHS[0],
220
+ fresh, now)
221
+
222
+ # The first event's `from` is a phase we know RAN but whose start was never
223
+ # emitted. Reported with start=None so a renderer can name it without
224
+ # drawing it on a time axis it has no coordinate for.
225
+ first_ts, first_data = events[0]
226
+ leading = None
227
+ from_phase = first_data.get("from")
228
+ if isinstance(from_phase, str) and from_phase:
229
+ leading = {
230
+ "phase": from_phase,
231
+ "start": UNKNOWN,
232
+ "end": first_ts,
233
+ "iteration": _iteration(first_data),
234
+ }
235
+
236
+ # One segment per event: it starts where the event fired and ends where the
237
+ # NEXT one did. Both endpoints are measured timestamps; nothing is derived
238
+ # from a duration model.
239
+ segments = []
240
+ for idx, (ts, data) in enumerate(events):
241
+ to_phase = data.get("to")
242
+ if not isinstance(to_phase, str) or not to_phase:
243
+ continue
244
+ nxt = events[idx + 1][0] if idx + 1 < len(events) else None
245
+ segments.append({
246
+ "phase": to_phase,
247
+ "start": ts,
248
+ # None, not now: the run's final phase has no recorded end. The
249
+ # caller anchors it to checked_at and knows it did so.
250
+ "end": nxt,
251
+ "ongoing": nxt is None,
252
+ "iteration": _iteration(data),
253
+ })
254
+
255
+ if not segments:
256
+ return _envelope(
257
+ [], leading,
258
+ "phase history not recorded: %d phase_change event(s) carried no "
259
+ "usable 'to' phase" % len(events),
260
+ fresh, now)
261
+
262
+ return _envelope(segments, leading, None, fresh, now)
@@ -0,0 +1,242 @@
1
+ """Read-only "releases" view over git tags and the VERSION file.
2
+
3
+ WHY THIS EXISTS. Releases had zero API surface. Unlike the other read-only
4
+ domains this one has sources that DO exist in a plain checkout -- git tags
5
+ (780 in this repo) and VERSION -- so it needs no run to have happened first.
6
+
7
+ THE SOURCES, and what each can and cannot tell us:
8
+
9
+ VERSION the version the working tree currently claims. It is the
10
+ value the next release WILL carry, so it is routinely AHEAD of the
11
+ newest tag while unreleased work sits on the branch.
12
+ git tags the versions that actually shipped. Read with
13
+ `git for-each-ref --sort=-v:refname refs/tags`, which orders by version
14
+ NUMERICALLY. Not --sort=-creatordate: a hotfix cut from an old branch is
15
+ newest by date while being an older version, and "newest tag" must mean
16
+ the same thing as the side VERSION is compared against.
17
+
18
+ THE AHEAD CASE is the reason this domain is worth exposing. When VERSION is
19
+ ahead of every tag, NO tag is current, and every row reads is_current False.
20
+ The envelope therefore states `version`, `newest_tag` and `version_is_ahead`
21
+ directly, so a caller can tell "VERSION is ahead of the newest tag" apart from
22
+ "VERSION was unreadable" -- two states that produce identical rows.
23
+
24
+ Version comparison is NUMERIC, on an int tuple. A string compare puts "9.11.0"
25
+ BELOW "9.8.1" and would report this very repo as behind its own newest tag,
26
+ which is precisely backwards. Anything that does not parse to ints reads
27
+ version_is_ahead=None: unknown, never False.
28
+
29
+ THE HONESTY RULE, applied to what this domain actually measures. A tag with no
30
+ readable creation date reads date=None, never today's date. An unparseable
31
+ version reads version_is_ahead=None, never False. There is no cost or token
32
+ field here, so record_is_measured() from autonomy/lib/efficiency_cost.py --
33
+ the canonical predicate used by dashboard/api_runs.py -- has nothing to judge
34
+ and is deliberately not imported; importing it unused would only imply a
35
+ measurement this reader does not make.
36
+
37
+ CHANGELOG.md is deliberately NOT read. It parses cleanly (`## v9.11.0`
38
+ headings), but it carries no field of the row shape that git and VERSION do
39
+ not already supply, and `source` must list paths actually consulted rather
40
+ than paths considered.
41
+
42
+ Every returned envelope states `source` (the real paths read), `freshness_s`
43
+ (age in seconds of the newest file that actually contributed, None when
44
+ nothing did) and an explicit `reason` when empty -- the shape established by
45
+ dashboard/api_runs.py.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import os
51
+ import subprocess
52
+ import time
53
+ from typing import Any, Optional
54
+
55
+ __all__ = ["list_releases", "UNKNOWN"]
56
+
57
+ # What an unmeasured value reads as. Same name and meaning as api_runs.UNKNOWN.
58
+ UNKNOWN = None
59
+
60
+ _VERSION_FILE = "VERSION"
61
+
62
+ # Real paths read, in the style of dashboard/api_runs.py._SOURCE_PATHS.
63
+ _SOURCE_PATHS = ("VERSION", "git tags (refs/tags)")
64
+
65
+ # One record per tag: "<tag>\x1f<iso date>". \x1f (unit separator) cannot occur
66
+ # in a git refname, so it cannot be produced by a tag name containing a pipe.
67
+ _TAG_FORMAT = "%(refname:short)\x1f%(creatordate:iso-strict)"
68
+
69
+ _GIT_TIMEOUT_S = 20
70
+
71
+
72
+ def _mtime(path: str) -> Optional[float]:
73
+ try:
74
+ return os.path.getmtime(path)
75
+ except OSError:
76
+ return None
77
+
78
+
79
+ def _freshness(mtimes: list, now: Optional[float] = None) -> Optional[int]:
80
+ """Age in seconds of the NEWEST file that contributed. None if none did.
81
+
82
+ Copied in behaviour from api_runs._freshness, including the clamp at 0 so
83
+ a file written during this call cannot report a negative age. Derived from
84
+ file mtimes ONLY: a tag's creation date is the age of the RELEASE, not the
85
+ age of this read, and using it would misreport freshness by years.
86
+ """
87
+ real = [m for m in mtimes if m is not None]
88
+ if not real:
89
+ return None
90
+ return max(0, int((now if now is not None else time.time()) - max(real)))
91
+
92
+
93
+ def _read_version(repo_dir: str) -> Optional[str]:
94
+ """The VERSION file's value, or None when absent/empty/unreadable."""
95
+ try:
96
+ with open(os.path.join(repo_dir, _VERSION_FILE), "r", encoding="utf-8") as fh:
97
+ return fh.read().strip() or None
98
+ except OSError:
99
+ return None
100
+
101
+
102
+ def _version_tuple(value: Optional[str]) -> Optional[tuple]:
103
+ """"v9.11.0" -> (9, 11, 0). None when it is not a numeric dotted version.
104
+
105
+ None is the whole point: an unparseable version must make every comparison
106
+ that depends on it read unknown rather than silently False. Pre-release
107
+ suffixes ("9.1.0-rc1") do not parse to ints and so read unknown rather
108
+ than being ordered by a rule this module never verified.
109
+ """
110
+ if not value:
111
+ return None
112
+ raw = value.strip().lstrip("vV")
113
+ parts = raw.split(".")
114
+ try:
115
+ return tuple(int(p) for p in parts)
116
+ except ValueError:
117
+ return None
118
+
119
+
120
+ def _git_tags(repo_dir: str, limit: int) -> tuple:
121
+ """([(tag, date|None)], reason|None), newest version first.
122
+
123
+ --count applies AFTER the sort, so this stays cheap against a repo with
124
+ hundreds of tags. A missing git binary and a non-repo directory are
125
+ reported as distinct reasons, and both yield an empty list -- never a
126
+ fabricated one.
127
+ """
128
+ cmd = [
129
+ "git", "for-each-ref",
130
+ "--sort=-v:refname",
131
+ "--count=%d" % limit,
132
+ "--format=" + _TAG_FORMAT,
133
+ "refs/tags",
134
+ ]
135
+ try:
136
+ proc = subprocess.run(
137
+ cmd, cwd=repo_dir, capture_output=True, text=True,
138
+ timeout=_GIT_TIMEOUT_S,
139
+ )
140
+ except FileNotFoundError:
141
+ return [], "git executable not found on PATH"
142
+ except subprocess.TimeoutExpired:
143
+ return [], "git for-each-ref timed out after %ds" % _GIT_TIMEOUT_S
144
+ except OSError as exc:
145
+ return [], "git for-each-ref failed: %s" % (exc,)
146
+
147
+ if proc.returncode != 0:
148
+ detail = (proc.stderr or "").strip().splitlines()
149
+ return [], "git for-each-ref failed in %s: %s" % (
150
+ repo_dir, detail[0] if detail else "exit %d" % proc.returncode)
151
+
152
+ out = []
153
+ for line in proc.stdout.splitlines():
154
+ if not line.strip():
155
+ continue
156
+ tag, _sep, date = line.partition("\x1f")
157
+ if not tag:
158
+ continue
159
+ # An empty date field reads None. creatordate is the tag's own date for
160
+ # an annotated tag and the tagged commit's date for a lightweight one;
161
+ # both are real measurements. Absent is absent -- never today.
162
+ out.append((tag, date.strip() or UNKNOWN))
163
+ return out, None
164
+
165
+
166
+ def list_releases(repo_dir: str, limit: int = 20, now: Optional[float] = None) -> dict:
167
+ """Releases from git tags, newest version first.
168
+
169
+ Returns an ENVELOPE, not a bare list, because the contract requires an
170
+ explicit reason when the result is empty and a list cannot carry one:
171
+
172
+ {"releases": [{"tag", "version", "date"|None, "is_current", "source"}],
173
+ "version": str|None, "newest_tag": str|None,
174
+ "version_is_ahead": bool|None,
175
+ "source": [...], "freshness_s": int|None, "reason": None|str}
176
+
177
+ version_is_ahead is True when the VERSION file names a version greater than
178
+ every tag -- unreleased work on the branch, which is the normal state
179
+ between releases. It is None (not False) whenever either side is missing or
180
+ unparseable, because "we could not compare" is not "it is not ahead".
181
+
182
+ is_current is a NUMERIC equality against VERSION, so exactly one tag can be
183
+ current, and none is current while VERSION is ahead.
184
+ """
185
+ envelope = {
186
+ "releases": [],
187
+ "version": UNKNOWN,
188
+ "newest_tag": UNKNOWN,
189
+ "version_is_ahead": UNKNOWN,
190
+ "source": list(_SOURCE_PATHS),
191
+ "freshness_s": None,
192
+ "reason": None,
193
+ }
194
+ if not repo_dir or not os.path.isdir(repo_dir):
195
+ envelope["reason"] = "no repository directory at %s" % (repo_dir,)
196
+ return envelope
197
+
198
+ version = _read_version(repo_dir)
199
+ envelope["version"] = version if version is not None else UNKNOWN
200
+ envelope["freshness_s"] = _freshness(
201
+ [_mtime(os.path.join(repo_dir, _VERSION_FILE))], now=now)
202
+
203
+ if limit is not None and limit <= 0:
204
+ envelope["reason"] = "limit must be positive, got %r" % (limit,)
205
+ return envelope
206
+
207
+ tags, reason = _git_tags(repo_dir, limit)
208
+ if reason is not None:
209
+ envelope["reason"] = reason
210
+ return envelope
211
+ if not tags:
212
+ envelope["reason"] = "no tags under refs/tags in %s" % (repo_dir,)
213
+ return envelope
214
+
215
+ version_t = _version_tuple(version)
216
+ envelope["newest_tag"] = tags[0][0]
217
+
218
+ newest_t = _version_tuple(tags[0][0])
219
+ if version_t is not None and newest_t is not None:
220
+ envelope["version_is_ahead"] = version_t > newest_t
221
+ else:
222
+ # Left as None with a stated reason: one side did not parse, so the
223
+ # comparison was not made. Reporting False here would assert a fact.
224
+ envelope["reason"] = (
225
+ "version_is_ahead unknown: %s did not parse as a numeric version"
226
+ % ("VERSION (%r)" % (version,) if version_t is None
227
+ else "newest tag (%r)" % (tags[0][0],))
228
+ )
229
+
230
+ for tag, date in tags:
231
+ tag_t = _version_tuple(tag)
232
+ envelope["releases"].append({
233
+ "tag": tag,
234
+ "version": tag.lstrip("vV") or tag,
235
+ "date": date,
236
+ # Numeric equality. A string compare would also make "v9.10.0" and
237
+ # "9.1.0" behave unpredictably here, not just in the ahead check.
238
+ "is_current": bool(tag_t is not None and version_t is not None
239
+ and tag_t == version_t),
240
+ "source": "git tag %s" % tag,
241
+ })
242
+ return envelope