alissa-tools-github-devloop 0.8.25__tar.gz → 0.8.27__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_devloop-0.8.25/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.27}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/__main__.py +39 -1
  3. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/alissa.py +147 -0
  4. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/config.py +152 -0
  5. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/fleet_vitals.py +2 -1
  6. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/ghclient.py +251 -5
  7. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/loop.py +1424 -29
  8. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/loop_events.py +75 -2
  9. alissa_tools_github_devloop-0.8.27/src/main/alissa/tools/github/devloop/prompts.py +761 -0
  10. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/state.py +248 -7
  11. alissa_tools_github_devloop-0.8.27/src/main/alissa/tools/github/devloop/version +1 -0
  12. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/page.py +60 -1
  13. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/server.py +108 -1
  14. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/sources.py +67 -6
  15. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  16. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  17. alissa_tools_github_devloop-0.8.25/src/main/alissa/tools/github/devloop/version +0 -1
  18. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/LICENSE +0 -0
  19. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/MANIFEST.in +0 -0
  20. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/NOTICE +0 -0
  21. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/README.md +0 -0
  22. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/requirements.txt +0 -0
  23. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/setup.cfg +0 -0
  24. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/setup.py +0 -0
  25. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  26. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  27. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  28. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  29. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  30. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  31. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/version.py +0 -0
  32. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  33. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  34. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  35. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  36. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_devloop-0.8.25 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.25
3
+ Version: 0.8.27
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -194,6 +194,40 @@ def build_parser() -> argparse.ArgumentParser:
194
194
  "sessions, even if the config enables it",
195
195
  )
196
196
 
197
+ over.add_argument(
198
+ "--revert-label",
199
+ metavar="LABEL",
200
+ help="label an operator applies to a MERGED PR to request a revert "
201
+ "session (a `TASK-<n>-REVERT-PR<k>` branch reverting the merge "
202
+ "commit, opened as a ready PR); the session removes the label on "
203
+ "completion. Env ALISSA_REVERT_LABEL (non-blank) wins over this flag",
204
+ )
205
+ revert = over.add_mutually_exclusive_group()
206
+ revert.add_argument(
207
+ "--revert",
208
+ dest="revert_enabled",
209
+ action="store_true",
210
+ default=None,
211
+ help="poll merged PRs carrying the revert label and spawn revert "
212
+ "sessions (the default)",
213
+ )
214
+ revert.add_argument(
215
+ "--no-revert",
216
+ dest="revert_enabled",
217
+ action="store_false",
218
+ help="never search for the revert label or spawn revert sessions, "
219
+ "even if the config enables it",
220
+ )
221
+ over.add_argument(
222
+ "--revert-any-author",
223
+ dest="revert_any_author",
224
+ action="store_true",
225
+ default=None,
226
+ help="let the revert lever act on merged PRs by ANY author, not only "
227
+ "the daemon's own (default off: a foreign-authored PR is refused "
228
+ "with a comment and unlabelled)",
229
+ )
230
+
197
231
  resume = over.add_mutually_exclusive_group()
198
232
  resume.add_argument(
199
233
  "--resume",
@@ -431,6 +465,9 @@ def overrides_from(args: argparse.Namespace) -> dict:
431
465
  "fix_rounds_enabled": args.fix_rounds_enabled,
432
466
  "maintain_label": args.maintain_label,
433
467
  "maintain_enabled": args.maintain_enabled,
468
+ "revert_label": args.revert_label,
469
+ "revert_enabled": args.revert_enabled,
470
+ "revert_any_author": args.revert_any_author,
434
471
  "resume_enabled": args.resume_enabled,
435
472
  "wedge_minutes": args.wedge_minutes,
436
473
  "rerequest_enabled": args.rerequest_enabled,
@@ -532,7 +569,7 @@ def log_effective_config(config: Config, login: str) -> None:
532
569
  log.info(
533
570
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
534
571
  "max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
535
- "(label %r); resume=%s; "
572
+ "(label %r); revert=%s (label %r, any_author=%s); resume=%s; "
536
573
  "rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
537
574
  "activity_claims=%s; janitor=%s (grace %s min, every %s min, cache "
538
575
  "cap %s MiB, warn at %s%%)",
@@ -542,6 +579,7 @@ def log_effective_config(config: Config, login: str) -> None:
542
579
  config.reap_grace_seconds // 60,
543
580
  config.fix_rounds_enabled,
544
581
  config.maintain_enabled, config.maintain_label,
582
+ config.revert_enabled, config.revert_label, config.revert_any_author,
545
583
  config.resume_enabled,
546
584
  config.rerequest_enabled,
547
585
  config.rerequest_grace_seconds // 60,
@@ -8,12 +8,93 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+ import os
12
+ import re
11
13
  from pathlib import Path
12
14
 
13
15
  from .proc import CommandError, run
16
+ from .prompts import ALLOWED_KEYS
14
17
 
15
18
  log = logging.getLogger(__name__)
16
19
 
20
+ # The prefix `alissa tmux` gives every session it manages: the roster's
21
+ # `name` is the MANAGED name (`develop-…`, what `alissa tmux kill` takes) and
22
+ # the real tmux session is that name under this prefix (`ali-develop-…`).
23
+ # The raw-tmux surface below targets the REAL session, so a managed name is
24
+ # prefixed and a name already carrying the prefix is used as is.
25
+ MANAGED_PREFIX = "ali-"
26
+
27
+ # The console's session-name rule (webui.server._SAFE_SESSION), stated once
28
+ # where the raw tmux argv is built: starts with a letter/digit/dot/underscore,
29
+ # carries only those and `-`, bounded length. Validating here makes the argv
30
+ # impossible to weaponise -- a `-flag`-shaped name or one with a space,
31
+ # `;` or `/` never reaches tmux -- and the console re-checks the same rule.
32
+ SAFE_SESSION = re.compile(r"\A[A-Za-z0-9._][A-Za-z0-9._-]{0,199}\Z")
33
+
34
+
35
+ def tmux_socket() -> str:
36
+ """The path of the tmux server socket the worker sessions live on:
37
+ `$TMUX_TMPDIR/tmux-<uid>/default` (tmux's own default). Spelled out
38
+ rather than left to tmux because the daemon may run without `TMUX` in
39
+ its environment and must still reach the SAME server `alissa tmux new`
40
+ created for the workers -- same container, same user, same socket."""
41
+ base = os.environ.get("TMUX_TMPDIR", "").strip() or "/tmp"
42
+ return os.path.join(base, f"tmux-{os.getuid()}", "default")
43
+
44
+
45
+ def tmux_target(name: str) -> str:
46
+ """The `-t` target for a managed session: the real tmux name, prefixed
47
+ with `=` so tmux matches it EXACTLY (its default is prefix matching, and
48
+ `develop-acme-widgets-i7-a1` is a prefix of `…-a10`) and suffixed with
49
+ `:` so the target is the session's CURRENT WINDOW (and its active pane)
50
+ -- a bare `=name` is a session target, which `capture-pane` and
51
+ `send-keys` refuse with "can't find pane" (tmux 3.3a)."""
52
+ real = name if name.startswith(MANAGED_PREFIX) else MANAGED_PREFIX + name
53
+ return "=" + real + ":"
54
+
55
+
56
+ def check_session_name(name: "str | None") -> str:
57
+ """`name` when it passes SAFE_SESSION, else ValueError. Every raw tmux
58
+ argv builder calls this first."""
59
+ if not name or not SAFE_SESSION.match(name):
60
+ raise ValueError(f"invalid session name {name!r}")
61
+ return name
62
+
63
+
64
+ def capture_pane_argv(name: str, lines: int) -> "list[str]":
65
+ """`tmux -S <socket> capture-pane -p -t =<real> -S -<lines>`: the last
66
+ `lines` lines of the session's active pane, printed to stdout, with
67
+ escape sequences stripped (`-p` without `-e`)."""
68
+ check_session_name(name)
69
+ return [
70
+ "tmux", "-S", tmux_socket(), "capture-pane", "-p",
71
+ "-t", tmux_target(name), "-S", f"-{max(1, int(lines))}",
72
+ ]
73
+
74
+
75
+ def pane_path_argv(name: str) -> "list[str]":
76
+ check_session_name(name)
77
+ return [
78
+ "tmux", "-S", tmux_socket(), "display-message", "-p",
79
+ "-t", tmux_target(name), "#{pane_current_path}",
80
+ ]
81
+
82
+
83
+ def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
84
+ """`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
85
+ from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
86
+ is a ValueError before any process is spawned: the responder presses a
87
+ dialog's buttons and can never type into a worker."""
88
+ check_session_name(name)
89
+ if not keys:
90
+ raise ValueError("send_keys needs at least one key")
91
+ bad = [k for k in keys if k not in ALLOWED_KEYS]
92
+ if bad:
93
+ raise ValueError(
94
+ f"key(s) not in the allowlist {sorted(ALLOWED_KEYS)}: {bad!r}"
95
+ )
96
+ return ["tmux", "-S", tmux_socket(), "send-keys", "-t", tmux_target(name), *keys]
97
+
17
98
 
18
99
  class Alissa:
19
100
  def enqueue_developer(
@@ -149,6 +230,72 @@ class Alissa:
149
230
  `tmux kill`); the reaper treats kills as best-effort."""
150
231
  run(["alissa", "tmux", "kill", name], timeout=60)
151
232
 
233
+ def capture_pane(self, name: str, lines: int = 40) -> str:
234
+ """The last `lines` of ONE managed session's pane over raw tmux
235
+ (`capture-pane -p`), or "" when tmux cannot answer (no such session,
236
+ the socket gone, a timeout).
237
+
238
+ The responder's evidence seam (issue #127). Like `tail_session` it
239
+ returns UNTRUSTED, possibly sensitive terminal content: the caller
240
+ classifies it and lets only the classification -- plus a scrubbed
241
+ signature line -- reach an operator-facing level. A READ, so it runs
242
+ under dry-run too: a dry pass has to classify what a live one would.
243
+ Raw tmux rather than the `alissa` CLI because the CLI's `tail` is a
244
+ convenience without `send-keys` beside it, and the answer has to land
245
+ on the very pane the capture read."""
246
+ try:
247
+ argv = capture_pane_argv(name, lines)
248
+ except ValueError as exc:
249
+ log.warning("capture_pane refused: %s", exc)
250
+ return ""
251
+ try:
252
+ out = run(argv, timeout=15)
253
+ except CommandError as exc:
254
+ log.debug("could not capture the pane of %s (%s)", name, exc)
255
+ return ""
256
+ return out.rstrip("\n")
257
+
258
+ def pane_path(self, name: str) -> str:
259
+ """The session's pane current path (`#{pane_current_path}`), or ""
260
+ when tmux cannot answer. One leg of the lane's worktree resolution:
261
+ a worker that `cd`ed into its `TASK-*` worktree names it here."""
262
+ try:
263
+ argv = pane_path_argv(name)
264
+ except ValueError as exc:
265
+ log.warning("pane_path refused: %s", exc)
266
+ return ""
267
+ try:
268
+ return run(argv, timeout=15).strip()
269
+ except CommandError as exc:
270
+ log.debug("could not read the pane path of %s (%s)", name, exc)
271
+ return ""
272
+
273
+ def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
274
+ """Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
275
+ -- on ONE managed session's pane. True when tmux accepted them.
276
+
277
+ The ONLY key-pressing surface in the fleet, and deliberately narrow:
278
+ an allowlisted enum, a validated session name, an exact-match target,
279
+ one `send-keys` per call. A key outside the allowlist is refused
280
+ before any process is spawned and logged at WARNING (it is a
281
+ programming error, never a pane's doing -- keys come from the
282
+ policy table, not from the screen). Dry-run logs the intent and
283
+ sends nothing."""
284
+ try:
285
+ argv = send_keys_argv(name, tuple(keys))
286
+ except ValueError as exc:
287
+ log.warning("send_keys refused for %s: %s", name, exc)
288
+ return False
289
+ if dry_run:
290
+ log.info("[dry-run] would send keys %s to %s", " ".join(keys), name)
291
+ return False
292
+ try:
293
+ run(argv, timeout=15)
294
+ except CommandError as exc:
295
+ log.warning("could not send keys %s to %s (%s)", " ".join(keys), name, exc)
296
+ return False
297
+ return True
298
+
152
299
  def worker_running(self) -> bool:
153
300
  """The queue only drains while `alissa worker` reconciles it."""
154
301
  try:
@@ -24,6 +24,8 @@ through to the config rather than fail the daemon's boot on a mode of `''`.
24
24
  `loop_events_enabled` rides the same shape of rail through
25
25
  `ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule, and
26
26
  `fleet_vitals_enabled` through `ALISSA_DEV_FLEET_VITALS_ENABLED` likewise.
27
+ `revert_label` rides `ALISSA_REVERT_LABEL` the same way (issue #131): a
28
+ non-blank value wins over the CLI and the file, a blank one falls through.
27
29
  """
28
30
 
29
31
  from __future__ import annotations
@@ -108,6 +110,25 @@ ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
108
110
  # non-blank-wins / blank-falls-through rule, same config error on a typo.
109
111
  ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
110
112
 
113
+ # The revert lever's label rail (issue #131): `revert_label` ridden the way
114
+ # ALISSA_DEV_REPOS_SOURCE rides repos_source -- a NON-BLANK value wins over
115
+ # the CLI and the file, a blank one falls through. Spelled as the issue names
116
+ # it (no `_DEV_` infix), because that is the name the operator runbook gives.
117
+ ENV_REVERT_LABEL = "ALISSA_REVERT_LABEL"
118
+
119
+ # The prompt responder's master switch (issue #127): `on` answers, `observe`
120
+ # classifies and narrates but sends nothing, `off` never captures a pane.
121
+ PROMPT_ON = "on"
122
+ PROMPT_OBSERVE = "observe"
123
+ PROMPT_OFF = "off"
124
+ _PROMPT_MODES = frozenset((PROMPT_ON, PROMPT_OBSERVE, PROMPT_OFF))
125
+
126
+ # Where lane W1's hooks leave the "this session is waiting for input" marker
127
+ # (`docker/claude/hooks/note-waiting.py`): `${ALISSA_WAITING_DIR}/<tmux
128
+ # session>.json`. The daemon reads the same directory, so the two halves
129
+ # agree by construction on a default deployment.
130
+ DEFAULT_WAITING_DIR = "/workspace/.waiting"
131
+
111
132
  _ENV_BOOL_TRUE = frozenset(("1", "true"))
112
133
  _ENV_BOOL_FALSE = frozenset(("0", "false"))
113
134
 
@@ -179,8 +200,16 @@ CONFIG_KEYS = (
179
200
  "fix_rounds_enabled",
180
201
  "maintain_label",
181
202
  "maintain_enabled",
203
+ "revert_label",
204
+ "revert_enabled",
205
+ "revert_any_author",
182
206
  "resume_enabled",
183
207
  "wedge_minutes",
208
+ "prompt_responder",
209
+ "prompt_quiet_seconds",
210
+ "prompt_kill_minutes",
211
+ "prompt_max_answers",
212
+ "waiting_dir",
184
213
  "rerequest_enabled",
185
214
  "rerequest_grace_minutes",
186
215
  "orphan_sweep_enabled",
@@ -469,6 +498,20 @@ class Config:
469
498
  maintain_label: str = "alissa:maintain"
470
499
  maintain_enabled: bool = True
471
500
 
501
+ # The revert lever (issue #131, the fourth polled edge): an operator
502
+ # labels a MERGED PR `revert_label` to request a session that reverts its
503
+ # merge commit on a fresh `TASK-<n>-REVERT-PR<k>` branch and opens the
504
+ # revert PR ready for review. One spawn per label application (the ledger
505
+ # keys on the labeled-at instant), no stale retry -- the operator's retry
506
+ # is unlabel + re-label. `revert_any_author` widens the lever past the
507
+ # loop's own PRs: off by default in `merge_author_logins`' spirit, because
508
+ # the daemon reverting a human's merge is a decision a human should make
509
+ # explicitly, in config, not by applying a label. Default ON like the
510
+ # maintenance edge, and every side effect still honors dry_run.
511
+ revert_label: str = "alissa:revert"
512
+ revert_enabled: bool = True
513
+ revert_any_author: bool = False
514
+
472
515
  # The third staleness signal (session liveness, read from the reaper's own
473
516
  # tmux listing) and the auto-resume it unlocks: a stale attempt whose
474
517
  # ledger session is provably gone is respawned with RESUME_DIRECTIVE /
@@ -514,6 +557,39 @@ class Config:
514
557
  # never this field.
515
558
  wedge_minutes: int = 0
516
559
 
560
+ # The prompt responder (issue #127): every poll, for every ALIVE worker
561
+ # session this daemon owns, a waiting marker from lane W1's hook OR a
562
+ # session quiet for `prompt_quiet_seconds` triggers a pane capture, the
563
+ # classifier and the policy table (`prompts.py`), and the answer lands
564
+ # in seconds -- no SSH, no LLM in the loop. `on` (the default) answers;
565
+ # `observe` classifies and narrates but sends no key, kills nothing and
566
+ # pages nobody; `off` never captures a pane. Every side effect honours
567
+ # dry_run (a dry pass classifies and logs, sends nothing).
568
+ prompt_responder: str = PROMPT_ON
569
+
570
+ # How long a worker session must have been quiet (`lastActivity` in the
571
+ # roster) before its pane is read WITHOUT a waiting marker. The marker
572
+ # is the fast path (seconds); this is the net under it, for a hook that
573
+ # did not fire. Floored at 30 so a session between two tool calls is not
574
+ # read as parked.
575
+ prompt_quiet_seconds: int = 90
576
+
577
+ # The unknown-dialog ladder's last rung: a dialog no signature knows is
578
+ # waited out one poll, dismissed with Escape on its second sighting, and
579
+ # after this many minutes still on screen the session is killed so the
580
+ # ordinary retry/resume edge takes over. Floored at 1.
581
+ prompt_kill_minutes: int = 10
582
+
583
+ # The per-session answer cap: after this many keystroke answers the
584
+ # session is killed-and-retried -- a worker that keeps producing prompts
585
+ # is diverging, and the narration says so. Floored at 1.
586
+ prompt_max_answers: int = 5
587
+
588
+ # Where the waiting markers are (see DEFAULT_WAITING_DIR). The hooks
589
+ # read ALISSA_WAITING_DIR at run time; the container's config generator
590
+ # writes the same value here so the daemon looks where the hooks wrote.
591
+ waiting_dir: str = DEFAULT_WAITING_DIR
592
+
517
593
  # The wedge self-heal (issue #61): the fix edge's crash-recovery net under
518
594
  # FIX_DIRECTIVE's terminal step. A fix session that posted its triage
519
595
  # replies and PUSHED, then died before re-requesting review, leaves the PR
@@ -735,6 +811,23 @@ class Config:
735
811
  return 0
736
812
  return max(self.wedge_minutes, self.stale_minutes) * 60
737
813
 
814
+ @property
815
+ def prompt_kill_seconds(self) -> int:
816
+ """The unknown-dialog kill rung in seconds, floored at one minute
817
+ here as well as in `build()` so a Config assembled without it can
818
+ never kill a session on its first sighting."""
819
+ return max(1, self.prompt_kill_minutes) * 60
820
+
821
+ @property
822
+ def prompt_quiet_floor(self) -> int:
823
+ """`prompt_quiet_seconds` with the 30 s floor applied again (the
824
+ `wedge_seconds` contract: `build()` clamps, and so does the read)."""
825
+ return max(30, self.prompt_quiet_seconds)
826
+
827
+ @property
828
+ def prompt_answer_cap(self) -> int:
829
+ return max(1, self.prompt_max_answers)
830
+
738
831
  @property
739
832
  def janitor_grace_seconds(self) -> int:
740
833
  """How long an anchor must have been terminal before its workspace may
@@ -893,6 +986,14 @@ class Config:
893
986
  env_map, ENV_FLEET_VITALS,
894
987
  bool(raw.get("fleet_vitals_enabled", cls.fleet_vitals_enabled)),
895
988
  )
989
+ # The revert label's rail (issue #131): repos_source's shape -- a
990
+ # non-blank ALISSA_REVERT_LABEL wins, blank falls through.
991
+ revert_label = str(raw.get("revert_label", cls.revert_label))
992
+ env_revert_label = env_map.get(ENV_REVERT_LABEL, "").strip()
993
+ if env_revert_label:
994
+ revert_label = env_revert_label
995
+ if not revert_label.strip():
996
+ raise ValueError("revert_label must not be blank")
896
997
 
897
998
  bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
898
999
 
@@ -1014,6 +1115,47 @@ class Config:
1014
1115
  )
1015
1116
  wedge = stale
1016
1117
 
1118
+ # The prompt responder's knobs (issue #127). The mode is an enum
1119
+ # validated by NAME like foreign_session_policy (a typo must not read
1120
+ # as "off"); the three numbers have FLOORS rather than 0-sentinels --
1121
+ # none of them has a meaningful "off" value, and each floor is the
1122
+ # smallest value that cannot answer a session that is merely between
1123
+ # two tool calls -- and are clamped up with a warning, the wedge
1124
+ # window's contract, because none of them is worth failing a boot.
1125
+ prompt_mode = str(raw.get("prompt_responder", cls.prompt_responder)).strip()
1126
+ if prompt_mode not in _PROMPT_MODES:
1127
+ raise ValueError(
1128
+ f"prompt_responder must be one of {sorted(_PROMPT_MODES)}, "
1129
+ f"got {prompt_mode!r}"
1130
+ )
1131
+ prompt_quiet = int(raw.get("prompt_quiet_seconds", cls.prompt_quiet_seconds))
1132
+ if prompt_quiet < 30:
1133
+ log.warning(
1134
+ "prompt_quiet_seconds=%d is below the 30 s floor — a shorter "
1135
+ "window would read a session between two tool calls as "
1136
+ "parked; clamping up to 30", prompt_quiet,
1137
+ )
1138
+ prompt_quiet = 30
1139
+ prompt_kill = int(raw.get("prompt_kill_minutes", cls.prompt_kill_minutes))
1140
+ if prompt_kill < 1:
1141
+ log.warning(
1142
+ "prompt_kill_minutes=%d is below the 1 min floor — the "
1143
+ "unknown-dialog ladder needs at least one poll between the "
1144
+ "dismissal and the kill; clamping up to 1", prompt_kill,
1145
+ )
1146
+ prompt_kill = 1
1147
+ prompt_answers = int(raw.get("prompt_max_answers", cls.prompt_max_answers))
1148
+ if prompt_answers < 1:
1149
+ log.warning(
1150
+ "prompt_max_answers=%d is below the floor of 1 — a cap of 0 "
1151
+ "would kill every session on its first prompt; clamping up "
1152
+ "to 1", prompt_answers,
1153
+ )
1154
+ prompt_answers = 1
1155
+ waiting_dir = str(raw.get("waiting_dir", cls.waiting_dir)).strip()
1156
+ if not waiting_dir:
1157
+ raise ValueError("waiting_dir must be a non-empty path")
1158
+
1017
1159
  # The janitor's grace, the reaper's sentinel one rung longer (0 = use
1018
1160
  # reap_grace_minutes). Negative is the only invalid value, and it is
1019
1161
  # the dangerous one: it would license reclaiming a workspace the
@@ -1106,10 +1248,20 @@ class Config:
1106
1248
  maintain_enabled=bool(
1107
1249
  raw.get("maintain_enabled", cls.maintain_enabled)
1108
1250
  ),
1251
+ revert_label=revert_label,
1252
+ revert_enabled=bool(raw.get("revert_enabled", cls.revert_enabled)),
1253
+ revert_any_author=bool(
1254
+ raw.get("revert_any_author", cls.revert_any_author)
1255
+ ),
1109
1256
  resume_enabled=bool(
1110
1257
  raw.get("resume_enabled", cls.resume_enabled)
1111
1258
  ),
1112
1259
  wedge_minutes=wedge,
1260
+ prompt_responder=prompt_mode,
1261
+ prompt_quiet_seconds=prompt_quiet,
1262
+ prompt_kill_minutes=prompt_kill,
1263
+ prompt_max_answers=prompt_answers,
1264
+ waiting_dir=waiting_dir,
1113
1265
  rerequest_enabled=bool(
1114
1266
  raw.get("rerequest_enabled", cls.rerequest_enabled)
1115
1267
  ),
@@ -208,7 +208,7 @@ def build_snapshot(
208
208
  ledgers = sources.ledgers()
209
209
 
210
210
  repo_by_session: "dict[str, str]" = {}
211
- for table in ("spawns", "fix_spawns", "maintain_spawns"):
211
+ for table in ("spawns", "fix_spawns", "maintain_spawns", "revert_spawns"):
212
212
  for row in ledgers[table]:
213
213
  session = row.get("session")
214
214
  if session and session not in repo_by_session:
@@ -243,6 +243,7 @@ def build_snapshot(
243
243
  int(latest.get("issue_candidates") or 0)
244
244
  + int(latest.get("pr_candidates") or 0)
245
245
  + int(latest.get("maintain_candidates") or 0)
246
+ + int(latest.get("revert_candidates") or 0)
246
247
  )
247
248
 
248
249
  drift = sources.drift()