alissa-tools-github-orcloop 0.1.0__tar.gz → 0.2.0__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 (24) hide show
  1. {alissa_tools_github_orcloop-0.1.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.2.0}/PKG-INFO +10 -7
  2. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/README.md +10 -7
  3. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa/tools/github/orcloop/__init__.py +3 -3
  4. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/__main__.py +209 -0
  5. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/alissa_client.py +393 -0
  6. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/config.py +333 -0
  7. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/ghclient.py +372 -0
  8. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/loop.py +915 -0
  9. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/markers.py +73 -0
  10. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/proc.py +62 -0
  11. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/state.py +166 -0
  12. alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/version +1 -0
  13. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +10 -7
  14. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +7 -0
  15. alissa_tools_github_orcloop-0.1.0/src/main/alissa/tools/github/orcloop/__main__.py +0 -45
  16. alissa_tools_github_orcloop-0.1.0/src/main/alissa/tools/github/orcloop/version +0 -1
  17. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/MANIFEST.in +0 -0
  18. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/requirements.txt +0 -0
  19. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/setup.cfg +0 -0
  20. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/setup.py +0 -0
  21. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  22. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  23. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  24. {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -46,13 +46,16 @@ The rule: a distribution owns a leaf package and declares only that subtree
46
46
  `__init__.py` at any namespace level would claim it for one distribution and
47
47
  shadow the others.
48
48
 
49
- ## Status — O0 scaffold
49
+ ## Status
50
50
 
51
- This is the **O0 scaffold** only: the distribution skeleton, repo tooling, and
52
- a CLI stub (`alissa-orcloop --version`). No daemon logic yetconfig surface,
53
- Alissa/GitHub clients, the decision loop, and the Docker image land across
54
- O1–O5 (see the repo [README](../README.md) lane map). The reserved runtime env
55
- prefix is `ALISSA_ORC_*`.
51
+ The daemon is live: the config surface (O1), the Alissa/GitHub clients and the
52
+ release ledger (O2), and the decision loopboth the release and close edges
53
+ (O3) are all in place, driven by the `alissa-orcloop` CLI. See the repo
54
+ [README](../README.md) for the end-to-end documentation: the pipeline role, the
55
+ `Autonomous-Dev` / `Autonomous-Scope` marker contract, both decision matrices,
56
+ the full `ALISSA_ORC_*` configuration surface, and the escalation catalog.
57
+ Packaging the daemon as a container image (O5) is the remaining lane. The
58
+ reserved runtime env prefix is `ALISSA_ORC_*`.
56
59
 
57
60
  ## Install
58
61
 
@@ -29,13 +29,16 @@ The rule: a distribution owns a leaf package and declares only that subtree
29
29
  `__init__.py` at any namespace level would claim it for one distribution and
30
30
  shadow the others.
31
31
 
32
- ## Status — O0 scaffold
33
-
34
- This is the **O0 scaffold** only: the distribution skeleton, repo tooling, and
35
- a CLI stub (`alissa-orcloop --version`). No daemon logic yetconfig surface,
36
- Alissa/GitHub clients, the decision loop, and the Docker image land across
37
- O1–O5 (see the repo [README](../README.md) lane map). The reserved runtime env
38
- prefix is `ALISSA_ORC_*`.
32
+ ## Status
33
+
34
+ The daemon is live: the config surface (O1), the Alissa/GitHub clients and the
35
+ release ledger (O2), and the decision loopboth the release and close edges
36
+ (O3) are all in place, driven by the `alissa-orcloop` CLI. See the repo
37
+ [README](../README.md) for the end-to-end documentation: the pipeline role, the
38
+ `Autonomous-Dev` / `Autonomous-Scope` marker contract, both decision matrices,
39
+ the full `ALISSA_ORC_*` configuration surface, and the escalation catalog.
40
+ Packaging the daemon as a container image (O5) is the remaining lane. The
41
+ reserved runtime env prefix is `ALISSA_ORC_*`.
39
42
 
40
43
  ## Install
41
44
 
@@ -6,9 +6,9 @@ releases tasks marked ready as `alissa:develop` issues, and closes the loop when
6
6
  the resulting PR merges. It is the mechanical driver that turns committed work in
7
7
  Alissa into GitHub issues the develop/review daemons can pick up.
8
8
 
9
- This is the **O0 scaffold**: the distribution skeleton, repo tooling, and CLI
10
- stub only no daemon logic. The moving parts (config surface, Alissa/GitHub
11
- clients, the decision loop, the Docker image) land across O1–O5; see the repo
9
+ O1 landed the config surface (`config.py`), the marker contract (`markers.py`),
10
+ and the real `alissa-orcloop` CLI. The remaining moving parts (Alissa/GitHub
11
+ clients, the decision loop, the Docker image) land across O2–O5; see the repo
12
12
  README's lane map. The reserved runtime env prefix is `ALISSA_ORC_*`.
13
13
  """
14
14
 
@@ -0,0 +1,209 @@
1
+ """CLI entry point: alissa-orcloop (or python -m alissa.tools.github.orcloop).
2
+
3
+ O1 wired the config surface and the argument parser; O3 wires the decision
4
+ loop behind it. The CLI resolves and validates the full configuration
5
+ (four-layer precedence, fail-closed `repos` guard), reports the effective
6
+ settings, then runs the orchestrator: `--once` for a single sweep (pair it
7
+ with `--dry-run -v` to print every decision and its reasons without touching
8
+ GitHub or the task graph), otherwise `run_forever`. Config errors still surface
9
+ here, at startup, with a clean exit code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import logging
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from .alissa_client import AlissaError
20
+ from .config import (
21
+ Config,
22
+ load_config_file,
23
+ resolve_config_path,
24
+ )
25
+ from .ghclient import RateLimited
26
+ from .loop import Orchestrator
27
+ from .proc import CommandError
28
+ from .version import version
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ p = argparse.ArgumentParser(
35
+ prog="alissa-orcloop",
36
+ description="Orchestrate the Alissa task graph into alissa:develop "
37
+ "issues and close the loop on merge — the orchestrator daemon of the "
38
+ "Alissa GitHub daemon family.",
39
+ epilog="Every setting below can also live in the config file; a CLI "
40
+ "flag wins over the file, and an ALISSA_ORC_* environment variable "
41
+ "wins over the flag. workspace_root is CLI-only, so one config can "
42
+ "drive several orchestrators over different workspaces.",
43
+ )
44
+
45
+ p.add_argument(
46
+ "--version", action="version", version=f"%(prog)s {version.value}"
47
+ )
48
+ p.add_argument(
49
+ "--workspace-root",
50
+ type=Path,
51
+ default=None,
52
+ metavar="PATH",
53
+ help="the workspace this orchestrator runs against (default: current "
54
+ "directory); also where the ledger defaults",
55
+ )
56
+ p.add_argument(
57
+ "-c",
58
+ "--config-path",
59
+ "--config",
60
+ dest="config_path",
61
+ type=Path,
62
+ default=None,
63
+ metavar="PATH",
64
+ help="config file; without it, ./orcloop.config.json then "
65
+ "<workspace-root>/orcloop.config.json, else defaults only",
66
+ )
67
+
68
+ mode = p.add_argument_group("mode")
69
+ mode.add_argument(
70
+ "--once", action="store_true", help="run a single pass and exit"
71
+ )
72
+ mode.add_argument("-v", "--verbose", action="store_true")
73
+
74
+ over = p.add_argument_group(
75
+ "config overrides (win over the config file; ALISSA_ORC_* wins over these)"
76
+ )
77
+ over.add_argument(
78
+ "--repo",
79
+ dest="repos",
80
+ action="append",
81
+ metavar="OWNER/REPO",
82
+ help="watch this repo; repeatable. When given, REPLACES the config "
83
+ "`repos` list. The allowlist is fail-closed: an empty allowlist is a "
84
+ "fatal startup error, not a no-op.",
85
+ )
86
+ over.add_argument("--label", metavar="LABEL", help="issue label released work carries")
87
+ over.add_argument("--poll-interval", type=int, metavar="SECONDS")
88
+ over.add_argument("--repo-parallelism", type=int, metavar="N", help="repos advanced per pass")
89
+ over.add_argument("--release-blocked-floor-days", type=int, metavar="DAYS")
90
+ over.add_argument("--marker-key", metavar="KEY", help="Autonomous-Dev trailer key")
91
+ over.add_argument("--scope-key", metavar="KEY", help="Autonomous-Scope trailer key")
92
+ over.add_argument("--alissa-endpoint", metavar="URL")
93
+ over.add_argument("--ledger-path", type=Path, metavar="PATH")
94
+
95
+ close = over.add_mutually_exclusive_group()
96
+ close.add_argument(
97
+ "--close-loop",
98
+ dest="close_loop_enabled",
99
+ action="store_true",
100
+ default=None,
101
+ help="drive the origin task forward when a released PR merges (the default)",
102
+ )
103
+ close.add_argument(
104
+ "--no-close-loop",
105
+ dest="close_loop_enabled",
106
+ action="store_false",
107
+ help="release-only: never act on merges, even if the config enables it",
108
+ )
109
+
110
+ dry = over.add_mutually_exclusive_group()
111
+ dry.add_argument(
112
+ "--dry-run",
113
+ dest="dry_run",
114
+ action="store_true",
115
+ default=None,
116
+ help="decide and log, but never release an issue or touch task/GitHub state",
117
+ )
118
+ dry.add_argument(
119
+ "--no-dry-run",
120
+ dest="dry_run",
121
+ action="store_false",
122
+ help="act for real even if the config sets dry_run",
123
+ )
124
+ return p
125
+
126
+
127
+ def overrides_from(args: argparse.Namespace) -> dict:
128
+ """CLI values, with None meaning 'not specified' so the config file shows
129
+ through. `repos` becomes a tuple so it matches the file form."""
130
+ return {
131
+ "repos": tuple(args.repos) if args.repos else None,
132
+ "label": args.label,
133
+ "poll_interval": args.poll_interval,
134
+ "repo_parallelism": args.repo_parallelism,
135
+ "release_blocked_floor_days": args.release_blocked_floor_days,
136
+ "marker_key": args.marker_key,
137
+ "scope_key": args.scope_key,
138
+ "alissa_endpoint": args.alissa_endpoint,
139
+ "ledger_path": args.ledger_path,
140
+ "close_loop_enabled": args.close_loop_enabled,
141
+ "dry_run": args.dry_run,
142
+ }
143
+
144
+
145
+ def resolve_config(args: argparse.Namespace) -> Config:
146
+ workspace_root = args.workspace_root or Path.cwd()
147
+ path = resolve_config_path(args.config_path, workspace_root)
148
+
149
+ file_data = load_config_file(path) if path else {}
150
+ log.info("config: %s", path or "none found — defaults + CLI + env only")
151
+
152
+ return Config.build(workspace_root, file_data, overrides_from(args))
153
+
154
+
155
+ def log_effective_config(config: Config) -> None:
156
+ """The resolved settings, defaults and all. INFO carries the decisions an
157
+ operator needs; -v (DEBUG) shows the full surface."""
158
+ log.info("watching %s for label %r", ", ".join(config.repos), config.label)
159
+ log.info(
160
+ "poll every %ss; repo_parallelism=%s; dry_run=%s; close_loop=%s",
161
+ config.poll_interval, config.repo_parallelism, config.dry_run,
162
+ config.close_loop_enabled,
163
+ )
164
+ log.debug("marker_key: %s", config.marker_key)
165
+ log.debug("scope_key: %s", config.scope_key)
166
+ log.debug("release_blocked_floor_days: %s", config.release_blocked_floor_days)
167
+ log.debug("alissa_endpoint: %s", config.alissa_endpoint)
168
+ log.debug("ledger_db: %s", config.ledger_db)
169
+
170
+
171
+ def main(argv: "list[str] | None" = None) -> int:
172
+ args = build_parser().parse_args(argv)
173
+
174
+ logging.basicConfig(
175
+ level=logging.DEBUG if args.verbose else logging.INFO,
176
+ format="%(asctime)s %(levelname)-7s %(message)s",
177
+ datefmt="%H:%M:%S",
178
+ )
179
+
180
+ try:
181
+ config = resolve_config(args)
182
+ log.info("workspace: %s", config.workspace_root)
183
+ log_effective_config(config)
184
+ except (FileNotFoundError, ValueError) as exc:
185
+ print(f"config error: {exc}", file=sys.stderr)
186
+ return 2
187
+ except KeyboardInterrupt:
188
+ return 0
189
+
190
+ orchestrator = Orchestrator(config)
191
+ try:
192
+ if args.once:
193
+ orchestrator.poll_once()
194
+ else:
195
+ log.info("starting orchestrator; polling every %ss", config.poll_interval)
196
+ orchestrator.run_forever()
197
+ except KeyboardInterrupt:
198
+ return 0
199
+ except (AlissaError, CommandError, RateLimited) as exc:
200
+ # A single --once pass surfaces a transport/adapter failure as a non-zero
201
+ # exit for the operator; run_forever swallows and backs off instead of
202
+ # exiting, so this only fires under --once.
203
+ print(f"orchestrator error: {exc}", file=sys.stderr)
204
+ return 1
205
+ return 0
206
+
207
+
208
+ if __name__ == "__main__":
209
+ raise SystemExit(main())
@@ -0,0 +1,393 @@
1
+ """Alissa task-graph adapter for the orchestrator.
2
+
3
+ TRANSPORT DECISION (the O2 spike, spec v1 §1)
4
+ =============================================
5
+ orcloop talks to Alissa as the operator actor over the **Alissa REST API**
6
+ (`https://api.alissa.app`, versioned under `/v1`), using the Python **stdlib
7
+ only** (`urllib`) and a personal access token from `ALISSA_API_TOKEN`. No third
8
+ -party HTTP library, no subprocess: the transport is in-process `urllib`, hidden
9
+ entirely behind this adapter.
10
+
11
+ Rationale, and why the two options the issue named were rejected:
12
+
13
+ * **REST over stdlib HTTP (CHOSEN).** The token authenticates every `/v1`
14
+ endpoint with a plain `Authorization: Bearer` header -- no browser/OAuth flow
15
+ -- and the REST surface exposes exactly the operations the loop needs
16
+ (filtered task list, task read, evidence, criteria, status, comments). Every
17
+ failure arrives as an HTTP status code, which maps cleanly onto the narrow
18
+ error taxonomy below (auth vs transient vs not-found). Stdlib `urllib` keeps
19
+ the distribution dependency-free, matching the family posture (devloop and
20
+ reviewloop ship no third-party runtime deps either). This is the same
21
+ transport the `alissa-api` skill's bundled `scripts/alissa_api.py` client
22
+ uses, so the shape is proven against the live endpoint.
23
+
24
+ * **JSON-RPC against the Alissa MCP HTTP endpoint (REJECTED -- no reachable
25
+ endpoint).** The spike found no MCP server in the daemon/worker environment
26
+ (`claude mcp list` is empty), so there is no MCP HTTP endpoint to speak
27
+ JSON-RPC to; the token authenticates the REST API, not an MCP transport. The
28
+ MCP surface is an editor/agent integration, not an operator daemon one --
29
+ building a JSON-RPC client against an endpoint that does not exist here was a
30
+ dead end. REST reaches the identical task-graph operations directly.
31
+
32
+ * **Shelling to the `alissa` CLI (REJECTED -- taxonomy + capability).** The CLI
33
+ can read/write tasks, but (a) its `task list` takes no `status`/marker filter
34
+ (per the `alissa-api` skill, filtered listing and ranking are REST/MCP only),
35
+ so the loop's `list_marked_committed_tasks` would have to over-fetch and
36
+ filter client-side, and (b) subprocess failures surface as an exit code plus
37
+ free-text stderr, which cannot be classified into auth/transient/not-found
38
+ as reliably as an HTTP status. REST gives both the server-side `status=`
39
+ filter and clean status codes.
40
+
41
+ Live smoke run confirming the choice is documented in the PR body.
42
+
43
+ ERROR TAXONOMY
44
+ ==============
45
+ Three narrow, actionable buckets so the loop can react without string-matching:
46
+
47
+ * ``AlissaAuthError`` -- 401/403 (missing/invalid token, or no role on the
48
+ task, e.g. the ``NOT_OWNER`` code). Permanent, operator-fixable; do not retry.
49
+ * ``AlissaNotFound`` -- 404 (task/evidence/criterion absent or not visible to
50
+ the bound actor). Permanent for this ref.
51
+ * ``AlissaTransient`` -- 408/429/5xx and every transport failure (DNS, refused
52
+ connection, timeout). Retry-later signals.
53
+
54
+ Anything else (a 400 validation error, say) surfaces as the base
55
+ ``AlissaError`` -- a bug to fix, not a condition to retry blindly.
56
+ """
57
+
58
+ from __future__ import annotations
59
+
60
+ import json
61
+ import logging
62
+ import os
63
+ import urllib.error
64
+ import urllib.parse
65
+ import urllib.request
66
+ from dataclasses import dataclass
67
+
68
+ log = logging.getLogger(__name__)
69
+
70
+ DEFAULT_BASE = "https://api.alissa.app"
71
+
72
+ # The readiness marker: an operator "releases" a committed task to the develop
73
+ # pipeline by placing this token in the task's description. It is deliberately
74
+ # the SAME string as the GitHub label orcloop stamps on the issue it creates --
75
+ # one token means both "ready to release" (on the Alissa task) and "for the
76
+ # develop daemon" (on the GitHub issue). The value is overridable per call so
77
+ # the O1 config surface / O3 loop can supply a configured marker; this constant
78
+ # is the documented default. See the repo README's pipeline description.
79
+ RELEASE_MARKER = "alissa:develop"
80
+
81
+
82
+ class AlissaError(RuntimeError):
83
+ """Base class. `status` is the HTTP code (0 for transport failures); `code`
84
+ is the API's machine error string (`{"error": ...}`) when one was sent."""
85
+
86
+ def __init__(self, status: int, payload: object, code: str | None = None):
87
+ self.status = status
88
+ self.payload = payload
89
+ self.code = code
90
+ super().__init__(f"HTTP {status}{f' {code}' if code else ''}: {payload}")
91
+
92
+
93
+ class AlissaAuthError(AlissaError):
94
+ """401/403: no valid token, or the bound actor has no role on the task.
95
+ Permanent and operator-fixable -- never retried."""
96
+
97
+
98
+ class AlissaNotFound(AlissaError):
99
+ """404: the task/evidence/criterion is absent or invisible to this actor."""
100
+
101
+
102
+ class AlissaTransient(AlissaError):
103
+ """408/429/5xx or a transport failure -- a retry-later signal."""
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class MarkedTask:
108
+ """A release candidate as the LIST endpoint reports it. `description` is the
109
+ list payload's `descriptionPreview` -- possibly truncated, enough to filter
110
+ on; the loop calls `get_task` for the full body before writing the issue."""
111
+
112
+ ref: str # TASK-<taskNumber>
113
+ task_number: int
114
+ title: str
115
+ status: str
116
+ description: str
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Criterion:
121
+ id: str
122
+ description: str
123
+ satisfied: bool
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Dependency:
128
+ """One upstream (depends-on) task, as the DETAIL endpoint reports it under
129
+ `upstreamTasks`. `ref` is `TASK-<n>` when the upstream row carries a task
130
+ number, else its opaque id (the upstream preview omits taskNumber for some
131
+ actors); `status` is what the O3 dependsOn gate reads."""
132
+
133
+ ref: str
134
+ status: str
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class Task:
139
+ """A task as the DETAIL endpoint reports it -- full description and DoD,
140
+ plus the validation criteria the loop satisfies on close.
141
+
142
+ O3 added the release-queue fields: `priority` and `created_at` drive the
143
+ priority-then-age ordering, and `dependencies` (with `depends_on_count`)
144
+ drives the dependsOn gate. They default to the "unset" shape so a detail
145
+ payload that omits them -- and any caller constructing a Task by hand --
146
+ reads as no-priority / age-zero / no-deps rather than raising."""
147
+
148
+ ref: str # TASK-<taskNumber>
149
+ task_number: int
150
+ title: str
151
+ status: str
152
+ description: str
153
+ definition_of_done: str
154
+ criteria: tuple[Criterion, ...]
155
+ priority: str = ""
156
+ created_at: int = 0 # ms since epoch; 0 when the payload omits createdAt
157
+ # The resolved upstream tasks (statuses the dependsOn gate reads).
158
+ dependencies: tuple[Dependency, ...] = ()
159
+ # len(dependsOn): may EXCEED len(dependencies) when an upstream is invisible
160
+ # to this actor -- the gate treats that mismatch as "not provably clear".
161
+ depends_on_count: int = 0
162
+
163
+
164
+ def _query(params: dict) -> str:
165
+ """`?a=b&…` from params: skip None, comma-join lists (any-of), bools as
166
+ true/false. Empty → ''. Mirrors the bundled alissa_api.py client."""
167
+ pairs: list[tuple[str, str]] = []
168
+ for key, val in params.items():
169
+ if val is None:
170
+ continue
171
+ if isinstance(val, bool):
172
+ pairs.append((key, "true" if val else "false"))
173
+ elif isinstance(val, (list, tuple)):
174
+ if val:
175
+ pairs.append((key, ",".join(str(v) for v in val)))
176
+ else:
177
+ pairs.append((key, str(val)))
178
+ return ("?" + urllib.parse.urlencode(pairs)) if pairs else ""
179
+
180
+
181
+ class AlissaClient:
182
+ """Thin REST adapter over the task-graph operations orcloop needs.
183
+
184
+ Transport is hidden: callers see `MarkedTask`/`Task` and the taxonomy
185
+ exceptions, never HTTP. Reads `ALISSA_API_TOKEN` and `ALISSA_BASE` from the
186
+ environment (both overridable in the constructor for tests)."""
187
+
188
+ def __init__(self, token: str | None = None, base: str | None = None, *, timeout: int = 30):
189
+ self.base = (base or os.environ.get("ALISSA_BASE") or DEFAULT_BASE).rstrip("/")
190
+ self._token = token if token is not None else os.environ.get("ALISSA_API_TOKEN")
191
+ self._timeout = timeout
192
+
193
+ # --- transport ---------------------------------------------------------
194
+
195
+ def _request(self, method: str, path: str, body: dict | None = None) -> object:
196
+ """One REST call. Translates every failure into the narrow taxonomy;
197
+ the caller never sees a raw urllib error or HTTP status."""
198
+ if not self._token:
199
+ # No token at all is an auth condition, not a transport one: the
200
+ # operator must set ALISSA_API_TOKEN. Fail the same way a 401 would.
201
+ raise AlissaAuthError(0, "ALISSA_API_TOKEN is not set")
202
+
203
+ url = f"{self.base}{path}"
204
+ data = json.dumps(body).encode("utf-8") if body is not None else None
205
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {self._token}"}
206
+ if data is not None:
207
+ headers["Content-Type"] = "application/json"
208
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
209
+ try:
210
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310
211
+ raw = resp.read().decode("utf-8")
212
+ except urllib.error.HTTPError as exc:
213
+ raise self._classify(exc) from None
214
+ except urllib.error.URLError as exc:
215
+ # DNS failure, refused connection, timeout: all retry-later.
216
+ raise AlissaTransient(0, str(exc.reason)) from None
217
+ return json.loads(raw) if raw.strip() else {}
218
+
219
+ @staticmethod
220
+ def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
221
+ """Map an HTTP error onto the taxonomy. The API sends JSON error bodies
222
+ (`{"error": CODE, "message": ...}`); the code rides along on the
223
+ exception when present, but classification keys on the STATUS -- codes
224
+ are advisory, statuses are the contract."""
225
+ detail: object = exc.read().decode("utf-8", "replace")
226
+ code: str | None = None
227
+ try:
228
+ parsed = json.loads(detail) # type: ignore[arg-type]
229
+ if isinstance(parsed, dict):
230
+ detail = parsed
231
+ raw_code = parsed.get("error")
232
+ code = raw_code if isinstance(raw_code, str) else None
233
+ except (json.JSONDecodeError, TypeError):
234
+ pass
235
+
236
+ status = exc.code
237
+ if status in (401, 403):
238
+ return AlissaAuthError(status, detail, code)
239
+ if status == 404:
240
+ return AlissaNotFound(status, detail, code)
241
+ if status == 408 or status == 429 or 500 <= status <= 599:
242
+ return AlissaTransient(status, detail, code)
243
+ return AlissaError(status, detail, code)
244
+
245
+ # --- adapter surface ---------------------------------------------------
246
+
247
+ def list_marked_committed_tasks(self, marker: str = RELEASE_MARKER) -> list[MarkedTask]:
248
+ """Committed tasks carrying the release `marker`, with descriptions.
249
+
250
+ The status filter runs server-side (`GET /v1/tasks?status=committed`);
251
+ the marker filter runs here against the fields the LIST endpoint returns
252
+ -- `title`, `descriptionPreview`, `dodPreview`. A marker buried past the
253
+ preview truncation window will be missed, which is why the operator
254
+ convention is to place it in the task description where it leads the
255
+ preview; the loop re-reads the full body via `get_task` before writing
256
+ the issue anyway.
257
+
258
+ An empty/whitespace marker returns [] rather than matching every task:
259
+ a blank marker must mean "release nothing", never "release everything"
260
+ -- the same fail-closed spirit the GitHub search carries.
261
+ """
262
+ marker = marker.strip()
263
+ if not marker:
264
+ log.warning(
265
+ "list_marked_committed_tasks: empty release marker -- nothing is "
266
+ "released. A blank marker would match every committed task, so "
267
+ "this fails closed; configure a marker to release something."
268
+ )
269
+ return []
270
+
271
+ payload = self._request("GET", "/v1/tasks" + _query({"status": "committed"}))
272
+ tasks = payload.get("tasks") if isinstance(payload, dict) else None
273
+ if not isinstance(tasks, list):
274
+ return []
275
+
276
+ out: list[MarkedTask] = []
277
+ for row in tasks:
278
+ if not isinstance(row, dict):
279
+ continue
280
+ number = row.get("taskNumber")
281
+ if not isinstance(number, int):
282
+ continue
283
+ title = row.get("title") or ""
284
+ description = row.get("descriptionPreview") or ""
285
+ dod = row.get("dodPreview") or ""
286
+ if marker not in f"{title}\n{description}\n{dod}":
287
+ continue
288
+ out.append(
289
+ MarkedTask(
290
+ ref=f"TASK-{number}",
291
+ task_number=number,
292
+ title=title,
293
+ status=row.get("status") or "",
294
+ description=description,
295
+ )
296
+ )
297
+ return out
298
+
299
+ def get_task(self, ref: str) -> Task:
300
+ """Full detail for one task. `ref` is any reference form the API accepts
301
+ (`TASK-<n>`, public id, url). Raises `AlissaNotFound` when the bound
302
+ actor has no role on it."""
303
+ payload = self._request("GET", f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}")
304
+ task = payload.get("task") if isinstance(payload, dict) else None
305
+ if not isinstance(task, dict):
306
+ # A 2xx with no task object is a contract violation, not a 404 --
307
+ # surface it as the base error rather than fabricating a Task.
308
+ raise AlissaError(200, f"no task object in response for {ref!r}")
309
+
310
+ criteria: list[Criterion] = []
311
+ for crit in task.get("validationCriteria") or []:
312
+ if not isinstance(crit, dict):
313
+ continue
314
+ criteria.append(
315
+ Criterion(
316
+ id=str(crit.get("id") or ""),
317
+ description=crit.get("description") or "",
318
+ satisfied=bool(crit.get("satisfied")),
319
+ )
320
+ )
321
+
322
+ # The dependsOn gate reads upstream statuses. `dependsOn` is the id
323
+ # list; `upstreamTasks` is the resolved view (id/title/status). We key
324
+ # the gate on the resolved statuses but remember the raw dependsOn
325
+ # count so an upstream this actor cannot see (present in dependsOn,
326
+ # absent from upstreamTasks) reads as "not provably clear".
327
+ dependencies: list[Dependency] = []
328
+ for up in task.get("upstreamTasks") or []:
329
+ if not isinstance(up, dict):
330
+ continue
331
+ up_number = up.get("taskNumber")
332
+ up_ref = (
333
+ f"TASK-{up_number}"
334
+ if isinstance(up_number, int)
335
+ else str(up.get("_id") or "")
336
+ )
337
+ dependencies.append(Dependency(ref=up_ref, status=up.get("status") or ""))
338
+ depends_on = task.get("dependsOn")
339
+ depends_on_count = len(depends_on) if isinstance(depends_on, list) else 0
340
+
341
+ created_raw = task.get("createdAt")
342
+ created_at = int(created_raw) if isinstance(created_raw, (int, float)) else 0
343
+
344
+ number = task.get("taskNumber")
345
+ return Task(
346
+ ref=f"TASK-{number}" if isinstance(number, int) else str(ref),
347
+ task_number=number if isinstance(number, int) else 0,
348
+ title=task.get("title") or "",
349
+ status=task.get("status") or "",
350
+ description=task.get("description") or "",
351
+ definition_of_done=task.get("definitionOfDone") or "",
352
+ criteria=tuple(criteria),
353
+ priority=task.get("priority") or "",
354
+ created_at=created_at,
355
+ dependencies=tuple(dependencies),
356
+ depends_on_count=depends_on_count,
357
+ )
358
+
359
+ def add_evidence(self, ref: str, title: str, markdown: str, *, item_type: str = "evidence") -> None:
360
+ """Attach an evidence/deliverable item (outputs surface, not context).
361
+ orcloop uses this to record loop-closure evidence on the origin task."""
362
+ self._request(
363
+ "POST",
364
+ f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/evidence",
365
+ {"title": title, "markdownContent": markdown, "itemType": item_type},
366
+ )
367
+
368
+ def satisfy_criterion(self, ref: str, criterion_id: str) -> None:
369
+ """Mark one validation criterion satisfied. No partial-credit shortcut:
370
+ every criterion is satisfied individually before a task can validate."""
371
+ self._request(
372
+ "POST",
373
+ f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}"
374
+ f"/criteria/{urllib.parse.quote(str(criterion_id), safe='')}/satisfy",
375
+ )
376
+
377
+ def set_status(self, ref: str, status: str) -> None:
378
+ """Move a task along the transition matrix (draft → committed →
379
+ in_progress → pending_validation → validated, with blocked/cancelled
380
+ side moves). Illegal transitions come back as the base `AlissaError`."""
381
+ self._request(
382
+ "POST",
383
+ f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/status",
384
+ {"status": status},
385
+ )
386
+
387
+ def add_comment(self, ref: str, body: str) -> None:
388
+ """Post a comment. The endpoint field is `content`, not `body`."""
389
+ self._request(
390
+ "POST",
391
+ f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/comments",
392
+ {"content": body},
393
+ )