alissa-tools-github-revloop 0.31.1__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.1/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.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/alissa.py +20 -0
  3. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/loop.py +290 -11
  4. alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/trust.py +473 -0
  5. alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/version +1 -0
  6. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  7. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  8. alissa_tools_github_revloop-0.31.1/src/main/alissa/tools/github/revloop/version +0 -1
  9. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/LICENSE +0 -0
  10. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/MANIFEST.in +0 -0
  11. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/NOTICE +0 -0
  12. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  18. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  19. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  20. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/config.py +0 -0
  21. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  22. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  23. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  24. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  25. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  26. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/state.py +0 -0
  27. {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {alissa_tools_github_revloop-0.31.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1
3
+ Version: 0.31.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -1141,6 +1141,26 @@ class Alissa:
1141
1141
  """
1142
1142
  run(["alissa", "tmux", "kill", session], timeout=30, check=False)
1143
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
+
1144
1164
  def add_repo_to_workspace(
1145
1165
  self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
1146
1166
  ) -> None:
@@ -40,6 +40,15 @@ from .alissa import (
40
40
  )
41
41
  from .alissa_client import AlissaClient
42
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
+ )
43
52
  from .config import (
44
53
  HUB_ADD,
45
54
  REPOS_BOWS,
@@ -1240,6 +1249,39 @@ def stalled_kind(session: str) -> str:
1240
1249
  return f"{ESCALATION_STALLED}:{session}"
1241
1250
 
1242
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
+
1243
1285
  def capout_kind(head_sha: str, granted: int) -> str:
1244
1286
  """The ping-ledger kind that dedupes ONE cap-out page.
1245
1287
 
@@ -1815,6 +1857,13 @@ class ReviewWatcher:
1815
1857
  # has listed this pass (the preflight refresh, or a sweep whose list
1816
1858
  # failed), and the refresh lists for itself.
1817
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
1818
1867
  # (repo full name, PR number) -> when the reviewer login was most
1819
1868
  # recently asked to review that PR, per the issue timeline (an ISO
1820
1869
  # stamp, or None for never) -- the round-admission gate's one GitHub
@@ -1954,6 +2003,39 @@ class ReviewWatcher:
1954
2003
  # Telemetry-class: the console's window onto the derived set.
1955
2004
  # Best-effort inside State, like every other telemetry write.
1956
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
+ )
1957
2039
 
1958
2040
  def _search_allowlist(self) -> "tuple[str, ...] | None":
1959
2041
  """The allowlist the review-requested search runs over, or None when
@@ -2371,14 +2453,28 @@ class ReviewWatcher:
2371
2453
  if age is not None:
2372
2454
  # Logged only once the gate has let the respawn through, so the
2373
2455
  # line cannot claim a re-enqueue that back-pressure then deferred.
2374
- log.warning(
2375
- "%s round %d has been in flight %.0f min with no submitted review "
2376
- "and its session is gone or finished re-enqueuing (reviewer "
2377
- "session presumed dead)",
2378
- pr.slug,
2379
- round_,
2380
- age / 60,
2381
- )
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
+ )
2382
2478
 
2383
2479
  return self._spawn(
2384
2480
  pr,
@@ -4041,6 +4137,22 @@ class ReviewWatcher:
4041
4137
  if ses.is_idle and quiet_for >= self.config.reap_grace_seconds:
4042
4138
  return None # idle-finished: it died without submitting -> respawn
4043
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
+
4044
4156
  if (
4045
4157
  age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
4046
4158
  and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
@@ -4356,7 +4468,9 @@ class ReviewWatcher:
4356
4468
 
4357
4469
  The two backing stores are not interchangeable and the split is the
4358
4470
  whole point: durable in production, process-lifetime in dry-run, so
4359
- 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.
4360
4474
  """
4361
4475
  if record:
4362
4476
  return self.state.pinged(pr.full_name, pr.number, kind)
@@ -4936,7 +5050,7 @@ class ReviewWatcher:
4936
5050
  stability=stability.text if stability is not None else "",
4937
5051
  )
4938
5052
 
4939
- hub, problem = self._ensure_hub(pr)
5053
+ hub, problem = self._ensure_hub(pr, task_ref=task.ref if task else None)
4940
5054
  if problem is not None:
4941
5055
  return Decision(Action.SKIPPED, problem, round_)
4942
5056
 
@@ -5040,7 +5154,9 @@ class ReviewWatcher:
5040
5154
  env_var=self.config.reviewer_token_env, reviewer=self.github.login
5041
5155
  )
5042
5156
 
5043
- 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]:
5044
5160
  """Resolve the reviewer's cwd, hub-ifying the repo first if configured.
5045
5161
 
5046
5162
  Returns (hub, problem). `problem` is non-None when the round cannot run.
@@ -5050,9 +5166,20 @@ class ReviewWatcher:
5050
5166
  _refused_before_start, above the CI gate. The re-read below is not
5051
5167
  redundant with it -- `add` can have created the hub in between, and this
5052
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.
5053
5174
  """
5054
5175
  hub = self.config.hub_for(pr.owner, pr.repo)
5055
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)
5056
5183
  return hub, None
5057
5184
 
5058
5185
  # Guarded twice: config.load() rejects 'add' without an allowlist, and
@@ -5085,8 +5212,160 @@ class ReviewWatcher:
5085
5212
  f"{hub} still does not exist — check hub_template against the "
5086
5213
  f"manifest's `dir:` override"
5087
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)
5088
5222
  return hub, None
5089
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
+
5090
5369
  def preflight(self) -> list[str]:
5091
5370
  """Startup checks. Returns warnings; raises on anything fatal."""
5092
5371
  warnings: list[str] = []
@@ -0,0 +1,473 @@
1
+ """Hub trust: keeping Claude Code's per-directory "trust this folder?" gate
2
+ pre-accepted for every directory a reviewer session may start in (issue
3
+ #136; the reviewer-side sibling of devloop PR #124 / issue #123).
4
+
5
+ Claude Code asks, once per directory, whether the folder it was started in
6
+ is trusted -- and `--dangerously-skip-permissions` does NOT suppress that
7
+ dialog. A headless session that meets it hangs: the directive `alissa tmux
8
+ queue` types into the pane is swallowed by the prompt, the session sits in
9
+ the roster reading alive, and the stale-round probe (correctly) declines to
10
+ respawn over a live session -- `round k is stale but session … is still
11
+ active — not respawning over a live reviewer`, every poll, for as long as
12
+ the pane sits there.
13
+
14
+ The container entrypoint seeds the gate at boot for the hubs it can NAME at
15
+ boot: the `ALISSA_REVIEW_REPOS` allowlist plus every hub already on disk.
16
+ Under `repos_source: bows` `ALISSA_REVIEW_REPOS` is empty -- the allowlist
17
+ is DERIVED from the feed Bodies of Work at runtime -- so a repo hub-ified
18
+ after boot was never trusted. This module closes that gap from the daemon's
19
+ side, in three places the loop calls:
20
+
21
+ * after every successful feed refresh: the derived list is written to
22
+ `{root}/.alissa-derived-repos` (`DERIVED_REPOS_FILENAME`) so the NEXT
23
+ boot's entrypoint seeding can read it, and the derived hubs are trusted
24
+ right away, hub-ified or not (trusting a path that does not exist yet is
25
+ exactly what the entrypoint already does for the static allowlist);
26
+ * at hub-ify time and before every spawn (`ReviewWatcher._ensure_hub`): the
27
+ hub root, `{hub}/main` (where the reviewer starts, per `hub_template`),
28
+ the `REVIEW-<task>` checkout the review skill may create for the PR head,
29
+ and every such checkout already on disk;
30
+ * after a first-run-dialog wedge kill: the hub the wedged session started
31
+ in, so the re-queued round does not meet the same prompt.
32
+
33
+ WHAT IS TRUSTED IS THE REVIEWER'S SHAPE, NOT THE DEVELOPER'S. devloop's
34
+ sessions start in the hub root and create `TASK-*` worktrees; this daemon
35
+ starts a session in `{root}/{repo}/main` (the `hub_template` default, the
36
+ docker README's "Reviewers `cd` into `{root}/{repo}/main`"), and the
37
+ alissa-code-review skill tells a reviewer that must run code to make a
38
+ throwaway `REVIEW-TASK-<id>` worktree beside `main/`. So `hub_trust_paths`
39
+ lists the hub root, `main/`, the session cwd when the template puts it
40
+ elsewhere, the checkout named for the round's task, and every `REVIEW-*`
41
+ directory on disk.
42
+
43
+ The merge is the entrypoint's, transposed: load-then-update of
44
+ `~/.claude.json` and `$CLAUDE_CONFIG_DIR/.claude.json` (when set --
45
+ `CLAUDE_CONFIG_DIR` reliably relocates only the credential file, so both
46
+ targets are written and whichever claude reads carries the flag), only ever
47
+ ADDING `projects[<path>].hasTrustDialogAccepted = true`. Idempotent: a target
48
+ that already carries every entry is not rewritten at all, which is also what
49
+ keeps the daemon's writes off a file Claude Code itself rewrites -- the only
50
+ time this module touches the file is when a NEW directory has to be
51
+ trusted. That write is guarded on three sides, because Claude Code itself
52
+ rewrites the same file while sessions run: the temp-file + rename keeps a
53
+ concurrent READER from ever seeing a torn file; a compare-and-swap
54
+ immediately before the rename (the file must still hold the bytes the merge
55
+ was computed from) keeps the OTHER WRITER's update from being discarded
56
+ wholesale -- the payload is the whole file, so a lost update could drop
57
+ another session's `projects` entries or the persisted login; and a re-read
58
+ after the rename catches a writer that landed right after ours and dropped
59
+ OUR entries. Either miss restarts the merge on top of the newer file, a
60
+ bounded number of times, and giving up is one WARNING. Never raises: trust
61
+ is a convenience for the session about to spawn, and a failed write is one
62
+ WARNING, never a lost poll.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import json
68
+ import logging
69
+ import os
70
+ import re
71
+ from pathlib import Path
72
+ from typing import Iterable, Sequence
73
+
74
+ log = logging.getLogger(__name__)
75
+
76
+ # The file the daemon writes the derived (bows-mode) allowlist to, under the
77
+ # workspace root, one `owner/repo` per line -- and the file the entrypoint's
78
+ # first-run seeding reads at the NEXT boot (`ALISSA_DERIVED_REPOS_FILE`
79
+ # overrides the location on both sides). The same name and override as
80
+ # devloop's, so an operator who knows one image knows the other.
81
+ DERIVED_REPOS_FILENAME = ".alissa-derived-repos"
82
+ ENV_DERIVED_REPOS_FILE = "ALISSA_DERIVED_REPOS_FILE"
83
+
84
+ # The state file Claude Code keeps its per-directory trust in, relative to
85
+ # $HOME and to $CLAUDE_CONFIG_DIR.
86
+ CLAUDE_STATE_FILENAME = ".claude.json"
87
+ ENV_CLAUDE_CONFIG_DIR = "CLAUDE_CONFIG_DIR"
88
+ TRUST_KEY = "hasTrustDialogAccepted"
89
+
90
+ # The throwaway worktree the alissa-code-review skill tells a reviewer to
91
+ # create beside `main/` when it must run code: `REVIEW-TASK-<id>`. Listed by
92
+ # prefix so a checkout already on disk is trusted whatever task it was for.
93
+ REVIEW_CHECKOUT_PREFIX = "REVIEW-"
94
+
95
+ # What a pane PARKED on one of Claude Code's first-run gates shows -- and
96
+ # what corroborates that it is parked there rather than merely mentioning
97
+ # it (devloop PR #124 review round 1). The gates' words are quoted by this
98
+ # repo's README, CHANGELOG, this module and issue #136, so a session that
99
+ # cats any of them, greps the tree or shows a diff has them on screen while
100
+ # at work -- and a match kills the session. The classifier therefore asks
101
+ # for the gate's SHAPE, not its vocabulary (see `pane_shows_first_run_dialog`):
102
+ #
103
+ # * the gate's ACCEPT option (`FIRST_RUN_DIALOG_OPTIONS`) is one of the last
104
+ # `FIRST_RUN_DIALOG_TAIL_LINES` non-blank lines of the capture, once the
105
+ # selection cursor, option numbering and box drawing are stripped;
106
+ # * nothing follows it but the gate's own chrome (`FIRST_RUN_DIALOG_CHROME`):
107
+ # a session that printed ANYTHING after the text -- a spinner, a tool
108
+ # call, the `>` input prompt an idle session sits at, a shell prompt --
109
+ # is not on the gate;
110
+ # * the gate's QUESTION (`FIRST_RUN_DIALOG_MARKERS`) appears STRICTLY above
111
+ # the option line -- the markers are the questions' own words, none of
112
+ # which the accept options contain, so the option line can never stand
113
+ # in for its question (PR #137 review round 1: with `trust this folder`
114
+ # as the marker and the option line counted, `Yes, I trust this folder`
115
+ # alone satisfied this leg).
116
+ #
117
+ # The trust dialog reads "Quick safety check: Is this a project you created
118
+ # or one you trust? … ❯ No, exit / Yes, I trust this folder"; the
119
+ # bypass-permissions gate reads "WARNING: Claude Code running in Bypass
120
+ # Permissions mode … No, exit / Yes, I accept" (either option order, with
121
+ # or without numbering, an optional "Enter to confirm · Esc to exit" footer
122
+ # and box drawing). Both are gates the entrypoint's seeding is meant to
123
+ # pre-answer, so a pane parked on either is a session the seeding missed,
124
+ # not a session at work. devloop's `hubs.py` checks the same first two legs
125
+ # on the same fixtures (shared verbatim); its third leg still counts the
126
+ # option line and carries `trust this folder` as the marker, which this
127
+ # module tightened -- a devloop follow-up, so the two seats agree again.
128
+ # The trust gate's older wording ("Do you trust the files in this folder?")
129
+ # is kept so a pinned Claude Code that still draws it is a gate too.
130
+ FIRST_RUN_DIALOG_MARKERS = (
131
+ "quick safety check",
132
+ "is this a project you created",
133
+ "trust the files in this folder",
134
+ "bypass permissions mode",
135
+ )
136
+ FIRST_RUN_DIALOG_OPTIONS = (
137
+ "yes, i trust this folder",
138
+ "yes, i accept",
139
+ )
140
+ FIRST_RUN_DIALOG_CHROME = (
141
+ "no, exit",
142
+ "enter to confirm",
143
+ )
144
+ FIRST_RUN_DIALOG_TAIL_LINES = 6
145
+
146
+ # What a gate draws AROUND its text: box drawing, the selection cursor and
147
+ # whitespace (stripped from both ends of a line), then "1." / "2)" option
148
+ # numbering. Nothing else is stripped -- a diff's `+`, a markdown bullet,
149
+ # a quote's backtick and the `>` of Claude Code's input prompt all stay,
150
+ # which is what keeps a line that merely quotes a gate from reading as one.
151
+ _DIALOG_DECORATION = "❯ \t│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═"
152
+ _DIALOG_NUMBERING = re.compile(r"^\d+[.)]\s*")
153
+
154
+ # How many times `seed_trust` restarts a merge that another writer moved
155
+ # the file under (see the module docstring) before it gives up with a
156
+ # WARNING. Claude Code's own rewrites are bursts around a session's start
157
+ # and end, not a stream, so a second pass is expected to land.
158
+ SEED_TRUST_ATTEMPTS = 3
159
+
160
+ # The wedge classification the loop logs and records for a stale round
161
+ # whose session's pane shows one of those gates.
162
+ WEDGE_FIRST_RUN_DIALOG = "wedged:first-run-dialog"
163
+
164
+
165
+ def claude_state_targets(
166
+ home: "Path | str | None" = None, config_dir: "Path | str | None" = None
167
+ ) -> "list[Path]":
168
+ """The `.claude.json` files to merge trust into: `$HOME/.claude.json`,
169
+ plus `$CLAUDE_CONFIG_DIR/.claude.json` when that variable is set and
170
+ non-blank. Both arguments default to the environment (`~` expansion
171
+ honours `HOME`), which is what lets a test point the merge at scratch
172
+ directories without touching the daemon's own files."""
173
+ home_dir = Path(home) if home is not None else Path(os.path.expanduser("~"))
174
+ targets = [home_dir / CLAUDE_STATE_FILENAME]
175
+ raw = (
176
+ str(config_dir)
177
+ if config_dir is not None
178
+ else os.environ.get(ENV_CLAUDE_CONFIG_DIR, "")
179
+ ).strip()
180
+ if raw:
181
+ extra = Path(raw) / CLAUDE_STATE_FILENAME
182
+ if extra != targets[0]:
183
+ targets.append(extra)
184
+ return targets
185
+
186
+
187
+ def hub_root(cwd: "Path | str") -> Path:
188
+ """The hub above a reviewer's session cwd. `hub_template` defaults to
189
+ `{root}/{repo}/main`, so the cwd's parent is the hub; a template that
190
+ starts sessions in the hub root itself names the hub directly."""
191
+ path = Path(cwd)
192
+ return path.parent if path.name == "main" else path
193
+
194
+
195
+ def review_checkout_name(task_ref: "str | None") -> "str | None":
196
+ """The throwaway worktree the review skill creates for a round that must
197
+ run code: `REVIEW-<task ref>` (`REVIEW-TASK-<id>`), or None without a
198
+ review task to name it after."""
199
+ ref = (task_ref or "").strip()
200
+ return f"{REVIEW_CHECKOUT_PREFIX}{ref}" if ref else None
201
+
202
+
203
+ def hub_trust_paths(
204
+ cwd: "Path | str", *, task_ref: "str | None" = None
205
+ ) -> "list[Path]":
206
+ """Every directory a reviewer session on this hub may START in or `cd`
207
+ into: the hub root, `{hub}/main` (the spawn cwd under the default
208
+ template), the session cwd itself when the template puts it elsewhere,
209
+ the `REVIEW-<task>` checkout the skill may create for this round's PR
210
+ head, and every `REVIEW-*` checkout already on disk (a re-queued round
211
+ may find a surviving one). The root, `main` and the named checkout are
212
+ listed whether or not they exist yet -- the point of seeding is to be
213
+ there BEFORE the directory is used. De-duplicated, first mention wins."""
214
+ start = Path(cwd)
215
+ root = hub_root(start)
216
+ paths = [root, root / "main", start]
217
+ checkout = review_checkout_name(task_ref)
218
+ if checkout:
219
+ paths.append(root / checkout)
220
+ try:
221
+ paths.extend(sorted(
222
+ p for p in root.glob(f"{REVIEW_CHECKOUT_PREFIX}*") if p.is_dir()
223
+ ))
224
+ except OSError:
225
+ pass
226
+ out: "list[Path]" = []
227
+ for p in paths:
228
+ if p not in out:
229
+ out.append(p)
230
+ return out
231
+
232
+
233
+ def _load_state(path: Path) -> "tuple[str | None, dict]":
234
+ """The state file's raw text (None when absent) and its parsed object
235
+ (`{}` when absent or not a JSON object). Only ABSENCE is folded into
236
+ "start fresh": an unparseable file raises `ValueError` up to
237
+ `seed_trust`, which skips that target rather than replacing it (a
238
+ corrupt state file is never silently overwritten). The raw text is what
239
+ `_write_atomic` compares against before its rename."""
240
+ try:
241
+ with open(path, encoding="utf-8") as fh:
242
+ raw = fh.read()
243
+ except FileNotFoundError:
244
+ return None, {}
245
+ data = json.loads(raw)
246
+ return raw, (data if isinstance(data, dict) else {})
247
+
248
+
249
+ def _raw_text(path: Path) -> "str | None":
250
+ try:
251
+ return path.read_text(encoding="utf-8")
252
+ except FileNotFoundError:
253
+ return None
254
+
255
+
256
+ def _write_atomic(path: Path, data: dict, expected: "str | None") -> bool:
257
+ """Write `data` over `path` by temp file + rename -- unless the file no
258
+ longer holds `expected` (the raw text the caller merged from), in which
259
+ case nothing is replaced and False is returned so the caller merges
260
+ again on top of whoever wrote in between. The rename keeps READERS
261
+ safe (never a torn file); the check before it keeps the OTHER WRITER's
262
+ update safe, down to the gap between the check and the rename."""
263
+ path.parent.mkdir(parents=True, exist_ok=True)
264
+ tmp = path.with_name(path.name + ".tmp")
265
+ with open(tmp, "w", encoding="utf-8") as fh:
266
+ json.dump(data, fh, indent=2)
267
+ fh.write("\n")
268
+ if _raw_text(path) != expected:
269
+ tmp.unlink(missing_ok=True)
270
+ return False
271
+ os.replace(tmp, path)
272
+ return True
273
+
274
+
275
+ def _add_trust(state: dict, wanted: "list[str]") -> "list[str]":
276
+ """Merge `projects[<path>].hasTrustDialogAccepted = true` for every
277
+ wanted path into `state` (in place, repairing a missing or non-object
278
+ `projects`). Returns the paths that were NOT already trusted."""
279
+ projects = state.get("projects")
280
+ if not isinstance(projects, dict):
281
+ projects = {}
282
+ state["projects"] = projects
283
+ added = []
284
+ for p in wanted:
285
+ entry = projects.get(p)
286
+ if not isinstance(entry, dict):
287
+ entry = {}
288
+ projects[p] = entry
289
+ if entry.get(TRUST_KEY) is not True:
290
+ entry[TRUST_KEY] = True
291
+ added.append(p)
292
+ return added
293
+
294
+
295
+ def _untrusted(state: dict, wanted: "list[str]") -> "list[str]":
296
+ projects = state.get("projects")
297
+ if not isinstance(projects, dict):
298
+ return list(wanted)
299
+ return [
300
+ p for p in wanted
301
+ if not isinstance(projects.get(p), dict)
302
+ or projects[p].get(TRUST_KEY) is not True
303
+ ]
304
+
305
+
306
+ def _merge_target(target: Path, wanted: "list[str]") -> bool:
307
+ """Merge `wanted` into ONE claude state file. True when the file was
308
+ written. Load, merge, compare-and-swap, verify -- and start over, up to
309
+ SEED_TRUST_ATTEMPTS times, whenever another writer moved the file
310
+ between the load and the rename (the swap refuses) or right after the
311
+ rename (the verify finds an entry missing). Never raises."""
312
+ wrote = False
313
+ for _ in range(SEED_TRUST_ATTEMPTS):
314
+ try:
315
+ raw, state = _load_state(target)
316
+ except (OSError, ValueError) as exc:
317
+ if wrote:
318
+ # Our write landed; whoever replaced it since left something
319
+ # this daemon will not touch. Nothing more to do here.
320
+ log.debug("trust: %s unreadable after the merge (%s)", target, exc)
321
+ return True
322
+ log.warning(
323
+ "trust: could not read %s (%s) — leaving it untouched; the "
324
+ "directories are NOT pre-trusted there: %s",
325
+ target, exc, ", ".join(wanted),
326
+ )
327
+ return False
328
+ added = _add_trust(state, wanted)
329
+ if not added:
330
+ # Every entry is present: it always was, or the pass before
331
+ # this one landed and survived.
332
+ return wrote
333
+ try:
334
+ swapped = _write_atomic(target, state, raw)
335
+ except OSError as exc:
336
+ log.warning(
337
+ "trust: could not write %s (%s) — the directories are NOT "
338
+ "pre-trusted there: %s", target, exc, ", ".join(added),
339
+ )
340
+ return wrote
341
+ if not swapped:
342
+ log.info(
343
+ "trust: %s changed underneath the merge — merging %s again "
344
+ "on top of the newer file", target, ", ".join(added),
345
+ )
346
+ continue
347
+ wrote = True
348
+ try:
349
+ _, after = _load_state(target)
350
+ except (OSError, ValueError) as exc:
351
+ log.debug("trust: %s unreadable right after the merge (%s)", target, exc)
352
+ return True
353
+ missing = _untrusted(after, wanted)
354
+ if not missing:
355
+ log.debug("trust: %s now pre-trusts %s", target, ", ".join(added))
356
+ return True
357
+ log.info(
358
+ "trust: %s was rewritten right after the merge and lost %s — "
359
+ "merging again", target, ", ".join(missing),
360
+ )
361
+ log.warning(
362
+ "trust: gave up merging into %s after %d attempts — another writer "
363
+ "kept rewriting it, so the directories may NOT be pre-trusted there: %s",
364
+ target, SEED_TRUST_ATTEMPTS, ", ".join(wanted),
365
+ )
366
+ return wrote
367
+
368
+
369
+ def seed_trust(
370
+ paths: "Iterable[Path | str]",
371
+ *,
372
+ home: "Path | str | None" = None,
373
+ config_dir: "Path | str | None" = None,
374
+ ) -> "list[Path]":
375
+ """Merge `projects[<path>].hasTrustDialogAccepted = true` for every path
376
+ into every claude state target. Returns the targets that were WRITTEN
377
+ (a target already carrying every entry is left untouched -- byte for
378
+ byte, no rewrite). Never raises and never removes anything: an
379
+ unparseable target is skipped with a WARNING rather than replaced, a
380
+ failed write is a WARNING too, and a target another writer keeps
381
+ moving under the merge is retried a bounded number of times (see
382
+ `_merge_target` and the module docstring) before that, too, is one
383
+ WARNING.
384
+
385
+ Paths are recorded as absolute strings, which is how Claude Code keys
386
+ them; a relative path is resolved against the cwd, which is never what a
387
+ caller means, so callers pass absolute hubs."""
388
+ wanted: "list[str]" = []
389
+ for p in paths:
390
+ s = os.fspath(Path(p))
391
+ if s not in wanted:
392
+ wanted.append(s)
393
+ changed: "list[Path]" = []
394
+ if not wanted:
395
+ return changed
396
+ for target in claude_state_targets(home, config_dir):
397
+ if _merge_target(target, wanted):
398
+ changed.append(target)
399
+ return changed
400
+
401
+
402
+ def derived_repos_path(root: "Path | str") -> Path:
403
+ """Where the derived allowlist is written under this workspace root, or
404
+ wherever `ALISSA_DERIVED_REPOS_FILE` points (the same override the
405
+ entrypoint honours, so an operator relocating one relocates both)."""
406
+ override = os.environ.get(ENV_DERIVED_REPOS_FILE, "").strip()
407
+ if override:
408
+ return Path(override)
409
+ return Path(root) / DERIVED_REPOS_FILENAME
410
+
411
+
412
+ def write_derived_repos(root: "Path | str", repos: "Sequence[str]") -> "Path | None":
413
+ """Record the derived allowlist, one `owner/repo` per line, for the next
414
+ boot's entrypoint seeding (the entrypoint's 3a block is the only reader:
415
+ it skips blank lines, `#` comments and lines without a `/`). Rewritten
416
+ only when the content differs.
417
+ Returns the path written, or None when nothing changed or the write
418
+ failed (logged; never raised -- the daemon has already trusted the hubs
419
+ itself, so this file only matters to a future boot)."""
420
+ path = derived_repos_path(root)
421
+ body = "".join(f"{r}\n" for r in repos)
422
+ try:
423
+ if path.is_file() and path.read_text(encoding="utf-8") == body:
424
+ return None
425
+ path.parent.mkdir(parents=True, exist_ok=True)
426
+ tmp = path.with_name(path.name + ".tmp")
427
+ tmp.write_text(body, encoding="utf-8")
428
+ os.replace(tmp, path)
429
+ except OSError as exc:
430
+ log.warning(
431
+ "could not record the derived allowlist at %s (%s) — the next "
432
+ "boot's entrypoint seeding will not pre-trust these hubs until a "
433
+ "refresh succeeds: %s", path, exc, ", ".join(repos),
434
+ )
435
+ return None
436
+ return path
437
+
438
+
439
+ def _dialog_line(line: str) -> str:
440
+ """A pane line reduced to the text a gate would render there (see
441
+ _DIALOG_DECORATION), casefolded."""
442
+ text = line.strip(_DIALOG_DECORATION)
443
+ text = _DIALOG_NUMBERING.sub("", text).strip(_DIALOG_DECORATION)
444
+ return text.casefold()
445
+
446
+
447
+ def _is_dialog_chrome(text: str) -> bool:
448
+ return any(text.startswith(chrome) for chrome in FIRST_RUN_DIALOG_CHROME)
449
+
450
+
451
+ def pane_shows_first_run_dialog(pane: str) -> bool:
452
+ """Whether a session's terminal tail is PARKED on one of Claude Code's
453
+ first-run gates: the gate's accept option among the last
454
+ FIRST_RUN_DIALOG_TAIL_LINES non-blank lines, nothing but the gate's own
455
+ chrome below it, and the gate's question strictly above it (the notes on
456
+ FIRST_RUN_DIALOG_MARKERS say why each leg is there). An empty capture
457
+ (the CLI could not tail) is never a dialog -- absence of evidence keeps
458
+ the defer -- and neither is a pane that merely MENTIONS a gate: a
459
+ session at work or idle at its prompt has printed something below the
460
+ words, and that something is not the gate's chrome."""
461
+ if not pane:
462
+ return False
463
+ lines = [t for t in (_dialog_line(ln) for ln in pane.splitlines()) if t]
464
+ tail = lines[-FIRST_RUN_DIALOG_TAIL_LINES:]
465
+ for offset in range(len(tail) - 1, -1, -1):
466
+ if any(tail[offset].startswith(o) for o in FIRST_RUN_DIALOG_OPTIONS):
467
+ break
468
+ else:
469
+ return False
470
+ if not all(_is_dialog_chrome(t) for t in tail[offset + 1:]):
471
+ return False
472
+ above = lines[:len(lines) - len(tail) + offset]
473
+ return any(m in t for t in above for m in FIRST_RUN_DIALOG_MARKERS)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.1
3
+ Version: 0.31.2
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -17,6 +17,7 @@ src/main/alissa/tools/github/revloop/loop_events.py
17
17
  src/main/alissa/tools/github/revloop/proc.py
18
18
  src/main/alissa/tools/github/revloop/prreview.py
19
19
  src/main/alissa/tools/github/revloop/state.py
20
+ src/main/alissa/tools/github/revloop/trust.py
20
21
  src/main/alissa/tools/github/revloop/version
21
22
  src/main/alissa/tools/github/revloop/version.py
22
23
  src/main/alissa/tools/github/revloop/webui/__init__.py