alissa-tools-github-revloop 0.31.2__tar.gz → 0.31.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {alissa_tools_github_revloop-0.31.2/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.4}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/alissa.py +258 -19
  3. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/config.py +119 -0
  4. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/loop.py +909 -30
  5. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/loop_events.py +49 -0
  6. alissa_tools_github_revloop-0.31.4/src/main/alissa/tools/github/revloop/prompts.py +779 -0
  7. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/state.py +197 -3
  8. alissa_tools_github_revloop-0.31.4/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/page.py +66 -0
  10. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/server.py +119 -3
  11. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/sources.py +85 -6
  12. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  13. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  14. alissa_tools_github_revloop-0.31.2/src/main/alissa/tools/github/revloop/version +0 -1
  15. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/LICENSE +0 -0
  16. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/MANIFEST.in +0 -0
  17. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/NOTICE +0 -0
  18. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/README.md +0 -0
  19. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/requirements.txt +0 -0
  20. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/setup.cfg +0 -0
  21. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/setup.py +0 -0
  22. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  25. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  26. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  27. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  28. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  29. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  30. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  31. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/version.py +0 -0
  32. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  33. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  34. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  35. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  36. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.2 → alissa_tools_github_revloop-0.31.4}/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.2
3
+ Version: 0.31.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -4,12 +4,15 @@ reviewer session (orchestration P1)."""
4
4
  from __future__ import annotations
5
5
 
6
6
  import logging
7
+ import os
7
8
  import re
9
+ import time
8
10
  from dataclasses import dataclass
9
11
  from datetime import datetime
10
12
  from pathlib import Path
11
13
 
12
14
  from .proc import CommandError, run, run_json
15
+ from .prompts import ALLOWED_KEYS
13
16
 
14
17
  log = logging.getLogger(__name__)
15
18
 
@@ -236,7 +239,7 @@ _VERDICT_RE = re.compile(
236
239
  # The envelope's merge-readiness judgment (issue #130). The alissa-code-review
237
240
  # skill writes it as one list line in the verdict envelope:
238
241
  # - **Merge-Readiness:** auto
239
- # - **Merge-Readiness:** operator — touches convex/schema.ts
242
+ # - **Merge-Readiness:** operator — schema-migration: removes users.legacyId
240
243
  # Tolerant of the markdown around the label -- an optional bullet, optional
241
244
  # `**` bold around the label (with the colon inside or outside it), optional
242
245
  # bold around the value -- but NOT of the value's case: `Auto` is not a
@@ -252,21 +255,83 @@ _READINESS_RE = re.compile(
252
255
  re.MULTILINE,
253
256
  )
254
257
 
258
+ # The operator reason's CLASS (issue #142). An `operator` judgment names WHY
259
+ # the merge is a human's, and until now it did so in prose the merge edge
260
+ # could not act on: of 14 operator verdicts on ~40 studio PRs, 3 named real
261
+ # merge risk and 11 were "the PR body lists an unverified item" -- validation
262
+ # work the human does at gate 2 anyway, not a reason to withhold the merge.
263
+ # So the reason now LEADS with one token from this closed enum, in the
264
+ # consumer's grammar:
265
+ #
266
+ # Merge-Readiness: auto
267
+ # Merge-Readiness: operator — <class>: <one-line reason>
268
+ #
269
+ # The order below is the SEVERITY order: when more than one row applies, the
270
+ # reviewer names the one nearest the top (the most severe wins), and exactly
271
+ # one. `auto` never carries a class. An operator line whose reason does not
272
+ # lead with a recognised token is still valid grammar -- it parses, value and
273
+ # reason intact -- and reads as UNCLASSED (`klass=None`); the consumer treats
274
+ # unclassed as a hard hold (fail closed), so a reviewer that forgets the
275
+ # class loses throughput, never safety. The token is matched exactly as
276
+ # written here (lowercase, hyphenated): `Schema-Migration:` is not a class
277
+ # this daemon reads, for the same reason `Auto` is not a value it carries.
278
+ READINESS_CLASSES: "tuple[str, ...]" = (
279
+ "schema-migration", # schema shape changes needing a migration / removal / rename on live data
280
+ "data-backfill", # one-off writes over existing rows (backfills, stored-shape version bumps)
281
+ "secrets-env", # new / changed env vars, secrets, credentials the deploy must carry
282
+ "infra-deploy", # Dockerfiles, workflows, Railway config, base-image pins -- what runs
283
+ "billing", # credit charging, pricing, tiers, quotas
284
+ "security", # auth, permission gates, redaction, sandboxing flagged operator-worthy
285
+ "unverified-ux", # the PR body's operator gate is a human look at a screen / copy / mockup
286
+ "unverified-runtime", # the PR body's operator gate is a live smoke / real-model run / replay
287
+ "release-act", # the merge itself is a release (VERSION bump -> publish, tag, npm)
288
+ )
289
+ _READINESS_CLASS_RE = re.compile(
290
+ r"^(" + "|".join(re.escape(klass) for klass in READINESS_CLASSES) + r")[ \t]*:"
291
+ )
292
+
255
293
  # The reason lands in a GitHub review body (as a line-anchored trailer) and in
256
294
  # a log line, so it is bounded and flattened at the parser: one line, no
257
295
  # backticks (a fence would swallow the trailer and everything after it), and
258
- # no more than this many characters.
296
+ # no more than this many characters. The class prefix is PART of the reason
297
+ # and survives the flattening untouched: the native trailer must stay
298
+ # byte-equal to the envelope's line, class included.
259
299
  MAX_READINESS_REASON_CHARS = 200
260
300
 
261
301
 
262
302
  def clean_readiness_reason(text: object) -> str:
263
- """Flatten a Merge-Readiness reason to one bounded, backtick-free line."""
303
+ """Flatten a Merge-Readiness reason to one bounded, backtick-free line.
304
+
305
+ The `<class>:` prefix, when present, is kept intact at the head of the
306
+ line -- classify_readiness_reason reads it off the cleaned reason, and the
307
+ emitter copies the cleaned reason whole.
308
+ """
264
309
  if not isinstance(text, str):
265
310
  return ""
266
311
  flat = " ".join(text.replace("`", "").split())
267
312
  return flat[:MAX_READINESS_REASON_CHARS].rstrip()
268
313
 
269
314
 
315
+ def classify_readiness_reason(value: object, reason: object) -> "str | None":
316
+ """The READINESS_CLASSES token an `operator` reason leads with, or None.
317
+
318
+ None for `auto` (never classed), for an operator reason that leads with
319
+ no recognised token (UNCLASSED -- the consumer's hard hold), and for a
320
+ token in the wrong case or spelling. The reason itself is never altered:
321
+ the class is read off it, not cut out of it.
322
+ """
323
+ if value != READINESS_OPERATOR or not isinstance(reason, str):
324
+ return None
325
+ match = _READINESS_CLASS_RE.match(reason)
326
+ return match.group(1) if match else None
327
+
328
+
329
+ def _classed(value: "str | None", raw_reason: object) -> "tuple[str | None, str, str | None]":
330
+ """The `(value, reason, klass)` triple both parsers hand back."""
331
+ reason = clean_readiness_reason(raw_reason)
332
+ return (value, reason, classify_readiness_reason(value, reason))
333
+
334
+
270
335
  # The trailer's own grammar (issue #134) -- the bare-line sibling of
271
336
  # _READINESS_RE. This is the line a native review ENDS with, whoever wrote
272
337
  # it: the daemon's emitter (loop.readiness_trailer) when it posts the verdict
@@ -285,37 +350,42 @@ _TRAILER_RE = re.compile(
285
350
  )
286
351
 
287
352
 
288
- def parse_trailer(body: object) -> "tuple[str | None, str]":
289
- """`(value, reason)` from the first bare `Merge-Readiness:` line in a
290
- review body, or `(None, "")` when no line matches the trailer grammar.
353
+ def parse_trailer(body: object) -> "tuple[str | None, str, str | None]":
354
+ """`(value, reason, klass)` from the first bare `Merge-Readiness:` line in
355
+ a review body, or `(None, "", None)` when no line matches the trailer
356
+ grammar.
291
357
 
292
358
  Strict where parse_readiness is tolerant: the line inside backticks
293
359
  mid-sentence (studio #1258) and the envelope's `- **Merge-Readiness:**`
294
360
  bullet both read as MISSING here, because the consumer's regex is what
295
361
  decides whether the merge edge sees the judgment at all. CRLF bodies (a
296
362
  review typed into the web form) are normalised first; the grammar itself
297
- is unchanged.
363
+ is unchanged. `klass` is the READINESS_CLASSES token an operator reason
364
+ leads with, or None (auto, or an unclassed operator -- issue #142).
298
365
  """
299
366
  if not isinstance(body, str):
300
- return (None, "")
367
+ return (None, "", None)
301
368
  match = _TRAILER_RE.search(body.replace("\r\n", "\n"))
302
369
  if match is None:
303
- return (None, "")
304
- return (match.group(1), clean_readiness_reason(match.group(2)))
370
+ return (None, "", None)
371
+ return _classed(match.group(1), match.group(2))
305
372
 
306
373
 
307
- def parse_readiness(blob: object) -> "tuple[str | None, str]":
308
- """`(value, reason)` from the first Merge-Readiness line in `blob`.
374
+ def parse_readiness(blob: object) -> "tuple[str | None, str, str | None]":
375
+ """`(value, reason, klass)` from the first Merge-Readiness line in `blob`.
309
376
 
310
377
  `value` is READINESS_AUTO / READINESS_OPERATOR, or None when no line
311
- parses; `reason` is the cleaned trailing text (empty when there is none).
378
+ parses; `reason` is the cleaned trailing text (empty when there is none),
379
+ class prefix included; `klass` is the READINESS_CLASSES token that reason
380
+ leads with, or None when the value is `auto`, the reason is unclassed, or
381
+ nothing parsed (issue #142).
312
382
  """
313
383
  if not isinstance(blob, str):
314
- return (None, "")
384
+ return (None, "", None)
315
385
  match = _READINESS_RE.search(blob)
316
386
  if match is None:
317
- return (None, "")
318
- return (match.group(1), clean_readiness_reason(match.group(2)))
387
+ return (None, "", None)
388
+ return _classed(match.group(1), match.group(2))
319
389
 
320
390
 
321
391
  @dataclass(frozen=True)
@@ -336,6 +406,83 @@ class Task:
336
406
  # kill on.
337
407
  REVIEW_SESSION_PREFIX = "review-"
338
408
 
409
+ # `alissa tmux new` names the REAL tmux session `ali-<name>`; the roster's
410
+ # `name` is the managed name without it. The raw tmux surface below addresses
411
+ # the real one, and the waiting marker (docker/claude/hooks/note-waiting.py)
412
+ # is named after it too.
413
+ MANAGED_PREFIX = "ali-"
414
+
415
+ # The console's session-name rule (webui.server), stated once so the raw
416
+ # tmux argv builders and the HTTP surface can never drift: a managed name
417
+ # starts with a letter or digit and carries only these characters, which is
418
+ # what keeps a `-flag`-shaped or shell-metacharacter name out of any argv.
419
+ SAFE_SESSION = re.compile(r"\A[A-Za-z0-9._][A-Za-z0-9._-]{0,199}\Z")
420
+
421
+
422
+ def tmux_socket() -> str:
423
+ """The path of the tmux server socket the reviewer sessions live on:
424
+ `$TMUX_TMPDIR/tmux-<uid>/default` (tmux's own default). Spelled out
425
+ rather than left to tmux because the daemon may run without `TMUX` in
426
+ its environment and must still reach the SAME server `alissa tmux new`
427
+ created for the reviewers -- same container, same user, same socket."""
428
+ base = os.environ.get("TMUX_TMPDIR", "").strip() or "/tmp"
429
+ return os.path.join(base, f"tmux-{os.getuid()}", "default")
430
+
431
+
432
+ def tmux_target(name: str) -> str:
433
+ """The `-t` target for a managed session: the real tmux name, prefixed
434
+ with `=` so tmux matches it EXACTLY (its default is prefix matching, and
435
+ `review-widgets-pr1-r1-abcdef` is a prefix of `…-r1-abcdef0`) and
436
+ suffixed with `:` so the target is the session's CURRENT WINDOW (and its
437
+ active pane) -- a bare `=name` is a session target, which `capture-pane`
438
+ and `send-keys` refuse with "can't find pane" (tmux 3.3a)."""
439
+ real = name if name.startswith(MANAGED_PREFIX) else MANAGED_PREFIX + name
440
+ return "=" + real + ":"
441
+
442
+
443
+ def check_session_name(name: "str | None") -> str:
444
+ """`name` when it passes SAFE_SESSION, else ValueError. Every raw tmux
445
+ argv builder calls this first."""
446
+ if not name or not SAFE_SESSION.match(name):
447
+ raise ValueError(f"invalid session name {name!r}")
448
+ return name
449
+
450
+
451
+ def capture_pane_argv(name: str, lines: int) -> "list[str]":
452
+ """`tmux -S <socket> capture-pane -p -t =<real> -S -<lines>`: the last
453
+ `lines` lines of the session's active pane, printed to stdout, with
454
+ escape sequences stripped (`-p` without `-e`)."""
455
+ check_session_name(name)
456
+ return [
457
+ "tmux", "-S", tmux_socket(), "capture-pane", "-p",
458
+ "-t", tmux_target(name), "-S", f"-{max(1, int(lines))}",
459
+ ]
460
+
461
+
462
+ def pane_path_argv(name: str) -> "list[str]":
463
+ check_session_name(name)
464
+ return [
465
+ "tmux", "-S", tmux_socket(), "display-message", "-p",
466
+ "-t", tmux_target(name), "#{pane_current_path}",
467
+ ]
468
+
469
+
470
+ def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
471
+ """`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
472
+ from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
473
+ is a ValueError before any process is spawned: the responder presses a
474
+ dialog's buttons and can never type into a reviewer."""
475
+ check_session_name(name)
476
+ if not keys:
477
+ raise ValueError("send_keys needs at least one key")
478
+ bad = [k for k in keys if k not in ALLOWED_KEYS]
479
+ if bad:
480
+ raise ValueError(
481
+ f"key(s) not in the allowlist {sorted(ALLOWED_KEYS)}: {bad!r}"
482
+ )
483
+ return ["tmux", "-S", tmux_socket(), "send-keys", "-t", tmux_target(name), *keys]
484
+
485
+
339
486
  # The reviewer-session GRAMMAR. The sweep kills sessions and the worker
340
487
  # container is shared with other lanes (`develop-*`, `fix-*`, `maintain-*`,
341
488
  # ...), so only a name that parses as one of the two shapes the review loop
@@ -419,11 +566,29 @@ class ManagedSession:
419
566
  # not report one -- treated as "long quiet", so a missing field can never
420
567
  # indefinitely immunize a session against the sweep.
421
568
  last_activity: float = 0.0
569
+ # The REAL tmux session name (`ali-<name>`, the roster's `session`
570
+ # field), or None when the CLI did not report one. The prompt responder
571
+ # names the waiting marker by it; absent, `MANAGED_PREFIX + name`.
572
+ session: "str | None" = None
422
573
 
423
574
  @property
424
575
  def is_idle(self) -> bool:
425
576
  return self.status == "idle"
426
577
 
578
+ @property
579
+ def tmux_name(self) -> str:
580
+ """The real tmux session name -- the roster's, else derived."""
581
+ return self.session or MANAGED_PREFIX + self.name
582
+
583
+ @property
584
+ def quiet_for(self) -> "float | None":
585
+ """Seconds since the last tmux activity, or None when the CLI
586
+ reported no timestamp (the sweep reads that 0 as "long quiet"; the
587
+ prompt responder reads it as "unknown" and waits for a marker)."""
588
+ if not self.last_activity:
589
+ return None
590
+ return max(0.0, time.time() - self.last_activity)
591
+
427
592
  @property
428
593
  def ref(self) -> "SessionRef | None":
429
594
  """What the name says about the round -- see parse_session_name."""
@@ -482,12 +647,17 @@ class VerdictEnvelope:
482
647
  envelope has no parseable `Merge-Readiness` line -- the case the native
483
648
  post fails closed on. `readiness_reason` is already one bounded,
484
649
  backtick-free line (see clean_readiness_reason); empty when the envelope
485
- gave none.
650
+ gave none. `readiness_class` is the READINESS_CLASSES token an operator
651
+ reason leads with (issue #142), or None: for `auto`, for an unclassed
652
+ operator reason, and for a missing line alike -- the reason keeps the
653
+ prefix either way, so the class is never lost between here and the
654
+ native trailer.
486
655
  """
487
656
 
488
657
  verdict: str
489
658
  readiness: "str | None" = None
490
659
  readiness_reason: str = ""
660
+ readiness_class: "str | None" = None
491
661
 
492
662
 
493
663
  @dataclass(frozen=True)
@@ -988,9 +1158,9 @@ class Alissa:
988
1158
  continue
989
1159
  match = _VERDICT_RE.search(blob)
990
1160
  if match:
991
- readiness, reason = parse_readiness(content)
1161
+ readiness, reason, klass = parse_readiness(content)
992
1162
  if readiness is None:
993
- readiness, reason = parse_readiness(title)
1163
+ readiness, reason, klass = parse_readiness(title)
994
1164
  found.append(
995
1165
  (Alissa._created_key(item.get("createdAt")),
996
1166
  index,
@@ -998,6 +1168,7 @@ class Alissa:
998
1168
  verdict=match.group(1).lower(),
999
1169
  readiness=readiness,
1000
1170
  readiness_reason=reason,
1171
+ readiness_class=klass,
1001
1172
  ))
1002
1173
  )
1003
1174
  break
@@ -1117,11 +1288,13 @@ class Alissa:
1117
1288
  name = row.get("name")
1118
1289
  if isinstance(name, str) and parse_session_name(name) is not None:
1119
1290
  last = row.get("lastActivity")
1291
+ real = row.get("session")
1120
1292
  sessions.append(
1121
1293
  ManagedSession(
1122
1294
  name=name,
1123
1295
  status=str(row.get("status") or ""),
1124
1296
  last_activity=float(last) if isinstance(last, (int, float)) else 0.0,
1297
+ session=real if isinstance(real, str) and real else None,
1125
1298
  )
1126
1299
  )
1127
1300
  return sessions
@@ -1161,6 +1334,72 @@ class Alissa:
1161
1334
  log.debug("could not tail %s: %s", session, exc)
1162
1335
  return ""
1163
1336
 
1337
+ def capture_pane(self, name: str, lines: int = 40) -> str:
1338
+ """The last `lines` of ONE managed session's pane over raw tmux
1339
+ (`capture-pane -p`), or "" when tmux cannot answer (no such session,
1340
+ the socket gone, a timeout).
1341
+
1342
+ The responder's evidence seam (issue #138). Like `tail_session` it
1343
+ returns UNTRUSTED, possibly sensitive terminal content: the caller
1344
+ classifies it and lets only the classification -- plus a scrubbed
1345
+ signature line -- reach an operator-facing level. A READ, so it runs
1346
+ under dry-run too: a dry pass has to classify what a live one would.
1347
+ Raw tmux rather than the `alissa` CLI because the CLI's `tail` is a
1348
+ convenience without `send-keys` beside it, and the answer has to land
1349
+ on the very pane the capture read."""
1350
+ try:
1351
+ argv = capture_pane_argv(name, lines)
1352
+ except ValueError as exc:
1353
+ log.warning("capture_pane refused: %s", exc)
1354
+ return ""
1355
+ try:
1356
+ out = run(argv, timeout=15)
1357
+ except CommandError as exc:
1358
+ log.debug("could not capture the pane of %s (%s)", name, exc)
1359
+ return ""
1360
+ return out.rstrip("\n")
1361
+
1362
+ def pane_path(self, name: str) -> str:
1363
+ """The session's pane current path (`#{pane_current_path}`), or ""
1364
+ when tmux cannot answer. One leg of the review-checkout resolution:
1365
+ a reviewer that `cd`ed into its `REVIEW-*` checkout names it here."""
1366
+ try:
1367
+ argv = pane_path_argv(name)
1368
+ except ValueError as exc:
1369
+ log.warning("pane_path refused: %s", exc)
1370
+ return ""
1371
+ try:
1372
+ return run(argv, timeout=15).strip()
1373
+ except CommandError as exc:
1374
+ log.debug("could not read the pane path of %s (%s)", name, exc)
1375
+ return ""
1376
+
1377
+ def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
1378
+ """Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
1379
+ -- on ONE managed session's pane. True when tmux accepted them.
1380
+
1381
+ The ONLY key-pressing surface in this daemon, and deliberately
1382
+ narrow: an allowlisted enum, a validated session name, an exact-match
1383
+ target, one `send-keys` per call. A key outside the allowlist is
1384
+ refused before any process is spawned and logged at WARNING (it is a
1385
+ programming error, never a pane's doing -- keys come from the
1386
+ policy table, not from the screen). Dry-run logs the intent and
1387
+ sends nothing."""
1388
+ try:
1389
+ argv = send_keys_argv(name, tuple(keys))
1390
+ except ValueError as exc:
1391
+ log.warning("send_keys refused for %s: %s", name, exc)
1392
+ return False
1393
+ if dry_run:
1394
+ log.info("[dry-run] would send keys %s to %s", " ".join(keys), name)
1395
+ return False
1396
+ try:
1397
+ run(argv, timeout=15)
1398
+ except CommandError as exc:
1399
+ log.warning("could not send keys %s to %s (%s)", " ".join(keys), name, exc)
1400
+ return False
1401
+ return True
1402
+
1164
1403
  def add_repo_to_workspace(
1165
1404
  self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
1166
1405
  ) -> None:
@@ -35,6 +35,7 @@ from __future__ import annotations
35
35
 
36
36
  import fnmatch
37
37
  import json
38
+ import logging
38
39
  import os
39
40
  import re
40
41
  from dataclasses import dataclass, field
@@ -42,6 +43,8 @@ from pathlib import Path
42
43
  from typing import Any, Mapping
43
44
  from urllib.parse import urlsplit
44
45
 
46
+ log = logging.getLogger(__name__)
47
+
45
48
  # A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
46
49
  _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
47
50
 
@@ -196,6 +199,11 @@ CONFIG_KEYS = (
196
199
  "task_list_bow_id",
197
200
  "loop_events_enabled",
198
201
  "fleet_vitals_enabled",
202
+ "prompt_responder",
203
+ "prompt_quiet_seconds",
204
+ "prompt_kill_minutes",
205
+ "prompt_max_answers",
206
+ "waiting_dir",
199
207
  "alissa_endpoint",
200
208
  "dry_run",
201
209
  )
@@ -454,6 +462,20 @@ def _validate_actor_id(entry: str) -> None:
454
462
 
455
463
  MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
456
464
 
465
+ # The prompt responder's master switch (issue #138; devloop #127's names):
466
+ # `on` answers, `observe` classifies and narrates but sends nothing, `off`
467
+ # never captures a pane.
468
+ PROMPT_ON = "on"
469
+ PROMPT_OBSERVE = "observe"
470
+ PROMPT_OFF = "off"
471
+ _PROMPT_MODES = frozenset((PROMPT_ON, PROMPT_OBSERVE, PROMPT_OFF))
472
+
473
+ # Where the container's hooks leave the "this session is waiting for input"
474
+ # marker (`docker/claude/hooks/note-waiting.py`): `${ALISSA_WAITING_DIR}/<tmux
475
+ # session>.json`. The daemon reads the same directory, so the two halves
476
+ # agree by construction on a default deployment.
477
+ DEFAULT_WAITING_DIR = "/workspace/.waiting"
478
+
457
479
  # A reviewer session that has not submitted after this long is presumed dead
458
480
  # (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
459
481
  # but only with a second signal agreeing: the timer alone cannot tell a dead
@@ -853,6 +875,40 @@ class Config:
853
875
  # about the client (its bearer token) comes from the environment.
854
876
  alissa_endpoint: str = DEFAULT_ALISSA_ENDPOINT
855
877
 
878
+ # The prompt responder (issue #138; devloop #127's knobs under the same
879
+ # names): every poll, for every ALIVE reviewer session this daemon owns,
880
+ # a waiting marker from the container's hook OR a session quiet for
881
+ # `prompt_quiet_seconds` triggers a pane capture, the classifier and the
882
+ # policy table (`prompts.py`), and the answer lands in seconds -- no
883
+ # SSH, no LLM in the loop. `on` (the default) answers; `observe`
884
+ # classifies and narrates but sends no key, kills nothing and pages
885
+ # nobody; `off` never captures a pane. Every side effect honours dry_run
886
+ # (a dry pass classifies and logs, sends nothing).
887
+ prompt_responder: str = PROMPT_ON
888
+
889
+ # How long a reviewer session must have been quiet (`lastActivity` in
890
+ # the roster) before its pane is read WITHOUT a waiting marker. The
891
+ # marker is the fast path (seconds); this is the net under it, for a
892
+ # hook that did not fire. Floored at 30 so a session between two tool
893
+ # calls is not read as parked.
894
+ prompt_quiet_seconds: int = 90
895
+
896
+ # The unknown-dialog ladder's last rung: a dialog no signature knows is
897
+ # waited out one poll, dismissed with Escape on its second sighting, and
898
+ # after this many minutes still on screen the session is killed so the
899
+ # ordinary stale-round re-entry takes over. Floored at 1.
900
+ prompt_kill_minutes: int = 10
901
+
902
+ # The per-session answer cap: after this many keystroke answers the
903
+ # session is killed and its round re-queued -- a reviewer that keeps
904
+ # producing prompts is diverging, and the narration says so. Floored at 1.
905
+ prompt_max_answers: int = 5
906
+
907
+ # Where the waiting markers are (see DEFAULT_WAITING_DIR). The hooks
908
+ # read ALISSA_WAITING_DIR at run time; the container's config renderer
909
+ # writes the same value here so the daemon looks where the hooks wrote.
910
+ waiting_dir: str = DEFAULT_WAITING_DIR
911
+
856
912
  dry_run: bool = False
857
913
 
858
914
  def __post_init__(self) -> None:
@@ -875,6 +931,23 @@ class Config:
875
931
  def manifest_path(self) -> Path:
876
932
  return self.workspace_root / "alissa-workspace.yaml"
877
933
 
934
+ @property
935
+ def prompt_kill_seconds(self) -> int:
936
+ """The unknown-dialog kill rung in seconds, floored at one minute
937
+ here as well as in `build()` so a Config assembled without it can
938
+ never kill a session on its first sighting."""
939
+ return max(1, self.prompt_kill_minutes) * 60
940
+
941
+ @property
942
+ def prompt_quiet_floor(self) -> int:
943
+ """`prompt_quiet_seconds` with the 30 s floor applied again
944
+ (`build()` clamps, and so does the read)."""
945
+ return max(30, self.prompt_quiet_seconds)
946
+
947
+ @property
948
+ def prompt_answer_cap(self) -> int:
949
+ return max(1, self.prompt_max_answers)
950
+
878
951
  def hub_for(self, owner: str, repo: str) -> Path:
879
952
  return Path(
880
953
  self.hub_template.format(
@@ -1217,6 +1290,47 @@ class Config:
1217
1290
  f"token — got {_describe(token_env)}.{rotate}"
1218
1291
  )
1219
1292
 
1293
+ # The prompt responder's knobs (issue #138). The mode is an enum
1294
+ # validated by NAME like repos_source (a typo must not read as
1295
+ # "off"); the three numbers have FLOORS rather than 0-sentinels --
1296
+ # none of them has a meaningful "off" value, and each floor is the
1297
+ # smallest value that cannot answer a session that is merely between
1298
+ # two tool calls -- and are clamped up with a warning, because none of
1299
+ # them is worth failing a boot.
1300
+ prompt_mode = str(raw.get("prompt_responder", cls.prompt_responder)).strip()
1301
+ if prompt_mode not in _PROMPT_MODES:
1302
+ raise ValueError(
1303
+ f"prompt_responder must be one of {sorted(_PROMPT_MODES)}, "
1304
+ f"got {prompt_mode!r}"
1305
+ )
1306
+ prompt_quiet = int(raw.get("prompt_quiet_seconds", cls.prompt_quiet_seconds))
1307
+ if prompt_quiet < 30:
1308
+ log.warning(
1309
+ "prompt_quiet_seconds=%d is below the 30 s floor — a shorter "
1310
+ "window would read a session between two tool calls as "
1311
+ "parked; clamping up to 30", prompt_quiet,
1312
+ )
1313
+ prompt_quiet = 30
1314
+ prompt_kill = int(raw.get("prompt_kill_minutes", cls.prompt_kill_minutes))
1315
+ if prompt_kill < 1:
1316
+ log.warning(
1317
+ "prompt_kill_minutes=%d is below the 1 min floor — the "
1318
+ "unknown-dialog ladder needs at least one poll between the "
1319
+ "dismissal and the kill; clamping up to 1", prompt_kill,
1320
+ )
1321
+ prompt_kill = 1
1322
+ prompt_answers = int(raw.get("prompt_max_answers", cls.prompt_max_answers))
1323
+ if prompt_answers < 1:
1324
+ log.warning(
1325
+ "prompt_max_answers=%d is below the floor of 1 — a cap of 0 "
1326
+ "would kill every session on its first prompt; clamping up "
1327
+ "to 1", prompt_answers,
1328
+ )
1329
+ prompt_answers = 1
1330
+ waiting_dir = str(raw.get("waiting_dir", cls.waiting_dir)).strip()
1331
+ if not waiting_dir:
1332
+ raise ValueError("waiting_dir must be a non-empty path")
1333
+
1220
1334
  state_path = raw.get("state_path")
1221
1335
  return cls(
1222
1336
  workspace_root=Path(workspace_root),
@@ -1248,6 +1362,11 @@ class Config:
1248
1362
  task_list_bow_id=bow_id,
1249
1363
  loop_events_enabled=bool(raw.get("loop_events_enabled", False)),
1250
1364
  fleet_vitals_enabled=bool(raw.get("fleet_vitals_enabled", False)),
1365
+ prompt_responder=prompt_mode,
1366
+ prompt_quiet_seconds=prompt_quiet,
1367
+ prompt_kill_minutes=prompt_kill,
1368
+ prompt_max_answers=prompt_answers,
1369
+ waiting_dir=waiting_dir,
1251
1370
  alissa_endpoint=endpoint,
1252
1371
  dry_run=bool(raw.get("dry_run", False)),
1253
1372
  )