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.
- {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
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/alissa.py +20 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/loop.py +290 -11
- alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/trust.py +473 -0
- alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/version +1 -0
- {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
- {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
- alissa_tools_github_revloop-0.31.1/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/README.md +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/setup.py +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {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
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {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
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {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
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.31.1 → alissa_tools_github_revloop-0.31.2}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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(
|
|
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)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.2
|
|
@@ -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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.31.1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|