alissa-tools-github-devloop 0.8.30__tar.gz → 0.8.32__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 (41) hide show
  1. alissa_tools_github_devloop-0.8.32/PKG-INFO +161 -0
  2. alissa_tools_github_devloop-0.8.32/README.md +139 -0
  3. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/ghclient.py +61 -9
  4. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/loop.py +907 -44
  5. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/loop_events.py +106 -2
  6. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/prompts.py +280 -7
  7. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/state.py +157 -8
  8. alissa_tools_github_devloop-0.8.32/src/main/alissa/tools/github/devloop/version +1 -0
  9. alissa_tools_github_devloop-0.8.32/src/main/alissa_tools_github_devloop.egg-info/PKG-INFO +161 -0
  10. alissa_tools_github_devloop-0.8.30/PKG-INFO +0 -78
  11. alissa_tools_github_devloop-0.8.30/README.md +0 -56
  12. alissa_tools_github_devloop-0.8.30/src/main/alissa/tools/github/devloop/version +0 -1
  13. alissa_tools_github_devloop-0.8.30/src/main/alissa_tools_github_devloop.egg-info/PKG-INFO +0 -78
  14. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/LICENSE +0 -0
  15. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/MANIFEST.in +0 -0
  16. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/NOTICE +0 -0
  17. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/requirements.txt +0 -0
  18. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/setup.cfg +0 -0
  19. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/setup.py +0 -0
  20. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  21. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  22. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  23. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  24. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  25. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/config.py +0 -0
  26. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/fleet_vitals.py +0 -0
  27. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  28. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  29. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  30. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/version.py +0 -0
  31. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  32. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  33. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  34. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  35. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  36. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  37. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  38. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  39. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  40. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  41. {alissa_tools_github_devloop-0.8.30 → alissa_tools_github_devloop-0.8.32}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: alissa-tools-github-devloop
3
+ Version: 0.8.32
4
+ Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
+ Home-page: https://alissa.app
6
+ Author: Fahera
7
+ Author-email: support@alissa.app
8
+ License: Apache-2.0
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Dynamic: author
14
+ Dynamic: author-email
15
+ Dynamic: description
16
+ Dynamic: description-content-type
17
+ Dynamic: home-page
18
+ Dynamic: license
19
+ Dynamic: license-file
20
+ Dynamic: requires-python
21
+ Dynamic: summary
22
+
23
+ # alissa-tools-github-devloop
24
+
25
+ The `alissa.tools.github.devloop` module: a GitHub watcher that turns
26
+ `alissa:develop`-labeled issues into fresh developer sessions — the
27
+ developer-side counterpart of
28
+ [`alissa-tools-github-reviewloop`](https://github.com/fahera-mx/alissa-github-review-daemon).
29
+
30
+ This distribution ships **only** that module. Everything above it —
31
+ `alissa`, `alissa.tools`, `alissa.tools.github` — is a
32
+ [PEP 420](https://peps.python.org/pep-0420/) namespace package with no
33
+ `__init__.py`, so other distributions in other repositories can contribute
34
+ their own packages under the same namespace:
35
+
36
+ ```
37
+ alissa/ ← namespace (no __init__.py)
38
+ └── tools/ ← namespace
39
+ └── github/ ← namespace
40
+ ├── devloop/ __init__.py ← THIS distribution
41
+ └── reviewloop/ __init__.py ← ships from alissa-github-review-daemon
42
+ ```
43
+
44
+ The rule: a distribution owns a leaf package and declares only that subtree
45
+ (`find_namespace_packages(include=[PACKAGE, f"{PACKAGE}.*"])`). Adding an
46
+ `__init__.py` at any namespace level would claim it for one distribution and
47
+ shadow the others.
48
+
49
+ ## Install
50
+
51
+ ```sh
52
+ pip install -e ./alissa-tools-github-devloop
53
+ ```
54
+
55
+ ## Console scripts
56
+
57
+ | Command | Entry point |
58
+ | --- | --- |
59
+ | `alissa-devloop` | `alissa.tools.github.devloop.__main__:main` |
60
+
61
+ ## Layout
62
+
63
+ `src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
64
+ distribution version lives in the plain-text `version` file next to the module
65
+ it versions (`src/main/alissa/tools/github/devloop/version`), read by both
66
+ `setup.py` and `version.py`.
67
+
68
+ ## Account-level conditions
69
+
70
+ The prompt responder (`prompts.py` classifies, `loop.py` acts) treats four
71
+ pane conditions as the ACCOUNT's, not the worker's — nothing a keystroke can
72
+ answer: `login_expired`, `auth_rejected` (a 401), `usage_limit` and
73
+ `out_of_credits`. Each pages the operator once per kind per 6 h on the lane's
74
+ issue/PR and holds every new spawn while it stands (at most 1 h on one pane,
75
+ then kill-and-retry re-confirms) — except `usage_limit`, whose session is
76
+ never killed and whose hold is the ledger's (below).
77
+
78
+ **Usage limits** (issues #142, #144). `usage_limit` admits the window wordings
79
+ (`You've hit your weekly limit`, `Usage limit reached`, …), the "limit
80
+ reached … resets …" family (`Claude usage limit reached. Your limit will
81
+ reset at 5pm (America/New_York).`, `5-hour limit reached ∙ resets 3pm`,
82
+ `Opus weekly limit reached ∙ resets Oct 6, 1pm`) and the
83
+ MODEL-named one Claude Code prints now — `You've reached your Fable limit.
84
+ Run /usage-credits to continue or switch models with /model.` (also `Opus`,
85
+ `Sonnet`, …): one capitalised model token, a known family name or any token
86
+ corroborated by that trailing hint on the same line. An over-limit account
87
+ answers the directive with that notice as the worker's ONLY assistant turn
88
+ and leaves the session idle at `❯`, which the banner rule (never a `●` row)
89
+ cannot see, so the **first-turn limit check** reads the responder's capture of
90
+ any young worker (`develop-*`, `fix-*`, `maintain-*`, `revert-*`, spawned by
91
+ this ledger inside the last `stale_minutes`): the notice, above an idle
92
+ prompt, with no other `●` turn since the directive. A match
93
+ (`source: "first_turn"`, `matched: "model_limit"`, the model) is:
94
+
95
+ - **held** — the daemon-wide spawn hold, clocked on the ledger from the
96
+ newest limit (it survives a restart and a listing failure) and lifted only
97
+ when a spawn made AFTER the limit gets through its first turn: after 1 h
98
+ ONE probe spawn is admitted at a time (none while a probe is in flight,
99
+ none while another account notice holds; under `max_sessions` the probe
100
+ is judged without the kept limited sessions, which would otherwise fill the
101
+ cap for good); a probe whose first turn is the notice again is refunded,
102
+ re-arms the clock and is **killed** — it did no work, and keeping it would
103
+ add one session per hour of the limit (past `max_sessions` too), so the
104
+ live sessions stay at the ones the limit found at work plus at most the one
105
+ probe in flight; the first probe that does
106
+ real work lifts the hold (`usage-limit-lifted:` on the escalation ledger,
107
+ `escalation.usage_limit_lifted` on the wire) and each kept limited
108
+ session's lane gets a line saying to resume it;
109
+ - **paged** — once per 6 h window, through the account-kind page;
110
+ - **never killed** (issue #144; a failed probe, above, is the one exception) —
111
+ a retry would hit the same wall, and the idle session is the lane's own work for the operator to resume once the
112
+ account is back (or `alissa tmux kill`, to let the retry edge respawn);
113
+ the wedge kills skip a limited session too;
114
+ - **refunded** — the spawn row is stamped `limited_at`, and every
115
+ `attempt_cap` count leaves limited rows out (numbering stays monotonic, so
116
+ session names never repeat). An account over its limit never caps a lane.
117
+
118
+ A usage-limit BANNER on a working session (`source: "pane"`) is held the same
119
+ way: its row is stamped (refunded), the hold is the ledger's, and the session
120
+ is never killed. The same notice after real worker output (a tool call, the
121
+ worker's own prose, a quoted tool row) stays worker output. Each limited
122
+ session is one `usage.limited` loop event: `data: { kind: "usage_limit",
123
+ source: "pane" | "first_turn", matched: "model_limit" | "usage_limit", model,
124
+ resetsAt? }` — `resetsAt` only when the notice states a reset, as an ISO-8601
125
+ UTC instant when that needs no guess (the legacy `|<epoch>` form, or a clock
126
+ time with an explicit zone) and as the stated phrase otherwise; no pane text,
127
+ no credential material.
128
+
129
+ **The fix edge stops presuming.** A stale fix round with no triage reply from
130
+ the author used to be re-enqueued on the timer alone ("presumed dead"). It now
131
+ reads the tmux roster first: ALIVE is in flight (the responder owns a live
132
+ idle pane; a once-per-episode "Fix loop stalled?" ping floors the wait),
133
+ INDETERMINATE defers one pass, and only DEAD respawns — "CONFIRMED dead", or,
134
+ after a refunded limit, "previous session limited (usage limit; attempt
135
+ refunded)". A kept limited session reads ALIVE, so no edge respawns over it.
136
+
137
+ ## The re-enqueue guard (issue #144)
138
+
139
+ The issue edge's respawn rule reads the linked-PR signal over **every** PR
140
+ state: `GitHub.linked_pull_requests` walks the timeline once and reports each
141
+ same-repo cross-referenced PR as open, merged or closed. When a stale
142
+ attempt's session is CONFIRMED dead with no open PR, a merged PR of the
143
+ daemon's own that closes the issue is `SKIPPED` "finished (PR #n merged)" and
144
+ a closed-unmerged one is `SKIPPED` "closed unmerged — orchestrator's page" —
145
+ never a respawn (provenance: token author and a closing keyword, fetched; an
146
+ unreadable candidate stands the respawn down). And `_spawn` re-reads the
147
+ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
148
+ closed between search and spawn", nothing is claimed, and the reserved slot
149
+ is handed back. The respawn-with-resume on an open draft PR is unchanged.
150
+
151
+ ## License
152
+
153
+ Licensed under the
154
+ [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) — the
155
+ `LICENSE` and `NOTICE` files shipped with this distribution carry the full text
156
+ and the attribution and trademark terms. Both ship inside the sdist and the
157
+ wheel's `.dist-info`.
158
+
159
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS
160
+ S. DE R.L. and are not covered by the license — the grant covers the code, not
161
+ the names.
@@ -0,0 +1,139 @@
1
+ # alissa-tools-github-devloop
2
+
3
+ The `alissa.tools.github.devloop` module: a GitHub watcher that turns
4
+ `alissa:develop`-labeled issues into fresh developer sessions — the
5
+ developer-side counterpart of
6
+ [`alissa-tools-github-reviewloop`](https://github.com/fahera-mx/alissa-github-review-daemon).
7
+
8
+ This distribution ships **only** that module. Everything above it —
9
+ `alissa`, `alissa.tools`, `alissa.tools.github` — is a
10
+ [PEP 420](https://peps.python.org/pep-0420/) namespace package with no
11
+ `__init__.py`, so other distributions in other repositories can contribute
12
+ their own packages under the same namespace:
13
+
14
+ ```
15
+ alissa/ ← namespace (no __init__.py)
16
+ └── tools/ ← namespace
17
+ └── github/ ← namespace
18
+ ├── devloop/ __init__.py ← THIS distribution
19
+ └── reviewloop/ __init__.py ← ships from alissa-github-review-daemon
20
+ ```
21
+
22
+ The rule: a distribution owns a leaf package and declares only that subtree
23
+ (`find_namespace_packages(include=[PACKAGE, f"{PACKAGE}.*"])`). Adding an
24
+ `__init__.py` at any namespace level would claim it for one distribution and
25
+ shadow the others.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ pip install -e ./alissa-tools-github-devloop
31
+ ```
32
+
33
+ ## Console scripts
34
+
35
+ | Command | Entry point |
36
+ | --- | --- |
37
+ | `alissa-devloop` | `alissa.tools.github.devloop.__main__:main` |
38
+
39
+ ## Layout
40
+
41
+ `src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
42
+ distribution version lives in the plain-text `version` file next to the module
43
+ it versions (`src/main/alissa/tools/github/devloop/version`), read by both
44
+ `setup.py` and `version.py`.
45
+
46
+ ## Account-level conditions
47
+
48
+ The prompt responder (`prompts.py` classifies, `loop.py` acts) treats four
49
+ pane conditions as the ACCOUNT's, not the worker's — nothing a keystroke can
50
+ answer: `login_expired`, `auth_rejected` (a 401), `usage_limit` and
51
+ `out_of_credits`. Each pages the operator once per kind per 6 h on the lane's
52
+ issue/PR and holds every new spawn while it stands (at most 1 h on one pane,
53
+ then kill-and-retry re-confirms) — except `usage_limit`, whose session is
54
+ never killed and whose hold is the ledger's (below).
55
+
56
+ **Usage limits** (issues #142, #144). `usage_limit` admits the window wordings
57
+ (`You've hit your weekly limit`, `Usage limit reached`, …), the "limit
58
+ reached … resets …" family (`Claude usage limit reached. Your limit will
59
+ reset at 5pm (America/New_York).`, `5-hour limit reached ∙ resets 3pm`,
60
+ `Opus weekly limit reached ∙ resets Oct 6, 1pm`) and the
61
+ MODEL-named one Claude Code prints now — `You've reached your Fable limit.
62
+ Run /usage-credits to continue or switch models with /model.` (also `Opus`,
63
+ `Sonnet`, …): one capitalised model token, a known family name or any token
64
+ corroborated by that trailing hint on the same line. An over-limit account
65
+ answers the directive with that notice as the worker's ONLY assistant turn
66
+ and leaves the session idle at `❯`, which the banner rule (never a `●` row)
67
+ cannot see, so the **first-turn limit check** reads the responder's capture of
68
+ any young worker (`develop-*`, `fix-*`, `maintain-*`, `revert-*`, spawned by
69
+ this ledger inside the last `stale_minutes`): the notice, above an idle
70
+ prompt, with no other `●` turn since the directive. A match
71
+ (`source: "first_turn"`, `matched: "model_limit"`, the model) is:
72
+
73
+ - **held** — the daemon-wide spawn hold, clocked on the ledger from the
74
+ newest limit (it survives a restart and a listing failure) and lifted only
75
+ when a spawn made AFTER the limit gets through its first turn: after 1 h
76
+ ONE probe spawn is admitted at a time (none while a probe is in flight,
77
+ none while another account notice holds; under `max_sessions` the probe
78
+ is judged without the kept limited sessions, which would otherwise fill the
79
+ cap for good); a probe whose first turn is the notice again is refunded,
80
+ re-arms the clock and is **killed** — it did no work, and keeping it would
81
+ add one session per hour of the limit (past `max_sessions` too), so the
82
+ live sessions stay at the ones the limit found at work plus at most the one
83
+ probe in flight; the first probe that does
84
+ real work lifts the hold (`usage-limit-lifted:` on the escalation ledger,
85
+ `escalation.usage_limit_lifted` on the wire) and each kept limited
86
+ session's lane gets a line saying to resume it;
87
+ - **paged** — once per 6 h window, through the account-kind page;
88
+ - **never killed** (issue #144; a failed probe, above, is the one exception) —
89
+ a retry would hit the same wall, and the idle session is the lane's own work for the operator to resume once the
90
+ account is back (or `alissa tmux kill`, to let the retry edge respawn);
91
+ the wedge kills skip a limited session too;
92
+ - **refunded** — the spawn row is stamped `limited_at`, and every
93
+ `attempt_cap` count leaves limited rows out (numbering stays monotonic, so
94
+ session names never repeat). An account over its limit never caps a lane.
95
+
96
+ A usage-limit BANNER on a working session (`source: "pane"`) is held the same
97
+ way: its row is stamped (refunded), the hold is the ledger's, and the session
98
+ is never killed. The same notice after real worker output (a tool call, the
99
+ worker's own prose, a quoted tool row) stays worker output. Each limited
100
+ session is one `usage.limited` loop event: `data: { kind: "usage_limit",
101
+ source: "pane" | "first_turn", matched: "model_limit" | "usage_limit", model,
102
+ resetsAt? }` — `resetsAt` only when the notice states a reset, as an ISO-8601
103
+ UTC instant when that needs no guess (the legacy `|<epoch>` form, or a clock
104
+ time with an explicit zone) and as the stated phrase otherwise; no pane text,
105
+ no credential material.
106
+
107
+ **The fix edge stops presuming.** A stale fix round with no triage reply from
108
+ the author used to be re-enqueued on the timer alone ("presumed dead"). It now
109
+ reads the tmux roster first: ALIVE is in flight (the responder owns a live
110
+ idle pane; a once-per-episode "Fix loop stalled?" ping floors the wait),
111
+ INDETERMINATE defers one pass, and only DEAD respawns — "CONFIRMED dead", or,
112
+ after a refunded limit, "previous session limited (usage limit; attempt
113
+ refunded)". A kept limited session reads ALIVE, so no edge respawns over it.
114
+
115
+ ## The re-enqueue guard (issue #144)
116
+
117
+ The issue edge's respawn rule reads the linked-PR signal over **every** PR
118
+ state: `GitHub.linked_pull_requests` walks the timeline once and reports each
119
+ same-repo cross-referenced PR as open, merged or closed. When a stale
120
+ attempt's session is CONFIRMED dead with no open PR, a merged PR of the
121
+ daemon's own that closes the issue is `SKIPPED` "finished (PR #n merged)" and
122
+ a closed-unmerged one is `SKIPPED` "closed unmerged — orchestrator's page" —
123
+ never a respawn (provenance: token author and a closing keyword, fetched; an
124
+ unreadable candidate stands the respawn down). And `_spawn` re-reads the
125
+ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
126
+ closed between search and spawn", nothing is claimed, and the reserved slot
127
+ is handed back. The respawn-with-resume on an open draft PR is unchanged.
128
+
129
+ ## License
130
+
131
+ Licensed under the
132
+ [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) — the
133
+ `LICENSE` and `NOTICE` files shipped with this distribution carry the full text
134
+ and the attribution and trademark terms. Both ship inside the sdist and the
135
+ wheel's `.dist-info`.
136
+
137
+ "Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS
138
+ S. DE R.L. and are not covered by the license — the grant covers the code, not
139
+ the names.
@@ -53,6 +53,13 @@ FIRST_PARENT_MAX_PAGES = 3
53
53
  SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
54
54
 
55
55
 
56
+ # The states `GitHub.linked_pull_requests` reports a cross-referenced pull request in
57
+ # (issue #144): still open, merged, or closed without a merge.
58
+ PR_OPEN = "open"
59
+ PR_MERGED = "merged"
60
+ PR_CLOSED = "closed"
61
+
62
+
56
63
  def parse_github_timestamp(ts: "str | None") -> "int | None":
57
64
  """A GitHub API timestamp (`2026-07-23T02:13:56Z`) as unix seconds, or
58
65
  None when absent/unparseable. GitHub emits UTC `Z` timestamps
@@ -1325,8 +1332,45 @@ class GitHub:
1325
1332
  is invisible either way, so `exhaustive` means complete UP TO the
1326
1333
  documented ceiling, not unbounded.
1327
1334
  """
1335
+ return [
1336
+ url
1337
+ for url, state in self.linked_pull_requests(
1338
+ owner, repo, number, exhaustive=exhaustive
1339
+ )
1340
+ if state == PR_OPEN
1341
+ ]
1342
+
1343
+ def linked_pull_requests(
1344
+ self,
1345
+ owner: str,
1346
+ repo: str,
1347
+ number: int,
1348
+ *,
1349
+ exhaustive: bool = False,
1350
+ ) -> "list[tuple[str, str]]":
1351
+ """Every same-repo pull request the issue's timeline cross-references,
1352
+ in ANY state, as `(url, state)` pairs -- `state` is `PR_OPEN`,
1353
+ `PR_MERGED` or `PR_CLOSED` (closed without a merge). The one walk
1354
+ behind `linked_open_prs` (which filters it to the open ones) and the
1355
+ issue edge's respawn rule (issue #144, design studio.alissa.app
1356
+ docs/design/loop-operator-surface.md §8.4): a DEAD session's lane
1357
+ with no open PR is only an implement-phase death when no MERGED or
1358
+ closed PR claims the issue either -- 2026-09-28, an operator's hand
1359
+ merge closed issue #1620 two seconds before the daemon read "no PR
1360
+ opened" off the open-only probe and spawned a duplicate lane.
1361
+
1362
+ Merged is read off the cross-reference's own payload
1363
+ (`source.issue.pull_request.merged_at`), so the walk costs nothing
1364
+ more than it did; the caller still fetches a candidate before acting
1365
+ on it, because a cross-reference proves only a MENTION (provenance is
1366
+ the fetched body's closing keyword and author). The walk, its bounds,
1367
+ its same-repo filter and the `exhaustive` switch are exactly
1368
+ `linked_open_prs`'s: the default stops at the first page that yields
1369
+ an OPEN PR (closed ones never stop it), so a lane with no open PR
1370
+ walks the whole timeline -- up to TIMELINE_MAX_PAGES -- and sees
1371
+ every closed one."""
1328
1372
  same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
1329
- urls: list[str] = []
1373
+ found: "dict[str, str]" = {}
1330
1374
  for page in range(1, TIMELINE_MAX_PAGES + 1):
1331
1375
  events = (
1332
1376
  self._api(
@@ -1352,15 +1396,25 @@ class GitHub:
1352
1396
  src = source.get("issue") if isinstance(source, dict) else None
1353
1397
  if not isinstance(src, dict):
1354
1398
  continue
1355
- if not src.get("pull_request"):
1399
+ pull = src.get("pull_request")
1400
+ if not pull:
1356
1401
  continue # the cross-reference came from a plain issue
1357
- if src.get("state") != "open":
1358
- continue
1359
1402
  url = src.get("html_url") or ""
1360
1403
  if not url.startswith(same_repo_pull):
1361
1404
  continue # a mention from another repo says nothing here
1362
- urls.append(url)
1363
- if urls and not exhaustive:
1405
+ if src.get("state") == "open":
1406
+ state = PR_OPEN
1407
+ elif isinstance(pull, dict) and pull.get("merged_at"):
1408
+ state = PR_MERGED
1409
+ elif src.get("state") == "closed":
1410
+ state = PR_CLOSED
1411
+ else:
1412
+ continue # no readable state: never guess
1413
+ # One PR can cross-reference the issue more than once (body
1414
+ # edit, comment mention); report it once, in its first
1415
+ # position -- the state is the PR's, not the event's.
1416
+ found.setdefault(url, state)
1417
+ if not exhaustive and PR_OPEN in found.values():
1364
1418
  # The DEFAULT caller's question is boolean -- is ANY open
1365
1419
  # same-repo PR linked? -- so once a page yields one, later
1366
1420
  # pages can only add more URLs to a message, never change the
@@ -1370,9 +1424,7 @@ class GitHub:
1370
1424
  break
1371
1425
  if len(events) < TIMELINE_PAGE_SIZE:
1372
1426
  break # short page: the timeline is exhausted
1373
- # One PR can cross-reference the issue more than once (body edit,
1374
- # comment mention); report it once.
1375
- return list(dict.fromkeys(urls))
1427
+ return list(found.items())
1376
1428
 
1377
1429
  def assign_self(self, owner: str, repo: str, number: int) -> None:
1378
1430
  """Assign the token identity -- the server-side in-flight marker.