alissa-tools-github-revloop 0.31.5__tar.gz → 0.31.7__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.5/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.7}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/alissa.py +188 -15
  3. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/config.py +110 -0
  4. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/ghclient.py +15 -0
  5. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/loop.py +633 -19
  6. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/loop_events.py +188 -3
  7. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/prompts.py +207 -6
  8. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/prreview.py +9 -4
  9. alissa_tools_github_revloop-0.31.7/src/main/alissa/tools/github/revloop/version +1 -0
  10. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  11. alissa_tools_github_revloop-0.31.5/src/main/alissa/tools/github/revloop/version +0 -1
  12. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/LICENSE +0 -0
  13. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/MANIFEST.in +0 -0
  14. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/NOTICE +0 -0
  15. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/README.md +0 -0
  16. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/requirements.txt +0 -0
  17. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/setup.cfg +0 -0
  18. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/setup.py +0 -0
  19. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  20. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  21. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  22. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  23. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  24. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  25. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/state.py +0 -0
  26. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  27. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  31. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  32. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  33. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  34. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  36. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.5 → alissa_tools_github_revloop-0.31.7}/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.5
3
+ Version: 0.31.7
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -248,12 +248,23 @@ _VERDICT_RE = re.compile(
248
248
  # own grammar; the reason runs to the end of the line and no further.
249
249
  READINESS_AUTO = "auto"
250
250
  READINESS_OPERATOR = "operator"
251
- _READINESS_RE = re.compile(
252
- r"^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?[ \t]*Merge-Readiness[ \t]*:?[ \t]*(?:\*\*)?"
253
- r"[ \t]*:?[ \t]*(?:\*\*)?(auto|operator)\b(?:\*\*)?"
254
- r"(?:[ \t]*[—–-][ \t]*(.*?))?[ \t]*$",
255
- re.MULTILINE,
256
- )
251
+
252
+
253
+ def _envelope_line_re(key: str) -> "re.Pattern[str]":
254
+ """The tolerant envelope-line grammar for one readiness KEY. Two keys
255
+ share it (issue #148): `Merge-Readiness` on a code PR, `Commit-Readiness`
256
+ on a plan PR -- the same markdown tolerance, the same case-sensitive
257
+ value, the same first-match-wins."""
258
+ return re.compile(
259
+ r"^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?[ \t]*" + re.escape(key)
260
+ + r"[ \t]*:?[ \t]*(?:\*\*)?"
261
+ r"[ \t]*:?[ \t]*(?:\*\*)?(auto|operator)\b(?:\*\*)?"
262
+ r"(?:[ \t]*[—–-][ \t]*(.*?))?[ \t]*$",
263
+ re.MULTILINE,
264
+ )
265
+
266
+
267
+ _READINESS_RE = _envelope_line_re("Merge-Readiness")
257
268
 
258
269
  # The operator reason's CLASS (issue #142). An `operator` judgment names WHY
259
270
  # the merge is a human's, and until now it did so in prose the merge edge
@@ -343,11 +354,19 @@ def _classed(value: "str | None", raw_reason: object) -> "tuple[str | None, str,
343
354
  # construction, a line this regex reads.
344
355
  READINESS_TRAILER_LABEL = "Merge-Readiness:"
345
356
  READINESS_MISSING = "missing"
346
- _TRAILER_RE = re.compile(
347
- r"^" + re.escape(READINESS_TRAILER_LABEL)
348
- + r"[ \t]*(auto|operator)(?:[ \t]*[—-][ \t]*(.+))?[ \t]*$",
349
- re.MULTILINE,
350
- )
357
+
358
+
359
+ def _trailer_line_re(label: str) -> "re.Pattern[str]":
360
+ """The strict bare-line grammar for one trailer LABEL (see _TRAILER_RE);
361
+ shared by the Merge- and the Commit-Readiness trailers (issue #148)."""
362
+ return re.compile(
363
+ r"^" + re.escape(label)
364
+ + r"[ \t]*(auto|operator)(?:[ \t]*[—-][ \t]*(.+))?[ \t]*$",
365
+ re.MULTILINE,
366
+ )
367
+
368
+
369
+ _TRAILER_RE = _trailer_line_re(READINESS_TRAILER_LABEL)
351
370
 
352
371
 
353
372
  def parse_trailer(body: object) -> "tuple[str | None, str, str | None]":
@@ -388,6 +407,102 @@ def parse_readiness(blob: object) -> "tuple[str | None, str, str | None]":
388
407
  return _classed(match.group(1), match.group(2))
389
408
 
390
409
 
410
+ # The Commit-Readiness judgment on a PLAN PR (issue #148; genloop design D8,
411
+ # §6.3, §7.1). A plan PR is never a code merge candidate: what its approve
412
+ # decides is whether merging the plan COMMITS its tasks, and orcloop's plan
413
+ # close edge reads that off this trailer the way its merge edge reads
414
+ # Merge-Readiness -- the same grammar with the other key:
415
+ #
416
+ # Commit-Readiness: auto
417
+ # Commit-Readiness: operator — <class>: <one-line reason>
418
+ #
419
+ # Eleven classes in SEVERITY order, the enum orcloop copies verbatim: the
420
+ # first six never auto-commit under any policy, the last five may. A task
421
+ # file's `commitClass` draws on the last ten -- `unanchored` is the
422
+ # refuter's alone. Same rules as the merge enum: exactly one class, the most
423
+ # severe applicable; `auto` never carries one; an operator reason that leads
424
+ # with no recognised token parses and reads as UNCLASSED (the hard hold).
425
+ #
426
+ # One rule is STRICTER than the merge grammar, because the acceptance names
427
+ # it (c4): a class on an `auto` line is REFUSED WHOLE -- the line reads as
428
+ # missing and the fallback holds -- rather than read as a bare `auto` with a
429
+ # stray reason. An `auto` whose trailing text is class-shaped is a reviewer
430
+ # that could not decide between the two forms, and the commit policy must
431
+ # never resolve that doubt toward committing.
432
+ COMMIT_READINESS_KEY = "Commit-Readiness"
433
+ COMMIT_READINESS_CLASSES: "tuple[str, ...]" = (
434
+ "unanchored", # the anchor does not resolve, is closed, or the sources are not its class
435
+ "schema-migration", # a leaf's scope names **/schema.ts, **/migrations/**, a backfill or a migration
436
+ "auth-secrets", # auth, permission gates, tokens, credentials, secrets, deploy env vars
437
+ "pricing-billing", # pricing, credits, tiers, quotas, invoices, refunds
438
+ "customer-promise", # copy or behaviour a customer is told about
439
+ "product-surface", # a new screen, route, tool, command or exported type
440
+ "gate-verification", # leaves that verify an unverified operator's-gate item
441
+ "drift-fix", # leaves that make a lagging repo follow a fleet-wide contract change
442
+ "flaky-test", # leaves that fix, quarantine or pin a test the loop capped out on
443
+ "exhaust-followup", # leaves that carry [triage:later] replies and unpursued [minor]/[nit]
444
+ "doc-correction", # leaves confined to docs, READMEs, API.md prose, design-doc pins
445
+ )
446
+ # The six that stay operator's under every commit policy (design §7.1).
447
+ COMMIT_READINESS_OPERATOR_ONLY: "tuple[str, ...]" = COMMIT_READINESS_CLASSES[:6]
448
+ COMMIT_READINESS_TRAILER_LABEL = COMMIT_READINESS_KEY + ":"
449
+ _COMMIT_READINESS_RE = _envelope_line_re(COMMIT_READINESS_KEY)
450
+ _COMMIT_TRAILER_RE = _trailer_line_re(COMMIT_READINESS_TRAILER_LABEL)
451
+ _COMMIT_READINESS_CLASS_RE = re.compile(
452
+ r"^(" + "|".join(re.escape(klass) for klass in COMMIT_READINESS_CLASSES)
453
+ + r")[ \t]*:"
454
+ )
455
+ # What "a class on an auto line" looks like: the reason leads with a
456
+ # `<token>:` -- a recognised class, a misspelt one or a merge-enum token
457
+ # alike. Deliberately wider than the enum, for the reason above.
458
+ _CLASS_SHAPED_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*[ \t]*:")
459
+
460
+
461
+ def classify_commit_readiness_reason(value: object, reason: object) -> "str | None":
462
+ """The COMMIT_READINESS_CLASSES token an `operator` reason leads with, or
463
+ None (auto, unclassed, misspelt, wrong case) -- classify_readiness_reason
464
+ over the commit enum."""
465
+ if value != READINESS_OPERATOR or not isinstance(reason, str):
466
+ return None
467
+ match = _COMMIT_READINESS_CLASS_RE.match(reason)
468
+ return match.group(1) if match else None
469
+
470
+
471
+ def _commit_classed(
472
+ value: "str | None", raw_reason: object
473
+ ) -> "tuple[str | None, str, str | None]":
474
+ """The `(value, reason, klass)` triple both commit parsers hand back, with
475
+ the class-on-auto refusal applied."""
476
+ reason = clean_readiness_reason(raw_reason)
477
+ if value == READINESS_AUTO and _CLASS_SHAPED_RE.match(reason):
478
+ return (None, "", None)
479
+ return (value, reason, classify_commit_readiness_reason(value, reason))
480
+
481
+
482
+ def parse_commit_trailer(body: object) -> "tuple[str | None, str, str | None]":
483
+ """`(value, reason, klass)` from the first bare `Commit-Readiness:` line of
484
+ a review body, or `(None, "", None)` -- parse_trailer with the other key,
485
+ plus the class-on-auto refusal."""
486
+ if not isinstance(body, str):
487
+ return (None, "", None)
488
+ match = _COMMIT_TRAILER_RE.search(body.replace("\r\n", "\n"))
489
+ if match is None:
490
+ return (None, "", None)
491
+ return _commit_classed(match.group(1), match.group(2))
492
+
493
+
494
+ def parse_commit_readiness(blob: object) -> "tuple[str | None, str, str | None]":
495
+ """`(value, reason, klass)` from the first Commit-Readiness line of a
496
+ verdict envelope -- parse_readiness with the other key, plus the
497
+ class-on-auto refusal."""
498
+ if not isinstance(blob, str):
499
+ return (None, "", None)
500
+ match = _COMMIT_READINESS_RE.search(blob)
501
+ if match is None:
502
+ return (None, "", None)
503
+ return _commit_classed(match.group(1), match.group(2))
504
+
505
+
391
506
  @dataclass(frozen=True)
392
507
  class Task:
393
508
  ref: str # TASK-<taskNumber>
@@ -467,6 +582,26 @@ def pane_path_argv(name: str) -> "list[str]":
467
582
  ]
468
583
 
469
584
 
585
+ # What `#{pane_current_command}` reads on a reviewer pane whose `claude` has
586
+ # EXITED (issue #146, devloop #140's set): the shell `alissa tmux new`
587
+ # launched it from, back in the foreground. A live reviewer reads `node`
588
+ # (Claude Code's runtime); a name here means the process the session was
589
+ # spawned for is gone, whatever the session listing says about liveness.
590
+ SHELL_COMMANDS = frozenset((
591
+ "bash", "sh", "zsh", "fish", "dash", "ksh", "ash", "tcsh", "csh",
592
+ ))
593
+
594
+
595
+ def pane_command_argv(name: str) -> "list[str]":
596
+ """`tmux -S <socket> display-message -p -t =<real>: #{pane_current_command}`:
597
+ the foreground process of the session's active pane, by name."""
598
+ check_session_name(name)
599
+ return [
600
+ "tmux", "-S", tmux_socket(), "display-message", "-p",
601
+ "-t", tmux_target(name), "#{pane_current_command}",
602
+ ]
603
+
604
+
470
605
  def send_keys_argv(name: str, keys: "tuple[str, ...]") -> "list[str]":
471
606
  """`tmux -S <socket> send-keys -t =<real> <key>…` with every key drawn
472
607
  from `prompts.ALLOWED_KEYS`. Anything else -- a letter, a word, a path --
@@ -595,10 +730,19 @@ class ManagedSession:
595
730
  return parse_session_name(self.name)
596
731
 
597
732
 
598
- def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
599
- """CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`."""
733
+ # The CR2 title's lead-in. A code PR's review task reads `Review PR …`; a PLAN
734
+ # PR's reads `Review plan <org>/<repo>#<n> (<plan id>)` (issue #148, genloop
735
+ # design §6.1) -- created downstream of nothing, since a plan has no origin
736
+ # task. Both leads resolve to the same PR, so one pattern serves the daemon's
737
+ # search, its cache check and `alissa-pr-review` alike.
738
+ REVIEW_TASK_TITLE_LEAD = r"^Review\s+(?:PR|plan)\s+"
739
+
740
+
741
+ def review_task_title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
742
+ """CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`, or
743
+ `Review plan <org>/<repo>#<n> (<plan id>)` for a plan PR."""
600
744
  return re.compile(
601
- rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b",
745
+ rf"{REVIEW_TASK_TITLE_LEAD}{re.escape(owner)}/{re.escape(repo)}#{number}\b",
602
746
  re.IGNORECASE,
603
747
  )
604
748
 
@@ -613,7 +757,7 @@ def is_review_task_for(owner: str, repo: str, number: int, task: "Task") -> bool
613
757
  would either pin a wrong task forever or re-fetch the corpus every pass
614
758
  while disagreeing with itself.
615
759
  """
616
- return bool(_title_pattern(owner, repo, number).match(task.title)) and task.is_open
760
+ return bool(review_task_title_pattern(owner, repo, number).match(task.title)) and task.is_open
617
761
 
618
762
 
619
763
  def _task_from_row(row: object) -> "Task | None":
@@ -656,11 +800,18 @@ class VerdictEnvelope:
656
800
  the narration reads the class back off the emitted line -- so the fact
657
801
  has one source, and a second copy here would be a reader-less field
658
802
  that could only ever drift from it (PR #143 round 1).
803
+
804
+ `commit_readiness` / `commit_readiness_reason` are the same pair for the
805
+ envelope's `Commit-Readiness` line (issue #148), read off the same
806
+ evidence item; only a PLAN PR's native post reads them, and a code PR's
807
+ envelope simply has none.
659
808
  """
660
809
 
661
810
  verdict: str
662
811
  readiness: "str | None" = None
663
812
  readiness_reason: str = ""
813
+ commit_readiness: "str | None" = None
814
+ commit_readiness_reason: str = ""
664
815
 
665
816
 
666
817
  @dataclass(frozen=True)
@@ -1167,6 +1318,9 @@ class Alissa:
1167
1318
  readiness, reason, _ = parse_readiness(content)
1168
1319
  if readiness is None:
1169
1320
  readiness, reason, _ = parse_readiness(title)
1321
+ commit, commit_reason, _ = parse_commit_readiness(content)
1322
+ if commit is None:
1323
+ commit, commit_reason, _ = parse_commit_readiness(title)
1170
1324
  found.append(
1171
1325
  (Alissa._created_key(item.get("createdAt")),
1172
1326
  index,
@@ -1174,6 +1328,8 @@ class Alissa:
1174
1328
  verdict=match.group(1).lower(),
1175
1329
  readiness=readiness,
1176
1330
  readiness_reason=reason,
1331
+ commit_readiness=commit,
1332
+ commit_readiness_reason=commit_reason,
1177
1333
  ))
1178
1334
  )
1179
1335
  break
@@ -1379,6 +1535,23 @@ class Alissa:
1379
1535
  log.debug("could not read the pane path of %s (%s)", name, exc)
1380
1536
  return ""
1381
1537
 
1538
+ def pane_command(self, name: str) -> str:
1539
+ """The session's pane current command (`#{pane_current_command}`),
1540
+ or "" when tmux cannot answer. The first-turn death check's one
1541
+ liveness probe (issue #146): a reviewer whose pane reads a shell
1542
+ (`SHELL_COMMANDS`) has lost its `claude`, however alive the tmux
1543
+ session itself is. A READ: runs under dry-run too."""
1544
+ try:
1545
+ argv = pane_command_argv(name)
1546
+ except ValueError as exc:
1547
+ log.warning("pane_command refused: %s", exc)
1548
+ return ""
1549
+ try:
1550
+ return run(argv, timeout=15).strip()
1551
+ except CommandError as exc:
1552
+ log.debug("could not read the pane command of %s (%s)", name, exc)
1553
+ return ""
1554
+
1382
1555
  def send_keys(self, name: str, *keys: str, dry_run: bool = False) -> bool:
1383
1556
  """Press `keys` -- each one of `prompts.ALLOWED_KEYS`, nothing else
1384
1557
  -- on ONE managed session's pane. True when tmux accepted them.
@@ -181,6 +181,8 @@ CONFIG_KEYS = (
181
181
  "bows_refresh_polls",
182
182
  "bow_owners",
183
183
  "authors",
184
+ "plan_repos",
185
+ "plan_authors",
184
186
  "operators",
185
187
  "agent_profile",
186
188
  "reviewer_login",
@@ -231,6 +233,42 @@ REPOS_SOURCE_ENV = "ALISSA_REVIEW_REPOS_SOURCE"
231
233
  BOWS_REFRESH_POLLS_ENV = "ALISSA_REVIEW_BOWS_REFRESH_POLLS"
232
234
  BOW_OWNERS_ENV = "ALISSA_REVIEW_BOW_OWNERS"
233
235
 
236
+ # The plan directive's two allowlists (issue #148; genloop design §6.1, D12).
237
+ # A PR gets the PLAN directive only when all four signals hold, and two of
238
+ # them are fleet configuration: the repository is one of `plan_repos` and the
239
+ # author one of `plan_authors`. Same `|`/`,` rail as the bows keys, same
240
+ # env > file > default precedence, same blank-falls-through rule.
241
+ #
242
+ # The plans repository is CONFIGURATION, never a literal in the code (the
243
+ # operator's decision on issue #148 -- the design's own name for it is
244
+ # superseded): the default names the repository by NAME under the
245
+ # `<owner>` placeholder, which matches that name under any owner, and a
246
+ # deployment names its own with the variable. A test proves no plans-repo
247
+ # literal survives outside this module.
248
+ #
249
+ # FAIL-CLOSED, and deliberately unlike `authors`: an EMPTY `plan_authors`
250
+ # means NO PR is a plan PR -- every PR, the plans repository's included, is
251
+ # reviewed under the code directive. The plan directive is a different
252
+ # contract (no code rubric, a Commit-Readiness trailer the plan close edge
253
+ # commits on), so it must be switched on by naming the generator's login,
254
+ # never by default. An empty `plan_repos` (the variable set to a lone
255
+ # separator, say) means the same.
256
+ PLAN_REPOS_ENV = "ALISSA_REVIEW_PLAN_REPOS"
257
+ PLAN_AUTHORS_ENV = "ALISSA_REVIEW_PLAN_AUTHORS"
258
+ PLAN_REPO_OWNER_PLACEHOLDER = "<owner>"
259
+ DEFAULT_PLAN_REPO_NAME = "alissa-dark-factory-plans"
260
+ DEFAULT_PLAN_REPOS: "tuple[str, ...]" = (
261
+ f"{PLAN_REPO_OWNER_PLACEHOLDER}/{DEFAULT_PLAN_REPO_NAME}",
262
+ )
263
+ # `owner/repo`, or `<owner>/repo`; GitHub's own character sets.
264
+ _PLAN_REPO_RE = re.compile(
265
+ r"^(?:" + re.escape(PLAN_REPO_OWNER_PLACEHOLDER)
266
+ + r"|[A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/[A-Za-z0-9._-]{1,100}\Z"
267
+ )
268
+ # A GitHub login, or a GitHub App's `<slug>[bot]` login.
269
+ _GITHUB_LOGIN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,38}(?:\[bot\])?\Z")
270
+ _LIST_SEPARATORS = re.compile(r"[|,]")
271
+
234
272
  # The default Studio API base the loop-events client posts to. Mirrors
235
273
  # alissa_client.DEFAULT_ENDPOINT (a test pins the two together); defined here
236
274
  # too so config stays importable without the client module.
@@ -377,6 +415,30 @@ def env_bow_owners(environ: "Mapping[str, str] | None" = None) -> "str | None":
377
415
  return _env_text(BOW_OWNERS_ENV, environ)
378
416
 
379
417
 
418
+ def env_plan_list(name: str, environ: "Mapping[str, str] | None" = None) -> "list[str] | None":
419
+ """One plan allowlist rail (`PLAN_REPOS_ENV` / `PLAN_AUTHORS_ENV`) split on
420
+ `|` and `,`, or None when unset/blank. A value of separators only is an
421
+ EMPTY list, not None: the operator said something, and what they said is
422
+ "nothing" -- which for these keys is the fail-closed answer."""
423
+ raw = _env_text(name, environ)
424
+ if raw is None:
425
+ return None
426
+ return [part.strip() for part in _LIST_SEPARATORS.split(raw) if part.strip()]
427
+
428
+
429
+ def _plan_list(value: Any, key: str, what: str, shape: "re.Pattern[str]") -> "tuple[str, ...]":
430
+ """A plan allowlist as a tuple of validated entries. A malformed entry is
431
+ refused at load, naming it -- a typo in a trust gate must not read as an
432
+ entry that silently matches nothing."""
433
+ entries = _string_list(value, key, what)
434
+ bad = [entry for entry in entries if not shape.match(entry)]
435
+ if bad:
436
+ raise ValueError(
437
+ f"{key} must be a list of {what}, got {', '.join(repr(b) for b in bad)}"
438
+ )
439
+ return entries
440
+
441
+
380
442
  def normalize_bow_owners(values: "Any") -> "tuple[str, ...]":
381
443
  """Validate the `bow_owners` layer value into the authority list.
382
444
 
@@ -768,6 +830,13 @@ class Config:
768
830
  # want agent reviews.
769
831
  authors: tuple[str, ...] = ()
770
832
 
833
+ # The plan directive's repository and author allowlists (issue #148); see
834
+ # PLAN_REPOS_ENV above. `plan_repos` entries are `owner/repo` or
835
+ # `<owner>/repo` (any owner); `plan_authors` EMPTY means no PR is a plan
836
+ # PR -- fail closed, the opposite of `authors`.
837
+ plan_repos: tuple[str, ...] = DEFAULT_PLAN_REPOS
838
+ plan_authors: tuple[str, ...] = ()
839
+
771
840
  # GitHub logins whose re-entry ack may raise a capped PR's effective cap
772
841
  # (loop.parse_reentry_ack). Empty -- the default -- means NO ack is ever
773
842
  # honoured: the lever fails closed, because anyone who can comment on a PR
@@ -1007,6 +1076,29 @@ class Config:
1007
1076
  return True
1008
1077
  return login.lower() in {a.lower() for a in self.authors}
1009
1078
 
1079
+ def is_plan_repo(self, full_name: str) -> bool:
1080
+ """Whether `owner/repo` is a plans repository -- signal one of four.
1081
+
1082
+ Case-insensitive, like `watches` under bows: GitHub names are. An
1083
+ entry whose owner is the `<owner>` placeholder matches its repository
1084
+ name under any owner, which is how the default names the plans
1085
+ repository without naming a fleet."""
1086
+ owner, _, name = full_name.partition("/")
1087
+ for entry in self.plan_repos:
1088
+ want_owner, _, want_name = entry.partition("/")
1089
+ if want_name.casefold() != name.casefold():
1090
+ continue
1091
+ if want_owner == PLAN_REPO_OWNER_PLACEHOLDER:
1092
+ return True
1093
+ if want_owner.casefold() == owner.casefold():
1094
+ return True
1095
+ return False
1096
+
1097
+ def is_plan_author(self, login: str) -> bool:
1098
+ """Whether a PR by this login may be a plan PR -- signal two of four.
1099
+ EMPTY means NOBODY (fail closed; see the field)."""
1100
+ return bool(login) and login.lower() in {a.lower() for a in self.plan_authors}
1101
+
1010
1102
  @classmethod
1011
1103
  def build(
1012
1104
  cls,
@@ -1095,6 +1187,13 @@ class Config:
1095
1187
  env_owners = env_bow_owners(environ)
1096
1188
  if env_owners is not None:
1097
1189
  raw["bow_owners"] = env_owners
1190
+ # The plan allowlists (issue #148), same rail, same reason.
1191
+ env_plan_repos = env_plan_list(PLAN_REPOS_ENV, environ)
1192
+ if env_plan_repos is not None:
1193
+ raw["plan_repos"] = env_plan_repos
1194
+ env_plan_authors = env_plan_list(PLAN_AUTHORS_ENV, environ)
1195
+ if env_plan_authors is not None:
1196
+ raw["plan_authors"] = env_plan_authors
1098
1197
 
1099
1198
  source = raw.get("repos_source", cls.repos_source)
1100
1199
  if source not in _REPOS_SOURCES:
@@ -1145,6 +1244,15 @@ class Config:
1145
1244
  operators = _string_list(
1146
1245
  raw.get("operators", ()), "operators", "GitHub logins"
1147
1246
  )
1247
+ plan_repos = _plan_list(
1248
+ raw.get("plan_repos", cls.plan_repos), "plan_repos",
1249
+ f"owner/repo (or {PLAN_REPO_OWNER_PLACEHOLDER}/repo) entries",
1250
+ _PLAN_REPO_RE,
1251
+ )
1252
+ plan_authors = _plan_list(
1253
+ raw.get("plan_authors", cls.plan_authors), "plan_authors",
1254
+ "GitHub logins", _GITHUB_LOGIN_RE,
1255
+ )
1148
1256
  if hub_mode == HUB_ADD and not repos and source == REPOS_STATIC:
1149
1257
  # Anyone who can request a review could otherwise cause an arbitrary
1150
1258
  # repo to be cloned onto this machine and opened as an agent's cwd.
@@ -1344,6 +1452,8 @@ class Config:
1344
1452
  bows_refresh_polls=refresh_polls,
1345
1453
  bow_owners=bow_owners,
1346
1454
  authors=authors,
1455
+ plan_repos=plan_repos,
1456
+ plan_authors=plan_authors,
1347
1457
  operators=operators,
1348
1458
  agent_profile=raw.get("agent_profile", "claude"),
1349
1459
  reviewer_login=raw.get("reviewer_login"),
@@ -202,6 +202,13 @@ class PullRequest:
202
202
  # `requested_teams` is a separate field and is deliberately not carried,
203
203
  # because nothing in the loop may ever withdraw a team's request.
204
204
  requested_reviewers: tuple[str, ...] = ()
205
+ # The head BRANCH name and the PR body, read off the same payload. Two of
206
+ # the four signals that make a PR a PLAN PR (issue #148, genloop design
207
+ # §6.1): a `PLAN-<stamp>-<slug>` head ref and the plan marker on a line of
208
+ # its own in the body. Both are PR-author text -- they select a directive
209
+ # and are never interpolated into one.
210
+ head_ref: str = ""
211
+ body: str = ""
205
212
 
206
213
  @property
207
214
  def full_name(self) -> str:
@@ -311,6 +318,11 @@ class CheckRollup:
311
318
  # than the one it stands in for (see ACTIONS_FALLBACK_NOTE), so the answer
312
319
  # carries which path produced it everywhere the rollup is reported.
313
320
  via_actions_fallback: bool = False
321
+ # Every context read, passing ones included -- what a gate keyed on ONE
322
+ # named check reads (the plan-lint gate, issue #148). `failing` and
323
+ # `running` answer "may an approve claim this head?"; only this answers
324
+ # "did the check called X conclude success?".
325
+ contexts: tuple[CheckContext, ...] = ()
314
326
 
315
327
  @property
316
328
  def summary(self) -> str:
@@ -393,6 +405,7 @@ def rollup_of(
393
405
  running=running,
394
406
  total=len(contexts),
395
407
  via_actions_fallback=via_actions_fallback,
408
+ contexts=tuple(contexts),
396
409
  )
397
410
 
398
411
 
@@ -697,6 +710,8 @@ class GitHub:
697
710
  for u in (data.get("requested_reviewers") or [])
698
711
  if (u or {}).get("login")
699
712
  ),
713
+ head_ref=str((data.get("head") or {}).get("ref") or ""),
714
+ body=str(data.get("body") or ""),
700
715
  )
701
716
 
702
717
  def reviews(self, owner: str, repo: str, number: int) -> list[Review]: