alissa-tools-github-revloop 0.31.3__tar.gz → 0.31.5__tar.gz

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 (38) hide show
  1. {alissa_tools_github_revloop-0.31.3/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.5}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/alissa.py +97 -19
  3. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/loop.py +102 -18
  4. alissa_tools_github_revloop-0.31.5/src/main/alissa/tools/github/revloop/version +1 -0
  5. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_revloop-0.31.3/src/main/alissa/tools/github/revloop/version +0 -1
  7. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/LICENSE +0 -0
  8. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/MANIFEST.in +0 -0
  9. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/NOTICE +0 -0
  10. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/README.md +0 -0
  11. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/requirements.txt +0 -0
  12. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/setup.cfg +0 -0
  13. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/setup.py +0 -0
  14. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  15. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  16. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  17. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  18. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/config.py +0 -0
  19. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  20. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  21. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  22. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  23. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  24. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  25. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/state.py +0 -0
  26. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  27. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  31. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  32. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  33. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  34. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  36. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.3 → alissa_tools_github_revloop-0.31.5}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.3
3
+ Version: 0.31.5
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -239,7 +239,7 @@ _VERDICT_RE = re.compile(
239
239
  # The envelope's merge-readiness judgment (issue #130). The alissa-code-review
240
240
  # skill writes it as one list line in the verdict envelope:
241
241
  # - **Merge-Readiness:** auto
242
- # - **Merge-Readiness:** operator — touches convex/schema.ts
242
+ # - **Merge-Readiness:** operator — schema-migration: removes users.legacyId
243
243
  # Tolerant of the markdown around the label -- an optional bullet, optional
244
244
  # `**` bold around the label (with the colon inside or outside it), optional
245
245
  # bold around the value -- but NOT of the value's case: `Auto` is not a
@@ -255,21 +255,83 @@ _READINESS_RE = re.compile(
255
255
  re.MULTILINE,
256
256
  )
257
257
 
258
+ # The operator reason's CLASS (issue #142). An `operator` judgment names WHY
259
+ # the merge is a human's, and until now it did so in prose the merge edge
260
+ # could not act on: of 14 operator verdicts on ~40 studio PRs, 3 named real
261
+ # merge risk and 11 were "the PR body lists an unverified item" -- validation
262
+ # work the human does at gate 2 anyway, not a reason to withhold the merge.
263
+ # So the reason now LEADS with one token from this closed enum, in the
264
+ # consumer's grammar:
265
+ #
266
+ # Merge-Readiness: auto
267
+ # Merge-Readiness: operator — <class>: <one-line reason>
268
+ #
269
+ # The order below is the SEVERITY order: when more than one row applies, the
270
+ # reviewer names the one nearest the top (the most severe wins), and exactly
271
+ # one. `auto` never carries a class. An operator line whose reason does not
272
+ # lead with a recognised token is still valid grammar -- it parses, value and
273
+ # reason intact -- and reads as UNCLASSED (`klass=None`); the consumer treats
274
+ # unclassed as a hard hold (fail closed), so a reviewer that forgets the
275
+ # class loses throughput, never safety. The token is matched exactly as
276
+ # written here (lowercase, hyphenated): `Schema-Migration:` is not a class
277
+ # this daemon reads, for the same reason `Auto` is not a value it carries.
278
+ READINESS_CLASSES: "tuple[str, ...]" = (
279
+ "schema-migration", # schema shape changes needing a migration / removal / rename on live data
280
+ "data-backfill", # one-off writes over existing rows (backfills, stored-shape version bumps)
281
+ "secrets-env", # new / changed env vars, secrets, credentials the deploy must carry
282
+ "infra-deploy", # Dockerfiles, workflows, Railway config, base-image pins -- what runs
283
+ "billing", # credit charging, pricing, tiers, quotas
284
+ "security", # auth, permission gates, redaction, sandboxing flagged operator-worthy
285
+ "unverified-ux", # the PR body's operator gate is a human look at a screen / copy / mockup
286
+ "unverified-runtime", # the PR body's operator gate is a live smoke / real-model run / replay
287
+ "release-act", # the merge itself is a release (VERSION bump -> publish, tag, npm)
288
+ )
289
+ _READINESS_CLASS_RE = re.compile(
290
+ r"^(" + "|".join(re.escape(klass) for klass in READINESS_CLASSES) + r")[ \t]*:"
291
+ )
292
+
258
293
  # The reason lands in a GitHub review body (as a line-anchored trailer) and in
259
294
  # a log line, so it is bounded and flattened at the parser: one line, no
260
295
  # backticks (a fence would swallow the trailer and everything after it), and
261
- # no more than this many characters.
296
+ # no more than this many characters. The class prefix is PART of the reason
297
+ # and survives the flattening untouched: the native trailer must stay
298
+ # byte-equal to the envelope's line, class included.
262
299
  MAX_READINESS_REASON_CHARS = 200
263
300
 
264
301
 
265
302
  def clean_readiness_reason(text: object) -> str:
266
- """Flatten a Merge-Readiness reason to one bounded, backtick-free line."""
303
+ """Flatten a Merge-Readiness reason to one bounded, backtick-free line.
304
+
305
+ The `<class>:` prefix, when present, is kept intact at the head of the
306
+ line -- classify_readiness_reason reads it off the cleaned reason, and the
307
+ emitter copies the cleaned reason whole.
308
+ """
267
309
  if not isinstance(text, str):
268
310
  return ""
269
311
  flat = " ".join(text.replace("`", "").split())
270
312
  return flat[:MAX_READINESS_REASON_CHARS].rstrip()
271
313
 
272
314
 
315
+ def classify_readiness_reason(value: object, reason: object) -> "str | None":
316
+ """The READINESS_CLASSES token an `operator` reason leads with, or None.
317
+
318
+ None for `auto` (never classed), for an operator reason that leads with
319
+ no recognised token (UNCLASSED -- the consumer's hard hold), and for a
320
+ token in the wrong case or spelling. The reason itself is never altered:
321
+ the class is read off it, not cut out of it.
322
+ """
323
+ if value != READINESS_OPERATOR or not isinstance(reason, str):
324
+ return None
325
+ match = _READINESS_CLASS_RE.match(reason)
326
+ return match.group(1) if match else None
327
+
328
+
329
+ def _classed(value: "str | None", raw_reason: object) -> "tuple[str | None, str, str | None]":
330
+ """The `(value, reason, klass)` triple both parsers hand back."""
331
+ reason = clean_readiness_reason(raw_reason)
332
+ return (value, reason, classify_readiness_reason(value, reason))
333
+
334
+
273
335
  # The trailer's own grammar (issue #134) -- the bare-line sibling of
274
336
  # _READINESS_RE. This is the line a native review ENDS with, whoever wrote
275
337
  # it: the daemon's emitter (loop.readiness_trailer) when it posts the verdict
@@ -288,37 +350,42 @@ _TRAILER_RE = re.compile(
288
350
  )
289
351
 
290
352
 
291
- def parse_trailer(body: object) -> "tuple[str | None, str]":
292
- """`(value, reason)` from the first bare `Merge-Readiness:` line in a
293
- review body, or `(None, "")` when no line matches the trailer grammar.
353
+ def parse_trailer(body: object) -> "tuple[str | None, str, str | None]":
354
+ """`(value, reason, klass)` from the first bare `Merge-Readiness:` line in
355
+ a review body, or `(None, "", None)` when no line matches the trailer
356
+ grammar.
294
357
 
295
358
  Strict where parse_readiness is tolerant: the line inside backticks
296
359
  mid-sentence (studio #1258) and the envelope's `- **Merge-Readiness:**`
297
360
  bullet both read as MISSING here, because the consumer's regex is what
298
361
  decides whether the merge edge sees the judgment at all. CRLF bodies (a
299
362
  review typed into the web form) are normalised first; the grammar itself
300
- is unchanged.
363
+ is unchanged. `klass` is the READINESS_CLASSES token an operator reason
364
+ leads with, or None (auto, or an unclassed operator -- issue #142).
301
365
  """
302
366
  if not isinstance(body, str):
303
- return (None, "")
367
+ return (None, "", None)
304
368
  match = _TRAILER_RE.search(body.replace("\r\n", "\n"))
305
369
  if match is None:
306
- return (None, "")
307
- return (match.group(1), clean_readiness_reason(match.group(2)))
370
+ return (None, "", None)
371
+ return _classed(match.group(1), match.group(2))
308
372
 
309
373
 
310
- def parse_readiness(blob: object) -> "tuple[str | None, str]":
311
- """`(value, reason)` from the first Merge-Readiness line in `blob`.
374
+ def parse_readiness(blob: object) -> "tuple[str | None, str, str | None]":
375
+ """`(value, reason, klass)` from the first Merge-Readiness line in `blob`.
312
376
 
313
377
  `value` is READINESS_AUTO / READINESS_OPERATOR, or None when no line
314
- parses; `reason` is the cleaned trailing text (empty when there is none).
378
+ parses; `reason` is the cleaned trailing text (empty when there is none),
379
+ class prefix included; `klass` is the READINESS_CLASSES token that reason
380
+ leads with, or None when the value is `auto`, the reason is unclassed, or
381
+ nothing parsed (issue #142).
315
382
  """
316
383
  if not isinstance(blob, str):
317
- return (None, "")
384
+ return (None, "", None)
318
385
  match = _READINESS_RE.search(blob)
319
386
  if match is None:
320
- return (None, "")
321
- return (match.group(1), clean_readiness_reason(match.group(2)))
387
+ return (None, "", None)
388
+ return _classed(match.group(1), match.group(2))
322
389
 
323
390
 
324
391
  @dataclass(frozen=True)
@@ -580,7 +647,15 @@ class VerdictEnvelope:
580
647
  envelope has no parseable `Merge-Readiness` line -- the case the native
581
648
  post fails closed on. `readiness_reason` is already one bounded,
582
649
  backtick-free line (see clean_readiness_reason); empty when the envelope
583
- gave none.
650
+ gave none, and it keeps the class prefix an operator reason leads with
651
+ (issue #142) -- so the class is never lost between here and the native
652
+ trailer.
653
+
654
+ The class is deliberately NOT a field. It is a function of the reason
655
+ (classify_readiness_reason), the emitter copies the reason whole, and
656
+ the narration reads the class back off the emitted line -- so the fact
657
+ has one source, and a second copy here would be a reader-less field
658
+ that could only ever drift from it (PR #143 round 1).
584
659
  """
585
660
 
586
661
  verdict: str
@@ -1086,9 +1161,12 @@ class Alissa:
1086
1161
  continue
1087
1162
  match = _VERDICT_RE.search(blob)
1088
1163
  if match:
1089
- readiness, reason = parse_readiness(content)
1164
+ # The class the triple carries is not stored: the
1165
+ # reason keeps its prefix, and the envelope's readers
1166
+ # re-derive the class from the line they emit.
1167
+ readiness, reason, _ = parse_readiness(content)
1090
1168
  if readiness is None:
1091
- readiness, reason = parse_readiness(title)
1169
+ readiness, reason, _ = parse_readiness(title)
1092
1170
  found.append(
1093
1171
  (Alissa._created_key(item.get("createdAt")),
1094
1172
  index,
@@ -427,13 +427,21 @@ NATIVE_VERDICT_BODY = (
427
427
  # the last non-empty line before the hidden verdict marker --
428
428
  #
429
429
  # Merge-Readiness: auto
430
- # Merge-Readiness: operator — <one-line reason>
430
+ # Merge-Readiness: operator — <class>: <one-line reason>
431
431
  #
432
432
  # The rules that make it safe to consume: it is emitted on APPROVE events
433
433
  # ONLY (a request_changes envelope with a stray `auto` line carries nothing;
434
434
  # an approve the checks gate downgraded carries nothing), and an envelope
435
435
  # with no parseable line fails CLOSED to `operator`, with a reason that tells
436
- # the operator why the merge waited on them.
436
+ # the operator why the merge waited on them -- and NO class (issue #142): the
437
+ # fallback is unclassed by construction, the consumer's hard hold.
438
+ #
439
+ # The operator reason leads with one token from alissa.READINESS_CLASSES
440
+ # (issue #142) so the consumer can apply a POLICY to the hold instead of
441
+ # holding every operator alike. The emitter copies the envelope's cleaned
442
+ # reason whole, class prefix included, so the native line is byte-equal to
443
+ # the envelope's; it never adds, drops or rewrites a class -- an unclassed
444
+ # envelope posts unclassed, and the narration says so.
437
445
  #
438
446
  # The label and the grammar live in alissa.py (READINESS_TRAILER_LABEL,
439
447
  # parse_trailer): the emitter below builds from the label the regex is built
@@ -441,6 +449,21 @@ NATIVE_VERDICT_BODY = (
441
449
  # same regex (issue #134) -- one grammar, two writers, no drift.
442
450
  READINESS_MISSING_REASON = "envelope carries no Merge-Readiness line"
443
451
 
452
+ # What the log line and the activity row say about an operator judgment whose
453
+ # reason leads with no recognised class (issue #142). Named because it is a
454
+ # state the merge policy acts on (hard hold), not an absence.
455
+ READINESS_UNCLASSED = "unclassed"
456
+
457
+
458
+ def readiness_class_term(value: "str | None", klass: "str | None") -> str:
459
+ """` class=<klass>` (or ` class=unclassed`) for an operator judgment, and
460
+ nothing at all for `auto` or a missing one -- `auto` never carries a
461
+ class, so naming one there would be noise the consumer must never read.
462
+ """
463
+ if value != READINESS_OPERATOR:
464
+ return ""
465
+ return f" class={klass or READINESS_UNCLASSED}"
466
+
444
467
 
445
468
  def readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
446
469
  """The line the native APPROVE carries for `envelope`'s judgment.
@@ -449,7 +472,9 @@ def readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
449
472
  the envelope onto the grammar. `auto` is emitted bare -- the consumer
450
473
  keys on the value, and an operator reads a reason only when the merge is
451
474
  theirs to make. A missing or unparseable judgment (None envelope, or one
452
- whose readiness did not parse) is `operator` with a reason saying so.
475
+ whose readiness did not parse) is `operator` with a reason saying so and
476
+ no class. The reason is copied whole, so an envelope's `<class>:` prefix
477
+ reaches the native line byte for byte (issue #142).
453
478
  """
454
479
  if envelope is not None and envelope.readiness == READINESS_AUTO:
455
480
  return f"{READINESS_TRAILER_LABEL} {READINESS_AUTO}"
@@ -679,18 +704,69 @@ _RECORD_THE_CAP = (
679
704
  # envelope without the line posts as `operator` -- so a reviewer who skips it
680
705
  # has silently withheld auto-merge. The two grammar sentences are the
681
706
  # README's ("The `Merge-Readiness` trailer on a native approve"), verbatim.
707
+ #
708
+ # The operator form carries a CLASS (issue #142): `operator — <class>:
709
+ # <reason>`, one token from alissa.READINESS_CLASSES, so the merge edge can
710
+ # apply a policy to the hold. The compact table below is the ONE place the
711
+ # directive states the enum (each token exactly once -- test_readiness pins
712
+ # it), in severity order with the most-severe rule and the reasoning the
713
+ # reviewer needs to classify honestly: an unverified-* hold is validation
714
+ # work, not merge risk, and saying so lets the policy merge it. The table's
715
+ # rows are the README's, condensed; the README is the long form.
716
+ #
717
+ # The table is closed, so the directive must also say what to do when NO
718
+ # row fits (PR #143 round 1): the skill's checklist has operator triggers
719
+ # the enum does not name (dependencies, a deleted public surface, a waived
720
+ # `[major]`, a judgment residual, an operator gate that is a human action).
721
+ # A reviewer told the class is mandatory would shoehorn such a reason into
722
+ # the nearest row, and the nearest row for a vague reason is one of the
723
+ # `unverified-*` pair -- exactly what the policy may merge unattended. So
724
+ # the directive names the way out: no row, no class, and unclassed is the
725
+ # correct hard hold, never a failure.
682
726
  _MERGE_READINESS_LINE = (
683
727
  "Your verdict envelope MUST carry the skill's `- **Merge-Readiness:** auto | "
684
- "operator — <reason>` line, AND your OWN native review (every `gh pr review` "
685
- "form and the reviews-API POST alike) MUST END with the bare line "
686
- "`Merge-Readiness: auto` or `Merge-Readiness: operator — <one-line reason>` "
687
- "as its last non-empty line — plain text at the start of the line, not in "
688
- "backticks, not bold, not mid-sentence — byte-equal in value and reason to "
689
- "the envelope's line; `auto` only on an APPROVE of the reviewed head. The "
690
- "merge edge reads that line off the review and holds an approve without "
691
- "it. The daemon copies the envelope's line onto a native review only when "
692
- "it posts the verdict itself, and an envelope without the line posts as "
693
- "`operator`. "
728
+ "operator — <class>: <reason>` line, AND your OWN native review (every `gh pr "
729
+ "review` form and the reviews-API POST alike) MUST END with the bare line "
730
+ "`Merge-Readiness: auto` or `Merge-Readiness: operator — <class>: <one-line "
731
+ "reason>` as its last non-empty line — plain text at the start of the line, "
732
+ "not in backticks, not bold, not mid-sentence — byte-equal in value, class "
733
+ "and reason to the envelope's line; `auto` only on an APPROVE of the "
734
+ "reviewed head. `<class>` is exactly ONE token from this closed enum, "
735
+ "lowercase and hyphenated, followed by a colon and the reason; the rows are "
736
+ "in severity order, most severe first: "
737
+ "`schema-migration` (schema shape changes that need a migration, removal or "
738
+ "rename on live data); "
739
+ "`data-backfill` (one-off writes over existing rows — backfills, version "
740
+ "bumps of stored shapes); "
741
+ "`secrets-env` (new or changed env vars, secrets or credentials the deploy "
742
+ "must carry); "
743
+ "`infra-deploy` (Dockerfiles, workflows, Railway config, base-image pins — "
744
+ "anything that changes what runs); "
745
+ "`billing` (credit charging, pricing, tiers, quotas); "
746
+ "`security` (auth, permission gates, redaction, sandboxing — anything the "
747
+ "rubric's third dimension flagged as operator-worthy); "
748
+ "`unverified-ux` (the PR body's operator gate is a human look at a screen, "
749
+ "copy or mockup); "
750
+ "`unverified-runtime` (the PR body's operator gate is a live smoke, "
751
+ "real-model run or replay the worker could not run); "
752
+ "`release-act` (the merge itself is a release — VERSION bump → publish, tag, "
753
+ "npm). "
754
+ "When more than one row applies, name the MOST SEVERE one — the row listed "
755
+ "first — and only it. If NO row fits — a dependency change, a deleted or "
756
+ "renamed public surface, a waived `[major]`, a judgment residual, an "
757
+ "operator gate that is a human ACTION rather than a look or a smoke run — "
758
+ "write the reason with NO class: an unclassed operator line is the correct "
759
+ "hard hold, never a failure, and shoehorning such a reason into the nearest "
760
+ "row is the one misclassification the merge policy cannot recover from. "
761
+ "Classify honestly, not defensively: an unverified-* "
762
+ "hold is validation work the human does at gate 2 anyway, not merge risk, "
763
+ "and the merge policy may merge it while it keeps holding every row above; "
764
+ "an operator line whose reason leads with no recognised token still parses "
765
+ "but reads as UNCLASSED — a hard hold that costs throughput, never safety. "
766
+ "`auto` never carries a class. The merge edge reads that line off the "
767
+ "review and holds an approve without it. The daemon copies the envelope's "
768
+ "line onto a native review only when it posts the verdict itself, and an "
769
+ "envelope without the line posts as `operator` with no class. "
694
770
  )
695
771
 
696
772
  # -- the reviewer session's own CI gate (issue #84) ---------------------------
@@ -3732,9 +3808,14 @@ class ReviewWatcher:
3732
3808
  pr.full_name, pr.number, judged, int(time.time()), url or ""
3733
3809
  )
3734
3810
  # The readiness carried on the post, in the same three records. Only
3735
- # an APPROVE carries one, so only an APPROVE names it.
3811
+ # an APPROVE carries one, so only an APPROVE names it. The class is
3812
+ # read BACK off the emitted line with the shared grammar (issue
3813
+ # #142), so what the narration names is what the consumer will parse
3814
+ # -- an unclassed operator says `class=unclassed`, the hard hold.
3736
3815
  readiness_value = trailer[len(READINESS_TRAILER_LABEL):].strip()
3737
- readiness_note = f" readiness={readiness_value}" if trailer else ""
3816
+ posted_value, _, posted_class = parse_trailer(trailer)
3817
+ class_term = readiness_class_term(posted_value, posted_class)
3818
+ readiness_note = f" readiness={readiness_value}{class_term}" if trailer else ""
3738
3819
  log.info(
3739
3820
  "%s round %d closed: native %s review submitted as %s (%s)%s%s",
3740
3821
  pr.slug, round_, event, self.github.login, url or "no url",
@@ -3744,7 +3825,7 @@ class ReviewWatcher:
3744
3825
  pr,
3745
3826
  f"- {_now()} — round {round_} — native `{event}` review submitted "
3746
3827
  f"as `{self.github.login}` (verdict of record){gate_note}"
3747
- + (f" — merge-readiness: `{readiness_value}`" if trailer else ""),
3828
+ + (f" — merge-readiness: `{readiness_value}`{class_term}" if trailer else ""),
3748
3829
  )
3749
3830
  if event == EVENT_COMMENT:
3750
3831
  # A degraded verdict takes the PR out of the loop (the review
@@ -4458,7 +4539,7 @@ class ReviewWatcher:
4458
4539
  if self.state.readiness_observed(pr.full_name, pr.number, pr.head_sha) is not None:
4459
4540
  return
4460
4541
 
4461
- value, reason = parse_trailer(newest.body)
4542
+ value, reason, klass = parse_trailer(newest.body)
4462
4543
  head7 = pr.head_sha[:7]
4463
4544
  if value is None:
4464
4545
  readiness = READINESS_MISSING
@@ -4476,7 +4557,10 @@ class ReviewWatcher:
4476
4557
  )
4477
4558
  else:
4478
4559
  readiness = value
4479
- term = f"{value} — {reason}" if reason else value
4560
+ # The class rides along (issue #142): `class=<token>` when the
4561
+ # operator reason leads with one, `class=unclassed` when it does
4562
+ # not, nothing for `auto`.
4563
+ term = (f"{value} — {reason}" if reason else value) + readiness_class_term(value, klass)
4480
4564
  log.info(
4481
4565
  "%s approve at %s by %s carries readiness=%s",
4482
4566
  pr.slug, head7, self.github.login, term,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.3
3
+ Version: 0.31.5
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera