alissa-tools-github-devloop 0.8.41__py3-none-any.whl → 0.8.43__py3-none-any.whl

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.
@@ -257,7 +257,8 @@ def build_snapshot(
257
257
  `queued` (the undispatched alissa queue items, None when unread) and
258
258
  `drain` (the holding drain row, or None) feed the `idle` block, whose
259
259
  owed / timers come from the latest poll snapshot and whose live /
260
- managed come from `roster` -- the same reading as `sessions`.
260
+ managed come from `roster` -- the same reading as `sessions`, with a dead
261
+ row backing no held lane left out of `managed` (issue #166).
261
262
 
262
263
  `config` is the seat's `config_block`, or None to leave the key out
263
264
  (the console's push, which has no derived answer to give)."""
@@ -275,13 +276,20 @@ def build_snapshot(
275
276
  if session and session not in repo_by_session:
276
277
  repo_by_session[str(session)] = str(row["repo_slug"])
277
278
 
279
+ # A dead row counts as managed only while it backs a lane the seat still
280
+ # holds (issue #166): Studio's park guard reads `sessions.managed` as well
281
+ # as the idle block's, so both follow the console's rule.
282
+ roster_live, roster_managed, _gone = parking.roster_counts(
283
+ roster, parking.lane_sessions(latest, ledgers=ledgers),
284
+ )
285
+
278
286
  sessions_block: "dict | None" = None
279
287
  session_list: "list[dict] | None" = None
280
288
  if roster is not None:
281
289
  records = sources.sessions(listing=[dict(r) for r in roster])
282
290
  sessions_block = {
283
291
  "live": sum(1 for r in records if r.get("live")),
284
- "managed": len(records),
292
+ "managed": roster_managed,
285
293
  }
286
294
  session_list = [
287
295
  _session_row(r, repo_by_session=repo_by_session, parse_session=parse)
@@ -307,11 +315,13 @@ def build_snapshot(
307
315
  + int(latest.get("revert_candidates") or 0)
308
316
  )
309
317
 
310
- roster_live, roster_managed = parking.roster_counts(roster)
311
318
  idle = parking.idle_block(
312
319
  latest, live=roster_live, managed=roster_managed, queued=queued,
313
320
  drain=drain, now=now_ts,
314
321
  )
322
+ # `gone` is the console's observation; Studio's idle schema is strict and
323
+ # does not carry it.
324
+ idle.pop("gone", None)
315
325
 
316
326
  drift = sources.drift()
317
327
  snapshot = {
@@ -451,6 +451,7 @@ import calendar
451
451
  import dataclasses
452
452
  import json
453
453
  import logging
454
+ import os
454
455
  import re
455
456
  import sqlite3
456
457
  import time
@@ -916,6 +917,117 @@ RESUME_DIRECTIVE = (
916
917
  "(best-effort — the daemon's reaper sweep collects stragglers)."
917
918
  )
918
919
 
920
+ # The discovery round's directive (issue #164, design
921
+ # alissa-build-discovery.md §3.2, lane DS-7). Sent INSTEAD of DEV_DIRECTIVE
922
+ # when the issue carries the trailer `Alissa-Kind: discovery` (rendered by
923
+ # Studio's feed_plan since DS-1): a no-code round in the PLANS repository
924
+ # that reads a code repository at the SHA pinned by `Alissa-Reads:` and writes
925
+ # one knowledge-base directory. What differs from the develop contract, and
926
+ # why each difference is load-bearing:
927
+ #
928
+ # - The read is DETACHED and read-only (a scratch `--detach` worktree off a
929
+ # hub of the read repo, else `gh api …?ref=<sha>`): no branch exists that
930
+ # could be pushed, and the push guard (docker/claude/hooks/guard-shell.py,
931
+ # armed by `write_discovery_marker`) refuses any push that is not to the
932
+ # plans repository anyway.
933
+ # - The read is bounded by the SHA, not by a file count (D1: no rationing);
934
+ # only the OUTPUT is bounded, with the findings schema's own limits.
935
+ # - The local `--kb` lint gates the push: findings the linter refuses never
936
+ # leave the machine, so the run of record never sees them.
937
+ # - The PR is opened READY, carries the discovery marker on a line of its
938
+ # own, and requests NO reviewer: revloop skips a marked PR confined to
939
+ # knowledge-base/ (§3.3), orcloop merges it on the lint of record (§3.5),
940
+ # and a draft would sit unmergeable until the orphan sweep flipped it.
941
+ # - No delivery note: the findings ARE the narrative, and their reader is a
942
+ # linter and a plan, not a human validator deciding on a merge.
943
+ #
944
+ # The {trailer_note} slot renders to nothing when the trailers parse; see
945
+ # render_discovery_directive for what it says when they do not.
946
+ DISCOVERY_DIRECTIVE = (
947
+ "You are a DISCOVERY worker, not an implementer and not a reviewer: this "
948
+ "round writes NO code. Answer the questions of GitHub issue {issue_url} "
949
+ "(attempt {attempt} of {cap}) by READING code, and record the answers as "
950
+ "findings — the issue is your self-contained spec, its numbered "
951
+ "questions are what you answer, and its acceptance criteria are "
952
+ "authoritative. {task_instruction} {trailer_note}"
953
+ "Golden path: your cwd is the worktree hub ROOT of the PLANS repository "
954
+ "{plans_repo} — create the task worktree there with `git worktree add "
955
+ "TASK-<n>-<DESC>`, where <n> is YOUR IMPLEMENTATION task's number (the "
956
+ "downstream task YOU created, NOT the origin task's), from there (one "
957
+ "task = one branch = one worktree; never clone, never work directly on "
958
+ "main). "
959
+ + _BRANCH_RULE
960
+ + _SHELL_RULE
961
+ + "READ the code at the pinned SHA and nowhere else, read-only: "
962
+ "`{read_repo}` at `{read_sha}` (the issue's `Alissa-Reads:` trailer). "
963
+ "If this workspace holds a hub of {read_repo} (a sibling of your hub "
964
+ "root with a `.source/`), read through a DETACHED scratch worktree: `git "
965
+ "-C <hub> worktree add --detach <scratch> {read_sha}` (fetch that SHA "
966
+ "into the hub first if it is missing) — no branch, never pushed — and "
967
+ "remove it with `git -C <hub> worktree remove --force <scratch>` when "
968
+ "you are done. Otherwise read through `gh api "
969
+ "repos/{read_repo}/contents/<path>?ref={read_sha}` and `gh search "
970
+ "code`. Never clone {read_repo}, and never commit or push to it: this "
971
+ "session's push guard refuses any `git push` to a repository other than "
972
+ "{plans_repo}. Never open a deny-listed path (`.env*`, `*.pem`, `*.key`, "
973
+ "`*.p12`, `id_rsa*`, `*secret*`, `*credential*`, `.npmrc`, `.git/**`, "
974
+ "`**/node_modules/**`). The read is bounded by the SHA, not by a file "
975
+ "count — read whatever the questions need; what is bounded is the "
976
+ "OUTPUT. "
977
+ "WRITE exactly one directory, `{kb_dir}/`, holding `findings.md` and "
978
+ "nothing else; no other file anywhere in the repository changes. "
979
+ "`findings.md` follows the findings schema v1 (`schema/findings.v1.json` "
980
+ "in the plans repository, and the knowledge-base section of its "
981
+ "README): YAML frontmatter with `schema: alissa-findings/v1`, `task` = "
982
+ "the origin task, `reads` = `{read_repo}` at `{read_sha}`, every "
983
+ "question either answered with cites (repo, path, line range and the "
984
+ "file's git blob SHA at the pinned SHA) or marked `unanswerable` with a "
985
+ "reason; then a `## Summary` and one section per question. Stay inside "
986
+ "the schema's limits: at most 32 KB, 1–12 questions, 12 risks, 8 "
987
+ "suggested tasks and 24 cites per entry, 400 lines per cite, 1,200 "
988
+ "characters of Summary and 6,000 per question's section. "
989
+ "LINT it locally before anything leaves the machine: `node "
990
+ "lint/plan-lint.ts --kb {kb_dir}` from your worktree (`npm ci` first if "
991
+ "`node_modules` is missing), with this seat's own `ALISSA_API_TOKEN` in "
992
+ "the environment for the K6 task check and the K7 cite check. Findings "
993
+ "the local run refuses are NEVER pushed — fix them and rerun until it "
994
+ "passes; `plan-lint could not run` is a refusal, not a pass. "
995
+ "Then commit, push the branch to {plans_repo}, and open a READY (not "
996
+ "draft) pull request whose body contains `Closes #{number}`, BOTH task "
997
+ "refs — the origin task AND your implementation task — and the marker "
998
+ "`<!-- alissa-discovery v1 -->` on a line of its own. That is the "
999
+ "body's single closing claim: never put a closing keyword (`close`, "
1000
+ "`fix`, `resolve` and their forms) before any other issue reference. "
1001
+ + _DEFAULT_BASE
1002
+ + "Attach the PR URL to your implementation task as evidence, and attach "
1003
+ "a `Findings` evidence item to the origin task — the `## Summary` "
1004
+ "section; one line per question (`q1 · answered · high`); the risk "
1005
+ "count; the PR URL and the blob URL of `findings.md` at the head commit "
1006
+ "— or, if the origin task refuses that write, to your implementation "
1007
+ "task, saying so in the PR body. Then move your implementation task to "
1008
+ "pending_validation. Do NOT request a reviewer — no `gh pr edit … "
1009
+ "--add-reviewer …`, no review skill: a discovery PR is checked by the "
1010
+ "plan-lint run of record and merged on it. NEVER merge, never push to "
1011
+ "main, and do NOT create further ali-* sessions — this loop is depth-1. "
1012
+ "Once your task is pending_validation and the PR is open, your last "
1013
+ "act: run `alissa tmux kill {session}` to end this session and free the "
1014
+ "machine (best-effort — the daemon's reaper sweep collects stragglers)."
1015
+ )
1016
+
1017
+ # Part of DISCOVERY_DIRECTIVE's {trailer_note} when the issue's `Alissa-Reads:`
1018
+ # trailer is absent or not `<owner>/<repo>@<40-hex sha>`. There is then no
1019
+ # pinned SHA to read, and a worker that picked one (the default branch's
1020
+ # head, say) would write findings K6 refuses at the run of record -- so the
1021
+ # round stops instead, and the defect goes where the plan author sees it.
1022
+ DISCOVERY_READS_MISSING_NOTE = (
1023
+ "This issue's `Alissa-Reads:` trailer is missing or malformed, so there "
1024
+ "is NO pinned SHA: do not pick one yourself. Comment on the issue naming "
1025
+ "the defect (it must read `Alissa-Reads: <owner>/<repo>@<40-hex sha>`), "
1026
+ "leave your implementation task in_progress with that note, open no "
1027
+ "pull request, and run `alissa tmux kill {session}` to end this "
1028
+ "session. "
1029
+ )
1030
+
919
1031
  # The handover clause (issue #121), shared by FIX_DIRECTIVE and
920
1032
  # FIX_RESUME_DIRECTIVE. When a later review round lands on the head a fix
921
1033
  # session is still working (the 2026-09-15 studio#1243 shape: a phantom round
@@ -1709,6 +1821,139 @@ def marker_file_name(tmux_session: str) -> str:
1709
1821
  return safe + ".json"
1710
1822
 
1711
1823
 
1824
+ # -- discovery rounds (issue #164) ---------------------------------------
1825
+ #
1826
+ # The trailers Studio's feed_plan renders on a discovery issue (design
1827
+ # alissa-build-discovery.md §2.2). Line-anchored and MULTILINE like
1828
+ # `Alissa-Task:`: a trailer sits on a line of its own, so a body QUOTING one
1829
+ # mid-sentence (a design issue explaining discovery) is not a discovery issue.
1830
+ _DISCOVERY_KIND_RE = re.compile(r"^Alissa-Kind:[ \t]*discovery[ \t]*\r?$", re.MULTILINE)
1831
+ _ALISSA_READS_RE = re.compile(
1832
+ r"^Alissa-Reads:[ \t]*(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)"
1833
+ r"@(?P<sha>[0-9a-f]{40})[ \t]*\r?$",
1834
+ re.MULTILINE,
1835
+ )
1836
+ _KB_SCOPE_RE = re.compile(
1837
+ r"^Autonomous-Scope:[ \t]*(?P<dir>knowledge-base/\d{4}/\d{2}/\d{2}/TASK-\d+)"
1838
+ r"/\*\*[ \t]*\r?$",
1839
+ re.MULTILINE,
1840
+ )
1841
+
1842
+ # The knowledge-base directory a discovery directive names when the issue's
1843
+ # `Autonomous-Scope:` does not spell one (feed_plan always derives it), and
1844
+ # the sentence telling the worker how to fill it in.
1845
+ DISCOVERY_KB_DIR_FALLBACK = "knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>"
1846
+ DISCOVERY_KB_DIR_NOTE = (
1847
+ "This issue's `Autonomous-Scope:` does not name the knowledge-base "
1848
+ "directory: in `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/` below, <n> is "
1849
+ "the ORIGIN task's number and the date its creation day (UTC). "
1850
+ )
1851
+
1852
+ # Where the daemon leaves the marker that arms the push guard for one
1853
+ # discovery session, and the env var both sides read it from. The daemon and
1854
+ # every worker run in one container with one environment, so the hook
1855
+ # (docker/claude/hooks/guard-shell.py, which spells the same default) and
1856
+ # this module agree on the directory without a config key.
1857
+ DISCOVERY_DIR_ENV = "ALISSA_DISCOVERY_DIR"
1858
+ DEFAULT_DISCOVERY_DIR = "/workspace/.discovery"
1859
+
1860
+
1861
+ @dataclass(frozen=True)
1862
+ class Discovery:
1863
+ """What a discovery issue's trailers say. `read_repo`/`read_sha` are None
1864
+ when `Alissa-Reads:` is absent or malformed, `kb_dir` when
1865
+ `Autonomous-Scope:` is not one knowledge-base directory."""
1866
+
1867
+ read_repo: "str | None"
1868
+ read_sha: "str | None"
1869
+ kb_dir: "str | None"
1870
+
1871
+
1872
+ def discovery_of(body: "str | None") -> "Discovery | None":
1873
+ """The discovery trailers of an issue body, or None when it does not
1874
+ carry `Alissa-Kind: discovery` -- the one signal that selects
1875
+ DISCOVERY_DIRECTIVE. The first `Alissa-Reads:` wins."""
1876
+ text = body or ""
1877
+ if not _DISCOVERY_KIND_RE.search(text):
1878
+ return None
1879
+ reads = _ALISSA_READS_RE.search(text)
1880
+ scope = _KB_SCOPE_RE.search(text)
1881
+ return Discovery(
1882
+ read_repo=reads.group("repo") if reads else None,
1883
+ read_sha=reads.group("sha") if reads else None,
1884
+ kb_dir=scope.group("dir") if scope else None,
1885
+ )
1886
+
1887
+
1888
+ def render_discovery_directive(
1889
+ *,
1890
+ issue_url: str,
1891
+ number: int,
1892
+ attempt: int,
1893
+ cap: int,
1894
+ task_instruction: str,
1895
+ plans_repo: str,
1896
+ session: str,
1897
+ discovery: Discovery,
1898
+ ) -> str:
1899
+ """DISCOVERY_DIRECTIVE for one issue. A missing `Alissa-Reads:` renders
1900
+ the read target as placeholders plus DISCOVERY_READS_MISSING_NOTE (stop,
1901
+ do not guess a SHA); a missing knowledge-base scope renders
1902
+ DISCOVERY_KB_DIR_FALLBACK plus DISCOVERY_KB_DIR_NOTE."""
1903
+ notes = ""
1904
+ if discovery.read_repo is None or discovery.read_sha is None:
1905
+ notes += DISCOVERY_READS_MISSING_NOTE.format(session=session)
1906
+ if discovery.kb_dir is None:
1907
+ notes += DISCOVERY_KB_DIR_NOTE
1908
+ return DISCOVERY_DIRECTIVE.format(
1909
+ issue_url=issue_url,
1910
+ number=number,
1911
+ attempt=attempt,
1912
+ cap=cap,
1913
+ task_instruction=task_instruction,
1914
+ trailer_note=notes,
1915
+ plans_repo=plans_repo,
1916
+ read_repo=discovery.read_repo or "<owner>/<repo>",
1917
+ read_sha=discovery.read_sha or "<sha>",
1918
+ kb_dir=discovery.kb_dir or DISCOVERY_KB_DIR_FALLBACK,
1919
+ session=session,
1920
+ )
1921
+
1922
+
1923
+ def discovery_dir() -> Path:
1924
+ return Path(os.environ.get(DISCOVERY_DIR_ENV, "").strip() or DEFAULT_DISCOVERY_DIR)
1925
+
1926
+
1927
+ def write_discovery_marker(session: str, plans_repo: str, number: int, discovery: Discovery) -> Path:
1928
+ """Arm the push guard for the managed session `session` (its tmux name is
1929
+ `ali-<session>`, the name the hook reads from `$ALISSA_TMUX_SESSION`):
1930
+ `<discovery_dir>/<tmux name>.json`, spelled like the waiting marker
1931
+ (`marker_file_name`), naming the one repository the session may push to.
1932
+ Written atomically (temp file + rename) so the hook never reads a torn
1933
+ file. Raises OSError; the caller stands the spawn down rather than start a
1934
+ discovery session the guard does not know about. Never removed: a
1935
+ session name is unique per issue and attempt, so a stale marker can only
1936
+ ever guard a later session on the same discovery issue."""
1937
+ tmux_name = f"{MANAGED_PREFIX}{session}"
1938
+ folder = discovery_dir()
1939
+ folder.mkdir(parents=True, exist_ok=True)
1940
+ path = folder / marker_file_name(tmux_name)
1941
+ payload = {
1942
+ "session": tmux_name,
1943
+ "plansRepo": plans_repo,
1944
+ "issue": number,
1945
+ "reads": (
1946
+ f"{discovery.read_repo}@{discovery.read_sha}"
1947
+ if discovery.read_repo is not None
1948
+ else None
1949
+ ),
1950
+ }
1951
+ tmp = path.with_name(path.name + ".tmp")
1952
+ tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
1953
+ os.replace(tmp, path)
1954
+ return path
1955
+
1956
+
1712
1957
  # The first-run-dialog wedge's once-per-episode marker (issue #123; see
1713
1958
  # first_run_dialog_kind). Like ESCALATION_WEDGED an ACT, not a page: the
1714
1959
  # daemon killed a session whose pane showed one of Claude Code's first-run
@@ -4243,6 +4488,11 @@ class DevWatcher:
4243
4488
  "resolution"
4244
4489
  )
4245
4490
  regenerate_rule = self._regenerate_rule(issue.owner, issue.repo)
4491
+ # A discovery issue (issue #164) gets DISCOVERY_DIRECTIVE on a fresh
4492
+ # spawn; a resume keeps RESUME_DIRECTIVE (it finishes a PR that
4493
+ # already exists). Both are discovery SESSIONS, so both get the
4494
+ # push-guard marker below.
4495
+ discovery = discovery_of(issue.body)
4246
4496
  if resume_pr is not None:
4247
4497
  directive = RESUME_DIRECTIVE.format(
4248
4498
  issue_url=issue.url,
@@ -4258,6 +4508,17 @@ class DevWatcher:
4258
4508
  dead_session=dead_session,
4259
4509
  regenerate_rule=regenerate_rule,
4260
4510
  )
4511
+ elif discovery is not None:
4512
+ directive = render_discovery_directive(
4513
+ issue_url=issue.url,
4514
+ number=issue.number,
4515
+ attempt=attempt,
4516
+ cap=self.config.attempt_cap,
4517
+ task_instruction=task_instruction,
4518
+ plans_repo=issue.full_name,
4519
+ session=name,
4520
+ discovery=discovery,
4521
+ )
4261
4522
  else:
4262
4523
  directive = DEV_DIRECTIVE.format(
4263
4524
  issue_url=issue.url,
@@ -4308,6 +4569,32 @@ class DevWatcher:
4308
4569
  attempt - 1,
4309
4570
  )
4310
4571
 
4572
+ # The push guard's marker (issue #164), before the assignment and the
4573
+ # enqueue: a discovery session must never exist without it, because
4574
+ # the marker is the only thing that tells the shell guard to refuse
4575
+ # its pushes to the read repository. A marker that cannot be written
4576
+ # stands the spawn down exactly like an unreadable issue -- nothing
4577
+ # claimed, the slot handed back, retried next poll. The issue
4578
+ # edge's own repository is the plans repository: Autonomous-Dev of a
4579
+ # discovery issue is the fleet's plans repo (design §2.2).
4580
+ if discovery is not None and not self.config.dry_run:
4581
+ try:
4582
+ write_discovery_marker(name, issue.full_name, issue.number, discovery)
4583
+ except OSError as exc:
4584
+ if budget is not None:
4585
+ budget.release()
4586
+ log.warning(
4587
+ "%s: could not write the discovery push-guard marker for "
4588
+ "%s (%s) — standing the spawn down this pass",
4589
+ issue.issue_slug, name, exc,
4590
+ )
4591
+ return Decision(
4592
+ Action.SKIPPED,
4593
+ f"discovery push-guard marker could not be written ({exc}) "
4594
+ f"— retried next poll",
4595
+ attempt - 1,
4596
+ )
4597
+
4311
4598
  # assign_self FIRST: the assignment is the server-side in-flight
4312
4599
  # marker, so it must land before anything is enqueued -- a crash
4313
4600
  # between the two leaves an assigned-but-sessionless issue (safe:
@@ -8,8 +8,8 @@ the devloop half of that contract, shared by the daemon (which computes the
8
8
  verdict per pass and pushes it with the fleet vitals) and the console (which
9
9
  serves it on ``GET /api/state`` and runs the drain handshake):
10
10
 
11
- * **The idle block.** ``{asOf, passAt, owed, live, managed, queued, timers,
12
- drainedAt}``. `owed` counts the candidates whose LAST evaluation was an
11
+ * **The idle block.** ``{asOf, passAt, owed, live, managed, queued, gone,
12
+ timers, drainedAt}``. `owed` counts the candidates whose LAST evaluation was an
13
13
  action or a timer the seat owns (a spawn, a fresh spawn row in its stale
14
14
  window, an owed respawn, a deferral, a pending stall ping, a grace the seat
15
15
  is waiting out, an evaluation that failed); a candidate the seat has
@@ -17,7 +17,9 @@ serves it on ``GET /api/state`` and runs the drain handshake):
17
17
  owed. Each pass records the verdict per candidate on its poll snapshot's
18
18
  stage (`owed`, `timer`), so the block is read back from the snapshot and
19
19
  costs no GitHub call. `live` / `managed` / `queued` are read from tmux and
20
- the alissa queue by whoever builds the block.
20
+ the alissa queue by whoever builds the block. `managed` is the live rows
21
+ plus the dead rows whose session backs a lane the seat still holds; a dead
22
+ row backing nothing is reported under `gone` instead (`roster_counts`).
21
23
  * **Unknown is never idle.** No snapshot yet → `owed` is null; a listing that
22
24
  failed → `live` / `managed` / `queued` are null. `idle_refusals` reads every
23
25
  null as a refusal.
@@ -29,7 +31,7 @@ serves it on ``GET /api/state`` and runs the drain handshake):
29
31
  from __future__ import annotations
30
32
 
31
33
  from datetime import datetime, timezone
32
- from typing import Iterable, Mapping, Sequence
34
+ from typing import Collection, Iterable, Mapping, Sequence
33
35
 
34
36
  # The timer kinds of the design's idle block (§2.1). devloop owns the first
35
37
  # three; the last two are revloop's and are listed so the union is stated once.
@@ -111,19 +113,107 @@ def queued_items(queues: "object") -> "int | None":
111
113
  return count
112
114
 
113
115
 
116
+ # The tmux prefix `alissa tmux` puts on a managed session's real name
117
+ # (`alissa.MANAGED_PREFIX`, restated so this module imports nothing of the
118
+ # daemon's). A listing row carries both spellings (`name` bare, `session`
119
+ # prefixed), a snapshot stage the bare one, a queue the prefixed one.
120
+ MANAGED_PREFIX = "ali-"
121
+
122
+ # The spawn ledgers (`Sources.ledgers` keys) with the snapshot edge each one
123
+ # feeds, the column holding its issue / PR number, and the slug separator the
124
+ # poll snapshot uses for it (`owner/repo#7`, `owner/repo#pr16`).
125
+ LEDGER_LANES = (
126
+ ("spawns", "issue", "issue", "#"),
127
+ ("fix_spawns", "fix", "number", "#pr"),
128
+ ("maintain_spawns", "maintain", "number", "#pr"),
129
+ ("revert_spawns", "revert", "number", "#pr"),
130
+ )
131
+
132
+ # A stage holding one of these actions has a worker in flight on it whatever
133
+ # its owed verdict says: its session is never an orphan.
134
+ IN_FLIGHT_ACTIONS = frozenset({"spawned", "in-flight"})
135
+
136
+
137
+ def bare_session(name: "object") -> str:
138
+ """A session name without its `ali-` tmux prefix ("" for a non-name)."""
139
+ text = str(name or "")
140
+ return text[len(MANAGED_PREFIX):] if text.startswith(MANAGED_PREFIX) else text
141
+
142
+
143
+ def lane_sessions(
144
+ snapshot: "Mapping | None",
145
+ *,
146
+ ledgers: "Mapping | None" = None,
147
+ queues: "object" = None,
148
+ ) -> "frozenset[str] | None":
149
+ """The bare names of the sessions that back a lane the seat still holds
150
+ (issue #166): the session a stage that is owed or in flight names; every
151
+ session the spawn ledgers record on such a stage's lane (an earlier
152
+ attempt the lane may still resume); and every session the alissa queue
153
+ holds an undispatched item for. None before the first pass -- with no
154
+ snapshot nothing can be called an orphan, so every row stays managed."""
155
+ if snapshot is None:
156
+ return None
157
+ names: "set[str]" = set()
158
+ held: "set[tuple[str, str]]" = set()
159
+ for stage in snapshot.get("stages") or ():
160
+ if not isinstance(stage, Mapping):
161
+ continue
162
+ if not (stage_owed(stage) or stage.get("stage") in IN_FLIGHT_ACTIONS):
163
+ continue
164
+ held.add((str(stage.get("edge") or ""), str(stage.get("slug") or "")))
165
+ if stage.get("session"):
166
+ names.add(bare_session(stage["session"]))
167
+ for table, edge, column, sep in LEDGER_LANES:
168
+ for row in (ledgers or {}).get(table) or ():
169
+ lane = (edge, f"{row.get('repo_slug')}{sep}{row.get(column)}")
170
+ if lane in held and row.get("session"):
171
+ names.add(bare_session(row["session"]))
172
+ if isinstance(queues, list):
173
+ for queue in queues:
174
+ if not isinstance(queue, Mapping):
175
+ continue
176
+ if any(
177
+ isinstance(item, Mapping) and not item.get("lastDispatchedAt")
178
+ for item in queue.get("items") or ()
179
+ ):
180
+ names.add(bare_session(queue.get("session")))
181
+ names.discard("")
182
+ return frozenset(names)
183
+
184
+
114
185
  def roster_counts(
115
186
  roster: "Sequence[Mapping] | None",
116
- ) -> "tuple[int | None, int | None]":
117
- """`(live, managed)` from an ``alissa tmux ls --json`` listing, the
118
- fleet-vitals `sessions` rule: a row is live unless it says otherwise.
119
- `(None, None)` when the listing failed."""
187
+ lanes: "Collection[str] | None" = None,
188
+ ) -> "tuple[int | None, int | None, list[str] | None]":
189
+ """`(live, managed, gone)` from an ``alissa tmux ls --json`` listing.
190
+
191
+ A row is live unless it says otherwise (the fleet-vitals `sessions`
192
+ rule: its `live` flag, else `status != "gone"`). A row is MANAGED when it
193
+ is live or when its session backs a lane the seat still holds -- `lanes`,
194
+ the bare names `lane_sessions` returns. A dead row matching no lane is an
195
+ orphan: it is not managed, and its name is reported in `gone` instead
196
+ (issue #166 -- one such row kept orcloop's `managed == 0` from ever
197
+ holding). `lanes=None` (no completed pass to read lanes from) keeps every
198
+ row managed: unknown is never idle. `(None, None, None)` when the listing
199
+ failed."""
120
200
  if roster is None:
121
- return None, None
122
- rows = [r for r in roster if isinstance(r, Mapping)]
123
- live = sum(
124
- 1 for r in rows if bool(r.get("live", r.get("status") != "gone"))
125
- )
126
- return live, len(rows)
201
+ return None, None, None
202
+ live = managed = 0
203
+ gone: "list[str]" = []
204
+ for row in roster:
205
+ if not isinstance(row, Mapping):
206
+ continue
207
+ if bool(row.get("live", row.get("status") != "gone")):
208
+ live += 1
209
+ managed += 1
210
+ continue
211
+ name = bare_session(row.get("name") or row.get("session"))
212
+ if lanes is None or name in lanes or bare_session(row.get("session")) in lanes:
213
+ managed += 1
214
+ else:
215
+ gone.append(name)
216
+ return live, managed, sorted(gone)
127
217
 
128
218
 
129
219
  def idle_block(
@@ -134,10 +224,13 @@ def idle_block(
134
224
  queued: "int | None",
135
225
  drain: "Mapping | None",
136
226
  now: "float | int",
227
+ gone: "Sequence[str] | None" = None,
137
228
  ) -> dict:
138
229
  """The seat's idle block from its latest poll snapshot (None before the
139
230
  first pass) and the caller's live readings. `drain` is the holding drain
140
- row (`State.drain`) or None."""
231
+ row (`State.drain`) or None. `gone` is `roster_counts`' orphaned rows,
232
+ reported as `{count, sessions}` (null when the listing was not read) --
233
+ an observation for the operator, never a refusal."""
141
234
  stages: "Iterable[Mapping]" = (snapshot or {}).get("stages") or ()
142
235
  owed: "int | None" = None
143
236
  timers: "list[dict]" = []
@@ -157,6 +250,10 @@ def idle_block(
157
250
  "live": live,
158
251
  "managed": managed,
159
252
  "queued": queued,
253
+ "gone": (
254
+ {"count": len(gone), "sessions": list(gone)}
255
+ if gone is not None else None
256
+ ),
160
257
  "timers": timers,
161
258
  "drainedAt": iso(drain.get("drained_at")) if drain else None,
162
259
  }
@@ -1 +1 @@
1
- 0.8.41
1
+ 0.8.43
@@ -438,17 +438,25 @@ class Sources:
438
438
  a drain holds. A caller that already holds the listing (None when it
439
439
  failed) or the latest snapshot (None before the first pass) hands it
440
440
  in; anything not handed in is read here. A failed listing is null
441
- counts, never zeros."""
441
+ counts, never zeros. A dead row backing no lane the seat holds is
442
+ reported under `gone`, not counted as managed (issue #166)."""
442
443
  if latest is _UNREAD:
443
444
  snaps = self.snapshots(1)
444
445
  latest = snaps[0] if snaps else None
445
446
  if listing is _UNREAD:
446
447
  listing = self._safe_json(["alissa", "tmux", "ls", "--json"])
447
448
  roster = listing if isinstance(listing, list) else None
448
- live, managed = parking.roster_counts(roster)
449
+ snapshot = latest if isinstance(latest, dict) else None
450
+ queues = self._safe_json(["alissa", "tmux", "queue", "ls", "--json"])
451
+ # A dead row counts as managed only while it backs a lane the seat
452
+ # still holds (issue #166).
453
+ lanes = parking.lane_sessions(
454
+ snapshot, ledgers=self.ledgers(), queues=queues,
455
+ )
456
+ live, managed, gone = parking.roster_counts(roster, lanes)
449
457
  return parking.idle_block(
450
- latest if isinstance(latest, dict) else None,
451
- live=live, managed=managed, queued=self.queued(),
458
+ snapshot, live=live, managed=managed,
459
+ queued=parking.queued_items(queues), gone=gone,
452
460
  drain=self.drain(), now=self._wall(),
453
461
  )
454
462
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.41
3
+ Version: 0.8.43
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -148,6 +148,78 @@ issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
148
148
  closed between search and spawn", nothing is claimed, and the reserved slot
149
149
  is handed back. The respawn-with-resume on an open draft PR is unchanged.
150
150
 
151
+ ## The discovery round (issue #164)
152
+
153
+ A discovery issue is a no-code lane in the fleet's **plans repository**
154
+ (design `alissa-build-discovery.md` §3.2, lane DS-7a). Studio's `feed_plan`
155
+ renders it with the trailers `Alissa-Kind: discovery`, `Alissa-Reads:
156
+ <owner>/<repo>@<40-hex sha>` (the code repository it reads, at a pinned SHA)
157
+ and `Autonomous-Scope: knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/**`. The
158
+ issue edge spawns the ordinary develop session — same name, same caps, same
159
+ liveness rules — with `DISCOVERY_DIRECTIVE` (in `loop.py`, byte-pinned in
160
+ `test_discovery.py`) instead of `DEV_DIRECTIVE`. Every other issue gets
161
+ today's directive byte for byte; a resume of a dead discovery session keeps
162
+ `RESUME_DIRECTIVE`.
163
+
164
+ The directive has the worker:
165
+
166
+ 1. create its `TASK-<n>-<DESC>` branch in the plans repository, the branch
167
+ grammar unchanged;
168
+ 2. read the code at the pinned SHA, read-only — a `git -C <hub> worktree add
169
+ --detach <scratch> <sha>` when the workspace has a hub of the read
170
+ repository (no branch, removed at the end), otherwise `gh api
171
+ repos/<repo>/contents/<path>?ref=<sha>` and `gh search code`; never a
172
+ clone, never a commit or push there. The read is bounded by the SHA, not a
173
+ file count; the output by the findings schema's limits;
174
+ 3. write exactly one directory, `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/`,
175
+ holding only `findings.md` (findings schema v1, `schema/findings.v1.json`
176
+ in the plans repository);
177
+ 4. run `node lint/plan-lint.ts --kb <dir>` locally with its own
178
+ `ALISSA_API_TOKEN` (K6/K7) and never push findings it refuses;
179
+ 5. open a ready pull request with `Closes #<n>`, both task refs and the
180
+ marker `<!-- alissa-discovery v1 -->` on a line of its own, attach a
181
+ `Findings` evidence item, move its task to `pending_validation`, request
182
+ **no** reviewer (revloop skips such PRs; orcloop merges on the lint of
183
+ record) and end its session.
184
+
185
+ **The push guard.** Before it enqueues a discovery session (fresh or
186
+ resumed), the daemon writes `${ALISSA_DISCOVERY_DIR:-/workspace/.discovery}/
187
+ ali-<session>.json` naming the plans repository; a marker that cannot be
188
+ written stands the spawn down for the pass. The container's shell guard
189
+ (`docker/claude/hooks/guard-shell.py`) finds the marker through
190
+ `$ALISSA_TMUX_SESSION` and refuses, with a PreToolUse deny and a reason, any
191
+ `git push` from that session whose destination is not the plans repository —
192
+ resolved from the remote name or URL in the directory the push runs in
193
+ (`-C`, `cd`), and refused outright when it cannot be resolved. Sessions with
194
+ no marker are untouched. `ALISSA_SHELL_GUARD=off` disables this guard with
195
+ the rest.
196
+
197
+ ## Seat parking: what counts as managed (issue #166)
198
+
199
+ The idle block on `GET /api/state` (`parking.idle_block`, issue #153) is what
200
+ orcloop's D2 invariant reads before it drains and parks the seat: idle only
201
+ when `live == managed == queued == owed == 0` with no timer and a fresh pass.
202
+ `live` and `managed` come from `alissa tmux ls --json`:
203
+
204
+ - a row is **live** unless it says otherwise (its `live` flag, else
205
+ `status != "gone"`);
206
+ - a row is **managed** when it is live, **or** when it is dead but its
207
+ session still backs a lane the seat holds — the session named by a stage
208
+ of the latest poll snapshot that is owed or in flight (`spawned` /
209
+ `in-flight`), any session the spawn ledgers record on such a stage's lane
210
+ (an earlier attempt the lane may still resume), or a session the alissa
211
+ queue holds an undispatched item for;
212
+ - a dead row backing none of these is an **orphan**: it is not managed, and
213
+ is reported in the block's `gone` field as `{count, sessions}` so the
214
+ console and orcloop's logs still show it.
215
+
216
+ Unknown is never idle: a failed listing leaves `live`, `managed` and `gone`
217
+ null, and before the first completed pass (no snapshot to read lanes from)
218
+ every dead row stays managed. The console only reports an orphan — it never
219
+ kills or reaps one. The fleet-vitals push applies the same rule to
220
+ `sessions.managed` and `idle.managed` (Studio's park guard reads both) and
221
+ leaves `gone` out, since Studio's idle schema is strict.
222
+
151
223
  ## License
152
224
 
153
225
  Licensed under the
@@ -4,29 +4,29 @@ alissa/tools/github/devloop/alissa.py,sha256=TJU4eSZc2MvP35HewCwBKvhPAel32vKjtt3
4
4
  alissa/tools/github/devloop/alissa_client.py,sha256=wmpXYWf9ThcLeYnz6gGeDz7gj5BpYjduuVASMc-QOOY,14873
5
5
  alissa/tools/github/devloop/bows.py,sha256=pzDNMRk_S78FX7vMNYMlXHxW6VPHPo1Xd2EzFMgVCUM,17325
6
6
  alissa/tools/github/devloop/config.py,sha256=j6W0Wrz7smFdFdSs3g5S6xadcxfFS5YEhQDL64yAuJk,75109
7
- alissa/tools/github/devloop/fleet_vitals.py,sha256=Hl2xshweh7A9R0N7fyYRg2WzHN4aNefVlt0TiXCv8zk,24979
7
+ alissa/tools/github/devloop/fleet_vitals.py,sha256=5mWnRkJh8u9soSplud3UMxkelT6EqAiz-ivX8UmfIqk,25477
8
8
  alissa/tools/github/devloop/ghclient.py,sha256=tYUHwmoy9CBBt-jSOz9nuAl8lpMvm1LH4A-wHx25bss,71842
9
9
  alissa/tools/github/devloop/hubs.py,sha256=yjnsnSHLG7GNNP8X2hILeAMV6P5n4JGc_IaAxpSP_YQ,18974
10
10
  alissa/tools/github/devloop/janitor.py,sha256=R8REoHBeY2cfW7fYJVhKr-F5z9Mjlc-vmv-z4KtvBZc,27360
11
- alissa/tools/github/devloop/loop.py,sha256=BFU9XgBTColvKwgiczxhWPgACn1LGiszV1HDxTIqaqM,599231
11
+ alissa/tools/github/devloop/loop.py,sha256=uMfuomSdIPC8KkWUGF0OrW3RR9OSC1txKVnXGZ1SeIc,613973
12
12
  alissa/tools/github/devloop/loop_events.py,sha256=tmso0OALqY2yAPW2Fo6ae0sYetQIdIeIYooJ2Sc2cc4,37921
13
- alissa/tools/github/devloop/parking.py,sha256=2Sys1bh_hG8qbq7cO9RxW1F1kAu3dCWHnvwg_iAFVvY,7987
13
+ alissa/tools/github/devloop/parking.py,sha256=vEf-qprN14-I4vu4M53tIFaA3827FiHLpKetBAgN4LQ,12409
14
14
  alissa/tools/github/devloop/proc.py,sha256=nzfsxJSHFWRQcrXmfv588KazFysv8dVQrhMPH1q5eRk,3134
15
15
  alissa/tools/github/devloop/prompts.py,sha256=VzN6ec5eF2wwg7QSCRg52WLcxo-tGbGRgQbc4HSahUc,55169
16
16
  alissa/tools/github/devloop/state.py,sha256=uVwhbkogUTjWXXFq-COKzUeYrpuGbHzi05CjfEzAtPA,88668
17
- alissa/tools/github/devloop/version,sha256=QOcLK9uM_DEwqdJtbll5EUYMrrUbOLIguSS-pnllhCs,7
17
+ alissa/tools/github/devloop/version,sha256=w6XCzs4ibf7WAa6RiS5RhCLRVAdNmHRvxJaC9NKDINI,7
18
18
  alissa/tools/github/devloop/version.py,sha256=znuuOaitXGrBpbskNy78_rlVpFdtmKmk_3k1ACxZrl0,1418
19
19
  alissa/tools/github/devloop/webui/__init__.py,sha256=vdXYoyGYzD7BrJ5lV6XdH8Bpw_pej8-RK0hjfxop7-8,1513
20
20
  alissa/tools/github/devloop/webui/__main__.py,sha256=wORyW5AkgsniK0TZcrOrvm_P7Q4Mr-fH-4S6qj9XcgA,6137
21
21
  alissa/tools/github/devloop/webui/auth.py,sha256=g9YEDao9nKt-cBEENp7vFqwJ-T8olN6uLiCzVIN8yhA,7890
22
22
  alissa/tools/github/devloop/webui/page.py,sha256=4OD3e01ByvVM4Wo04AZyFuyOYTFEMT2lagktZAvyCKk,30862
23
23
  alissa/tools/github/devloop/webui/server.py,sha256=YuUmcng5qdWqSurcuyxH3FKKkBBfrpDtvtZLidJOGiU,26226
24
- alissa/tools/github/devloop/webui/sources.py,sha256=Ud8357-Ag4qC39V3mlepqnArui9p1fTJ058bAdN29Jc,36730
24
+ alissa/tools/github/devloop/webui/sources.py,sha256=8_f5kQv6DGD0A2Y4goKH-TSIQAab-O5uoO3f2nNRhI0,37209
25
25
  alissa/tools/github/devloop/webui/sysinfo.py,sha256=ptlogH9alFv0vZ-pQfyUaA3w-K7LQS3tQHGv0d8Tdlk,9649
26
- alissa_tools_github_devloop-0.8.41.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
27
- alissa_tools_github_devloop-0.8.41.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
28
- alissa_tools_github_devloop-0.8.41.dist-info/METADATA,sha256=Ngui7ERzDLYBQHzZ1Q6mdtFV1mnek3FUeFnwqk5YhG8,8093
29
- alissa_tools_github_devloop-0.8.41.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
- alissa_tools_github_devloop-0.8.41.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
31
- alissa_tools_github_devloop-0.8.41.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
32
- alissa_tools_github_devloop-0.8.41.dist-info/RECORD,,
26
+ alissa_tools_github_devloop-0.8.43.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
27
+ alissa_tools_github_devloop-0.8.43.dist-info/licenses/NOTICE,sha256=FKstNs3uw_tQod4_1XcFLD1FUTJQXf4FC0oXJAOA_HE,260
28
+ alissa_tools_github_devloop-0.8.43.dist-info/METADATA,sha256=ostOBBFoEkmV42blENKqNX5CrVK2dCdPHLIxfYVrzuY,12222
29
+ alissa_tools_github_devloop-0.8.43.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
30
+ alissa_tools_github_devloop-0.8.43.dist-info/entry_points.txt,sha256=8aC7Ajzx4bu9ZRR37a40IDfuStacvZP5KsAxY1dbFKU,145
31
+ alissa_tools_github_devloop-0.8.43.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
32
+ alissa_tools_github_devloop-0.8.43.dist-info/RECORD,,