alissa-tools-github-genloop 0.1.0__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.
@@ -0,0 +1,14 @@
1
+ """genloop — the generator seat of the Alissa GitHub daemon family (design
2
+ `docs/design/genloop.md` v0.2 in `ali-fhr/studio.alissa.app`, lane L5).
3
+
4
+ Where orcloop releases committed work, devloop develops it and revloop
5
+ reviews it, genloop PROPOSES it: on a cadence it spawns one judgment session
6
+ (`genloop-<stamp>`) that reads the operator's direction and the loop's
7
+ exhaust and writes at most one plan on a `PLAN-` branch of the fleet's plans
8
+ repository; the seat then opens that plan's pull request, requests the
9
+ refuter, records the plan and emits the `plan.*` / `pass.*` loop events. The
10
+ reserved runtime env prefix is `ALISSA_GEN_*`; the seat is OFF unless
11
+ `ALISSA_GEN_ENABLED` is set.
12
+ """
13
+
14
+ __all__: list[str] = []
@@ -0,0 +1,89 @@
1
+ """`alissa-genloop` — run the genloop seat.
2
+
3
+ alissa-genloop # poll forever (every ALISSA_GEN_POLL_INTERVAL seconds)
4
+ alissa-genloop --once # one poll, print its decision line, exit
5
+ alissa-genloop --dry-run # narrate every side effect instead of performing it
6
+ alissa-genloop --version
7
+
8
+ Configuration is the environment only (`ALISSA_GEN_*`, pass-through-when-unset,
9
+ see `config.py`); tokens are `GH_TOKEN` (read by `gh`) and `ALISSA_API_TOKEN`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import logging
16
+ import signal
17
+ import sys
18
+ import threading
19
+ from typing import Sequence
20
+
21
+ from .alissa_cli import AlissaCli
22
+ from .alissa_client import AlissaClient
23
+ from .config import Config, ConfigError
24
+ from .github import GitHub
25
+ from .loop import GenLoop
26
+ from .loop_events import LoopEventRecorder
27
+ from .state import State
28
+ from .version import version
29
+
30
+ log = logging.getLogger("alissa.tools.github.genloop")
31
+
32
+
33
+ def build_loop(config: Config) -> GenLoop:
34
+ alissa = AlissaClient(base=config.alissa_endpoint or None)
35
+ return GenLoop(
36
+ config,
37
+ state=State.in_dir(config.state_path),
38
+ alissa=alissa,
39
+ github=GitHub(),
40
+ cli=AlissaCli(),
41
+ events=LoopEventRecorder(alissa, dry_run=config.dry_run),
42
+ )
43
+
44
+
45
+ def main(argv: Sequence[str] | None = None) -> int:
46
+ parser = argparse.ArgumentParser(prog="alissa-genloop", description="The genloop seat (design lane L5).")
47
+ parser.add_argument("--once", action="store_true", help="run one poll and exit")
48
+ parser.add_argument("--dry-run", action="store_true", help="narrate side effects instead of performing them")
49
+ parser.add_argument("--log-level", default="INFO", help="logging level (default INFO)")
50
+ parser.add_argument("--version", action="version", version=f"{version.name} {version.value}")
51
+ args = parser.parse_args(argv)
52
+
53
+ logging.basicConfig(
54
+ level=getattr(logging, str(args.log_level).upper(), logging.INFO),
55
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
56
+ )
57
+ try:
58
+ config = Config.from_env()
59
+ if args.dry_run:
60
+ config = config.replace(dry_run=True)
61
+ except ConfigError as exc:
62
+ log.error("genloop: configuration refused — %s", exc)
63
+ return 2
64
+ log.info("genloop %s starting: %s", version.value, config.summary())
65
+ loop = build_loop(config)
66
+
67
+ if args.once:
68
+ print(loop.poll())
69
+ return 0
70
+
71
+ stop = threading.Event()
72
+
73
+ def _stop(signum, _frame): # pragma: no cover - signal plumbing
74
+ log.info("genloop: signal %d — stopping after this poll", signum)
75
+ stop.set()
76
+
77
+ signal.signal(signal.SIGTERM, _stop)
78
+ signal.signal(signal.SIGINT, _stop)
79
+ while not stop.is_set():
80
+ try:
81
+ loop.poll()
82
+ except Exception: # noqa: BLE001 - one bad poll must not end the seat
83
+ log.exception("genloop: the poll raised — retrying next interval")
84
+ stop.wait(config.poll_interval)
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__": # pragma: no cover
89
+ sys.exit(main())
@@ -0,0 +1,89 @@
1
+ """The `alissa` CLI surface the seat needs: enqueue one managed session, probe
2
+ the worker, list the managed tmux sessions, kill ONE of them.
3
+
4
+ Copy-adapted from orcloop's `alissa_cli.py` (itself devloop's), deliberately
5
+ NOT redesigned: the seats drive the same worker through the same four
6
+ commands, and a divergence here is a divergence in what "a live session"
7
+ means between the seats that count them.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Sequence
16
+
17
+ from .proc import CommandError, run
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+
22
+ def _run(argv: Sequence[str], *, timeout: int, check: bool = True) -> str:
23
+ """`proc.run`, with a CLI that could not be EXECUTED (missing binary,
24
+ unexecutable file) folded into the same `CommandError` as a CLI that
25
+ answered badly -- every caller treats "the CLI did not answer" as a hold."""
26
+ try:
27
+ return run(argv, timeout=timeout, check=check)
28
+ except OSError as exc:
29
+ raise CommandError(argv, -1, f"could not execute {argv[0]!r}: {exc}") from exc
30
+
31
+
32
+ class AlissaCli:
33
+ def enqueue_session(self, *, session: str, directive: str, cwd: Path, agent: str) -> None:
34
+ """`alissa tmux queue add <session> --agent <agent> --cwd <cwd>
35
+ <directive>`, then `queue set <session> respawn off` (best-effort): a
36
+ pass is one-shot, the seat's interval is the cadence."""
37
+ _run(
38
+ ["alissa", "tmux", "queue", "add", session, "--agent", agent, "--cwd", str(cwd), directive],
39
+ timeout=60,
40
+ )
41
+ try:
42
+ _run(["alissa", "tmux", "queue", "set", session, "respawn", "off"], timeout=30, check=False)
43
+ except CommandError: # pragma: no cover - defence in depth
44
+ log.warning("could not set respawn off for %s", session)
45
+
46
+ def list_sessions(self) -> "list[dict]":
47
+ """`alissa tmux ls --json`. Raises CommandError whenever the CLI does
48
+ not answer USABLY (non-zero exit, empty or unparseable stdout, not a
49
+ list): a working CLI prints `[]` when there are no sessions, so
50
+ anything else is UNKNOWN -- and unknown holds the spawn (L2)."""
51
+ argv = ["alissa", "tmux", "ls", "--json"]
52
+ out = _run(argv, timeout=60)
53
+ if not out.strip():
54
+ raise CommandError(argv, 0, "`alissa tmux ls --json` printed nothing")
55
+ try:
56
+ data = json.loads(out)
57
+ except json.JSONDecodeError:
58
+ raise CommandError(argv, 0, "`alissa tmux ls --json` returned unparseable output") from None
59
+ if not isinstance(data, list):
60
+ raise CommandError(argv, 0, f"`alissa tmux ls --json` returned {type(data).__name__}, not a list")
61
+ return [entry for entry in data if isinstance(entry, dict)]
62
+
63
+ def queued(self, name: str) -> bool:
64
+ """True while `alissa tmux queue ls <name> --json` still holds a
65
+ pending item: the session was enqueued but not yet dispatched, so its
66
+ absence from `tmux ls` does not mean it ended. Raises CommandError
67
+ when the CLI does not answer usably (unknown, which holds)."""
68
+ argv = ["alissa", "tmux", "queue", "ls", name, "--json"]
69
+ out = _run(argv, timeout=60)
70
+ try:
71
+ data = json.loads(out)
72
+ except json.JSONDecodeError:
73
+ raise CommandError(argv, 0, "`alissa tmux queue ls --json` returned unparseable output") from None
74
+ if not isinstance(data, dict) or not isinstance(data.get("items"), list):
75
+ raise CommandError(argv, 0, "`alissa tmux queue ls --json` returned no `items` list")
76
+ return bool(data["items"])
77
+
78
+ def kill_session(self, name: str) -> None:
79
+ """Kill ONE managed session by name -- never the tmux server."""
80
+ _run(["alissa", "tmux", "kill", name], timeout=60)
81
+
82
+ def worker_running(self) -> bool:
83
+ """The queue only drains while `alissa worker` reconciles it."""
84
+ try:
85
+ out = _run(["alissa", "worker", "status"], timeout=30, check=False)
86
+ except CommandError:
87
+ return False
88
+ lowered = out.lower()
89
+ return "not running" not in lowered and "no worker" not in lowered
@@ -0,0 +1,183 @@
1
+ """Thin REST adapter over the Alissa routes the genloop seat calls.
2
+
3
+ Transport (`_request`, `_classify`) is copied from orcloop's client: stdlib
4
+ `urllib` over HTTPS, `ALISSA_API_TOKEN` / `ALISSA_BASE` from the environment,
5
+ every failure mapped onto a narrow taxonomy so callers never see a raw urllib
6
+ error or status.
7
+
8
+ The seat runs on the planner's own agent-bound token (D12). Studio answers
9
+ that token with its SPONSOR's rows on every read below; of the writes, the
10
+ seat's own two are `POST /v1/loop-events` and `POST /v1/loop/plans`. The
11
+ third, `PUT /v1/factory/proposal-rules` (§4.4's mechanical rule write), is
12
+ documented by Studio as a `403 FORBIDDEN` for an agent-bound token -- the seat
13
+ attempts it and records the refusal rather than retrying it every poll.
14
+
15
+ Routes (Studio `API.md`):
16
+
17
+ * `GET /v1/ping` -> who the token acts as.
18
+ * `GET /v1/factory/steering` -> `{records: [...]}`, fleet row first.
19
+ * `GET /v1/factory/proposal-rules` -> `{rules: [...]}`, unexpired only.
20
+ * `PUT /v1/factory/proposal-rules` -> one rule, `201` new / `200 renewed`.
21
+ * `GET /v1/code/budget?scope=&projectId=&window=` -> `{state, spentUsd?, capUsd?, remainingUsd?}`.
22
+ * `GET /v1/loop/generated` -> `{openLanes, plansToday, plansOpen}`.
23
+ * `POST /v1/loop/plans` -> idempotent on `(repo, prNumber)`.
24
+ * `POST /v1/loop-events` -> idempotent on each `dedupeKey`.
25
+ * `GET /v1/bodies-of-work?includeShared=true` -> `{bodiesOfWork: [...]}`.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import http.client
31
+ import json
32
+ import logging
33
+ import os
34
+ import urllib.error
35
+ import urllib.parse
36
+ import urllib.request
37
+ from typing import Sequence
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ DEFAULT_BASE = "https://api.alissa.app"
42
+
43
+
44
+ class AlissaError(RuntimeError):
45
+ """Base class. `status` is the HTTP code (0 for transport failures); `code`
46
+ is the API's machine error string (`{"error": ...}`) when one was sent."""
47
+
48
+ def __init__(self, status: int, payload: object, code: str | None = None):
49
+ self.status = status
50
+ self.payload = payload
51
+ self.code = code
52
+ super().__init__(f"HTTP {status}{f' {code}' if code else ''}: {payload}")
53
+
54
+
55
+ class AlissaAuthError(AlissaError):
56
+ """401/403: no valid token, or the bound actor may not do this."""
57
+
58
+
59
+ class AlissaNotFound(AlissaError):
60
+ """404."""
61
+
62
+
63
+ class AlissaTransient(AlissaError):
64
+ """408/429/5xx or a transport failure: retry next poll."""
65
+
66
+
67
+ def _query(params: dict) -> str:
68
+ pairs = [(k, str(v)) for k, v in params.items() if v is not None and v != ""]
69
+ return "?" + urllib.parse.urlencode(pairs) if pairs else ""
70
+
71
+
72
+ class AlissaClient:
73
+ def __init__(self, token: str | None = None, base: str | None = None, *, timeout: int = 30):
74
+ self.base = (base or os.environ.get("ALISSA_BASE") or DEFAULT_BASE).rstrip("/")
75
+ self._token = token if token is not None else os.environ.get("ALISSA_API_TOKEN")
76
+ self._timeout = timeout
77
+
78
+ # --- transport ---------------------------------------------------------
79
+
80
+ def _request(self, method: str, path: str, body: dict | None = None) -> object:
81
+ if not self._token:
82
+ raise AlissaAuthError(0, "ALISSA_API_TOKEN is not set")
83
+ url = f"{self.base}{path}"
84
+ data = json.dumps(body).encode("utf-8") if body is not None else None
85
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {self._token}"}
86
+ if data is not None:
87
+ headers["Content-Type"] = "application/json"
88
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
89
+ try:
90
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310
91
+ raw = resp.read().decode("utf-8")
92
+ except urllib.error.HTTPError as exc:
93
+ raise self._classify(exc) from None
94
+ except urllib.error.URLError as exc:
95
+ raise AlissaTransient(0, str(exc.reason)) from None
96
+ except (TimeoutError, OSError, http.client.HTTPException) as exc:
97
+ # A read timeout or reset after the connection is up is not a
98
+ # URLError; without this branch it would end the process.
99
+ raise AlissaTransient(0, str(exc) or type(exc).__name__) from None
100
+ return json.loads(raw) if raw.strip() else {}
101
+
102
+ @staticmethod
103
+ def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
104
+ try:
105
+ detail: object = exc.read().decode("utf-8", "replace")
106
+ except (TimeoutError, OSError, http.client.HTTPException):
107
+ detail = ""
108
+ code: str | None = None
109
+ try:
110
+ parsed = json.loads(detail) # type: ignore[arg-type]
111
+ if isinstance(parsed, dict):
112
+ detail = parsed
113
+ raw_code = parsed.get("error")
114
+ code = raw_code if isinstance(raw_code, str) else None
115
+ except (json.JSONDecodeError, TypeError):
116
+ pass
117
+ status = exc.code
118
+ if status in (401, 403):
119
+ return AlissaAuthError(status, detail, code)
120
+ if status == 404:
121
+ return AlissaNotFound(status, detail, code)
122
+ if status == 408 or status == 429 or 500 <= status <= 599:
123
+ return AlissaTransient(status, detail, code)
124
+ return AlissaError(status, detail, code)
125
+
126
+ # --- reads -------------------------------------------------------------
127
+
128
+ def ping(self) -> dict:
129
+ payload = self._request("GET", "/v1/ping")
130
+ return payload if isinstance(payload, dict) else {}
131
+
132
+ def factory_steering(self) -> list[dict]:
133
+ """The operator's steering rows (fleet first). Empty when nothing is
134
+ set -- which steers nothing and parks nothing."""
135
+ payload = self._request("GET", "/v1/factory/steering")
136
+ records = payload.get("records") if isinstance(payload, dict) else None
137
+ return [row for row in records if isinstance(row, dict)] if isinstance(records, list) else []
138
+
139
+ def proposal_rules(self) -> list[dict]:
140
+ payload = self._request("GET", "/v1/factory/proposal-rules")
141
+ rules = payload.get("rules") if isinstance(payload, dict) else None
142
+ return [row for row in rules if isinstance(row, dict)] if isinstance(rules, list) else []
143
+
144
+ def budget_status(self, scope: str, *, project_id: str | None = None, window: str = "30d") -> dict:
145
+ """The cost meter's `budgetStatus`: `{state, spentUsd?, capUsd?,
146
+ remainingUsd?, ...}`, `state` one of ok / warn / exhausted / unmetered."""
147
+ payload = self._request(
148
+ "GET", "/v1/code/budget" + _query({"scope": scope, "projectId": project_id, "window": window}),
149
+ )
150
+ if not isinstance(payload, dict) or not isinstance(payload.get("state"), str):
151
+ raise AlissaError(0, payload, "budget answered no state")
152
+ return payload
153
+
154
+ def loop_generated(self) -> dict:
155
+ """`{openLanes, plansToday, plansOpen}`."""
156
+ payload = self._request("GET", "/v1/loop/generated")
157
+ if not isinstance(payload, dict) or not isinstance(payload.get("openLanes"), int):
158
+ raise AlissaError(0, payload, "generated answered no openLanes")
159
+ return payload
160
+
161
+ def list_bodies_of_work(self) -> list[dict]:
162
+ payload = self._request("GET", "/v1/bodies-of-work?includeShared=true")
163
+ rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
164
+ return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
165
+
166
+ # --- writes ------------------------------------------------------------
167
+
168
+ def post_loop_plan(self, record: dict) -> dict:
169
+ """Record a plan or move its status forward. Idempotent on `(repo,
170
+ prNumber)`: the same status answers `200 {duplicate: true}`."""
171
+ payload = self._request("POST", "/v1/loop/plans", record)
172
+ return payload if isinstance(payload, dict) else {}
173
+
174
+ def post_loop_events(self, events: "Sequence[dict]") -> dict:
175
+ payload = self._request("POST", "/v1/loop-events", {"events": list(events)})
176
+ return payload if isinstance(payload, dict) else {}
177
+
178
+ def put_proposal_rule(self, *, pattern: str, scope: str, scope_key: str, reason: str, source_url: str) -> dict:
179
+ body: dict = {"pattern": pattern, "scope": scope, "reason": reason, "sourceUrl": source_url}
180
+ if scope_key:
181
+ body["scopeKey"] = scope_key
182
+ payload = self._request("PUT", "/v1/factory/proposal-rules", body)
183
+ return payload if isinstance(payload, dict) else {}
@@ -0,0 +1,294 @@
1
+ """The genloop seat's configuration: every `ALISSA_GEN_*` knob, its one
2
+ default, and the validation that runs before the first poll.
3
+
4
+ PASS-THROUGH-WHEN-UNSET (design L10, the three seats' rule)
5
+ =========================================================
6
+ `Config.from_env` reads a key ONLY when its `ALISSA_GEN_<KEY>` variable is set
7
+ and non-empty. A key the operator did not set is absent from the mapping
8
+ `Config.build` sees, so the default below is what takes effect -- one source
9
+ of truth for every default, this module. The container's ARG->ENV block bakes
10
+ every variable to the empty string, which therefore means "unset", never
11
+ "empty value".
12
+
13
+ THE PLANS REPOSITORY IS PER-FLEET CONFIGURATION
14
+ ==============================================
15
+ The operator's decision on issue #1 overrides the design's literal: a fleet's
16
+ plans repository is `ALISSA_GEN_PLANS_REPO`, defaulting to
17
+ `<ALISSA_GEN_OWNER>/` + `PLANS_REPO_NAME`. `PLANS_REPO_NAME` below is the ONE
18
+ place the repository's name is written in this distribution, and
19
+ `test_no_plans_repo_literal` holds every other file of the repository to that.
20
+
21
+ FAIL CLOSED (design L9)
22
+ =======================
23
+ `enabled` defaults to OFF: an image deployed without `ALISSA_GEN_ENABLED`
24
+ idles and says why every poll. Once enabled, a seat that cannot name its plans
25
+ repository, its reviewer or (in `static` mode) one fed repo refuses to boot
26
+ rather than guess.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import dataclasses
32
+ import os
33
+ import re
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Mapping
37
+
38
+ ENV_PREFIX = "ALISSA_GEN_"
39
+
40
+ # The default plans repository's NAME (its owner is the fleet's, see above).
41
+ PLANS_REPO_NAME = "alissa-dark-factory-plans"
42
+
43
+ REPOS_SOURCE_BOWS = "bows"
44
+ REPOS_SOURCE_STATIC = "static"
45
+ REPOS_SOURCES = (REPOS_SOURCE_BOWS, REPOS_SOURCE_STATIC)
46
+
47
+ # The feed body-of-work title prefix every seat reads (`autodev: <owner>/<repo>`).
48
+ BOW_PREFIX = "autodev:"
49
+
50
+ DEFAULT_WORKSPACE_ROOT = "/workspace"
51
+ DEFAULT_STATE_DIRNAME = ".genloop"
52
+
53
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
54
+ _OWNER_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
55
+ _LOGIN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?$")
56
+
57
+ # Every key, with its library default. The env name is ENV_PREFIX + KEY.upper().
58
+ DEFAULTS: dict[str, object] = {
59
+ # The deploy-time switch (D11, L9): off unless the operator turns it on.
60
+ "enabled": False,
61
+ # Seconds between passes (D14: six hours).
62
+ "interval": 6 * 3600,
63
+ # Seconds between polls. A poll ingests a finished pass, opens its PR and
64
+ # walks the floors; the interval above is what spaces the SPAWNS.
65
+ "poll_interval": 300,
66
+ # The exhaust look-back the directive hands the pass (§3).
67
+ "window_days": 30,
68
+ # D10's two counters.
69
+ "plans_per_day": 2,
70
+ "open_lanes_max": 4,
71
+ # A live pass older than stale_intervals x interval is stalled (L2).
72
+ "stale_intervals": 2,
73
+ # GitHub logins whose closing comment on a plan PR writes a rule (§4.4).
74
+ "operators": (),
75
+ # The fleet's GitHub owner; the plans repository defaults under it.
76
+ "owner": "",
77
+ # `owner/name`; empty means `<owner>/` + PLANS_REPO_NAME.
78
+ "plans_repo": "",
79
+ # Where the fed-repo allowlist comes from: the `autodev:` feed bodies of
80
+ # work (`bows`, the fleet's mode) or `repos` (`static`).
81
+ "repos_source": REPOS_SOURCE_BOWS,
82
+ "repos": (),
83
+ # In `bows` mode, only feeds owned by these actor ids count (empty: any
84
+ # feed the token can read).
85
+ "bow_owners": (),
86
+ # Who reviews a plan PR (D12: the reviewer bot, never the seat).
87
+ "reviewers": ("alissa-app",),
88
+ # This seat's own GitHub login; empty means `gh api user`.
89
+ "login": "",
90
+ # The pass's skill and the worker agent profile that runs it.
91
+ "skill": "alissa-code-genloop",
92
+ "agent": "claude",
93
+ # Paths. Empty means "derive" (see the properties on Config).
94
+ "workspace_root": "",
95
+ "state_dir": "",
96
+ "plans_hub": "",
97
+ # Alissa's base URL; empty means the client's default (ALISSA_BASE).
98
+ "alissa_endpoint": "",
99
+ # Narrate every side effect instead of performing it.
100
+ "dry_run": False,
101
+ }
102
+
103
+ CONFIG_KEYS = tuple(DEFAULTS)
104
+ _BOOL_KEYS = frozenset(k for k, v in DEFAULTS.items() if isinstance(v, bool))
105
+ _INT_KEYS = frozenset(k for k, v in DEFAULTS.items() if isinstance(v, int) and not isinstance(v, bool))
106
+ _LIST_KEYS = frozenset(k for k, v in DEFAULTS.items() if isinstance(v, tuple))
107
+
108
+ # Lower bounds, so a typo cannot make the seat spin.
109
+ _INT_MIN = {
110
+ "interval": 60,
111
+ "poll_interval": 10,
112
+ "window_days": 1,
113
+ "plans_per_day": 0,
114
+ "open_lanes_max": 0,
115
+ "stale_intervals": 1,
116
+ }
117
+
118
+ _TRUE = frozenset(("1", "true", "yes", "on"))
119
+ _FALSE = frozenset(("0", "false", "no", "off"))
120
+
121
+
122
+ class ConfigError(ValueError):
123
+ """The configuration cannot run; the message names the variable."""
124
+
125
+
126
+ def env_name(key: str) -> str:
127
+ return ENV_PREFIX + key.upper()
128
+
129
+
130
+ def split_entries(raw: str) -> tuple[str, ...]:
131
+ """The family's list syntax: `|`-separated, `,` also accepted, blanks dropped."""
132
+ return tuple(part.strip() for part in re.split(r"[|,]", raw) if part.strip())
133
+
134
+
135
+ def _coerce(key: str, value: object) -> object:
136
+ if key in _BOOL_KEYS:
137
+ if isinstance(value, bool):
138
+ return value
139
+ text = str(value).strip().lower()
140
+ if text in _TRUE:
141
+ return True
142
+ if text in _FALSE:
143
+ return False
144
+ raise ConfigError(f"{env_name(key)}={value!r} is not a boolean (true/false)")
145
+ if key in _INT_KEYS:
146
+ try:
147
+ number = int(str(value).strip())
148
+ except ValueError:
149
+ raise ConfigError(f"{env_name(key)}={value!r} is not an integer") from None
150
+ floor = _INT_MIN.get(key)
151
+ if floor is not None and number < floor:
152
+ raise ConfigError(f"{env_name(key)}={number} is below its minimum {floor}")
153
+ return number
154
+ if key in _LIST_KEYS:
155
+ if isinstance(value, (list, tuple)):
156
+ return tuple(str(v).strip() for v in value if str(v).strip())
157
+ return split_entries(str(value))
158
+ return str(value).strip()
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class Config:
163
+ enabled: bool = False
164
+ interval: int = 6 * 3600
165
+ poll_interval: int = 300
166
+ window_days: int = 30
167
+ plans_per_day: int = 2
168
+ open_lanes_max: int = 4
169
+ stale_intervals: int = 2
170
+ operators: tuple[str, ...] = ()
171
+ owner: str = ""
172
+ plans_repo: str = ""
173
+ repos_source: str = REPOS_SOURCE_BOWS
174
+ repos: tuple[str, ...] = ()
175
+ bow_owners: tuple[str, ...] = ()
176
+ reviewers: tuple[str, ...] = ("alissa-app",)
177
+ login: str = ""
178
+ skill: str = "alissa-code-genloop"
179
+ agent: str = "claude"
180
+ workspace_root: str = ""
181
+ state_dir: str = ""
182
+ plans_hub: str = ""
183
+ alissa_endpoint: str = ""
184
+ dry_run: bool = False
185
+
186
+ # -- construction ------------------------------------------------------
187
+
188
+ @classmethod
189
+ def build(cls, mapping: Mapping[str, object] | None = None) -> "Config":
190
+ """A validated Config from `mapping` (key -> raw value); a key absent
191
+ from the mapping takes its default from `DEFAULTS`."""
192
+ mapping = dict(mapping or {})
193
+ unknown = sorted(set(mapping) - set(CONFIG_KEYS))
194
+ if unknown:
195
+ raise ConfigError(f"unknown config key(s): {', '.join(unknown)}")
196
+ values = {key: _coerce(key, mapping[key]) if key in mapping else DEFAULTS[key] for key in CONFIG_KEYS}
197
+ config = cls(**values) # type: ignore[arg-type]
198
+ config.validate()
199
+ return config
200
+
201
+ @classmethod
202
+ def from_env(cls, environ: Mapping[str, str] | None = None) -> "Config":
203
+ """Pass-through-when-unset: only the set, non-empty `ALISSA_GEN_*`
204
+ variables reach `build`. `workspace_root` also honours the image's
205
+ `ALISSA_WORKSPACE_ROOT`."""
206
+ env = os.environ if environ is None else environ
207
+ mapping: dict[str, object] = {}
208
+ for key in CONFIG_KEYS:
209
+ raw = env.get(env_name(key))
210
+ if raw is not None and raw.strip():
211
+ mapping[key] = raw
212
+ if "workspace_root" not in mapping and (env.get("ALISSA_WORKSPACE_ROOT") or "").strip():
213
+ mapping["workspace_root"] = env["ALISSA_WORKSPACE_ROOT"]
214
+ return cls.build(mapping)
215
+
216
+ def replace(self, **changes: object) -> "Config":
217
+ config = dataclasses.replace(self, **changes) # type: ignore[arg-type]
218
+ config.validate()
219
+ return config
220
+
221
+ # -- validation ----------------------------------------------------------
222
+
223
+ def validate(self) -> None:
224
+ if self.repos_source not in REPOS_SOURCES:
225
+ raise ConfigError(
226
+ f"{env_name('repos_source')}={self.repos_source!r} is not one of {', '.join(REPOS_SOURCES)}"
227
+ )
228
+ if self.owner and not _OWNER_RE.match(self.owner):
229
+ raise ConfigError(f"{env_name('owner')}={self.owner!r} is not a GitHub owner")
230
+ if self.plans_repo and not _REPO_RE.match(self.plans_repo):
231
+ raise ConfigError(f"{env_name('plans_repo')}={self.plans_repo!r} is not owner/name")
232
+ for repo in self.repos:
233
+ if not _REPO_RE.match(repo):
234
+ raise ConfigError(f"{env_name('repos')} entry {repo!r} is not owner/name")
235
+ for key in ("operators", "reviewers"):
236
+ for login in getattr(self, key):
237
+ if not _LOGIN_RE.match(login):
238
+ raise ConfigError(f"{env_name(key)} entry {login!r} is not a GitHub login")
239
+ if self.login and not _LOGIN_RE.match(self.login):
240
+ raise ConfigError(f"{env_name('login')}={self.login!r} is not a GitHub login")
241
+ if not self.enabled:
242
+ # An idle seat needs nothing else; it says why it idles every poll.
243
+ return
244
+ if not self.plans_repo and not self.owner:
245
+ raise ConfigError(
246
+ f"genloop is enabled but cannot name its plans repository: set {env_name('plans_repo')} "
247
+ f"(owner/name) or {env_name('owner')} (the default is <owner>/{PLANS_REPO_NAME})"
248
+ )
249
+ if not self.reviewers:
250
+ raise ConfigError(f"genloop is enabled with an empty {env_name('reviewers')} — a plan PR needs a refuter")
251
+ if self.repos_source == REPOS_SOURCE_STATIC and not self.repos:
252
+ raise ConfigError(
253
+ f"{env_name('repos_source')}=static with an empty {env_name('repos')} — the seat is fail-closed"
254
+ )
255
+ if self.login and self.login in self.reviewers:
256
+ raise ConfigError(
257
+ f"{env_name('login')}={self.login!r} is also a reviewer — the generator never reviews its "
258
+ "own plan (D12)"
259
+ )
260
+
261
+ # -- derived -------------------------------------------------------------
262
+
263
+ @property
264
+ def plans_repository(self) -> str:
265
+ """`owner/name` of the fleet's plans repository."""
266
+ if self.plans_repo:
267
+ return self.plans_repo
268
+ return f"{self.owner}/{PLANS_REPO_NAME}" if self.owner else ""
269
+
270
+ @property
271
+ def workspace_path(self) -> Path:
272
+ return Path(self.workspace_root or DEFAULT_WORKSPACE_ROOT)
273
+
274
+ @property
275
+ def state_path(self) -> Path:
276
+ return Path(self.state_dir) if self.state_dir else self.workspace_path / DEFAULT_STATE_DIRNAME
277
+
278
+ @property
279
+ def plans_hub_path(self) -> Path:
280
+ """The plans repository's worktree hub -- the one checkout the
281
+ container holds (D14). Defaults to `<workspace>/<repo name>`."""
282
+ if self.plans_hub:
283
+ return Path(self.plans_hub)
284
+ name = self.plans_repository.split("/", 1)[-1] if self.plans_repository else PLANS_REPO_NAME
285
+ return self.workspace_path / name
286
+
287
+ def summary(self) -> str:
288
+ """One line for the boot log; no secrets live in the config."""
289
+ return (
290
+ f"enabled={self.enabled} interval={self.interval}s poll={self.poll_interval}s "
291
+ f"plans_repo={self.plans_repository or '<unset>'} repos_source={self.repos_source} "
292
+ f"plans_per_day={self.plans_per_day} open_lanes_max={self.open_lanes_max} "
293
+ f"reviewers={'|'.join(self.reviewers)} dry_run={self.dry_run}"
294
+ )