loki-mode 9.2.0 → 9.4.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.
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a ci-gate verdict into the next command a human should actually run.
3
+
4
+ WHY THIS EXISTS. ci-gate.py decides correctly and gate-report.py carries that
5
+ decision to the CI run page. Both stop at WHAT happened. Neither says what to
6
+ do about it, and the state that most needs saying is the one nobody knows how
7
+ to act on:
8
+
9
+ cost UNEVALUABLE -- cost is UNMEASURED for /ws, no efficiency record
10
+ carried an observed cost or token count.
11
+
12
+ An engineer reading that has a blocked merge and no next step. The observed
13
+ response to an unactionable blocker is not to fix it, it is to route around it
14
+ -- delete the flag, add a `|| true`, lower the ceiling until it passes. A gate
15
+ that cannot be acted on gets disabled, and a disabled gate is worse than none
16
+ because the workflow file still claims it runs. So this file's whole job is
17
+ the third column: what it checked, what it found, and the CONCRETE command.
18
+
19
+ python3 tools/ci-gate.py <ws> --max-usd 5 --json \
20
+ | python3 tools/gate-explain.py
21
+
22
+ THE RULE THAT CONSTRAINS EVERY REMEDY IN THIS FILE:
23
+
24
+ NEVER PRINT A COMMAND YOU CANNOT JUSTIFY.
25
+
26
+ A remedy is a suggestion a tired operator will paste without reading, and then
27
+ trust the result of. A guessed command is therefore worse than silence in both
28
+ directions: it wastes the fix, and it manufactures confidence that the axis was
29
+ addressed. So the remedy table below is keyed on the policy names ci-gate
30
+ actually emits, every flag in it was checked against that tool's own --help,
31
+ and an unrecognised policy prints exactly
32
+
33
+ no known remedy for this policy
34
+
35
+ rather than a plausible-looking guess assembled from the policy's name.
36
+
37
+ THE THREE STATES STAY THREE. This repo has paid for collapsing them on fifteen
38
+ surfaces. PASS is "we checked and it is fine". FAIL is "we checked and it is
39
+ not". UNEVALUABLE is "we could not check", which is neither -- so it is never
40
+ worded as a failure (that would send an operator to fix a budget that was never
41
+ exceeded) and never worded as a pass (that is the green-leak the whole tool
42
+ line exists to stop). Each has its own sentence and its own remedy.
43
+
44
+ WHY THE EXIT CODE IS RECONCILED, NOT RELAYED. A shell pipe keeps only the last
45
+ command's status, so ci-gate's 2 is discarded by the `|` and this file must
46
+ re-emit it, exactly as gate-report.py does. Relaying the header's exit_code
47
+ alone is not enough: a body claiming exit_code 0 while carrying an UNEVALUABLE
48
+ row would make this file PRINT "the gate could not check this" and EXIT 0 in
49
+ the same breath -- a green-leak in the one tool whose subject is green-leaks.
50
+ The rows are the evidence and the header is a claim, so the code is the weakest
51
+ link over both, the same rule ci-gate applies.
52
+
53
+ Empty or malformed stdin is an error with exit 2, never an empty explanation:
54
+ nothing to explain is indistinguishable from a clean run, and an assumed pass
55
+ on unreadable input is the same defect wearing different clothes.
56
+
57
+ Exit: mirrors the reconciled input verdict (0 pass, 1 failed, 2 unevaluable);
58
+ 2 on input this file could not parse; 64 on a usage error.
59
+ """
60
+
61
+ import argparse
62
+ import json
63
+ import sys
64
+
65
+ sys.dont_write_bytecode = True
66
+
67
+ PASS, FAIL, UNEVALUABLE = 0, 1, 2
68
+ EXIT_USAGE = 64
69
+
70
+ _EXIT = {"PASS": PASS, "FAIL": FAIL, "UNEVALUABLE": UNEVALUABLE}
71
+ _STATE = {v: k for k, v in _EXIT.items()}
72
+
73
+ # ponytail: no efficiency record reaches this file. The input is a ci-gate
74
+ # verdict -- policy rows with prose reasons -- so record_is_measured() from
75
+ # autonomy/lib/efficiency_cost.py has nothing here to apply itself to, and
76
+ # importing it to look thorough would be a second cost predicate in disguise.
77
+ # The measured/unmeasured judgement was already made upstream by cost-guard.py
78
+ # and arrives as the row's own words, which this file relays verbatim.
79
+
80
+ # WHAT EACH STATE MEANS, in one place. Worded so no state can be misread as
81
+ # another: "could not check" never contains pass or fail language.
82
+ _MEANING = {
83
+ "PASS": "the gate checked this and it passed",
84
+ "FAIL": "the gate checked this and it failed",
85
+ "UNEVALUABLE": "the gate could not check this, so it is neither a pass "
86
+ "nor a failure -- the axis is unverified",
87
+ }
88
+
89
+ _NO_REMEDY = "no known remedy for this policy"
90
+
91
+ # THE REMEDY TABLE. Keyed on the policy names ci-gate.py emits (grep `_run("`
92
+ # there: "cost" and "receipt"). Every flag below was verified against that
93
+ # tool's own --help output before being written down. Adding a row here without
94
+ # running the command it prints is how this file starts lying.
95
+ _REMEDY = {
96
+ ("cost", "FAIL"):
97
+ "the run cost more than the ceiling. Inspect the spend, then either "
98
+ "reduce it or raise the ceiling deliberately:\n"
99
+ " python3 tools/cost-guard.py <workspace> --max-usd <ceiling> --json",
100
+ ("cost", "UNEVALUABLE"):
101
+ "no iteration recorded an observed cost or token count, so the budget "
102
+ "question has no data to answer it. Re-run the work so the provider "
103
+ "writes usage, then confirm a record now carries real numbers:\n"
104
+ " ls <workspace>/.loki/metrics/efficiency/iteration-*.json\n"
105
+ " python3 tools/cost-guard.py <workspace> --max-usd <ceiling> --json",
106
+ ("receipt", "FAIL"):
107
+ "attestation was required and did not hold. Read the attestation's own "
108
+ "per-axis states before changing anything:\n"
109
+ " python3 tools/receipt-attest.py "
110
+ "<workspace>/.loki/proofs/*/proof.json --json",
111
+ ("receipt", "UNEVALUABLE"):
112
+ "the receipt exists but at least one axis could not be checked here. "
113
+ "The attestation names which axis and why:\n"
114
+ " python3 tools/receipt-attest.py "
115
+ "<workspace>/.loki/proofs/*/proof.json --json",
116
+ }
117
+
118
+ _PASS_REMEDY = "nothing to do"
119
+
120
+ # ci-gate's "no policy configured" verdict: an empty policies list. It is a
121
+ # real, actionable finding, not an empty report, so it gets its own row.
122
+ _NO_POLICY = (
123
+ "the gate ran with no policy configured, so it enforced nothing. A gate "
124
+ "that checked nothing has no pass to report. Give it at least one policy:\n"
125
+ " python3 tools/ci-gate.py <workspace> --max-usd <ceiling> "
126
+ "--require-receipt --json")
127
+
128
+
129
+ def _remedy(policy, state):
130
+ """The command for this policy, or a refusal. Never a guess."""
131
+ if state == "PASS":
132
+ return _PASS_REMEDY
133
+ return _REMEDY.get((policy, state), _NO_REMEDY)
134
+
135
+
136
+ def explain_row(row):
137
+ """One policy row as (policy, state, found, remedy). Never defaults PASS."""
138
+ if not isinstance(row, dict):
139
+ return ("?", "UNEVALUABLE", "malformed policy entry: %r" % (row,),
140
+ _NO_REMEDY)
141
+ policy = str(row.get("policy") or "?")
142
+ state = row.get("state")
143
+ state = state.upper() if isinstance(state, str) \
144
+ and state.upper() in _EXIT else None
145
+ reason = row.get("reason")
146
+ found = reason.strip() if isinstance(reason, str) and reason.strip() \
147
+ else "no detail reported"
148
+ if state is None:
149
+ # The input carried no verdict this file recognises. Filling that gap
150
+ # with PASS would be manufacturing evidence.
151
+ return (policy, "UNEVALUABLE",
152
+ "the gate reported no recognised state for this policy "
153
+ "(was %r), so it has not been checked" % (row.get("state"),),
154
+ _NO_REMEDY)
155
+ return (policy, state, found, _remedy(policy, state))
156
+
157
+
158
+ def explain_rows(verdict):
159
+ policies = verdict.get("policies")
160
+ rows = []
161
+ if isinstance(policies, list):
162
+ rows = [explain_row(r) for r in policies]
163
+ if rows:
164
+ return rows
165
+ # No policy results arrived. Whatever the header claims, nothing was
166
+ # checked -- so this is UNEVALUABLE unconditionally, and a top-level PASS
167
+ # here must not be believed.
168
+ return [("(none)", "UNEVALUABLE", _overall_reason(verdict), _NO_POLICY)]
169
+
170
+
171
+ def _overall_reason(verdict):
172
+ reason = verdict.get("reason")
173
+ if isinstance(reason, str) and reason.strip():
174
+ return reason.strip()
175
+ return "the gate reported no policy results and no reason"
176
+
177
+
178
+ def verdict_state(verdict):
179
+ """Weakest link over the header's claim and every row's evidence."""
180
+ claimed = verdict.get("state")
181
+ claimed = claimed.upper() if isinstance(claimed, str) \
182
+ and claimed.upper() in _EXIT else "UNEVALUABLE"
183
+ worst = max([_EXIT[claimed]] + [_EXIT[r[1]] for r in explain_rows(verdict)])
184
+ return _STATE[worst]
185
+
186
+
187
+ def exit_code(verdict):
188
+ """The input's own exit semantics, reconciled. A pipe drops them.
189
+
190
+ A header exit_code of 0 sitting above an UNEVALUABLE row is not a pass, so
191
+ the code is raised to match the explanation actually printed. The number a
192
+ CI job branches on can never disagree with the sentences a human just read.
193
+ """
194
+ code = verdict.get("exit_code")
195
+ if isinstance(code, bool) or not isinstance(code, int) \
196
+ or code not in _STATE:
197
+ code = UNEVALUABLE
198
+ return max(code, _EXIT[verdict_state(verdict)])
199
+
200
+
201
+ def render_text(verdict):
202
+ out = []
203
+ for policy, state, found, remedy in explain_rows(verdict):
204
+ out.append("POLICY: %s [%s]" % (policy, state))
205
+ out.append(" CHECKED: %s" % _MEANING[state])
206
+ out.append(" FOUND: %s" % _one_line(found))
207
+ out.append(" NEXT: %s" % _indent(remedy))
208
+ out.append("")
209
+ state = verdict_state(verdict)
210
+ out.append("GATE: %s -- %s" % (_headline(state), _overall_reason(verdict)))
211
+ return "\n".join(out)
212
+
213
+
214
+ def render_json(verdict):
215
+ state = verdict_state(verdict)
216
+ return json.dumps({
217
+ "state": state,
218
+ "exit_code": exit_code(verdict),
219
+ "reason": _overall_reason(verdict),
220
+ "policies": [
221
+ {"policy": p, "state": s, "checked": _MEANING[s],
222
+ "found": _one_line(f), "next_command": r}
223
+ for p, s, f, r in explain_rows(verdict)],
224
+ }, indent=2)
225
+
226
+
227
+ def _headline(state):
228
+ if state in ("PASS", "FAIL"):
229
+ return state
230
+ # Spelled out on the summary line too: a bare word gets skimmed, and this
231
+ # is the state a skimming reader most often files under "fine".
232
+ return "UNEVALUABLE (the gate could not check this -- not a pass)"
233
+
234
+
235
+ def _one_line(text):
236
+ return " ".join(text.split())
237
+
238
+
239
+ def _indent(text):
240
+ return text.replace("\n", "\n ")
241
+
242
+
243
+ class _Parser(argparse.ArgumentParser):
244
+ # argparse exits 2 on a usage error, and 2 means "could not be checked"
245
+ # here -- a mistyped flag would read as a blind gate. Overriding error()
246
+ # rather than parse_args() leaves --help exiting 0.
247
+ def error(self, message):
248
+ self.print_usage(sys.stderr)
249
+ print("%s: error: %s" % (self.prog, message), file=sys.stderr)
250
+ raise SystemExit(EXIT_USAGE)
251
+
252
+
253
+ def main(argv=None):
254
+ # No positional argument, deliberately: this reads a verdict on stdin, so a
255
+ # bare path on the command line is a mistake, and treating it as input
256
+ # would judge the wrong thing. It is rejected as a usage error.
257
+ ap = _Parser(
258
+ description="Explain a ci-gate verdict and give the next command.")
259
+ ap.add_argument("--json", action="store_true", dest="as_json",
260
+ help="emit the explanation as JSON")
261
+ args = ap.parse_args(argv)
262
+
263
+ # Parse args BEFORE touching stdin, so --help and a usage error answer
264
+ # immediately instead of blocking on a terminal that will never send EOF.
265
+ raw = sys.stdin.read()
266
+ if not raw.strip():
267
+ sys.stderr.write("gate-explain: empty stdin -- expected ci-gate JSON. "
268
+ "Nothing to explain is not a pass.\n")
269
+ return UNEVALUABLE
270
+ try:
271
+ verdict = json.loads(raw)
272
+ except ValueError as exc:
273
+ sys.stderr.write("gate-explain: could not parse the gate verdict: %s "
274
+ "-- unreadable input is an error, not a pass.\n" % exc)
275
+ return UNEVALUABLE
276
+ if not isinstance(verdict, dict):
277
+ sys.stderr.write("gate-explain: expected a JSON object from ci-gate, "
278
+ "got %s\n" % type(verdict).__name__)
279
+ return UNEVALUABLE
280
+
281
+ # Render fully before printing: a partial explanation reads as the whole
282
+ # verdict.
283
+ out = render_json(verdict) if args.as_json else render_text(verdict)
284
+ code = exit_code(verdict)
285
+ print(out)
286
+ return code
287
+
288
+
289
+ if __name__ == "__main__":
290
+ sys.exit(main())