alissa-tools-github-revloop 0.22.0__tar.gz → 0.24.0__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 (32) hide show
  1. {alissa_tools_github_revloop-0.22.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.24.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/__main__.py +12 -0
  3. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/alissa.py +171 -17
  4. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/config.py +75 -1
  5. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/loop.py +11 -1
  6. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/prreview.py +9 -4
  7. alissa_tools_github_revloop-0.24.0/src/main/alissa/tools/github/revloop/version +1 -0
  8. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_revloop-0.22.0/src/main/alissa/tools/github/revloop/version +0 -1
  10. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/LICENSE +0 -0
  11. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/MANIFEST.in +0 -0
  12. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/NOTICE +0 -0
  13. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/README.md +0 -0
  14. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/requirements.txt +0 -0
  15. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/setup.cfg +0 -0
  16. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/setup.py +0 -0
  17. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  18. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  19. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  20. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
  21. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.22.0 → alissa_tools_github_revloop-0.24.0}/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.22.0
3
+ Version: 0.24.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -14,6 +14,7 @@ from .config import (
14
14
  ON_MISSING_CREATE,
15
15
  ON_MISSING_SKIP,
16
16
  ON_MISSING_SPAWN,
17
+ TASK_LIST_BOW_ENV,
17
18
  Config,
18
19
  load_config_file,
19
20
  resolve_config_path,
@@ -181,6 +182,16 @@ def build_parser() -> argparse.ArgumentParser:
181
182
  help="list the sponsor-union corpus even if the config narrows it",
182
183
  )
183
184
 
185
+ over.add_argument(
186
+ "--task-list-bow",
187
+ dest="task_list_bow_id",
188
+ metavar="BOW_ID",
189
+ help="scope `alissa task list` to one body of work (--bow): the Convex "
190
+ "_id of the BOW review tasks are created into, NOT a repo's `autodev:` "
191
+ "feed BOW and not a mirrorInstanceId. A review task outside it is "
192
+ f"invisible to this daemon. Overridden by ${TASK_LIST_BOW_ENV}",
193
+ )
194
+
184
195
  dry = over.add_mutually_exclusive_group()
185
196
  dry.add_argument(
186
197
  "--dry-run",
@@ -221,6 +232,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
221
232
  "checks_spawn_wait_seconds": args.checks_spawn_wait_seconds,
222
233
  "review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
223
234
  "task_list_self_scope": args.task_list_self_scope,
235
+ "task_list_bow_id": args.task_list_bow_id,
224
236
  "dry_run": args.dry_run,
225
237
  }
226
238
 
@@ -22,6 +22,12 @@ log = logging.getLogger(__name__)
22
22
  # 0.2.0 validates `--status` against exactly these seven client-side and exits 1
23
23
  # on anything else BEFORE issuing any request, so a single non-canonical value
24
24
  # does not narrow the call slightly less -- it costs the whole narrowed call.
25
+ #
26
+ # A status Alissa adds lands HERE first: a name added to `OPEN_STATUSES` alone
27
+ # reads as non-canonical and drops the narrowing for the whole fleet. That is
28
+ # the safe direction -- wide, never a wrong task resolved -- and the canonical
29
+ # test fails on it, but it fails naming `OPEN_STATUSES`, which is the constant
30
+ # that is right; this is the one that is stale (PR #98 round 1).
25
31
  CANONICAL_TASK_STATUSES = frozenset(
26
32
  {
27
33
  "draft",
@@ -79,24 +85,24 @@ def _status_filter(statuses: "set[str] | frozenset[str]") -> str:
79
85
  and `--self` narrowing down with it and pinning this process to the
80
86
  whole-corpus list (`list_tasks`). One wide call beats every call being wide.
81
87
 
82
- Loud rather than silent: the condition is a code defect (a status added to
83
- `OPEN_STATUSES` that Alissa cannot issue), and the tests pin against it, so
84
- this is the last line rather than the first.
88
+ Pure on purpose. The condition is a code defect and deserves saying out
89
+ loud, but not from here: this runs at IMPORT, which is the one moment the
90
+ daemon's logging is not configured yet (see
91
+ `Alissa._warn_status_filter_dropped`). The tests are what pin the condition;
92
+ the runtime warning is the second line, and it is emitted where it can both
93
+ be formatted and be true.
85
94
  """
86
- unknown = sorted(set(statuses) - CANONICAL_TASK_STATUSES)
87
- if unknown:
88
- log.warning(
89
- "not narrowing `alissa task list` by status: %s not canonical "
90
- "Alissa status(es) -- the CLI rejects the whole call over one "
91
- "unknown value, so this daemon lists the corpus unnarrowed",
92
- ", ".join(unknown),
93
- )
95
+ if set(statuses) - CANONICAL_TASK_STATUSES:
94
96
  return ""
95
97
  return ",".join(sorted(statuses))
96
98
 
97
99
 
98
100
  TASK_LIST_STATUS_FILTER = _status_filter(OPEN_STATUSES)
99
101
 
102
+ # Why the filter above is empty, when it is -- kept for the diagnostic, which
103
+ # cannot be emitted from module scope. Empty in the healthy case.
104
+ NON_CANONICAL_OPEN_STATUSES = tuple(sorted(OPEN_STATUSES - CANONICAL_TASK_STATUSES))
105
+
100
106
  # `--self` drops the SPONSOR's corpus and keeps only the calling actor's rows.
101
107
  #
102
108
  # NOT enabled by default, and the reason is measured rather than cautious. On
@@ -116,6 +122,48 @@ TASK_LIST_SELF_FLAG = "--self"
116
122
  TASK_LIST_VIEW_FLAG = "--view"
117
123
  TASK_LIST_DIGEST_VIEW = "digest"
118
124
 
125
+ # `--bow` narrows the CANDIDATE SET rather than filtering the answer, and that
126
+ # is the whole reason it is worth more than the flags above (issue #100).
127
+ #
128
+ # `--status` / `--self` / `--view` all start from the operator's involvement
129
+ # index -- every non-terminal task they are party to -- and cut it down. A BOW
130
+ # scope starts from that body of work's junction rows instead, which carry a
131
+ # denormalized status, so a row that does not match never costs a task-document
132
+ # read at all. The cache story is the same shape: a BOW-scoped query is
133
+ # invalidated only by writes INSIDE that BOW, so the operator's unrelated task
134
+ # churn stops re-billing this daemon's 30-second poll.
135
+ #
136
+ # It really is a SWAP and not an intersection, and that was measured against the
137
+ # installed CLI on 2026-08-28 rather than assumed (PR #101 round 1): across five
138
+ # real BOWs, not one answer was a subset of the plain 1335-row one, and the rows
139
+ # outside it were all non-terminal. Two consequences the composition story has to
140
+ # respect, from the same probe:
141
+ #
142
+ # * `--self` is IGNORED under `--bow`. `--bow X --self` returns exactly what
143
+ # `--bow X` returns, and that set is not a subset of the `--self` answer. So
144
+ # a BOW-scoped deployment gets the body of work's whole membership, actor-
145
+ # owned or not -- WIDER than `--self`, never narrower.
146
+ # * `--include-terminal` is ignored too (a BOW holding 52 tasks, 17 of them
147
+ # `validated`, answers 35 either way), so the default non-terminal filter is
148
+ # applied server-side but the flag that lifts it is not. `--status` could not
149
+ # be probed -- this CLI has none -- and on that evidence should not be
150
+ # assumed to compose either.
151
+ #
152
+ # None of that is a correctness risk, which is why the flags are still sent: a
153
+ # filter the server ignores makes the answer WIDER, `is_open` still runs
154
+ # client-side, and the argv is one the CLI accepts. It costs bytes, not reviews.
155
+ # Treat the list above as a dated snapshot, not a contract: this CLI has grown
156
+ # flags twice now without moving off version `0.1.0`.
157
+ #
158
+ # Opt-in per deployment for the same reason `--self` is, only sharper: a review
159
+ # task that is not attached to the configured BOW is INVISIBLE to the daemon --
160
+ # and on the default `on_missing_review_task` that is not even a missed round but
161
+ # a round spawned untethered from its task -- and review tasks are only born into
162
+ # a BOW once the sessions that create them say so. A configured BOW that nothing
163
+ # writes into lists empty; `list_tasks` retries plain and drops the BOW alone.
164
+ # That is a safety net, not a licence to guess the id.
165
+ TASK_LIST_BOW_FLAG = "--bow"
166
+
119
167
 
120
168
  @dataclass(frozen=True)
121
169
  class TaskListFlags:
@@ -129,6 +177,7 @@ class TaskListFlags:
129
177
  status: bool = False
130
178
  self_scope: bool = False
131
179
  digest: bool = False
180
+ bow: bool = False
132
181
 
133
182
 
134
183
  # How deeply an OPTION may be indented in a commander help listing. Commander
@@ -364,7 +413,12 @@ class TaskDetail:
364
413
 
365
414
 
366
415
  class Alissa:
367
- def __init__(self, *, task_list_self_scope: bool = False) -> None:
416
+ def __init__(
417
+ self,
418
+ *,
419
+ task_list_self_scope: bool = False,
420
+ task_list_bow_id: "str | None" = None,
421
+ ) -> None:
368
422
  """`task_list_self_scope` opts the list call into `--self`.
369
423
 
370
424
  Default OFF, and that default is evidence, not caution -- see
@@ -372,8 +426,15 @@ class Alissa:
372
426
  of a DEPLOYMENT (who creates its review tasks), not of this code, and an
373
427
  operator who knows their review tasks are all actor-owned should be able
374
428
  to say so.
429
+
430
+ `task_list_bow_id` opts the list call into `--bow <id>` (issue #100).
431
+ Unset -- the default -- is today's call shape, unconditionally. It is
432
+ the id ALREADY RESOLVED through the config layers (file < CLI < env);
433
+ this class does not read the environment, so the one place that decides
434
+ what the id is stays `config.env_task_list_bow_id`.
375
435
  """
376
436
  self._task_list_self_scope = bool(task_list_self_scope)
437
+ self._task_list_bow_id = (task_list_bow_id or "").strip() or None
377
438
  # The probe's answer, memoized for the process; None = not probed yet.
378
439
  # A probe that FAILS is deliberately not memoized (see probe_task_list).
379
440
  self._task_list_flags: "TaskListFlags | None" = None
@@ -383,6 +444,26 @@ class Alissa:
383
444
  # wrongly is worse than a list that is large: `find_review_task` reads an
384
445
  # empty corpus as "this PR has no review task".
385
446
  self._task_list_narrowing_disabled = False
447
+ # Set when the BOW SPECIFICALLY has been disproved at runtime -- a
448
+ # BOW-scoped call that answered empty. Separate from the flag above
449
+ # because the evidence is different in kind: `--status`/`--self`/
450
+ # `--view` filter the same corpus, so an empty answer from them is
451
+ # anomalous, while `--bow` REPLACES the corpus and "this body of work
452
+ # holds nothing" is a legitimate answer (see `list_tasks`).
453
+ self._task_list_bow_disabled = False
454
+ # One diagnostic per client for a status filter that could not be sent
455
+ # (see `_warn_status_filter_dropped`); this path runs every poll pass.
456
+ self._status_filter_warned = False
457
+
458
+ @property
459
+ def task_list_bow_id(self) -> "str | None":
460
+ """The BOW this client scopes its task list to, or None.
461
+
462
+ Read by the boot preflight, which must warn on the id this CLIENT holds
463
+ rather than on the one the config file happens to carry -- the env layer
464
+ wins over both file and CLI, so those two can disagree.
465
+ """
466
+ return self._task_list_bow_id
386
467
 
387
468
  # -- the `alissa task list` narrowing probe -----------------------------
388
469
 
@@ -424,10 +505,43 @@ class Alissa:
424
505
  status=_advertises(helptext, TASK_LIST_STATUS_FLAG),
425
506
  self_scope=_advertises(helptext, TASK_LIST_SELF_FLAG),
426
507
  digest=_advertises(helptext, TASK_LIST_VIEW_FLAG),
508
+ bow=_advertises(helptext, TASK_LIST_BOW_FLAG),
427
509
  )
428
510
  self._task_list_flags = flags
429
511
  return flags
430
512
 
513
+ def _warn_status_filter_dropped(self) -> None:
514
+ """Say, once, that a status narrowing this CLI offers is going unused.
515
+
516
+ Not emitted where the filter is COMPUTED, which is import time, and that
517
+ is the whole point of it living here (PR #98 round 1): `__main__` pulls
518
+ this module in through `.loop` long before it calls
519
+ `logging.basicConfig`, so a warning at module scope falls through to
520
+ `logging.lastResort` -- bare on stderr, no timestamp or logger name, and
521
+ outside whatever handler the deployment configured.
522
+
523
+ It would also be premature there. At import nothing yet knows whether
524
+ this CLI serves `--status` at all, and on a CLI that does not, the
525
+ filter costs nothing and there is nothing to warn about. Here the
526
+ message is only emitted when the flag was on offer and could not be
527
+ used, which is the only case where the defect actually costs reads.
528
+ """
529
+ if self._status_filter_warned:
530
+ return
531
+ self._status_filter_warned = True
532
+ reason = (
533
+ "non-canonical status(es) in OPEN_STATUSES: "
534
+ + ", ".join(NON_CANONICAL_OPEN_STATUSES)
535
+ if NON_CANONICAL_OPEN_STATUSES
536
+ else "OPEN_STATUSES is empty"
537
+ )
538
+ log.warning(
539
+ "`alissa task list` offers --status and this daemon is not using "
540
+ "it (%s) -- the CLI rejects the whole call over one unknown value, "
541
+ "so the corpus is listed unnarrowed",
542
+ reason,
543
+ )
544
+
431
545
  def task_list_argv(self, *, narrow_status: bool = True) -> list[str]:
432
546
  """The narrowest `alissa task list` this CLI actually supports.
433
547
 
@@ -438,11 +552,23 @@ class Alissa:
438
552
  if self._task_list_narrowing_disabled:
439
553
  return argv
440
554
  flags = self.probe_task_list()
555
+ # The BOW goes FIRST because it is the only one of these that chooses a
556
+ # candidate set rather than filtering one, and reading the argv left to
557
+ # right is reading them in that order. The others are still sent: the
558
+ # projection (`--view digest`) genuinely applies within a BOW, and the
559
+ # ones the server ignores there cost bytes rather than reviews -- see
560
+ # TASK_LIST_BOW_FLAG for which is which, and for why that list is a
561
+ # dated snapshot rather than a contract.
562
+ if flags.bow and self._task_list_bow_id and not self._task_list_bow_disabled:
563
+ argv += [TASK_LIST_BOW_FLAG, self._task_list_bow_id]
441
564
  # An empty filter is not a filter: `_status_filter` answers `""` when
442
565
  # the open set carries a status the CLI would reject, and sending
443
566
  # `--status ""` would be that same rejected call with an extra step.
444
- if flags.status and narrow_status and TASK_LIST_STATUS_FILTER:
445
- argv += [TASK_LIST_STATUS_FLAG, TASK_LIST_STATUS_FILTER]
567
+ if flags.status and narrow_status:
568
+ if TASK_LIST_STATUS_FILTER:
569
+ argv += [TASK_LIST_STATUS_FLAG, TASK_LIST_STATUS_FILTER]
570
+ else:
571
+ self._warn_status_filter_dropped()
446
572
  if flags.self_scope and self._task_list_self_scope:
447
573
  argv.append(TASK_LIST_SELF_FLAG)
448
574
  if flags.digest:
@@ -458,8 +584,9 @@ class Alissa:
458
584
  filtering. Newer CLIs offer more, so the call is now assembled from a
459
585
  boot-time probe of the installed CLI's help (`task_list_argv`): a status
460
586
  filter covering exactly the statuses a live review task can hold, a lean
461
- `--view digest`, and `--self` when the deployment says its review tasks
462
- are actor-owned. None of it is required; an absent flag is simply not
587
+ `--view digest`, `--self` when the deployment says its review tasks are
588
+ actor-owned, and `--bow <id>` when it has named the body of work they
589
+ are created into. None of it is required; an absent flag is simply not
463
590
  sent.
464
591
 
465
592
  Narrowing is still the SECOND line of defence, not the first. Even a
@@ -514,7 +641,34 @@ class Alissa:
514
641
  "empty corpus from a filter this API does not serve", " ".join(argv),
515
642
  )
516
643
  tasks = self._tasks_from(run_json(plain, timeout=90) or [])
517
- if tasks:
644
+ if not tasks:
645
+ return tasks
646
+ if TASK_LIST_BOW_FLAG in argv:
647
+ # "The plain list found rows" proves nothing about a BOW-scoped
648
+ # call, because the two answers are not comparable: `--bow` swaps
649
+ # the candidate set rather than filtering the actor's, so its answer
650
+ # is not a subset of the plain one (measured 2026-08-28 -- see
651
+ # TASK_LIST_BOW_FLAG). Condemning the other three flags on this
652
+ # evidence would drop the whole optimization on the state the
653
+ # rollout STARTS in: a review BOW nothing has been created into yet.
654
+ #
655
+ # The BOW itself is still dropped for the process, and that is not a
656
+ # third option so much as the only tractable one: with a swap there
657
+ # is no way to tell a mistyped id from an empty-but-correct one, and
658
+ # holding the BOW would mean paying two list calls every pass, for
659
+ # ever, on a typo. Wide-but-complete, the direction everything on
660
+ # this path degrades in.
661
+ self._task_list_bow_disabled = True
662
+ log.warning(
663
+ "the BOW-scoped task list answered empty while the plain list "
664
+ "returned %d task(s) — that is not evidence the call is broken "
665
+ "(a BOW-scoped list REPLACES the actor's corpus), so only the "
666
+ "BOW is dropped for this process and the other narrowing "
667
+ "stands. Either the configured id is wrong, or nothing has been "
668
+ "created into that body of work yet; once it is populated, "
669
+ "restart the daemon to scope to it again", len(tasks),
670
+ )
671
+ else:
518
672
  self._task_list_narrowing_disabled = True
519
673
  log.warning(
520
674
  "the plain task list returned %d task(s) — the narrowed call is "
@@ -6,6 +6,13 @@ Settings come from three layers, later winning over earlier:
6
6
  2. a JSON config file (see `resolve_config_path`)
7
7
  3. CLI arguments
8
8
 
9
+ `task_list_bow_id` adds a fourth above them all — the environment
10
+ (`ALISSA_REVIEW_TASK_BOW`, see `env_task_list_bow_id`). It is the only key that
11
+ does, and the reason is that it is the only one a second entry point needs:
12
+ `alissa-pr-review` builds its own `Alissa` client with no config file and no
13
+ argv of the daemon's, so an id that lives only in the file or the flags reaches
14
+ the poll loop and silently misses that call site.
15
+
9
16
  `workspace_root` is deliberately **not** a config key — it is a property of the
10
17
  running process, not of the settings. That lets one config file drive several
11
18
  daemons over different workspaces on the same machine, each pointed with
@@ -15,6 +22,7 @@ daemons over different workspaces on the same machine, each pointed with
15
22
  from __future__ import annotations
16
23
 
17
24
  import json
25
+ import os
18
26
  import re
19
27
  from dataclasses import dataclass
20
28
  from pathlib import Path
@@ -135,9 +143,30 @@ CONFIG_KEYS = (
135
143
  "checks_spawn_wait_seconds",
136
144
  "review_task_miss_ttl_polls",
137
145
  "task_list_self_scope",
146
+ "task_list_bow_id",
138
147
  "dry_run",
139
148
  )
140
149
 
150
+ # The environment variable carrying the review BOW id (issue #100). It outranks
151
+ # both the config file and the CLI flag -- see the module docstring for why this
152
+ # one key gets a layer the others do not.
153
+ TASK_LIST_BOW_ENV = "ALISSA_REVIEW_TASK_BOW"
154
+
155
+
156
+ def env_task_list_bow_id(environ: "Mapping[str, str] | None" = None) -> "str | None":
157
+ """The review BOW id from the environment, or None when it is not set.
158
+
159
+ `environ` is passed in rather than read from `os.environ` at every call site
160
+ so the precedence is a pure function the tests can drive (the same shape
161
+ `webui.auth.require_passcode` uses). None and empty are the SAME answer: an
162
+ exported-but-empty variable is how a container renders "unset", and reading
163
+ it as an id would send `--bow ""` -- a call the CLI would take and answer
164
+ with nobody's tasks.
165
+ """
166
+ raw = (os.environ if environ is None else environ).get(TASK_LIST_BOW_ENV)
167
+ return (raw or "").strip() or None
168
+
169
+
141
170
  MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
142
171
 
143
172
  # A reviewer session that has not submitted after this long is presumed dead
@@ -343,6 +372,19 @@ class Config:
343
372
  # when the installed CLI does not advertise the flag.
344
373
  task_list_self_scope: bool = False
345
374
 
375
+ # The Convex `_id` of the body of work review tasks are created into. Set it
376
+ # and `alissa task list` is scoped to that BOW's junction rows (`--bow`)
377
+ # instead of the operator's whole involvement index; leave it None -- the
378
+ # default -- and the call shape is exactly what it has always been.
379
+ #
380
+ # Off by default because a review task OUTSIDE the configured BOW is
381
+ # invisible to the daemon, which is a missed round rather than a slower one,
382
+ # and nothing creates review tasks into a BOW until the operator's review
383
+ # protocol says to. Ignored when the installed CLI does not advertise the
384
+ # flag. See alissa.TASK_LIST_BOW_FLAG, and the README for the two ways to
385
+ # get the id wrong (a repo's `autodev:` feed BOW; a `mirrorInstanceId`).
386
+ task_list_bow_id: str | None = None
387
+
346
388
  dry_run: bool = False
347
389
 
348
390
  def __post_init__(self) -> None:
@@ -395,9 +437,15 @@ class Config:
395
437
  workspace_root: Path,
396
438
  file_data: Mapping[str, Any] | None = None,
397
439
  overrides: Mapping[str, Any] | None = None,
440
+ environ: Mapping[str, str] | None = None,
398
441
  ) -> "Config":
399
442
  """Merge the layers and validate. `overrides` entries that are None mean
400
- "not specified on the CLI" and fall through to the file / defaults."""
443
+ "not specified on the CLI" and fall through to the file / defaults.
444
+
445
+ `environ` is the fourth layer and applies to `task_list_bow_id` alone;
446
+ it wins over both of the others (see the module docstring). Defaults to
447
+ the real environment, so callers that do not care pass nothing.
448
+ """
401
449
  raw: dict[str, Any] = dict(file_data or {})
402
450
 
403
451
  if "workspace_root" in raw:
@@ -419,6 +467,31 @@ class Config:
419
467
  if value is not None:
420
468
  raw[key] = value
421
469
 
470
+ # Applied AFTER the CLI overrides, which is the whole of "file < CLI <
471
+ # env": the variable is how a container hands the daemon an id that no
472
+ # file on its volume and no argv in its unit carries, so it has to be
473
+ # able to say something the other two do not.
474
+ env_bow = env_task_list_bow_id(environ)
475
+ if env_bow is not None:
476
+ raw["task_list_bow_id"] = env_bow
477
+ bow_id = raw.get("task_list_bow_id")
478
+ if bow_id is not None and not isinstance(bow_id, str):
479
+ # Deliberately NOT the `int(...)`/`bool(...)` coercion the numeric
480
+ # keys around here use. Those coerce into a value the validation
481
+ # below them then checks; there is nothing downstream to check an
482
+ # id against, so `0` would coerce to a well-formed `--bow 0` that
483
+ # can never resolve -- landing the deployment in the empty-answer
484
+ # path instead of telling it anything. A misspelled KEY already
485
+ # fails loudly at load; a mistyped VALUE should too.
486
+ raise ValueError(
487
+ f"task_list_bow_id must be the review BOW's Convex _id as a "
488
+ f"string (or null for unset), got a {type(bow_id).__name__}"
489
+ )
490
+ # Normalised the same way the env layer is, so `""` from a config file
491
+ # or a flag means "unset" too rather than `--bow ""` -- a call the CLI
492
+ # takes and answers with nobody's tasks.
493
+ bow_id = (bow_id or "").strip() or None
494
+
422
495
  mode = raw.get("on_missing_review_task", ON_MISSING_SPAWN)
423
496
  if mode not in _MISSING_MODES:
424
497
  raise ValueError(
@@ -580,6 +653,7 @@ class Config:
580
653
  checks_spawn_wait_seconds=spawn_wait,
581
654
  review_task_miss_ttl_polls=miss_ttl,
582
655
  task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
656
+ task_list_bow_id=bow_id,
583
657
  dry_run=bool(raw.get("dry_run", False)),
584
658
  )
585
659
 
@@ -1425,7 +1425,8 @@ class ReviewWatcher:
1425
1425
  config.reviewer_login, token_env=config.reviewer_token_env
1426
1426
  )
1427
1427
  self.alissa = alissa or Alissa(
1428
- task_list_self_scope=config.task_list_self_scope
1428
+ task_list_self_scope=config.task_list_self_scope,
1429
+ task_list_bow_id=config.task_list_bow_id,
1429
1430
  )
1430
1431
  self.state = state or State(config.state_db)
1431
1432
  # (repo, number, comment id) of every re-entry directive already
@@ -4063,6 +4064,15 @@ class ReviewWatcher:
4063
4064
  "does not advertise `task list --self` — the task list is not "
4064
4065
  "actor-scoped; upgrade the CLI or drop the key"
4065
4066
  )
4067
+ # Read off the CLIENT, not the config: the env layer outranks both the
4068
+ # file and the flag, so an operator who set only ALISSA_REVIEW_TASK_BOW
4069
+ # would otherwise get no warning about the CLI that cannot serve it.
4070
+ if self.alissa.task_list_bow_id and not self.alissa.probe_task_list().bow:
4071
+ warnings.append(
4072
+ "a review task BOW is configured but the installed `alissa` "
4073
+ "CLI does not advertise `task list --bow` — the task list is "
4074
+ "not BOW-scoped; upgrade the CLI or drop the key"
4075
+ )
4066
4076
 
4067
4077
  if not self.config.dry_run and not self.alissa.worker_running():
4068
4078
  warnings.append(
@@ -35,6 +35,7 @@ import sys
35
35
  import time
36
36
 
37
37
  from .alissa import Alissa, VERDICT_APPROVE, VERDICT_REQUEST_CHANGES
38
+ from .config import env_task_list_bow_id
38
39
  from .ghclient import GitHub
39
40
  from .proc import CommandError, run, run_json
40
41
 
@@ -75,9 +76,9 @@ def _review_task_ref(alissa: Alissa, owner: str, repo: str, number: int) -> str
75
76
  Deliberately not `Alissa.find_review_task` (which filters to open tasks): we
76
77
  still want the ref just after the task is validated, to read its verdict.
77
78
  `narrow_status=False` says the same thing to the LIST call for the same
78
- reason -- the daemon's server-side status filter is exactly its `is_open`
79
- set, and narrowing by it here would re-impose the filter this function
80
- exists to avoid.
79
+ reason -- the daemon's server-side status filter is either exactly its
80
+ `is_open` set or absent altogether, and in the first case narrowing by it
81
+ here would re-impose the filter this function exists to avoid.
81
82
  """
82
83
  pattern = re.compile(
83
84
  rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b", re.IGNORECASE
@@ -145,7 +146,11 @@ def main(argv: list[str] | None = None) -> int:
145
146
  )
146
147
 
147
148
  gh = GitHub()
148
- alissa = Alissa()
149
+ # The same BOW narrowing the daemon's poll uses (issue #100), from the one
150
+ # channel this command has: it runs in a task worktree, so there is no
151
+ # revloop config file under its cwd and no daemon argv to inherit. Unset ->
152
+ # the call shape this command has always made.
153
+ alissa = Alissa(task_list_bow_id=env_task_list_bow_id())
149
154
 
150
155
  try:
151
156
  # Identity guard: GitHub forbids requesting review from the PR author, so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.22.0
3
+ Version: 0.24.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera