alissa-tools-github-revloop 0.31.0__tar.gz → 0.31.2__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 (37) hide show
  1. {alissa_tools_github_revloop-0.31.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.2}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/alissa.py +57 -0
  3. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/loop.py +415 -19
  4. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/state.py +57 -1
  5. alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/trust.py +473 -0
  6. alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/version +1 -0
  7. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  8. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  9. alissa_tools_github_revloop-0.31.0/src/main/alissa/tools/github/revloop/version +0 -1
  10. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/LICENSE +0 -0
  11. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/MANIFEST.in +0 -0
  12. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/NOTICE +0 -0
  13. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/README.md +0 -0
  14. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/requirements.txt +0 -0
  15. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/setup.cfg +0 -0
  16. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/setup.py +0 -0
  17. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  18. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  19. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  20. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  21. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/config.py +0 -0
  22. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  23. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  24. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  25. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  26. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  27. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  31. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  32. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  33. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  34. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  36. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.2}/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.0
3
+ Version: 0.31.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -267,6 +267,43 @@ def clean_readiness_reason(text: object) -> str:
267
267
  return flat[:MAX_READINESS_REASON_CHARS].rstrip()
268
268
 
269
269
 
270
+ # The trailer's own grammar (issue #134) -- the bare-line sibling of
271
+ # _READINESS_RE. This is the line a native review ENDS with, whoever wrote
272
+ # it: the daemon's emitter (loop.readiness_trailer) when it posts the verdict
273
+ # itself, or the reviewer session's own `gh pr review` on the normal path.
274
+ # It is the consumer's grammar verbatim (README, "The `Merge-Readiness`
275
+ # trailer"): line-anchored, no bullet, no bold, no backticks, first match
276
+ # wins, value case-sensitive. The label is the ONE constant the emitter
277
+ # builds from, so the two cannot drift: a line the emitter writes is, by
278
+ # construction, a line this regex reads.
279
+ READINESS_TRAILER_LABEL = "Merge-Readiness:"
280
+ READINESS_MISSING = "missing"
281
+ _TRAILER_RE = re.compile(
282
+ r"^" + re.escape(READINESS_TRAILER_LABEL)
283
+ + r"[ \t]*(auto|operator)(?:[ \t]*[—-][ \t]*(.+))?[ \t]*$",
284
+ re.MULTILINE,
285
+ )
286
+
287
+
288
+ def parse_trailer(body: object) -> "tuple[str | None, str]":
289
+ """`(value, reason)` from the first bare `Merge-Readiness:` line in a
290
+ review body, or `(None, "")` when no line matches the trailer grammar.
291
+
292
+ Strict where parse_readiness is tolerant: the line inside backticks
293
+ mid-sentence (studio #1258) and the envelope's `- **Merge-Readiness:**`
294
+ bullet both read as MISSING here, because the consumer's regex is what
295
+ decides whether the merge edge sees the judgment at all. CRLF bodies (a
296
+ review typed into the web form) are normalised first; the grammar itself
297
+ is unchanged.
298
+ """
299
+ if not isinstance(body, str):
300
+ return (None, "")
301
+ match = _TRAILER_RE.search(body.replace("\r\n", "\n"))
302
+ if match is None:
303
+ return (None, "")
304
+ return (match.group(1), clean_readiness_reason(match.group(2)))
305
+
306
+
270
307
  def parse_readiness(blob: object) -> "tuple[str | None, str]":
271
308
  """`(value, reason)` from the first Merge-Readiness line in `blob`.
272
309
 
@@ -1104,6 +1141,26 @@ class Alissa:
1104
1141
  """
1105
1142
  run(["alissa", "tmux", "kill", session], timeout=30, check=False)
1106
1143
 
1144
+ def tail_session(self, session: str, lines: int) -> str:
1145
+ """The last `lines` of ONE session's terminal, via `alissa tmux tail`.
1146
+
1147
+ The stale-round probe's evidence seam (issue #136): it asks whether a
1148
+ session that reads alive is parked on Claude Code's first-run dialog.
1149
+ Read-only, and best-effort by contract -- a CLI that cannot capture
1150
+ the pane (the session just died, no tmux server, a timeout) answers
1151
+ the EMPTY string, which the classifier treats as "no evidence", never
1152
+ as the dialog: absence of a capture keeps the existing defer.
1153
+ """
1154
+ try:
1155
+ return run(
1156
+ ["alissa", "tmux", "tail", "-n", str(lines), session],
1157
+ timeout=30,
1158
+ check=False,
1159
+ )
1160
+ except CommandError as exc:
1161
+ log.debug("could not tail %s: %s", session, exc)
1162
+ return ""
1163
+
1107
1164
  def add_repo_to_workspace(
1108
1165
  self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
1109
1166
  ) -> None:
@@ -23,7 +23,9 @@ from pathlib import Path
23
23
 
24
24
  from .alissa import (
25
25
  READINESS_AUTO,
26
+ READINESS_MISSING,
26
27
  READINESS_OPERATOR,
28
+ READINESS_TRAILER_LABEL,
27
29
  VERDICT_APPROVE,
28
30
  VERDICT_REQUEST_CHANGES,
29
31
  VerdictEnvelope,
@@ -33,10 +35,20 @@ from .alissa import (
33
35
  Task,
34
36
  TaskDetail,
35
37
  is_review_task_for,
38
+ parse_trailer,
36
39
  session_repo_slug,
37
40
  )
38
41
  from .alissa_client import AlissaClient
39
42
  from .bows import EMPTY_SET_WARNING, FEED_PREFIX, BowRepoSource
43
+ from .trust import (
44
+ FIRST_RUN_DIALOG_MARKERS,
45
+ WEDGE_FIRST_RUN_DIALOG,
46
+ hub_root as hub_root_of,
47
+ hub_trust_paths,
48
+ pane_shows_first_run_dialog,
49
+ seed_trust,
50
+ write_derived_repos,
51
+ )
40
52
  from .config import (
41
53
  HUB_ADD,
42
54
  REPOS_BOWS,
@@ -413,7 +425,11 @@ NATIVE_VERDICT_BODY = (
413
425
  # an approve the checks gate downgraded carries nothing), and an envelope
414
426
  # with no parseable line fails CLOSED to `operator`, with a reason that tells
415
427
  # the operator why the merge waited on them.
416
- READINESS_TRAILER_LABEL = "Merge-Readiness:"
428
+ #
429
+ # The label and the grammar live in alissa.py (READINESS_TRAILER_LABEL,
430
+ # parse_trailer): the emitter below builds from the label the regex is built
431
+ # from, and _observe_session_readiness reads a session's own review with that
432
+ # same regex (issue #134) -- one grammar, two writers, no drift.
417
433
  READINESS_MISSING_REASON = "envelope carries no Merge-Readiness line"
418
434
 
419
435
 
@@ -644,15 +660,28 @@ _RECORD_THE_CAP = (
644
660
  "from a stale template default. "
645
661
  )
646
662
 
647
- # The merge-readiness line (issue #130). The verdict envelope is where the
648
- # reviewer's judgment lives; the daemon copies it onto the native review as a
649
- # trailer the merge edge reads, and an envelope without the line posts as
650
- # `operator` -- so a reviewer who skips it has silently withheld auto-merge.
663
+ # The merge-readiness line (issue #130, #134). The verdict envelope is where
664
+ # the reviewer's judgment lives. On the NORMAL path the session posts its own
665
+ # native review, and that body passes through nothing -- so the session must
666
+ # write the trailer itself, as the bare last non-empty line, in the consumer's
667
+ # grammar (studio #1258 wrote it inside backticks mid-sentence and the merge
668
+ # edge held the approve). The daemon copies the envelope's line onto a native
669
+ # review only on the rounds where it posts the verdict itself, and an
670
+ # envelope without the line posts as `operator` -- so a reviewer who skips it
671
+ # has silently withheld auto-merge. The two grammar sentences are the
672
+ # README's ("The `Merge-Readiness` trailer on a native approve"), verbatim.
651
673
  _MERGE_READINESS_LINE = (
652
674
  "Your verdict envelope MUST carry the skill's `- **Merge-Readiness:** auto | "
653
- "operator — <reason>` line the daemon copies it onto the native review as "
654
- "the `Merge-Readiness:` trailer the merge edge reads, and an envelope "
655
- "without the line posts as `operator`. "
675
+ "operator — <reason>` line, AND your OWN native review (every `gh pr review` "
676
+ "form and the reviews-API POST alike) MUST END with the bare line "
677
+ "`Merge-Readiness: auto` or `Merge-Readiness: operator — <one-line reason>` "
678
+ "as its last non-empty line — plain text at the start of the line, not in "
679
+ "backticks, not bold, not mid-sentence — byte-equal in value and reason to "
680
+ "the envelope's line; `auto` only on an APPROVE of the reviewed head. The "
681
+ "merge edge reads that line off the review and holds an approve without "
682
+ "it. The daemon copies the envelope's line onto a native review only when "
683
+ "it posts the verdict itself, and an envelope without the line posts as "
684
+ "`operator`. "
656
685
  )
657
686
 
658
687
  # -- the reviewer session's own CI gate (issue #84) ---------------------------
@@ -1220,6 +1249,39 @@ def stalled_kind(session: str) -> str:
1220
1249
  return f"{ESCALATION_STALLED}:{session}"
1221
1250
 
1222
1251
 
1252
+ # The first-run-dialog wedge's ping-ledger kind prefix (issue #136; see
1253
+ # ReviewWatcher._wedged_on_first_run_dialog). Like the stalled ping it recurs
1254
+ # per EPISODE, and the episode identity is the session name: the daemon
1255
+ # killed a session whose pane sat on one of Claude Code's first-run gates,
1256
+ # seeded the trust the gate was asking for, and re-queued the round itself.
1257
+ # The row dedupes the WARNING (one per episode -- a kill that fails and is
1258
+ # retried next poll logs its retry at INFO) and, under its own kind, the
1259
+ # activity-comment line.
1260
+ ESCALATION_FIRST_RUN_DIALOG = "first-run-dialog"
1261
+
1262
+ # How many pane lines the first-run-dialog check reads. The trust dialog is a
1263
+ # ~12-line box and the bypass-permissions gate ~20; a session sitting on
1264
+ # either has printed nothing since, so the last 40 lines hold the whole
1265
+ # prompt with room for the banner above it.
1266
+ FIRST_RUN_PANE_TAIL_LINES = 40
1267
+
1268
+
1269
+ def first_run_dialog_kind(session: str) -> str:
1270
+ """The ping-ledger kind that dedupes ONE first-run-dialog wedge episode's
1271
+ WARNING. stalled_kind's episode reasoning: every spawn of every round can
1272
+ meet the dialog (a hub trusted after session k was killed is trusted for
1273
+ session k+1, but a seeding that failed to land leaves the next session
1274
+ on the same prompt), and episode k's row must not silence episode
1275
+ k+1's. The session name is nonce-unique per spawn, so it is the key."""
1276
+ return f"{ESCALATION_FIRST_RUN_DIALOG}:{session}"
1277
+
1278
+
1279
+ def first_run_dialog_activity_kind(session: str) -> str:
1280
+ """The ping-ledger kind that dedupes the episode's activity-comment line;
1281
+ recorded only after the append lands, so a transient failure retries."""
1282
+ return f"activity-{ESCALATION_FIRST_RUN_DIALOG}:{session}"
1283
+
1284
+
1223
1285
  def capout_kind(head_sha: str, granted: int) -> str:
1224
1286
  """The ping-ledger kind that dedupes ONE cap-out page.
1225
1287
 
@@ -1795,6 +1857,13 @@ class ReviewWatcher:
1795
1857
  # has listed this pass (the preflight refresh, or a sweep whose list
1796
1858
  # failed), and the refresh lists for itself.
1797
1859
  self._pass_roster: list[ManagedSession] | None = None
1860
+ # The (repo, number, round) whose alive session the stale-round probe
1861
+ # just killed as `wedged:first-run-dialog` (issue #136), so the
1862
+ # respawn site logs its re-enqueue at INFO instead of a second
1863
+ # WARNING: the classifier's WARNING already said everything the
1864
+ # "presumed dead" line would, and the issue's contract is ONE.
1865
+ # Consumed by the respawn that follows in the same evaluate().
1866
+ self._dialog_wedge_cleared: "tuple[str, int, int] | None" = None
1798
1867
  # (repo full name, PR number) -> when the reviewer login was most
1799
1868
  # recently asked to review that PR, per the issue timeline (an ISO
1800
1869
  # stamp, or None for never) -- the round-admission gate's one GitHub
@@ -1934,6 +2003,39 @@ class ReviewWatcher:
1934
2003
  # Telemetry-class: the console's window onto the derived set.
1935
2004
  # Best-effort inside State, like every other telemetry write.
1936
2005
  self.state.record_derived_repos(source.sources())
2006
+ self._record_derived(source.derived)
2007
+
2008
+ def _record_derived(self, derived: "tuple[str, ...]") -> None:
2009
+ """Issue #136: make the derived allowlist visible to the entrypoint's
2010
+ boot-time trust seeding (`{root}/.alissa-derived-repos`) and trust
2011
+ the derived hubs NOW -- hub-ified or not, exactly as the entrypoint
2012
+ trusts a static allowlist's hubs before they exist. Both are
2013
+ best-effort and idempotent; dry-run writes nothing. Called after
2014
+ every refresh, so a retained set (a refresh that failed keeps the
2015
+ last derived set) is re-asserted too, at no cost when nothing
2016
+ changed."""
2017
+ if not derived:
2018
+ return
2019
+ if self.config.dry_run:
2020
+ log.info(
2021
+ "[dry-run] would record %d derived repo(s) and pre-trust "
2022
+ "their hubs for claude", len(derived),
2023
+ )
2024
+ return
2025
+ write_derived_repos(self.config.workspace_root, derived)
2026
+ paths: "list[Path]" = []
2027
+ for full_name in derived:
2028
+ owner, _, repo = full_name.partition("/")
2029
+ if not owner or not repo:
2030
+ continue
2031
+ paths.extend(hub_trust_paths(self.config.hub_for(owner, repo)))
2032
+ changed = seed_trust(paths)
2033
+ if changed:
2034
+ log.info(
2035
+ "repos_source=bows: pre-trusted the hubs of %d derived "
2036
+ "repo(s) for claude (root and main/ each) in %s",
2037
+ len(derived), ", ".join(str(t) for t in changed),
2038
+ )
1937
2039
 
1938
2040
  def _search_allowlist(self) -> "tuple[str, ...] | None":
1939
2041
  """The allowlist the review-requested search runs over, or None when
@@ -2191,6 +2293,14 @@ class ReviewWatcher:
2191
2293
  # "that round has no native verdict", producing a duplicate post over a
2192
2294
  # session that closed its own round correctly.
2193
2295
  owed = completed - self.state.abandoned_rounds(pr.full_name, number)
2296
+
2297
+ # Observation only (issue #134): a session-posted APPROVE on the
2298
+ # current head is looked at once for the Merge-Readiness trailer the
2299
+ # merge edge reads. Above the native post and convergence because it
2300
+ # decides nothing -- it reports; and it must run on the pass that
2301
+ # converges, since a converged PR leaves the search set.
2302
+ self._observe_session_readiness(pr, my_reviews, completed)
2303
+
2194
2304
  if task is not None and owed > native:
2195
2305
  # Terminal for this pass either way. On a landed post the review
2196
2306
  # request it consumed drops the PR out of the search, so
@@ -2343,14 +2453,28 @@ class ReviewWatcher:
2343
2453
  if age is not None:
2344
2454
  # Logged only once the gate has let the respawn through, so the
2345
2455
  # line cannot claim a re-enqueue that back-pressure then deferred.
2346
- log.warning(
2347
- "%s round %d has been in flight %.0f min with no submitted review "
2348
- "and its session is gone or finished re-enqueuing (reviewer "
2349
- "session presumed dead)",
2350
- pr.slug,
2351
- round_,
2352
- age / 60,
2353
- )
2456
+ # A round whose session the probe killed as wedged on the
2457
+ # first-run dialog was already WARNED once by the classifier, so
2458
+ # its re-enqueue is INFO (issue #136: one WARNING per episode).
2459
+ wedged = self._dialog_wedge_cleared == (pr.full_name, number, round_)
2460
+ self._dialog_wedge_cleared = None
2461
+ if wedged:
2462
+ log.info(
2463
+ "%s round %d re-enqueuing after its session was killed "
2464
+ "(%s)",
2465
+ pr.slug,
2466
+ round_,
2467
+ WEDGE_FIRST_RUN_DIALOG,
2468
+ )
2469
+ else:
2470
+ log.warning(
2471
+ "%s round %d has been in flight %.0f min with no submitted "
2472
+ "review and its session is gone or finished — re-enqueuing "
2473
+ "(reviewer session presumed dead)",
2474
+ pr.slug,
2475
+ round_,
2476
+ age / 60,
2477
+ )
2354
2478
 
2355
2479
  return self._spawn(
2356
2480
  pr,
@@ -4013,6 +4137,22 @@ class ReviewWatcher:
4013
4137
  if ses.is_idle and quiet_for >= self.config.reap_grace_seconds:
4014
4138
  return None # idle-finished: it died without submitting -> respawn
4015
4139
 
4140
+ # Issue #136: alive is a process, not a reviewer. A session sitting
4141
+ # on Claude Code's first-run "trust this folder?" (or the
4142
+ # bypass-permissions) gate is listed, registers activity, and will
4143
+ # never submit anything -- the directive typed into the pane was
4144
+ # swallowed by the prompt. Only here, on a SUCCESSFUL listing that
4145
+ # names the row, and only when the pane SHOWS the gate: every other
4146
+ # alive-but-idle case keeps the floored defer below, exactly as
4147
+ # before. A match has been killed and its hub seeded, so the round
4148
+ # re-queues NOW as the dead-session path would (same attempt/round
4149
+ # accounting: `reenqueued`, the `stale_reenqueued` bucket).
4150
+ if self._wedged_on_first_run_dialog(
4151
+ pr, round_, session, str(row["task_ref"] or "") or None, cap
4152
+ ):
4153
+ self._dialog_wedge_cleared = (pr.full_name, pr.number, round_)
4154
+ return None
4155
+
4016
4156
  if (
4017
4157
  age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
4018
4158
  and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
@@ -4042,6 +4182,95 @@ class ReviewWatcher:
4042
4182
  deferred=True,
4043
4183
  )
4044
4184
 
4185
+ def _observe_session_readiness(
4186
+ self, pr: PullRequest, my_reviews: list[Review], round_: int
4187
+ ) -> None:
4188
+ """Say whether the session's own APPROVE carries the trailer (issue #134).
4189
+
4190
+ The merge edge reads `Merge-Readiness:` off the reviewer identity's
4191
+ APPROVE on the current head. On the normal path that review is the
4192
+ SESSION's -- posted by its own `gh pr review`, passing through nothing
4193
+ here -- so a body without the bare line (studio #1258 had it inside
4194
+ backticks mid-sentence) is an approve the edge holds, silently. The
4195
+ fix is the skill's and the directive's; this is the daemon SEEING it:
4196
+ one WARNING and one activity row per (PR, head) when the trailer is
4197
+ missing, the `readiness=auto|operator` term when it parses.
4198
+
4199
+ Observation only, by contract: never a review posted, never the
4200
+ session's review edited, never a round re-run. Scoped to the newest
4201
+ reviewer-identity APPROVED review on the current head -- the review the
4202
+ merge edge reads, whatever the identity wrote after it (a round-k
4203
+ reviewer that fell back to `--comment`, a follow-up write-up) -- and
4204
+ NOT the daemon's own post: that one is emitter-built and already
4205
+ reported `readiness=` at post time (a second row for it would be
4206
+ noise). Once per (PR, head) via the ledger flag beside the verdicts
4207
+ row: a new head re-arms, a re-poll of the same head is silent.
4208
+
4209
+ Emit, then record, like every other activity note here: the ledger
4210
+ flag lands only after the activity row did, so a transient comment
4211
+ failure retries next poll instead of losing the row; and never under
4212
+ `--dry-run`, where the row cannot land and a durable flag would let a
4213
+ diagnostic pass silence the daemon it was run to diagnose (see
4214
+ `_warn_identity_drift`). The flag rides a verdict row that
4215
+ `last_verdict_at` also reads, so it is stamped only with the review's
4216
+ own time -- an unreadable GitHub stamp keeps the log line and skips
4217
+ both the row and the flag rather than inventing a verdict at "now".
4218
+ """
4219
+ approves = [
4220
+ r for r in my_reviews
4221
+ if r.state == "APPROVED" and r.commit_id and r.commit_id == pr.head_sha
4222
+ ]
4223
+ newest = approves[-1] if approves else None
4224
+ if newest is None or newest.verdict_round is not None:
4225
+ return
4226
+ if self.state.readiness_observed(pr.full_name, pr.number, pr.head_sha) is not None:
4227
+ return
4228
+
4229
+ value, reason = parse_trailer(newest.body)
4230
+ head7 = pr.head_sha[:7]
4231
+ if value is None:
4232
+ readiness = READINESS_MISSING
4233
+ log.warning(
4234
+ "%s approve at %s by %s carries no Merge-Readiness trailer — the "
4235
+ "merge edge will hold it; the session must end its review body "
4236
+ "with the line (see directive)",
4237
+ pr.slug, head7, self.github.login,
4238
+ )
4239
+ line = (
4240
+ f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
4241
+ f"`{self.github.login}` at `{head7}` — readiness=missing (no "
4242
+ f"`{READINESS_TRAILER_LABEL}` trailer on the review body; the merge "
4243
+ f"edge will hold it)"
4244
+ )
4245
+ else:
4246
+ readiness = value
4247
+ term = f"{value} — {reason}" if reason else value
4248
+ log.info(
4249
+ "%s approve at %s by %s carries readiness=%s",
4250
+ pr.slug, head7, self.github.login, term,
4251
+ )
4252
+ line = (
4253
+ f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
4254
+ f"`{self.github.login}` at `{head7}` — readiness={term}"
4255
+ )
4256
+
4257
+ posted_at = _epoch(newest.submitted_at)
4258
+ if posted_at is None:
4259
+ # No stamp to key a verdict row on; the log line above is the
4260
+ # whole report for this poll, and the next poll says it again.
4261
+ log.debug(
4262
+ "%s: approve at %s has no readable submitted_at; readiness row "
4263
+ "and flag skipped", pr.slug, head7,
4264
+ )
4265
+ return
4266
+ if not self._append_activity(pr, line):
4267
+ return # retried next poll, like every other activity note
4268
+ if self.config.dry_run:
4269
+ return
4270
+ self.state.note_readiness(
4271
+ pr.full_name, pr.number, pr.head_sha, int(posted_at), readiness, newest.url,
4272
+ )
4273
+
4045
4274
  def _convergence_reason(
4046
4275
  self, my_reviews: list[Review], resolved: "ResolvedTask", head_sha: str
4047
4276
  ) -> str | None:
@@ -4239,7 +4468,9 @@ class ReviewWatcher:
4239
4468
 
4240
4469
  The two backing stores are not interchangeable and the split is the
4241
4470
  whole point: durable in production, process-lifetime in dry-run, so
4242
- neither mode can ever silence the other. See _warn_identity_drift.
4471
+ neither mode can ever silence the other. See _warn_identity_drift;
4472
+ the first-run-dialog wedge (_wedged_on_first_run_dialog) keys its
4473
+ one-WARNING-per-episode gate through the same pair.
4243
4474
  """
4244
4475
  if record:
4245
4476
  return self.state.pinged(pr.full_name, pr.number, kind)
@@ -4819,7 +5050,7 @@ class ReviewWatcher:
4819
5050
  stability=stability.text if stability is not None else "",
4820
5051
  )
4821
5052
 
4822
- hub, problem = self._ensure_hub(pr)
5053
+ hub, problem = self._ensure_hub(pr, task_ref=task.ref if task else None)
4823
5054
  if problem is not None:
4824
5055
  return Decision(Action.SKIPPED, problem, round_)
4825
5056
 
@@ -4923,7 +5154,9 @@ class ReviewWatcher:
4923
5154
  env_var=self.config.reviewer_token_env, reviewer=self.github.login
4924
5155
  )
4925
5156
 
4926
- def _ensure_hub(self, pr: PullRequest) -> tuple[Path, str | None]:
5157
+ def _ensure_hub(
5158
+ self, pr: PullRequest, *, task_ref: "str | None" = None
5159
+ ) -> tuple[Path, str | None]:
4927
5160
  """Resolve the reviewer's cwd, hub-ifying the repo first if configured.
4928
5161
 
4929
5162
  Returns (hub, problem). `problem` is non-None when the round cannot run.
@@ -4933,9 +5166,20 @@ class ReviewWatcher:
4933
5166
  _refused_before_start, above the CI gate. The re-read below is not
4934
5167
  redundant with it -- `add` can have created the hub in between, and this
4935
5168
  is the check that says so.
5169
+
5170
+ Either way out (hub present, or hub-ified here) the hub is pre-trusted
5171
+ for claude BEFORE the caller enqueues the session (issue #136; see
5172
+ `_trust_hub`). `task_ref` names the round's review task, so the
5173
+ `REVIEW-<task>` checkout the skill may create is trusted too.
4936
5174
  """
4937
5175
  hub = self.config.hub_for(pr.owner, pr.repo)
4938
5176
  if hub.is_dir():
5177
+ # A hub that appeared between boots was never seeded by the
5178
+ # entrypoint at all, and a REVIEW-* checkout may have appeared
5179
+ # since the last spawn: trust is re-asserted before EVERY spawn.
5180
+ # Idempotent and cheap -- a state file already carrying every
5181
+ # path is not rewritten.
5182
+ self._trust_hub(hub, "before spawn", task_ref)
4939
5183
  return hub, None
4940
5184
 
4941
5185
  # Guarded twice: config.load() rejects 'add' without an allowlist, and
@@ -4968,8 +5212,160 @@ class ReviewWatcher:
4968
5212
  f"{hub} still does not exist — check hub_template against the "
4969
5213
  f"manifest's `dir:` override"
4970
5214
  )
5215
+ # Issue #136: the entrypoint pre-trusts only the hubs it can name at
5216
+ # boot, and under `repos_source: bows` a hub created here was never
5217
+ # among them -- the first session on it would sit on Claude Code's
5218
+ # "trust this folder?" dialog while the stale-round probe read it as
5219
+ # alive. Seed the hub root, main/ (the spawn cwd) and the review
5220
+ # checkout BEFORE the session is enqueued.
5221
+ self._trust_hub(hub, "hub-ified", task_ref)
4971
5222
  return hub, None
4972
5223
 
5224
+ def _trust_hub(
5225
+ self, cwd: Path, why: str, task_ref: "str | None" = None
5226
+ ) -> "list[Path]":
5227
+ """Pre-accept Claude Code's trust dialog for every directory a
5228
+ reviewer session on this hub may start in or `cd` into (see
5229
+ `trust.hub_trust_paths`: the hub root, `main/`, the `REVIEW-<task>`
5230
+ checkout), in both claude state files. Returns the state files that
5231
+ changed. Dry-run seeds nothing. Best-effort by construction
5232
+ (`seed_trust` never raises): a session can still be spawned into an
5233
+ untrusted hub -- it just may wedge on the dialog, which the
5234
+ stale-round probe now detects -- so a failed seed must never cost the
5235
+ spawn."""
5236
+ paths = hub_trust_paths(cwd, task_ref=task_ref)
5237
+ if self.config.dry_run:
5238
+ log.info(
5239
+ "[dry-run] would pre-trust %d claude dir(s) for hub %s (%s)",
5240
+ len(paths), hub_root_of(cwd), why,
5241
+ )
5242
+ return []
5243
+ changed = seed_trust(paths)
5244
+ if changed:
5245
+ log.info(
5246
+ "trusted hub %s for claude (%s): %d dir(s) — %s — written to %s",
5247
+ hub_root_of(cwd), why, len(paths),
5248
+ ", ".join(str(p) for p in paths),
5249
+ ", ".join(str(t) for t in changed),
5250
+ )
5251
+ return changed
5252
+
5253
+ def _wedged_on_first_run_dialog(
5254
+ self,
5255
+ pr: PullRequest,
5256
+ round_: int,
5257
+ session: str,
5258
+ task_ref: "str | None",
5259
+ cap: int,
5260
+ ) -> bool:
5261
+ """Issue #136: is this stale round's alive session sitting on one of
5262
+ Claude Code's first-run gates -- and if so, clear the lane.
5263
+
5264
+ Reads the pane (`alissa tmux tail`, 40 lines) and asks
5265
+ `trust.pane_shows_first_run_dialog` whether the session is PARKED on
5266
+ a gate -- the gate's accept option among the last lines with nothing
5267
+ but the gate's own chrome below it, its question strictly above -- not merely
5268
+ whether the gate's words appear: this repo's own README, CHANGELOG,
5269
+ `trust.py` and issue #136 quote them, so a session that cats or
5270
+ diffs any of them has them on screen while at work, and a kill here
5271
+ would double the round. A pane not parked on a gate, or one the CLI
5272
+ could not capture, answers False and the caller keeps the floored
5273
+ defer it always took (the other alive-but-idle causes -- an expired
5274
+ login, a usage limit, any other permission prompt -- are
5275
+ deliberately NOT classified here).
5276
+
5277
+ A match is `wedged:first-run-dialog`: ONE WARNING per episode (keyed
5278
+ first_run_dialog_kind(session) -- in the durable ping ledger in
5279
+ production, in the process-lifetime `_dry_run_drift` set under
5280
+ dry-run, the `_warn_identity_drift` split, so a diagnostic pass can
5281
+ never silence production nor production the diagnostic; a kill that
5282
+ fails and is retried next poll logs at INFO), then the row's own session is
5283
+ killed (`alissa tmux kill <name>`, never a sweep), the hub it started
5284
+ in is trusted (root, main/, the review checkout -- the very entries
5285
+ the gate was asking for, so the re-queued round does not meet it
5286
+ again), one activity-comment line records the act, and True tells
5287
+ the caller to re-queue the round NOW, exactly as a dead session
5288
+ would (the respawn is `reenqueued`, lands in the `stale_reenqueued`
5289
+ bucket, and burns the round's attempt the same way). Dry-run
5290
+ classifies and logs (once per episode, in memory) but writes no
5291
+ ledger row, kills nothing and answers False (the defer holds). A
5292
+ kill that fails answers False too: the defer holds and the
5293
+ next poll, reading the same pane, tries again -- the remedy for a
5294
+ session that will not die, not a loop to guard against.
5295
+
5296
+ The pane is untrusted third-party terminal content: only its
5297
+ CLASSIFICATION reaches the log; the capture itself stays at DEBUG.
5298
+ """
5299
+ pane = self.alissa.tail_session(session, FIRST_RUN_PANE_TAIL_LINES)
5300
+ if not pane_shows_first_run_dialog(pane):
5301
+ return False
5302
+ log.debug(
5303
+ "%s: last %d pane lines of %s:\n%s",
5304
+ WEDGE_FIRST_RUN_DIALOG, FIRST_RUN_PANE_TAIL_LINES, session, pane,
5305
+ )
5306
+ cwd = self.config.hub_for(pr.owner, pr.repo)
5307
+ hub = hub_root_of(cwd)
5308
+ kind = first_run_dialog_kind(session)
5309
+ # Durable in production, process-lifetime in dry-run (review round 1
5310
+ # of PR #137): the two stores never meet, so a `--once --dry-run`
5311
+ # pass over the default state path cannot eat production's WARNING.
5312
+ record = not self.config.dry_run
5313
+ if self._drift_gated(pr, kind, record):
5314
+ log.info(
5315
+ "%s: session %s (%s round %d) is still on claude's first-run "
5316
+ "dialog — retrying the kill",
5317
+ WEDGE_FIRST_RUN_DIALOG, session, pr.slug, round_,
5318
+ )
5319
+ else:
5320
+ log.warning(
5321
+ "%s: session %s (%s round %d) reads alive but its pane shows "
5322
+ "claude's first-run dialog (%s) — the directive was swallowed "
5323
+ "by the prompt and no verdict will ever be submitted. Killing "
5324
+ "it, pre-trusting hub %s (root, main/, review checkout) for "
5325
+ "claude, and re-queuing round %d now (counted exactly as a dead "
5326
+ "session's respawn). Operator lever: `alissa tmux tail "
5327
+ "<session>` to see the prompt, `tmux send-keys -t <session> "
5328
+ "Down Enter` to accept it by hand, or kill + seed as the daemon "
5329
+ "does",
5330
+ WEDGE_FIRST_RUN_DIALOG, session, pr.slug, round_,
5331
+ " / ".join(repr(m) for m in FIRST_RUN_DIALOG_MARKERS), hub,
5332
+ round_,
5333
+ )
5334
+ self._note_drift_gate(pr, kind, record)
5335
+ if self.config.dry_run:
5336
+ log.info(
5337
+ "[dry-run] would kill %s, pre-trust %s and re-queue round %d",
5338
+ session, hub, round_,
5339
+ )
5340
+ return False
5341
+ try:
5342
+ self.alissa.kill_session(session)
5343
+ except CommandError as exc:
5344
+ log.warning(
5345
+ "%s: could not kill %s (%s) — kills are best-effort; the round "
5346
+ "keeps its defer and the kill is retried next poll",
5347
+ WEDGE_FIRST_RUN_DIALOG, session, exc,
5348
+ )
5349
+ return False
5350
+ self._trust_hub(cwd, WEDGE_FIRST_RUN_DIALOG, task_ref)
5351
+ activity_kind = first_run_dialog_activity_kind(session)
5352
+ if not self.state.pinged(pr.full_name, pr.number, activity_kind):
5353
+ landed = self._append_activity(
5354
+ pr,
5355
+ self._activity_line(
5356
+ session,
5357
+ round_,
5358
+ f"{WEDGE_FIRST_RUN_DIALOG} — session `{session}` read alive "
5359
+ f"but its pane sat on claude's first-run dialog (the hub was "
5360
+ f"not pre-trusted); killed, hub `{hub}` pre-trusted, round "
5361
+ f"{round_} re-queued",
5362
+ cap,
5363
+ ),
5364
+ )
5365
+ if landed:
5366
+ self.state.record_ping(pr.full_name, pr.number, activity_kind)
5367
+ return True
5368
+
4973
5369
  def preflight(self) -> list[str]:
4974
5370
  """Startup checks. Returns warnings; raises on anything fatal."""
4975
5371
  warnings: list[str] = []