alissa-tools-github-devloop 0.8.19__tar.gz → 0.8.20__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 (35) hide show
  1. {alissa_tools_github_devloop-0.8.19/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.20}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/janitor.py +73 -24
  3. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/loop.py +236 -50
  4. alissa_tools_github_devloop-0.8.20/src/main/alissa/tools/github/devloop/version +1 -0
  5. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_devloop-0.8.19/src/main/alissa/tools/github/devloop/version +0 -1
  7. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/LICENSE +0 -0
  8. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/MANIFEST.in +0 -0
  9. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/NOTICE +0 -0
  10. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/README.md +0 -0
  11. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/requirements.txt +0 -0
  12. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/setup.cfg +0 -0
  13. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/setup.py +0 -0
  14. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  15. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  17. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  18. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  19. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/config.py +0 -0
  20. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  21. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
  22. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  23. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/state.py +0 -0
  24. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/version.py +0 -0
  25. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  26. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  27. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  28. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  29. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  30. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  31. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  32. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  33. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  35. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.20}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.19
3
+ Version: 0.8.20
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -42,16 +42,58 @@ from .proc import CommandError, run
42
42
 
43
43
  log = logging.getLogger(__name__)
44
44
 
45
- # A task workspace's directory name, which is ALSO its branch name: the
46
- # directives mandate `git worktree add TASK-<n>-<DESC>` with <n> the worker's
47
- # own implementation task, and `pr_task_ref` in loop.py already reads the same
48
- # shape off a PR's head ref. Full-matched, never prefix-matched, for
49
- # `parse_worker_session`'s reason: this module's consumer deletes directories,
50
- # and `TASK-` matches anything an operator felt like naming that way. The
51
- # description charset is the branch charset the fix edge already refuses to
52
- # leave (`[A-Za-z0-9._-]+`), so a name that would need shell quoting is not
53
- # one of ours.
54
- TASK_WORKSPACE_RE = re.compile(r"TASK-(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?")
45
+ # A task workspace's directory name. The directives mandate `git worktree add
46
+ # TASK-<n>-<DESC>` with <n> the worker's own implementation task, and
47
+ # `pr_task_ref` in loop.py reads the same shape off a PR's head ref -- and,
48
+ # since issue #115, the PR-side directives tolerate a branch behind one
49
+ # `task/` prefix and then say `git worktree add task--TASK-<n>-<DESC>
50
+ # task/TASK-<n>-<DESC>`: the directory is the branch with `/` rewritten to
51
+ # `--` (WORKTREE_DIR_SEPARATOR) so the worktree stays a flat direct child of
52
+ # the hub. So a workspace directory is the flat shape, or the flat shape
53
+ # behind `task--`, or `task--<n>-<desc>` (the `TASK-` literal dropped, the
54
+ # third shape the incident's workers produced). Full-matched, never
55
+ # prefix-matched, for `parse_worker_session`'s reason: this module's consumer
56
+ # deletes directories, and `TASK-` matches anything an operator felt like
57
+ # naming that way. The description charset is the branch charset the fix
58
+ # edge already refuses to leave (`[A-Za-z0-9._-]+`), so a name that would
59
+ # need shell quoting is not one of ours.
60
+ TASK_WORKSPACE_RE = re.compile(
61
+ r"(?:task--(?:TASK-)?|TASK-)(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?"
62
+ )
63
+
64
+ # The same grammar as a BRANCH name -- the directory grammar with the
65
+ # rewritten separator put back. `is_task_branch` asks this one; the two are
66
+ # kept side by side so the pair a `task--…` directory promises (`task/…`
67
+ # checked out in it) is one mapping stated once (`branch_of_dir`).
68
+ TASK_BRANCH_RE = re.compile(
69
+ r"(?:task/(?:TASK-)?|TASK-)(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?"
70
+ )
71
+
72
+ # What a branch's `/` becomes in its worktree directory's name, and the
73
+ # prefix that marks a directory as carrying one. Only a directory that BEGINS
74
+ # with `task--` is ever read back as a prefixed branch, so a flat branch that
75
+ # happens to contain `--` mid-name maps to itself in both directions.
76
+ WORKTREE_DIR_SEPARATOR = "--"
77
+ _PREFIXED_DIR = "task" + WORKTREE_DIR_SEPARATOR
78
+ _PREFIXED_BRANCH = "task/"
79
+
80
+
81
+ def dir_of_branch(branch: str) -> str:
82
+ """The worktree directory name for `branch`: `/` rewritten to `--`, so a
83
+ `task/TASK-<n>-<DESC>` branch lives in `task--TASK-<n>-<DESC>` and a flat
84
+ branch in a directory of its own name. Pure string mapping -- the shape
85
+ gates are the caller's (`loop.worktree_dir` applies them)."""
86
+ return branch.replace("/", WORKTREE_DIR_SEPARATOR)
87
+
88
+
89
+ def branch_of_dir(name: str) -> str:
90
+ """The branch a workspace directory `name` promises: the inverse of
91
+ `dir_of_branch` for the one prefix the daemon tolerates. `task--X` ->
92
+ `task/X`; anything else is its own branch name."""
93
+ if name.startswith(_PREFIXED_DIR):
94
+ return _PREFIXED_BRANCH + name[len(_PREFIXED_DIR):]
95
+ return name
96
+
55
97
 
56
98
  # Where the `archive` holdout policy pushes unpushed work (issue #99): a NEW
57
99
  # ref under this prefix, named after the task branch. Never the task branch
@@ -85,12 +127,14 @@ DEFAULT_CACHE_PATHS: "tuple[str, ...]" = (
85
127
  class TaskWorkspace:
86
128
  """One per-task workspace directory, parsed back into what it is.
87
129
 
88
- `branch` is the directory name verbatim -- the two are the same string by
89
- construction (`git worktree add TASK-<n>-<DESC>` names the worktree
90
- directory and the branch together), and keeping both names for one value
91
- is what makes the removal code read honestly: the directory is removed by
92
- path, the branch is deleted by name, and a future layout where they differ
93
- has one place to fix.
130
+ `branch` is the branch the directory name PROMISES (`branch_of_dir`):
131
+ the directory name itself for the flat shape (`git worktree add
132
+ TASK-<n>-<DESC>` names the worktree directory and the branch together),
133
+ and `task/…` for a `task--…` directory (the PR-side directives' flat
134
+ worktree dir for a tolerated `task/`-prefixed branch, issue #115). The
135
+ removal code reads honestly either way: the directory is removed by path,
136
+ the branch is deleted by name, and the reclaim's first gate checks that
137
+ the checked-out branch IS the promised one.
94
138
  """
95
139
 
96
140
  path: Path
@@ -125,18 +169,23 @@ def parse_task_workspace(path: Path, hub: Path) -> "TaskWorkspace | None":
125
169
  if match is None:
126
170
  return None
127
171
  return TaskWorkspace(
128
- path=path, branch=path.name, task=int(match["task"]), hub=hub
172
+ path=path,
173
+ branch=branch_of_dir(path.name),
174
+ task=int(match["task"]),
175
+ hub=hub,
129
176
  )
130
177
 
131
178
 
132
179
  def is_task_branch(name: str) -> bool:
133
- """Is `name` one of devloop's own task branches (`TASK-<n>-<DESC>`)?
134
-
135
- The same full-match grammar `parse_task_workspace` admits directories
136
- with, re-asserted by name at the one place that pushes: the archive path
137
- writes a ref to the remote, and its name is derived from this one, so a
138
- branch the daemon did not create must never get a `holdout/` twin."""
139
- return TASK_WORKSPACE_RE.fullmatch(name) is not None
180
+ """Is `name` one of devloop's own task branches (`TASK-<n>-<DESC>`, or the
181
+ tolerated `task/TASK-<n>-<DESC>` / `task/<n>-<desc>`)?
182
+
183
+ The branch twin of the full-match grammar `parse_task_workspace` admits
184
+ directories with, re-asserted by name at the one place that pushes: the
185
+ archive path writes a ref to the remote, and its name is derived from
186
+ this one, so a branch the daemon did not create must never get a
187
+ `holdout/` twin."""
188
+ return TASK_BRANCH_RE.fullmatch(name) is not None
140
189
 
141
190
 
142
191
  def holdout_ref(branch: str) -> str:
@@ -223,7 +223,9 @@ attempt number in, and a fresh budget reusing attempt 1 could collide with
223
223
  a prior epoch's zombie session. With no readable labeled event the budget
224
224
  conservatively stays lifetime-wide (the pre-epoch behavior). The re-fetch
225
225
  guards mirror the fix edge's: closed/merged between search and fetch, a
226
- foreign author, and a foreign-shaped head ref all skip.
226
+ foreign author, and a foreign-shaped head ref all skip (a `task/`-prefixed
227
+ worker branch is NOT foreign since issue #115 -- WORKER_HEAD_REF -- and the
228
+ skip is paged on the PR once per head rather than logged every poll).
227
229
 
228
230
  All three edges share ONE backpressure valve, `max_sessions` (0 = unlimited,
229
231
  the default -- today's behavior preserved). The daemon spawns a session per
@@ -696,6 +698,23 @@ _REVIEW_SEAT = (
696
698
  "foreign session the daemon reaps, and its verdict reaches nobody. "
697
699
  )
698
700
 
701
+ # The branch-NAME rule, stated as a hard MUST with its negative and its
702
+ # consequence (issue #115). The 2026-09-09/10 studio incident: three workers
703
+ # in one window named their branches `task/…` despite the golden-path line
704
+ # above -- the common convention won over a directive that only stated the
705
+ # positive. The daemon now TOLERATES exactly that one deviation on the way
706
+ # back in (WORKER_HEAD_REF), but the rule is still the flat shape, because
707
+ # every other prefix is still foreign and the tolerance is a repair, not a
708
+ # licence. Shared by the issue edge (where the branch is created) and, as a
709
+ # restatement, by the fix and maintain directives (where it is reused).
710
+ _BRANCH_RULE = (
711
+ "The branch name is a hard rule, not a convention: name the branch "
712
+ "exactly `TASK-<n>-<DESC>` — NO `task/`, `feat/` or any other prefix, no "
713
+ "lowercase `task-`; the daemon's fix and maintain edges only spawn on "
714
+ "that shape, and a PR on any other shape gets no fix round, no "
715
+ "maintenance and no resume until an operator renames it. "
716
+ )
717
+
699
718
  DEV_DIRECTIVE = (
700
719
  "You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
701
720
  "{issue_url} (attempt {attempt} of {cap}) — the issue is your "
@@ -708,7 +727,8 @@ DEV_DIRECTIVE = (
708
727
  "origin collides with every sibling of that origin), from there (one "
709
728
  "task = one branch = one worktree; never clone, never work directly on "
710
729
  "main). "
711
- "Implement to the issue's acceptance criteria and VERIFY locally (run the "
730
+ + _BRANCH_RULE
731
+ + "Implement to the issue's acceptance criteria and VERIFY locally (run the "
712
732
  "repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
713
733
  "body contains `Closes #{number}` and BOTH task refs — the origin task "
714
734
  "AND your implementation task: the PR body is the canonical cross-actor "
@@ -758,10 +778,12 @@ RESUME_DIRECTIVE = (
758
778
  "session got. Golden path: your cwd is the repo's worktree hub ROOT and "
759
779
  "the PR's head branch `{head_ref}` ALREADY EXISTS — REUSE its worktree if "
760
780
  "one is still on the volume (the dead session's work is in it), otherwise "
761
- "re-attach it with `git worktree add {head_ref} {head_ref}`. Work ONLY on "
762
- "that branch: never a new branch, never a clone, never main, and NEVER a "
763
- "second PR — the open one IS the deliverable, and pushing more commits to "
764
- "it is how you deliver. "
781
+ "re-attach it with `git worktree add {worktree_dir} {head_ref}` (the "
782
+ "directory and the branch differ only when the branch carries a prefix: "
783
+ "`/` becomes `--` in the directory name). Work ONLY on that branch, "
784
+ "under its EXISTING name: never a new branch, never a rename, never a "
785
+ "clone, never main, and NEVER a second PR — the open one IS the "
786
+ "deliverable, and pushing more commits to it is how you deliver. "
765
787
  "Then complete the ORIGINAL acceptance criteria — the issue's criteria "
766
788
  "are unchanged and still authoritative — and VERIFY locally (run the "
767
789
  "repo's test/style/type scripts) before publishing. NEVER force-push: "
@@ -798,9 +820,13 @@ FIX_DIRECTIVE = (
798
820
  "of your job: the PR body is the canonical cross-actor record. "
799
821
  "Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
800
822
  "branch `{head_ref}` already exists — re-attach its worktree with `git "
801
- "worktree add {head_ref} {head_ref}` if it is missing, and work ONLY on "
802
- "that branch (never a new branch, never a clone, never main). "
803
- "FIRST verify the triage of every prior review finding per CR8: exactly "
823
+ "worktree add {worktree_dir} {head_ref}` if it is missing (the directory "
824
+ "and the branch differ only when the branch carries a prefix: `/` becomes "
825
+ "`--` in the directory name), and work ONLY on that branch under its "
826
+ "EXISTING name (never a new branch, never a rename, never a clone, never "
827
+ "main). "
828
+ + _BRANCH_RULE
829
+ + "FIRST verify the triage of every prior review finding per CR8: exactly "
804
830
  "one reasoned `[triage:*]` reply on each finding's thread — post any that "
805
831
  "are missing. Then fix the pursued findings on the same branch and VERIFY "
806
832
  "locally (run the repo's test/style/type scripts). "
@@ -847,10 +873,13 @@ FIX_RESUME_DIRECTIVE = (
847
873
  "far the dead session got. Golden path: your cwd is the repo's worktree "
848
874
  "hub ROOT and the PR's head branch `{head_ref}` ALREADY EXISTS — REUSE "
849
875
  "its worktree if one is still on the volume (the dead session's work is "
850
- "in it), otherwise re-attach it with `git worktree add {head_ref} "
851
- "{head_ref}`. Work ONLY on that branch: never a new branch, never a "
852
- "clone, never main, and NEVER a second PR. "
853
- "The dead session may have posted SOME of its CR8 triage replies already: "
876
+ "in it), otherwise re-attach it with `git worktree add {worktree_dir} "
877
+ "{head_ref}` (the directory and the branch differ only when the branch "
878
+ "carries a prefix: `/` becomes `--` in the directory name). Work ONLY on "
879
+ "that branch under its EXISTING name: never a new branch, never a "
880
+ "rename, never a clone, never main, and NEVER a second PR. "
881
+ + _BRANCH_RULE
882
+ + "The dead session may have posted SOME of its CR8 triage replies already: "
854
883
  "read every finding's thread and post exactly the ones that are missing — "
855
884
  "one reasoned `[triage:*]` reply per finding, NEVER a duplicate on a "
856
885
  "thread it already answered. Then fix the pursued findings, completing "
@@ -890,9 +919,13 @@ MAINTAIN_DIRECTIVE = (
890
919
  "freshen it. "
891
920
  "Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
892
921
  "branch `{head_ref}` already exists — re-attach its worktree with `git "
893
- "worktree add {head_ref} {head_ref}` if it is missing, and work ONLY on "
894
- "that branch (never a new branch, never a clone, never main). "
895
- "FIRST the idempotent no-op check: if the branch is already conflict-free "
922
+ "worktree add {worktree_dir} {head_ref}` if it is missing (the directory "
923
+ "and the branch differ only when the branch carries a prefix: `/` becomes "
924
+ "`--` in the directory name), and work ONLY on that branch under its "
925
+ "EXISTING name (never a new branch, never a rename, never a clone, never "
926
+ "main). "
927
+ + _BRANCH_RULE
928
+ + "FIRST the idempotent no-op check: if the branch is already conflict-free "
896
929
  "AND current vs origin/main, remove the `{label}` label from the PR "
897
930
  "(`gh pr edit {number} --remove-label \"{label}\"`) and exit — push "
898
931
  "nothing, re-request nobody; this no-op path is what makes retries safe. "
@@ -1032,6 +1065,21 @@ STACKED_BASE_COMMENT = (
1032
1065
  "belongs to release gating, not to the PR graph."
1033
1066
  )
1034
1067
 
1068
+ FOREIGN_HEAD_COMMENT = (
1069
+ "**Dev loop: foreign-shaped head ref `{head_ref}`** — the daemon will not "
1070
+ "run fix or maintain rounds on it. Worker branches must be named exactly "
1071
+ "`TASK-<n>-<DESC>` (a single `task/` prefix is tolerated: "
1072
+ "`task/TASK-<n>-<DESC>` or `task/<n>-<desc>`); any other prefix, a "
1073
+ "second `/`, whitespace or shell metacharacters keep the daemon off the "
1074
+ "branch, because the name is interpolated into a worker session's "
1075
+ "prompt as a worktree path and a shell token. Until the head moves, "
1076
+ "review findings on this PR reach no session and a maintenance label is "
1077
+ "ignored. Operator options: rename the branch (`git branch -m` + push, "
1078
+ "then re-open or retarget the PR), or re-open the PR from a "
1079
+ "`TASK-<n>-<DESC>` branch. Paged once per head; a new head on the same "
1080
+ "shape pages again."
1081
+ )
1082
+
1035
1083
  ASSIGNMENT_REJECTED_COMMENT = (
1036
1084
  "**Dev loop cannot start** — the daemon tried to self-assign this issue "
1037
1085
  "as its in-flight marker, but GitHub silently dropped the assignment: "
@@ -1251,6 +1299,16 @@ ESCALATION_ORPHAN_REJECTED = "orphan-rejected"
1251
1299
  # number, like every other PR-side kind here.
1252
1300
  ESCALATION_STACKED_BASE = "stacked-base"
1253
1301
 
1302
+ # The fix/maintain edges' foreign-shaped-head skip, paged ON the PR once per
1303
+ # (PR, head) (`foreign_head_kind`, issue #115). Before it, the skip was one
1304
+ # WARNING per poll in a log nobody tails and NOTHING on the PR: three
1305
+ # reviewed studio PRs sat for 1-12 h behind it. Keyed per HEAD, not per PR,
1306
+ # because the remedy is to move the head (rename the branch, re-open from a
1307
+ # `TASK-<n>-<DESC>` one) and a PR whose head moved and is still foreign has
1308
+ # earned a second page. The `foreign-head` prefix keeps these rows from
1309
+ # colliding with any issue-edge kind when an issue and a PR share a number.
1310
+ ESCALATION_FOREIGN_HEAD = "foreign-head"
1311
+
1254
1312
  # The `archive` holdout policy could not archive a workspace (issue #99): the
1255
1313
  # commit, the push to the holdout ref, or the ls-remote verification failed,
1256
1314
  # or the remote sha did not equal the local HEAD. Keyed per WORKSPACE slug
@@ -1341,6 +1399,11 @@ def rerequest_rejected_kind(round_: int, head_sha: str) -> str:
1341
1399
  return f"{ESCALATION_REREQUEST_REJECTED}:r{round_}@{head_sha}"
1342
1400
 
1343
1401
 
1402
+ def foreign_head_kind(head_sha: str) -> str:
1403
+ """The ledger kind for the foreign-shaped-head page on ONE (PR, head)."""
1404
+ return f"{ESCALATION_FOREIGN_HEAD}:{head_sha}"
1405
+
1406
+
1344
1407
  def orphan_kind(head_sha: str) -> str:
1345
1408
  """The ledger kind that marks ONE draft's mechanical handoff as DONE.
1346
1409
 
@@ -1521,16 +1584,60 @@ STALLED_DEFER_MULTIPLE = 2
1521
1584
  # long enough to need scrolling is one nobody reads.
1522
1585
  DISK_OFFENDERS = 3
1523
1586
 
1524
- # The daemon's own branch shape. head_ref reaches every worker directive as
1525
- # BOTH a worktree path and a shell token (`git worktree add {head_ref}
1526
- # {head_ref}`), and a PR's head branch is not strictly self-authored (anyone
1527
- # with push access can move it) -- so every PR-side spawn gates on it: the fix
1528
- # and maintain edges via `_foreign_head_skip`, the issue edge's resume via
1529
- # `_resumable_pr`. Slashes that would nest the worktree path, whitespace and
1530
- # shell metacharacters are all outside it, and a ref outside this shape is not
1531
- # this daemon's work anyway: skip conservatively rather than sanitize.
1587
+ # The daemon's own branch shape -- the one its directives NAME (`TASK-<n>-
1588
+ # <DESC>`, flat). head_ref reaches every worker directive as a shell token
1589
+ # (`git worktree add {worktree_dir} {head_ref}`), and a PR's head branch is
1590
+ # not strictly self-authored (anyone with push access can move it) -- so every
1591
+ # PR-side spawn gates on a shape test: the fix and maintain edges via
1592
+ # `_foreign_head_skip`, the issue edge's resume via `_resumable_pr`. This is
1593
+ # also the shape the WORKTREE DIRECTORY must have (`worktree_dir`), since a
1594
+ # slash there would nest the worktree under the hub. Whitespace and shell
1595
+ # metacharacters are all outside it: skip conservatively rather than sanitize.
1532
1596
  DAEMON_HEAD_REF = re.compile(r"[A-Za-z0-9._-]+")
1533
1597
 
1598
+ # The shape a WORKER'S branch is tolerated in: the daemon's own flat shape, or
1599
+ # that shape behind exactly one `task/` prefix (issue #115). The 2026-09-09/10
1600
+ # studio incident: three of five worker sessions in one window named their
1601
+ # branches `task/TASK-<n>-<desc>` / `task/<n>-<desc>` despite the directive,
1602
+ # and DAEMON_HEAD_REF -- flat by design, because head_ref used to be the
1603
+ # worktree PATH as well as the branch -- stranded all three: no fix round, no
1604
+ # maintain, no resume, a WARNING per poll for 12 h. The work was complete and
1605
+ # reviewed; only the gate stood between it and a fix session. So the PR-side
1606
+ # gates admit this ONE common deviation, with the worktree path derived by
1607
+ # `worktree_dir` so the directory stays flat. Everything else stays foreign:
1608
+ # any other prefix (`feat/x`), a second slash (`a/b/c`), whitespace, shell
1609
+ # metacharacters. `DAEMON_HEAD_REF` stays what the daemon itself names, and
1610
+ # DEV_DIRECTIVE still forbids the prefix -- tolerating it on the way back in
1611
+ # is not the same as licensing it on the way out.
1612
+ WORKER_HEAD_REF = re.compile(r"(?:task/)?[A-Za-z0-9._-]+")
1613
+
1614
+
1615
+ def worktree_dir(head_ref: str) -> str:
1616
+ """The worktree DIRECTORY a PR-side directive tells its session to create
1617
+ for `head_ref`: the branch name with `/` rewritten to `--`, so the
1618
+ worktree stays a flat direct child of the hub whatever the branch is
1619
+ called (`task/TASK-42384172-sales-chat-surface` ->
1620
+ `task--TASK-42384172-sales-chat-surface`; a flat name maps to itself).
1621
+
1622
+ Both values are interpolated into an agent prompt as shell tokens, so
1623
+ BOTH pass the shape tests before anything is rendered: the branch must be
1624
+ WORKER_HEAD_REF and the directory DAEMON_HEAD_REF (flat, no metacharacters).
1625
+ A `head_ref` that fails either raises rather than rendering -- the gates
1626
+ upstream (`_foreign_head_skip`, `_resumable_pr`) are what keep such a ref
1627
+ from reaching here, and this is the assertion that they did. The mapping
1628
+ itself is the janitor's (`janitor.dir_of_branch`), so the directory the
1629
+ directive names and the one the janitor reclaims cannot drift apart.
1630
+ """
1631
+ if not WORKER_HEAD_REF.fullmatch(head_ref or ""):
1632
+ raise ValueError(f"head ref {head_ref!r} is not a worker branch shape")
1633
+ directory = janitor.dir_of_branch(head_ref)
1634
+ if not DAEMON_HEAD_REF.fullmatch(directory):
1635
+ raise ValueError(
1636
+ f"worktree dir {directory!r} for head ref {head_ref!r} is not flat"
1637
+ )
1638
+ return directory
1639
+
1640
+
1534
1641
  # GitHub's closing keywords, the PROVENANCE signal behind a resume. Every
1535
1642
  # other admission test the resume applies asks what STATE a PR is in (open,
1536
1643
  # draft, unreviewed, self-authored, daemon-shaped head) -- and a sibling
@@ -1621,6 +1728,14 @@ def closed_issue_numbers(body: str, owner: str, repo: str) -> "tuple[int, ...]":
1621
1728
  # mid-name is not the convention.
1622
1729
  DAEMON_TASK_BRANCH = re.compile(r"TASK-(\d+)(?:-|$)")
1623
1730
 
1731
+ # The same convention as a WORKER may have written it (issue #115, the
1732
+ # tolerated deviation WORKER_HEAD_REF admits): the flat form, or the flat form
1733
+ # behind one `task/`, or `task/<n>-<desc>` with the `TASK-` literal dropped --
1734
+ # the three shapes the studio incident's workers actually produced. The task
1735
+ # number is the segment after the prefix either way (`task/609837646-…` counts
1736
+ # as TASK-609837646). Still anchored at the start, still no other prefix.
1737
+ WORKER_TASK_BRANCH = re.compile(r"(?:task/(?:TASK-)?|TASK-)(\d+)(?:-|$)")
1738
+
1624
1739
  # The follow-up PR's provenance trailer, mandated by _APPROVE_FOLLOWUP and read
1625
1740
  # by the orphan sweep's liveness matrix.
1626
1741
  #
@@ -1680,7 +1795,9 @@ def pr_task_ref(head_ref: str, body: str) -> "int | None":
1680
1795
  dead session cannot have failed to establish (it exists before the PR
1681
1796
  does), while a body ref can be missing precisely because the session died
1682
1797
  before writing it. Falling back to the body still catches the PR whose
1683
- branch was named some other way but whose record is intact.
1798
+ branch was named some other way but whose record is intact. The branch is
1799
+ read with WORKER_TASK_BRANCH, so a `task/`-prefixed worker branch names
1800
+ its task exactly as a flat one does (issue #115).
1684
1801
 
1685
1802
  Deliberately NOT `parse_task_ref`: that reads the `Alissa-Task:` TRAILER an
1686
1803
  ISSUE carries, and a PR body's task refs are prose ("origin TASK-1, mine
@@ -1688,7 +1805,7 @@ def pr_task_ref(head_ref: str, body: str) -> "int | None":
1688
1805
  logging and the activity line only -- nothing keys off WHICH task it is,
1689
1806
  only that the PR names one.
1690
1807
  """
1691
- match = DAEMON_TASK_BRANCH.match(head_ref or "")
1808
+ match = WORKER_TASK_BRANCH.match(head_ref or "")
1692
1809
  if match:
1693
1810
  return int(match.group(1))
1694
1811
  match = TASK_REF_RE.search(body or "")
@@ -2965,6 +3082,7 @@ class DevWatcher:
2965
3082
  session=name,
2966
3083
  pr_url=resume_pr.url,
2967
3084
  head_ref=resume_pr.head_ref,
3085
+ worktree_dir=worktree_dir(resume_pr.head_ref),
2968
3086
  dead_session=dead_session,
2969
3087
  )
2970
3088
  else:
@@ -3121,11 +3239,12 @@ class DevWatcher:
3121
3239
  timeline: still OPEN, authored by the token identity (a foreign PR
3122
3240
  must never grow a session pushing to its branch -- `linked_open_prs`
3123
3241
  filters on open + same-repo only, so a collaborator's or a fork's PR
3124
- qualifies there), and a head ref of this daemon's own flat shape.
3125
- Resolving head_ref here is also what lets RESUME_DIRECTIVE NAME the
3126
- branch instead of telling the agent to derive it from the PR -- an
3127
- unsanitized token read out of GitHub is exactly what DAEMON_HEAD_REF
3128
- exists to keep out of an agent prompt.
3242
+ qualifies there), and a head ref of a worker branch shape
3243
+ (WORKER_HEAD_REF: the daemon's own flat shape, or that shape behind
3244
+ one tolerated `task/`). Resolving head_ref here is also what lets
3245
+ RESUME_DIRECTIVE NAME the branch instead of telling the agent to
3246
+ derive it from the PR -- an unsanitized token read out of GitHub is
3247
+ exactly what the shape test exists to keep out of an agent prompt.
3129
3248
 
3130
3249
  Those four tests all ask what STATE a PR is in; none asks whether it
3131
3250
  is OURS. `linked_open_prs` admits any open same-repo PR that
@@ -3212,7 +3331,7 @@ class DevWatcher:
3212
3331
  pr.author,
3213
3332
  )
3214
3333
  continue
3215
- if not DAEMON_HEAD_REF.fullmatch(pr.head_ref):
3334
+ if not WORKER_HEAD_REF.fullmatch(pr.head_ref):
3216
3335
  log.warning(
3217
3336
  "%s: %s head ref %r does not match the daemon's own "
3218
3337
  "branch shape — not resuming onto a foreign-shaped "
@@ -4507,9 +4626,10 @@ class DevWatcher:
4507
4626
  # head_ref reaches nothing but log lines here (the sweep spawns no
4508
4627
  # session, so it never becomes a worktree path or a shell token), but
4509
4628
  # the shape is still the provenance signal every other PR-side act
4510
- # gates on: this daemon only ever creates flat `TASK-<n>-<DESC>`
4511
- # branches, so a ref outside that shape is not its work.
4512
- if not DAEMON_HEAD_REF.fullmatch(pr.head_ref):
4629
+ # gates on: this daemon's workers create flat `TASK-<n>-<DESC>`
4630
+ # branches (or, tolerated, the same behind one `task/` -- issue
4631
+ # #115), so a ref outside that shape is not its work.
4632
+ if not WORKER_HEAD_REF.fullmatch(pr.head_ref):
4513
4633
  return Decision(
4514
4634
  Action.SKIPPED,
4515
4635
  f"draft head ref {pr.head_ref!r} does not match the daemon's "
@@ -5423,28 +5543,92 @@ class DevWatcher:
5423
5543
 
5424
5544
  def _foreign_head_skip(self, pr: PullRequest, attempt: int) -> "Decision | None":
5425
5545
  """The head-ref shape guard for the PR-side edges that evaluate ONE
5426
- PR (fix and maintain): this daemon only ever creates flat
5427
- `TASK-<n>-<DESC>` branches, so a ref outside DAEMON_HEAD_REF is not
5428
- its work. Returns the SKIPPED Decision, or None when the ref is safe
5429
- to interpolate. (The issue edge's resume applies the same shape test
5430
- via `_resumable_pr`, which filters candidates rather than skipping
5431
- the item.)"""
5432
- if DAEMON_HEAD_REF.fullmatch(pr.head_ref):
5546
+ PR (fix and maintain): this daemon's workers create flat
5547
+ `TASK-<n>-<DESC>` branches -- or, tolerated since issue #115, the
5548
+ same behind exactly one `task/` -- so a ref outside WORKER_HEAD_REF
5549
+ is not its work. Returns the SKIPPED Decision, or None when the ref
5550
+ is safe to interpolate (as `worktree_dir(head_ref)` + `head_ref`).
5551
+ (The issue edge's resume applies the same shape test via
5552
+ `_resumable_pr`, which filters candidates rather than skipping the
5553
+ item.)
5554
+
5555
+ A skip is PAGED on the PR, once per (PR, head) -- `_page_foreign_head`
5556
+ -- rather than logged at WARNING every poll: the skip is a standing
5557
+ condition nothing else in the system will ever surface (no session is
5558
+ spawned, so no activity line; the reviewer daemon has done its part),
5559
+ and the 2026-09-09/10 incident measured the cost of a log-only signal
5560
+ at 1-12 h per PR. The decision itself is unchanged by the page.
5561
+ """
5562
+ if WORKER_HEAD_REF.fullmatch(pr.head_ref):
5433
5563
  return None
5434
- log.warning(
5435
- "%s head ref %r does not match the daemon's own branch shape "
5436
- "— not spawning a worker session on a foreign-shaped branch",
5437
- pr.pr_slug,
5438
- pr.head_ref,
5439
- )
5564
+ self._page_foreign_head(pr)
5440
5565
  return Decision(
5441
5566
  Action.SKIPPED,
5442
5567
  f"head ref {pr.head_ref!r} does not match the daemon's own "
5443
- f"branch shape ([A-Za-z0-9._-]+) — foreign-shaped branch, "
5444
- f"not this daemon's work",
5568
+ f"branch shape (TASK-<n>-<DESC>, or task/ + that) — "
5569
+ f"foreign-shaped branch, not this daemon's work",
5445
5570
  attempt,
5446
5571
  )
5447
5572
 
5573
+ def _page_foreign_head(self, pr: PullRequest) -> bool:
5574
+ """Page the operator ONCE per (PR, head) that the head-ref guard is
5575
+ skipping this PR, on the PR. Returns whether the page was raised.
5576
+
5577
+ `_check_stacked_base`'s doctrine: advisory by construction (the guard
5578
+ still skips; nothing renames or closes anything), and the row lands
5579
+ only AFTER the comment posts, so a transient POST failure retries next
5580
+ poll rather than being recorded as delivered. Dry-run logs and records
5581
+ nothing. Every poll after the first sees the row and logs at DEBUG --
5582
+ the per-poll WARNING this replaces was the silence the incident hid
5583
+ behind. A head with no readable sha cannot be keyed and keeps the
5584
+ per-poll WARNING instead of paging (only reachable from a malformed
5585
+ payload).
5586
+ """
5587
+ if not pr.head_sha:
5588
+ log.warning(
5589
+ "%s head ref %r does not match the daemon's own branch shape "
5590
+ "— not spawning a worker session on a foreign-shaped branch "
5591
+ "(no head sha to key a page on)",
5592
+ pr.pr_slug,
5593
+ pr.head_ref,
5594
+ )
5595
+ return False
5596
+ kind = foreign_head_kind(pr.head_sha)
5597
+ if self.state.escalated(pr.full_name, pr.number, kind):
5598
+ log.debug(
5599
+ "%s head ref %r is foreign-shaped — skipped (already paged "
5600
+ "on head %s)",
5601
+ pr.pr_slug,
5602
+ pr.head_ref,
5603
+ pr.head_sha[:8],
5604
+ )
5605
+ return False
5606
+
5607
+ log.error(
5608
+ "FOREIGN HEAD %s: head ref %r does not match the daemon's own "
5609
+ "branch shape — no fix or maintain round will spawn on it; "
5610
+ "paging the operator on the PR once for head %s",
5611
+ pr.pr_slug,
5612
+ pr.head_ref,
5613
+ pr.head_sha[:8],
5614
+ )
5615
+ body = FOREIGN_HEAD_COMMENT.format(head_ref=pr.head_ref)
5616
+ if self.config.dry_run:
5617
+ log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
5618
+ return False
5619
+ try:
5620
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
5621
+ except CommandError as exc:
5622
+ log.error(
5623
+ "could not post the foreign-head comment on %s: %s — not "
5624
+ "recording the page; it retries next poll",
5625
+ pr.pr_slug,
5626
+ exc,
5627
+ )
5628
+ return False
5629
+ self.state.record_escalation(pr.full_name, pr.number, kind)
5630
+ return True
5631
+
5448
5632
  def _spawn_fix(
5449
5633
  self,
5450
5634
  pr: PullRequest,
@@ -5521,6 +5705,7 @@ class DevWatcher:
5521
5705
  attempt=attempt,
5522
5706
  cap=self.config.attempt_cap,
5523
5707
  head_ref=pr.head_ref,
5708
+ worktree_dir=worktree_dir(pr.head_ref),
5524
5709
  # Re-request the reviewer whose request_changes this round
5525
5710
  # answers -- precise, and always resolvable (the review exists).
5526
5711
  reviewer=review.author,
@@ -5780,6 +5965,7 @@ class DevWatcher:
5780
5965
  attempt=attempt,
5781
5966
  cap=self.config.attempt_cap,
5782
5967
  head_ref=pr.head_ref,
5968
+ worktree_dir=worktree_dir(pr.head_ref),
5783
5969
  reviewers=reviewers,
5784
5970
  session=name,
5785
5971
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.19
3
+ Version: 0.8.20
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera