alissa-tools-github-revloop 0.31.6__tar.gz → 0.31.8__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.6/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.8}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/alissa.py +151 -15
  3. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/config.py +110 -0
  4. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/ghclient.py +15 -0
  5. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/loop.py +455 -16
  6. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/loop_events.py +49 -1
  7. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/prreview.py +9 -4
  8. alissa_tools_github_revloop-0.31.8/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_revloop-0.31.6/src/main/alissa/tools/github/revloop/version +0 -1
  11. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/LICENSE +0 -0
  12. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/MANIFEST.in +0 -0
  13. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/NOTICE +0 -0
  14. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/README.md +0 -0
  15. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/requirements.txt +0 -0
  16. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/setup.cfg +0 -0
  17. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/setup.py +0 -0
  18. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  19. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  20. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  21. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  22. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  23. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  25. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/state.py +0 -0
  26. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  27. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  29. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  30. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  31. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  32. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  33. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  34. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  35. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  36. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  37. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.6 → alissa_tools_github_revloop-0.31.8}/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.6
3
+ Version: 0.31.8
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>
@@ -615,10 +730,19 @@ class ManagedSession:
615
730
  return parse_session_name(self.name)
616
731
 
617
732
 
618
- def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
619
- """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."""
620
744
  return re.compile(
621
- 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",
622
746
  re.IGNORECASE,
623
747
  )
624
748
 
@@ -633,7 +757,7 @@ def is_review_task_for(owner: str, repo: str, number: int, task: "Task") -> bool
633
757
  would either pin a wrong task forever or re-fetch the corpus every pass
634
758
  while disagreeing with itself.
635
759
  """
636
- 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
637
761
 
638
762
 
639
763
  def _task_from_row(row: object) -> "Task | None":
@@ -676,11 +800,18 @@ class VerdictEnvelope:
676
800
  the narration reads the class back off the emitted line -- so the fact
677
801
  has one source, and a second copy here would be a reader-less field
678
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.
679
808
  """
680
809
 
681
810
  verdict: str
682
811
  readiness: "str | None" = None
683
812
  readiness_reason: str = ""
813
+ commit_readiness: "str | None" = None
814
+ commit_readiness_reason: str = ""
684
815
 
685
816
 
686
817
  @dataclass(frozen=True)
@@ -1187,6 +1318,9 @@ class Alissa:
1187
1318
  readiness, reason, _ = parse_readiness(content)
1188
1319
  if readiness is None:
1189
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)
1190
1324
  found.append(
1191
1325
  (Alissa._created_key(item.get("createdAt")),
1192
1326
  index,
@@ -1194,6 +1328,8 @@ class Alissa:
1194
1328
  verdict=match.group(1).lower(),
1195
1329
  readiness=readiness,
1196
1330
  readiness_reason=reason,
1331
+ commit_readiness=commit,
1332
+ commit_readiness_reason=commit_reason,
1197
1333
  ))
1198
1334
  )
1199
1335
  break
@@ -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]:
@@ -26,6 +26,7 @@ from typing import Callable
26
26
 
27
27
  from . import prompts as prompts_mod
28
28
  from .alissa import (
29
+ COMMIT_READINESS_TRAILER_LABEL,
29
30
  MANAGED_PREFIX,
30
31
  SHELL_COMMANDS,
31
32
  READINESS_AUTO,
@@ -41,6 +42,7 @@ from .alissa import (
41
42
  Task,
42
43
  TaskDetail,
43
44
  is_review_task_for,
45
+ parse_commit_trailer,
44
46
  parse_trailer,
45
47
  session_repo_slug,
46
48
  )
@@ -495,6 +497,30 @@ def readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
495
497
  return f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR} — {READINESS_MISSING_REASON}"
496
498
 
497
499
 
500
+ # The Commit-Readiness trailer (issue #148; genloop design D8, §6.3): what a
501
+ # PLAN PR's native APPROVE carries instead of Merge-Readiness. Same emitter
502
+ # rules as readiness_trailer -- APPROVE events only, the envelope's reason
503
+ # copied whole (class prefix included), and an envelope with no parseable
504
+ # line fails CLOSED to an UNCLASSED `operator` whose reason says why: the
505
+ # design's fallback, verbatim. A plan PR never carries Merge-Readiness: it is
506
+ # not a code merge candidate, and a stray `auto` there is a line orcloop's
507
+ # merge edge must never read on a plan.
508
+ COMMIT_READINESS_MISSING_REASON = "envelope carries no Commit-Readiness line"
509
+
510
+
511
+ def commit_readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
512
+ """The line a plan PR's native APPROVE carries for `envelope`'s
513
+ Commit-Readiness judgment -- readiness_trailer over the other key."""
514
+ label = COMMIT_READINESS_TRAILER_LABEL
515
+ if envelope is not None and envelope.commit_readiness == READINESS_AUTO:
516
+ return f"{label} {READINESS_AUTO}"
517
+ if envelope is not None and envelope.commit_readiness == READINESS_OPERATOR:
518
+ if envelope.commit_readiness_reason:
519
+ return f"{label} {READINESS_OPERATOR} — {envelope.commit_readiness_reason}"
520
+ return f"{label} {READINESS_OPERATOR}"
521
+ return f"{label} {READINESS_OPERATOR} — {COMMIT_READINESS_MISSING_REASON}"
522
+
523
+
498
524
  # Appended to the body when the round's review task is known.
499
525
  _VERDICT_TASK_NOTE = ", and the CR6 verdict envelope is on `{task_ref}`"
500
526
 
@@ -1117,6 +1143,302 @@ ROUND_K_DIRECTIVE = (
1117
1143
  + _RELEASE_SLOT
1118
1144
  )
1119
1145
 
1146
+ # -- the plan directive (issue #148; genloop design D8, D18, §6, §7.1) --------
1147
+ #
1148
+ # A PLAN PR is a generated plan -- one new directory under `plans/` holding
1149
+ # `plan.md` and one task file per task -- opened by the generator seat in the
1150
+ # plans repository. Merging it COMMITS its tasks, so its reviewer is the
1151
+ # refuter that makes a merged plan trustworthy, and the code rubric would
1152
+ # review that prose as if it were code. So a plan PR gets its own two round
1153
+ # directives, beside the code ones, and its own trailer.
1154
+ #
1155
+ # All FOUR signals, or it is not a plan PR (design §6.1): the repository is
1156
+ # in `plan_repos`, the author in `plan_authors` (both fleet configuration,
1157
+ # config.py -- `plan_authors` empty means nobody), the head ref matches the
1158
+ # generator's branch grammar, and the body carries the plan marker on a line
1159
+ # of its own. Any other PR in the plans repository -- a `TASK-` lane on its
1160
+ # linter or README -- and any PR by the generator's login elsewhere is
1161
+ # reviewed under the code directive: a plan author never pushes code, and if
1162
+ # it did, the code rubric is the safer reading.
1163
+ PLAN_HEAD_REF_RE = re.compile(r"^PLAN-\d{8}T\d{6}Z-[a-z0-9-]+\Z")
1164
+ PLAN_MARKER = "<!-- alissa-genloop:plan v1 -->"
1165
+
1166
+ # The plan's id (the directory name, `<yyyy-mm-dd>-<slug>`, design §5.1), as
1167
+ # the generator writes it into the PR body (`Alissa-Plan: <id>`, §2.14). It is
1168
+ # PR-author text, so it reaches the directive only through this grammar --
1169
+ # an id that does not match is not quoted at all, and the session is told to
1170
+ # read the id from `plan.md` instead.
1171
+ PLAN_ID_RE = re.compile(
1172
+ r"^Alissa-Plan:[ \t]*(\d{4}-\d{2}-\d{2}-[a-z0-9-]{3,48})[ \t]*$", re.MULTILINE
1173
+ )
1174
+
1175
+ # The check run the round waits for (design D18). Its name is a string, and
1176
+ # that is the whole of the dependency on the lint lane: without the workflow
1177
+ # the run never appears and the round holds, which is fail-closed.
1178
+ PLAN_LINT_CHECK = "plan-lint"
1179
+
1180
+ # The ledger ping that marks round `<n>` of a PR as a PLAN round, so the loop
1181
+ # events derived from the ledger can carry `data.kind: "plan"` (issue #148
1182
+ # c6). Recorded when the plan gate first sees the round -- before any hold --
1183
+ # so the held round's `checks.held` is marked too. loop_events keeps a pinned
1184
+ # copy of the prefix (it cannot import this module).
1185
+ PLAN_ROUND_PREFIX = "plan-round:"
1186
+ ROUND_KIND_PLAN = "plan"
1187
+
1188
+
1189
+ def plan_round_kind(round_: int) -> str:
1190
+ """The ping kind that marks round `round_` of a PR as a plan round."""
1191
+ return f"{PLAN_ROUND_PREFIX}{int(round_)}"
1192
+
1193
+
1194
+ def plan_signals(config: Config, pr: PullRequest) -> "dict[str, bool]":
1195
+ """The four signals of design §6.1, by name, for the one predicate below
1196
+ and for the log line that says which one a near-miss lacked."""
1197
+ body = pr.body.replace("\r\n", "\n")
1198
+ return {
1199
+ "repo": config.is_plan_repo(pr.full_name),
1200
+ "author": config.is_plan_author(pr.author),
1201
+ "head_ref": bool(PLAN_HEAD_REF_RE.match(pr.head_ref)),
1202
+ "marker": any(line.strip() == PLAN_MARKER for line in body.split("\n")),
1203
+ }
1204
+
1205
+
1206
+ def is_plan_pr(config: Config, pr: PullRequest) -> bool:
1207
+ """All four signals hold -- the ONLY way a PR gets the plan directive."""
1208
+ return all(plan_signals(config, pr).values())
1209
+
1210
+
1211
+ def plan_id_of(pr: PullRequest) -> "str | None":
1212
+ """The plan id the PR body declares, or None when it declares none in the
1213
+ grammar (see PLAN_ID_RE)."""
1214
+ match = PLAN_ID_RE.search(pr.body.replace("\r\n", "\n"))
1215
+ return match.group(1) if match else None
1216
+
1217
+
1218
+ def plan_review_task_title(pr: PullRequest) -> str:
1219
+ """The CR2 title of a plan PR's review task (design §6.1)."""
1220
+ return f"Review plan {pr.slug} ({plan_id_of(pr) or '<plan id>'})"
1221
+
1222
+
1223
+ # Who the reviewer is and which protocol it runs. The skill is QUOTED BY PATH,
1224
+ # never inlined: `alissa-code-review:references/plan-directive.md` is the
1225
+ # skill's own statement of the plan rubric (a sibling change in the skills
1226
+ # repository), and this directive states the parts the daemon must be able
1227
+ # to rely on whatever version of the skill the container carries.
1228
+ _PLAN_PROTOCOL = (
1229
+ "Load the alissa-code-review skill and run its protocol unchanged — fresh "
1230
+ "instance, CR1–CR9, one review task, severity-tagged comments via gh pr "
1231
+ "review, one verdict envelope, the task moved to pending_validation — but "
1232
+ "walk the PLAN rubric of "
1233
+ "`alissa-code-review:references/plan-directive.md` instead of the code "
1234
+ "rubric: this PR is a plan (one new directory under `plans/` holding "
1235
+ "`plan.md` and one `tasks/<id>.md` per task), not code. The review task is "
1236
+ "titled `Review plan <org>/<repo>#<n> (<plan id>)` and is created "
1237
+ "downstream of nothing — a plan has no origin task; the rest of CR2 is "
1238
+ "unchanged. "
1239
+ )
1240
+
1241
+ # Nothing mechanical is the reviewer's (design D18): every deterministic
1242
+ # predicate is the plan-lint job's, and the daemon has already held the round
1243
+ # until that job's run on this head concluded success. v0.1's automatic
1244
+ # request_changes rules are gone from the directive -- each became a lint
1245
+ # rule, a close-edge rule or a rubric question -- and this clause is where
1246
+ # the directive says so rather than re-stating any of them as a rule.
1247
+ _PLAN_LINT_OWNS_THE_MECHANICS = (
1248
+ "NOTHING MECHANICAL IS YOURS. The daemon held this round until the "
1249
+ "`" + PLAN_LINT_CHECK + "` check run on this head concluded success, so "
1250
+ "every deterministic predicate — the frontmatter schema, an acyclic graph, "
1251
+ "edges that resolve, a scope on every task with no broad glob, one to "
1252
+ "eight tasks, an open anchor, a commitClass on every task, the generated "
1253
+ "graph and summary, a diff confined to one new plan directory — is the "
1254
+ "lint's and has passed. Do not re-check them and never request changes "
1255
+ "on one; read the `" + PLAN_LINT_CHECK + "` run like any other check under "
1256
+ "the CI GATE below. Your findings are judgment, and only the six "
1257
+ "questions below. "
1258
+ )
1259
+
1260
+ # The refuter's checkout is the plans repository; the target code a plan
1261
+ # names lives elsewhere and is read remotely (design D19).
1262
+ _PLAN_READ_TARGETS = (
1263
+ "READ THE TARGET CODE REMOTELY. Your checkout is the plans repository — "
1264
+ "this PR's own. Every task names where it lands (`repo`, `scope`): read "
1265
+ "that code, its issues and its pull requests under the reviewer login, "
1266
+ "which can read every fed repository, with `gh api "
1267
+ "repos/<repo>/contents/<path>`, `gh search code`, `gh issue list --repo "
1268
+ "<repo>` and `gh pr list --repo <repo>`. NEVER clone a target repository. "
1269
+ )
1270
+
1271
+ # The rubric: six questions, in this order, byte-pinned (design §6.2).
1272
+ PLAN_RUBRIC = (
1273
+ "THE RUBRIC — answer all six questions, in this order, each with one line "
1274
+ "in your verdict envelope and a finding wherever the answer is no: "
1275
+ "(1) Worth doing? Does the plan serve its anchor in a way the operator "
1276
+ "would recognise, and does *Why now* hold against the program's "
1277
+ "milestones and the direction? "
1278
+ "(2) Correctly split into tasks? Is each task one PR's worth for one "
1279
+ "worker with one definition of done; is nothing a milestone in disguise; "
1280
+ "is nothing split so fine that the pieces cannot be reviewed alone? "
1281
+ "(3) Scopes honest and narrow? Does each `scope` name the files the task "
1282
+ "will actually touch — the shared hotspots included (`convex/schema.ts`, "
1283
+ "`packages/client/src/**`, `API.md`) — and nothing it will not? The lint "
1284
+ "refuses the broad glob; you refuse the narrow lie. "
1285
+ "(4) Dependencies right? Are the edges the real order; is anything "
1286
+ "missing that would make two tasks collide on a scope; is anything hard "
1287
+ "that should be soft? The lint proves the graph resolves and is acyclic; "
1288
+ "you judge whether it is true. "
1289
+ "(5) Duplicates an open task? The same intent under another title — `gh "
1290
+ "issue list --repo <repo> --label alissa:develop --state all`, the "
1291
+ "merged-PR titles of the window and `GET /v1/tasks?status=…` for the fleet "
1292
+ "actor, read for every task's `repo`. "
1293
+ "(6) Consistent with the accepted design docs and the operator's "
1294
+ "direction? Does any task contradict a decision in the target repo's "
1295
+ "`docs/design/` (the `D<n>` rows, read with `gh api`) or a priority in the "
1296
+ "steering record; does it touch an `offLimits` topic or an unexpired "
1297
+ "`notNow[]` entry by another name (`GET /v1/factory/steering`, `GET "
1298
+ "/v1/factory/proposal-rules`)? "
1299
+ )
1300
+
1301
+ # Severity (design §6.2): the skill's tags and triage, minus the design-lane
1302
+ # cap -- a plan is this seat's shipped product.
1303
+ _PLAN_SEVERITY = (
1304
+ "Severity tags and the triage loop are the skill's (CR5, CR8, CR9), with "
1305
+ "one exception: the design-lane rule that caps findings on prose at "
1306
+ "`[minor]` does NOT apply — a plan is the shipped product of this review, "
1307
+ "and a wrong scope or a wrong dependency is a `[major]`. "
1308
+ )
1309
+
1310
+ # The Commit-Readiness line (design §6.3, §7.1): the plan's merge decision,
1311
+ # in the Merge-Readiness grammar with the other key. The checklist and the
1312
+ # eleven-class table are the design's, condensed; alissa.py holds the enum
1313
+ # (COMMIT_READINESS_CLASSES) and test_readiness pins the two together.
1314
+ _COMMIT_READINESS_LINE = (
1315
+ "Your verdict envelope MUST carry `- **Commit-Readiness:** auto` or `- "
1316
+ "**Commit-Readiness:** operator — <class>: <one-line reason>` as the "
1317
+ "first line under `## Summary`, AND your OWN native review (every `gh pr "
1318
+ "review` form and the reviews-API POST alike) MUST END with the bare line "
1319
+ "`Commit-Readiness: auto` or `Commit-Readiness: operator — <class>: "
1320
+ "<one-line reason>` as its last non-empty line — plain text at the start "
1321
+ "of the line, not in backticks, not bold — byte-equal in value, class and "
1322
+ "reason to the envelope's line; the value is case-sensitive. Write NO "
1323
+ "Merge-Readiness line: a plan PR is never a code merge candidate. Decide "
1324
+ "the line by walking this checklist in order after the verdict; the first "
1325
+ "item that fires is the reason: "
1326
+ "(1) the verdict is request_changes → `operator — verdict is "
1327
+ "request_changes`; "
1328
+ "(2) the anchor is a milestone, criterion or objective and any leaf's "
1329
+ "shape is outside the five auto classes → the operator-only class that "
1330
+ "fits, `product-surface` when none is more specific; "
1331
+ "(3) any leaf's scope names `**/schema.ts`, `**/migrations/**`, a backfill "
1332
+ "or a migration → `schema-migration`; "
1333
+ "(4) auth, permission gates, secrets, credentials, tokens → "
1334
+ "`auth-secrets`; "
1335
+ "(5) pricing, credits, tiers, quotas, invoices → `pricing-billing`; "
1336
+ "(6) copy or behaviour a customer is told about (landing pages, offer "
1337
+ "prompts, WhatsApp wording, public pages) → `customer-promise`; "
1338
+ "(7) a waived `[major]`, an open `[blocker]` / `[major]`, or a cap-out → "
1339
+ "`operator — review outcome: …` with no class; "
1340
+ "(8) the plan's class (the most severe commitClass of its tasks) is "
1341
+ "higher than yours → the plan's class; "
1342
+ "(9) a judgment residual, stated → no class. "
1343
+ "Otherwise `auto`, which asserts the plan is one of the five auto classes "
1344
+ "and nothing above fired. `<class>` is exactly ONE token from this closed "
1345
+ "enum, lowercase and hyphenated, followed by a colon and the reason; the "
1346
+ "rows are in severity order, most severe first, and the first six never "
1347
+ "auto-commit: "
1348
+ "`unanchored` (the anchor does not resolve, is closed, or the sources do "
1349
+ "not belong to the class named); "
1350
+ "`schema-migration` (a leaf's scope names `**/schema.ts`, "
1351
+ "`**/migrations/**`, or its description says backfill, migration, "
1352
+ "rename, removal); "
1353
+ "`auth-secrets` (auth, permission gates, tokens, credentials, secrets, env "
1354
+ "vars a deploy must carry); "
1355
+ "`pricing-billing` (pricing, credits, tiers, quotas, invoices, refunds); "
1356
+ "`customer-promise` (copy or behaviour a customer is told about: landing "
1357
+ "pages, offer prompts, WhatsApp wording, public pages, emails); "
1358
+ "`product-surface` (a new screen, route, tool, command or exported type — "
1359
+ "new product, however small — and the default for a milestone- or "
1360
+ "objective-anchored plan whose leaves fit no row above); "
1361
+ "then the five that may: "
1362
+ "`gate-verification` (leaves that verify an unverified operator's-gate "
1363
+ "item with a test, a replay or a recorded smoke); "
1364
+ "`drift-fix` (leaves that make a lagging repo follow a fleet-wide "
1365
+ "contract change); "
1366
+ "`flaky-test` (leaves that fix, quarantine with a named owner, or pin a "
1367
+ "test the loop capped out on); "
1368
+ "`exhaust-followup` (leaves that carry `[triage:later]` replies and "
1369
+ "unpursued `[minor]` / `[nit]` findings on merged code); "
1370
+ "`doc-correction` (leaves confined to `docs/**`, READMEs, `API.md` prose "
1371
+ "and design-doc pins where the code did not move). "
1372
+ "Exactly one class, the most severe applicable row; your class is never "
1373
+ "lower than the plan's class. `auto` never carries a class — an `auto` "
1374
+ "line with a class is refused whole and read as no line at all — and is "
1375
+ "written only when the plan's class is one of the last five rows and "
1376
+ "nothing above fired: before writing it, check that every leaf's scope "
1377
+ "stays out of the hold paths, that no leaf adds a route, a tool, a table "
1378
+ "or a public type, and that every leaf's definition of done is a PR bar "
1379
+ "the loop already knows. When no row fits, write no class: an unclassed "
1380
+ "operator line is the correct hard hold, never a failure. No backticks in "
1381
+ "the reason. The daemon copies the envelope's line onto a native review "
1382
+ "only when it posts the verdict itself, and an envelope without the line "
1383
+ "posts as `operator — " + COMMIT_READINESS_MISSING_REASON + "`, with no "
1384
+ "class. "
1385
+ )
1386
+
1387
+ PLAN_ROUND_1_DIRECTIVE = (
1388
+ "You are a PLAN REVIEWER — the refuter of a generated plan — not an "
1389
+ "implementer and not its generator. {assignment} "
1390
+ + _PLAN_PROTOCOL
1391
+ + _PLAN_LINT_OWNS_THE_MECHANICS
1392
+ + _PLAN_READ_TARGETS
1393
+ + PLAN_RUBRIC
1394
+ + _PLAN_SEVERITY
1395
+ + _SHELL_RULE
1396
+ + _REVIEWER_RULE
1397
+ + _RECORD_THE_CAP
1398
+ + _COMMIT_READINESS_LINE
1399
+ + "{credential}"
1400
+ + _CHECKS_BEFORE_VERDICT
1401
+ + "{checks}"
1402
+ + "{stability}"
1403
+ + _CLOSE_THE_ROUND +
1404
+ "NEVER push commits, merge, or change PR state. "
1405
+ "Do NOT create further ali-* sessions. "
1406
+ + _RELEASE_SLOT
1407
+ )
1408
+
1409
+ PLAN_ROUND_K_DIRECTIVE = (
1410
+ "You are a PLAN REVIEWER — the refuter of a generated plan — not an "
1411
+ "implementer and not its generator — round {round} of a review loop (cap "
1412
+ "{cap}). {assignment} "
1413
+ + _PLAN_PROTOCOL
1414
+ + "Follow the skill's round-k section: verify the generator's triage "
1415
+ "reply to every prior finding on its thread, verify the amended plan "
1416
+ "directory against each, walk the full rubric again on the new head, "
1417
+ "record a round-{round} verdict envelope. "
1418
+ + _PLAN_LINT_OWNS_THE_MECHANICS
1419
+ + _PLAN_READ_TARGETS
1420
+ + PLAN_RUBRIC
1421
+ + _PLAN_SEVERITY
1422
+ + _SHELL_RULE
1423
+ + _REVIEWER_RULE
1424
+ + _RECORD_THE_CAP
1425
+ + _COMMIT_READINESS_LINE
1426
+ + "{credential}"
1427
+ + _CHECKS_BEFORE_VERDICT
1428
+ + "{checks}"
1429
+ + "{stability}"
1430
+ + _CLOSE_THE_ROUND +
1431
+ "NEVER push commits, merge, or change PR state. "
1432
+ "Do NOT create further ali-* sessions. "
1433
+ + _RELEASE_SLOT
1434
+ )
1435
+
1436
+ # What the plan-lint gate tells the log and the console while it holds.
1437
+ PLAN_LINT_HELD = (
1438
+ "round {round} waits for {check} — the `{check}` run at {sha} is {state}; "
1439
+ "a plan round never starts before the lint is green"
1440
+ )
1441
+
1120
1442
  # The operator re-entry ack (issue #42). A capped PR is unreviewable until an
1121
1443
  # operator says otherwise, and the cap-out message used to OFFER "re-enter with
1122
1444
  # a fresh cap" without any mechanism existing: the only lever was raising
@@ -2119,6 +2441,10 @@ class ReviewWatcher:
2119
2441
  # that resets each pass never reaches its bound and the diagnostic would
2120
2442
  # report an eternal hold production never takes).
2121
2443
  self._dry_run_check_waits: dict[tuple[str, int, int, str], float] = {}
2444
+ # (repo slug, number, head) the plan-lint gate has already announced
2445
+ # at INFO (issue #148). Process-lifetime and log-only: the hold itself
2446
+ # is re-decided from the rollup every poll.
2447
+ self._plan_lint_logged: set[tuple[str, int, str]] = set()
2122
2448
  # The task corpus THIS poll pass already fetched, or None until some
2123
2449
  # PR in it misses the review-task cache. `alissa task list` returns
2124
2450
  # every non-terminal task this actor owns (hundreds of rows, ~250 KB)
@@ -3394,6 +3720,11 @@ class ReviewWatcher:
3394
3720
  rollup = self.github.check_rollup(pr.owner, pr.repo, pr.head_sha)
3395
3721
  sha, short = pr.head_sha, pr.head_sha[:8]
3396
3722
 
3723
+ if is_plan_pr(self.config, pr):
3724
+ held = self._gate_spawn_on_plan_lint(pr, round_, rollup)
3725
+ if held is not None:
3726
+ return SpawnChecks(hold=held)
3727
+
3397
3728
  if rollup.state == CHECKS_RED:
3398
3729
  failing = directive_data([
3399
3730
  CHECKS_AT_SPAWN_FAILING.format(
@@ -3476,6 +3807,61 @@ class ReviewWatcher:
3476
3807
  )
3477
3808
  )
3478
3809
 
3810
+ def _gate_spawn_on_plan_lint(
3811
+ self, pr: PullRequest, round_: int, rollup: CheckRollup
3812
+ ) -> "Decision | None":
3813
+ """Hold a PLAN round until the `plan-lint` run on its head concluded
3814
+ success (issue #148; design D18, §6.1). None lets the round through
3815
+ to the ordinary CI gate.
3816
+
3817
+ UNBOUNDED, unlike the CI wait above it: that wait bounds a CI system
3818
+ that never reports, because a review must never be cancelled by one;
3819
+ a plan round owes nothing to a plan the lint has not passed. A
3820
+ `failure` is not the reviewer's to explain -- the generator reads it
3821
+ on its next pass and pushes a new head, which re-arms this gate. A
3822
+ MISSING run holds too (the lint lane is a soft dependency: without
3823
+ the workflow nothing ever passes, which is fail-closed), and so do an
3824
+ unreadable rollup and a `skipped` / `neutral` conclusion: only
3825
+ `success` proves the lint ran and passed.
3826
+
3827
+ The hold is the ordinary pre-spawn hold -- a QUEUED with
3828
+ `checks_held`, the `spawn_checks_holds` row that derives the
3829
+ `checks.held` loop event once per (PR, round, head) -- and the round
3830
+ is marked as a plan round in the ledger first, so that event carries
3831
+ `data.kind: "plan"`. One INFO line per (PR, head); repeats are DEBUG,
3832
+ because a lint failure can hold for as long as the generator takes.
3833
+ """
3834
+ self._note_plan_round(pr, round_)
3835
+ runs = [c for c in rollup.contexts if c.name == PLAN_LINT_CHECK]
3836
+ if rollup.state == CHECKS_UNKNOWN:
3837
+ state = f"unreadable ({rollup.unreadable or 'no reason recorded'})"
3838
+ elif not runs:
3839
+ state = "missing"
3840
+ elif any(c.running for c in runs):
3841
+ state = "still running"
3842
+ elif all(c.conclusion == "success" for c in runs):
3843
+ return None
3844
+ else:
3845
+ state = ", ".join(sorted({c.conclusion for c in runs if c.conclusion}))
3846
+
3847
+ self._checks_wait_since(pr, round_)
3848
+ reason = PLAN_LINT_HELD.format(
3849
+ round=round_, check=PLAN_LINT_CHECK, sha=pr.head_sha[:8], state=state
3850
+ )
3851
+ seen = (pr.full_name, pr.number, pr.head_sha)
3852
+ level = logging.DEBUG if seen in self._plan_lint_logged else logging.INFO
3853
+ self._plan_lint_logged.add(seen)
3854
+ log.log(level, "%s: %s", pr.slug, reason)
3855
+ return Decision(Action.QUEUED, reason, round_, checks_held=True)
3856
+
3857
+ def _note_plan_round(self, pr: PullRequest, round_: int) -> None:
3858
+ """Mark round `round_` of this PR as a PLAN round in the ledger, once
3859
+ (the ping is idempotent per kind). Never under --dry-run, which
3860
+ writes no durable state."""
3861
+ if self.config.dry_run:
3862
+ return
3863
+ self.state.record_ping(pr.full_name, pr.number, plan_round_kind(round_))
3864
+
3479
3865
  def _checks_wait_since(self, pr: PullRequest, round_: int) -> float:
3480
3866
  """When this round's pre-spawn CI wait began -- the stamp its bound is
3481
3867
  measured from. Durable in production, per-process in dry-run (which
@@ -3796,7 +4182,17 @@ class ReviewWatcher:
3796
4182
  # checks gate turned into a REQUEST_CHANGES or COMMENT carries none,
3797
4183
  # because `auto` on anything but an APPROVE is exactly what the
3798
4184
  # consumer must never see (issue #130).
3799
- trailer = readiness_trailer(envelope) if event == EVENT_APPROVE else ""
4185
+ # A PLAN PR's approve carries Commit-Readiness and nothing else
4186
+ # (issue #148): the plan close edge commits on it, and a plan is
4187
+ # never a code merge candidate.
4188
+ plan = is_plan_pr(self.config, pr)
4189
+ label = COMMIT_READINESS_TRAILER_LABEL if plan else READINESS_TRAILER_LABEL
4190
+ if event != EVENT_APPROVE:
4191
+ trailer = ""
4192
+ elif plan:
4193
+ trailer = commit_readiness_trailer(envelope)
4194
+ else:
4195
+ trailer = readiness_trailer(envelope)
3800
4196
  body = gate.lead + NATIVE_VERDICT_BODY.format(
3801
4197
  round=round_,
3802
4198
  verdict=verdict,
@@ -3849,10 +4245,15 @@ class ReviewWatcher:
3849
4245
  # read BACK off the emitted line with the shared grammar (issue
3850
4246
  # #142), so what the narration names is what the consumer will parse
3851
4247
  # -- an unclassed operator says `class=unclassed`, the hard hold.
3852
- readiness_value = trailer[len(READINESS_TRAILER_LABEL):].strip()
3853
- posted_value, _, posted_class = parse_trailer(trailer)
4248
+ readiness_value = trailer[len(label):].strip()
4249
+ posted_value, _, posted_class = (
4250
+ parse_commit_trailer(trailer) if plan else parse_trailer(trailer)
4251
+ )
3854
4252
  class_term = readiness_class_term(posted_value, posted_class)
3855
- readiness_note = f" readiness={readiness_value}{class_term}" if trailer else ""
4253
+ readiness_name = "commit-readiness" if plan else "readiness"
4254
+ readiness_note = (
4255
+ f" {readiness_name}={readiness_value}{class_term}" if trailer else ""
4256
+ )
3856
4257
  log.info(
3857
4258
  "%s round %d closed: native %s review submitted as %s (%s)%s%s",
3858
4259
  pr.slug, round_, event, self.github.login, url or "no url",
@@ -3862,7 +4263,11 @@ class ReviewWatcher:
3862
4263
  pr,
3863
4264
  f"- {_now()} — round {round_} — native `{event}` review submitted "
3864
4265
  f"as `{self.github.login}` (verdict of record){gate_note}"
3865
- + (f" — merge-readiness: `{readiness_value}`{class_term}" if trailer else ""),
4266
+ + (
4267
+ f" — {'commit' if plan else 'merge'}-readiness: "
4268
+ f"`{readiness_value}`{class_term}"
4269
+ if trailer else ""
4270
+ ),
3866
4271
  )
3867
4272
  if event == EVENT_COMMENT:
3868
4273
  # A degraded verdict takes the PR out of the loop (the review
@@ -4576,20 +4981,29 @@ class ReviewWatcher:
4576
4981
  if self.state.readiness_observed(pr.full_name, pr.number, pr.head_sha) is not None:
4577
4982
  return
4578
4983
 
4579
- value, reason, klass = parse_trailer(newest.body)
4984
+ # A plan PR's approve is read for its Commit-Readiness trailer, the
4985
+ # one the plan close edge commits on (issue #148); the flag, the row
4986
+ # and the narration are otherwise the same.
4987
+ plan = is_plan_pr(self.config, pr)
4988
+ if plan:
4989
+ value, reason, klass = parse_commit_trailer(newest.body)
4990
+ label, name, edge = COMMIT_READINESS_TRAILER_LABEL, "commit-readiness", "plan close"
4991
+ else:
4992
+ value, reason, klass = parse_trailer(newest.body)
4993
+ label, name, edge = READINESS_TRAILER_LABEL, "readiness", "merge"
4580
4994
  head7 = pr.head_sha[:7]
4581
4995
  if value is None:
4582
4996
  readiness = READINESS_MISSING
4583
4997
  log.warning(
4584
- "%s approve at %s by %s carries no Merge-Readiness trailer — the "
4585
- "merge edge will hold it; the session must end its review body "
4998
+ "%s approve at %s by %s carries no %s trailer — the "
4999
+ "%s edge will hold it; the session must end its review body "
4586
5000
  "with the line (see directive)",
4587
- pr.slug, head7, self.github.login,
5001
+ pr.slug, head7, self.github.login, label.rstrip(":"), edge,
4588
5002
  )
4589
5003
  line = (
4590
5004
  f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
4591
- f"`{self.github.login}` at `{head7}` — readiness=missing (no "
4592
- f"`{READINESS_TRAILER_LABEL}` trailer on the review body; the merge "
5005
+ f"`{self.github.login}` at `{head7}` — {name}=missing (no "
5006
+ f"`{label}` trailer on the review body; the {edge} "
4593
5007
  f"edge will hold it)"
4594
5008
  )
4595
5009
  else:
@@ -4599,12 +5013,12 @@ class ReviewWatcher:
4599
5013
  # not, nothing for `auto`.
4600
5014
  term = (f"{value} — {reason}" if reason else value) + readiness_class_term(value, klass)
4601
5015
  log.info(
4602
- "%s approve at %s by %s carries readiness=%s",
4603
- pr.slug, head7, self.github.login, term,
5016
+ "%s approve at %s by %s carries %s=%s",
5017
+ pr.slug, head7, self.github.login, name, term,
4604
5018
  )
4605
5019
  line = (
4606
5020
  f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
4607
- f"`{self.github.login}` at `{head7}` — readiness={term}"
5021
+ f"`{self.github.login}` at `{head7}` — {name}={term}"
4608
5022
  )
4609
5023
 
4610
5024
  posted_at = _epoch(newest.submitted_at)
@@ -5375,7 +5789,29 @@ class ReviewWatcher:
5375
5789
  # `task is None` here means spawn_anyway/warn_and_spawn: the skip mode
5376
5790
  # was decided in _refused_before_start, above the CI gate, so a round
5377
5791
  # that will never start buys no rollup.
5378
- if task is None:
5792
+ plan = is_plan_pr(self.config, pr)
5793
+ if plan and task is None:
5794
+ # The ordinary round 1 of a plan PR, not a CR2 gap: a plan has no
5795
+ # origin task and no implementer to have made one, so its first
5796
+ # reviewer creates the review task (design §6.1).
5797
+ log.info(
5798
+ "%s is a plan PR with no review task yet — its reviewer "
5799
+ "creates %r downstream of nothing",
5800
+ pr.slug, plan_review_task_title(pr),
5801
+ )
5802
+ assignment = (
5803
+ f"Review the plan PR {pr.url} . There is no Alissa review task "
5804
+ f"for it yet — create it per CR2, titled "
5805
+ f"`{plan_review_task_title(pr)}`, downstream of nothing (a plan "
5806
+ f"has no origin task), before recording your verdict."
5807
+ + (
5808
+ ""
5809
+ if plan_id_of(pr)
5810
+ else " The PR body declares no plan id: take it from the "
5811
+ "`id` field of the plan's `plan.md` frontmatter."
5812
+ )
5813
+ )
5814
+ elif task is None:
5379
5815
  log.warning(
5380
5816
  "%s has no open Alissa review task (CR2) — spawning against the PR "
5381
5817
  "URL; the reviewer must create or locate one before recording a verdict",
@@ -5390,7 +5826,10 @@ class ReviewWatcher:
5390
5826
  assignment = f"You've been assigned Alissa review task {task.ref}."
5391
5827
 
5392
5828
  name = session_name(pr, round_)
5393
- template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
5829
+ if plan:
5830
+ template = PLAN_ROUND_1_DIRECTIVE if round_ == 1 else PLAN_ROUND_K_DIRECTIVE
5831
+ else:
5832
+ template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
5394
5833
  directive = template.format(
5395
5834
  assignment=assignment,
5396
5835
  round=round_,
@@ -68,6 +68,14 @@ refused. The suffix is the design's R1 key: Studio's reducer flips a
68
68
  credential only when the suffix matches the stored row, so a container still
69
69
  running an OLD token after a same-mode replace cannot flip the new, good
70
70
  credential. `reason` is a fixed sentence, never the pane's text.
71
+
72
+ A PLAN round (issue #148; genloop design §6, lane L4) is marked the same
73
+ ledger-derived way: when the plan gate first sees round `<n>` of a plan PR it
74
+ records one `plan-round:<n>` ping, and every ROUND event of that (repo, PR,
75
+ round) -- `round.spawned`, `round.verdict`, `round.abandoned`,
76
+ `round.capped` and both gates' `checks.held` -- carries `data.kind: "plan"`.
77
+ A code round carries no `kind` at all, so every pre-existing payload is
78
+ unchanged byte for byte.
71
79
  """
72
80
 
73
81
  from __future__ import annotations
@@ -113,6 +121,18 @@ _PROMPT_PAGE_RE = re.compile(
113
121
  )
114
122
 
115
123
 
124
+ # The plan-round marker (issue #148): `plan-round:<round>`, recorded by
125
+ # loop._note_plan_round (a test pins the two prefixes together).
126
+ PLAN_ROUND_PREFIX = "plan-round:"
127
+ _PLAN_ROUND_RE = re.compile(r"^plan-round:(?P<round>\d+)$")
128
+ ROUND_KIND_PLAN = "plan"
129
+ # The events a round kind is stamped on: the per-round kinds, and nothing
130
+ # that is about a session or the whole PR.
131
+ ROUND_EVENT_KINDS = frozenset({
132
+ "round.spawned", "round.verdict", "round.abandoned", "round.capped",
133
+ "checks.held",
134
+ })
135
+
116
136
  # The `auth.rejected` row (issue #146): one per SESSION,
117
137
  # `auth-rejected:<session>#<round>:<source>:<matched>:<envName>:<suffix>`.
118
138
  # The session leads so "already recorded for this session" is one prefix read
@@ -547,6 +567,32 @@ def _reap_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
547
567
  ]
548
568
 
549
569
 
570
+ def _plan_rounds(pings: "list[dict]") -> "set[tuple[str, int, int]]":
571
+ """Every (repo, PR, round) the ledger marks as a plan round."""
572
+ out = set()
573
+ for row in pings:
574
+ match = _PLAN_ROUND_RE.match(str(row["kind"]))
575
+ if match is not None:
576
+ out.add((row["repo"], int(row["number"]), int(match.group("round"))))
577
+ return out
578
+
579
+
580
+ def _mark_plan_rounds(
581
+ stamped: "list[tuple[int, dict]]", plan_rounds: "set[tuple[str, int, int]]"
582
+ ) -> None:
583
+ """Stamp `data.kind: "plan"` on the round events of plan rounds, in
584
+ place. An event with no round (a `round.capped` no spawn row explains)
585
+ cannot be attributed and is left unmarked."""
586
+ if not plan_rounds:
587
+ return
588
+ for _, event in stamped:
589
+ if event["kind"] not in ROUND_EVENT_KINDS or "round" not in event:
590
+ continue
591
+ key = (event.get("repo"), event.get("prNumber"), event["round"])
592
+ if key in plan_rounds:
593
+ event["data"] = {**event.get("data", {}), "kind": ROUND_KIND_PLAN}
594
+
595
+
550
596
  def derive_events(state: State, *, since: int = 0) -> "list[dict]":
551
597
  """Every loop event the ledger implies whose stamp is >= `since`,
552
598
  oldest first.
@@ -565,10 +611,12 @@ def derive_events(state: State, *, since: int = 0) -> "list[dict]":
565
611
  stamped += _spawn_events(spawns)
566
612
  stamped += _verdict_events(state.read_verdict_posts())
567
613
  stamped += _capped_events(state.read_escalations(), spawns)
568
- stamped += _ping_events(state.read_pings(), notices)
614
+ pings = state.read_pings()
615
+ stamped += _ping_events(pings, notices)
569
616
  stamped += _spawn_hold_events(state.read_spawn_checks_holds())
570
617
  stamped += _grant_events(state.read_grants())
571
618
  stamped += _reap_events(state.read_reaps())
619
+ _mark_plan_rounds(stamped, _plan_rounds(pings))
572
620
 
573
621
  stamped = [(at, event) for at, event in stamped if at >= since]
574
622
  stamped.sort(key=lambda pair: pair[0])
@@ -34,7 +34,12 @@ import re
34
34
  import sys
35
35
  import time
36
36
 
37
- from .alissa import Alissa, VERDICT_APPROVE, VERDICT_REQUEST_CHANGES
37
+ from .alissa import (
38
+ Alissa,
39
+ VERDICT_APPROVE,
40
+ VERDICT_REQUEST_CHANGES,
41
+ review_task_title_pattern,
42
+ )
38
43
  from .config import env_task_list_bow_id
39
44
  from .ghclient import GitHub
40
45
  from .proc import CommandError, run, run_json
@@ -80,9 +85,9 @@ def _review_task_ref(alissa: Alissa, owner: str, repo: str, number: int) -> str
80
85
  `is_open` set or absent altogether, and in the first case narrowing by it
81
86
  here would re-impose the filter this function exists to avoid.
82
87
  """
83
- pattern = re.compile(
84
- rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b", re.IGNORECASE
85
- )
88
+ # The daemon's own title grammar, so a plan PR's `Review plan …` task
89
+ # (issue #148) resolves here exactly as it does in the poll loop.
90
+ pattern = review_task_title_pattern(owner, repo, number)
86
91
  matches = [
87
92
  t for t in alissa.list_tasks(narrow_status=False) if pattern.match(t.title)
88
93
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.6
3
+ Version: 0.31.8
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera