Metadata-Version: 2.4
Name: alissa-tools-github-devloop
Version: 0.8.44
Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
Home-page: https://alissa.app
Author: Fahera
Author-email: support@alissa.app
License: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Dynamic: author
Dynamic: author-email
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: requires-python
Dynamic: summary

# alissa-tools-github-devloop

The `alissa.tools.github.devloop` module: a GitHub watcher that turns
`alissa:develop`-labeled issues into fresh developer sessions — the
developer-side counterpart of
[`alissa-tools-github-reviewloop`](https://github.com/fahera-mx/alissa-github-review-daemon).

This distribution ships **only** that module. Everything above it —
`alissa`, `alissa.tools`, `alissa.tools.github` — is a
[PEP 420](https://peps.python.org/pep-0420/) namespace package with no
`__init__.py`, so other distributions in other repositories can contribute
their own packages under the same namespace:

```
alissa/                                  ← namespace (no __init__.py)
└── tools/                               ← namespace
    └── github/                          ← namespace
        ├── devloop/      __init__.py    ← THIS distribution
        └── reviewloop/   __init__.py    ← ships from alissa-github-review-daemon
```

The rule: a distribution owns a leaf package and declares only that subtree
(`find_namespace_packages(include=[PACKAGE, f"{PACKAGE}.*"])`). Adding an
`__init__.py` at any namespace level would claim it for one distribution and
shadow the others.

## Install

```sh
pip install -e ./alissa-tools-github-devloop
```

## Console scripts

| Command | Entry point |
| --- | --- |
| `alissa-devloop` | `alissa.tools.github.devloop.__main__:main` |

## Layout

`src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
distribution version lives in the plain-text `version` file next to the module
it versions (`src/main/alissa/tools/github/devloop/version`), read by both
`setup.py` and `version.py`.

## Account-level conditions

The prompt responder (`prompts.py` classifies, `loop.py` acts) treats four
pane conditions as the ACCOUNT's, not the worker's — nothing a keystroke can
answer: `login_expired`, `auth_rejected` (a 401), `usage_limit` and
`out_of_credits`. Each pages the operator once per kind per 6 h on the lane's
issue/PR and holds every new spawn while it stands (at most 1 h on one pane,
then kill-and-retry re-confirms) — except `usage_limit`, whose session is
never killed and whose hold is the ledger's (below).

**Usage limits** (issues #142, #144). `usage_limit` admits the window wordings
(`You've hit your weekly limit`, `Usage limit reached`, …), the "limit
reached … resets …" family (`Claude usage limit reached. Your limit will
reset at 5pm (America/New_York).`, `5-hour limit reached ∙ resets 3pm`,
`Opus weekly limit reached ∙ resets Oct 6, 1pm`) and the
MODEL-named one Claude Code prints now — `You've reached your Fable limit.
Run /usage-credits to continue or switch models with /model.` (also `Opus`,
`Sonnet`, …): one capitalised model token, a known family name or any token
corroborated by that trailing hint on the same line. An over-limit account
answers the directive with that notice as the worker's ONLY assistant turn
and leaves the session idle at `❯`, which the banner rule (never a `●` row)
cannot see, so the **first-turn limit check** reads the responder's capture of
any young worker (`develop-*`, `fix-*`, `maintain-*`, `revert-*`, spawned by
this ledger inside the last `stale_minutes`): the notice, above an idle
prompt, with no other `●` turn since the directive. A match
(`source: "first_turn"`, `matched: "model_limit"`, the model) is:

- **held** — the daemon-wide spawn hold, clocked on the ledger from the
  newest limit (it survives a restart and a listing failure) and lifted only
  when a spawn made AFTER the limit gets through its first turn: after 1 h
  ONE probe spawn is admitted at a time (none while a probe is in flight,
  none while another account notice holds; under `max_sessions` the probe
  is judged without the kept limited sessions, which would otherwise fill the
  cap for good); a probe whose first turn is the notice again is refunded,
  re-arms the clock and is **killed** — it did no work, and keeping it would
  add one session per hour of the limit (past `max_sessions` too), so the
  live sessions stay at the ones the limit found at work plus at most the one
  probe in flight; the first probe that does
  real work lifts the hold (`usage-limit-lifted:` on the escalation ledger,
  `escalation.usage_limit_lifted` on the wire) and each kept limited
  session's lane gets a line saying to resume it;
- **paged** — once per 6 h window, through the account-kind page;
- **never killed** (issue #144; a failed probe, above, is the one exception) —
  a retry would hit the same wall, and the idle session is the lane's own work for the operator to resume once the
  account is back (or `alissa tmux kill`, to let the retry edge respawn);
  the wedge kills skip a limited session too;
- **refunded** — the spawn row is stamped `limited_at`, and every
  `attempt_cap` count leaves limited rows out (numbering stays monotonic, so
  session names never repeat). An account over its limit never caps a lane.

A usage-limit BANNER on a working session (`source: "pane"`) is held the same
way: its row is stamped (refunded), the hold is the ledger's, and the session
is never killed. The same notice after real worker output (a tool call, the
worker's own prose, a quoted tool row) stays worker output. Each limited
session is one `usage.limited` loop event: `data: { kind: "usage_limit",
source: "pane" | "first_turn", matched: "model_limit" | "usage_limit", model,
resetsAt? }` — `resetsAt` only when the notice states a reset, as an ISO-8601
UTC instant when that needs no guess (the legacy `|<epoch>` form, or a clock
time with an explicit zone) and as the stated phrase otherwise; no pane text,
no credential material.

**The fix edge stops presuming.** A stale fix round with no triage reply from
the author used to be re-enqueued on the timer alone ("presumed dead"). It now
reads the tmux roster first: ALIVE is in flight (the responder owns a live
idle pane; a once-per-episode "Fix loop stalled?" ping floors the wait),
INDETERMINATE defers one pass, and only DEAD respawns — "CONFIRMED dead", or,
after a refunded limit, "previous session limited (usage limit; attempt
refunded)". A kept limited session reads ALIVE, so no edge respawns over it.

## The re-enqueue guard (issue #144)

The issue edge's respawn rule reads the linked-PR signal over **every** PR
state: `GitHub.linked_pull_requests` walks the timeline once and reports each
same-repo cross-referenced PR as open, merged or closed. When a stale
attempt's session is CONFIRMED dead with no open PR, a merged PR of the
daemon's own that closes the issue is `SKIPPED` "finished (PR #n merged)" and
a closed-unmerged one is `SKIPPED` "closed unmerged — orchestrator's page" —
never a respawn (provenance: token author and a closing keyword, fetched; an
unreadable candidate stands the respawn down). And `_spawn` re-reads the
issue immediately before every enqueue: a closed issue is `SKIPPED` "issue
closed between search and spawn", nothing is claimed, and the reserved slot
is handed back. The respawn-with-resume on an open draft PR is unchanged.

## The discovery round (issue #164)

A discovery issue is a no-code lane in the fleet's **plans repository**
(design `alissa-build-discovery.md` §3.2, lane DS-7a). Studio's `feed_plan`
renders it with the trailers `Alissa-Kind: discovery`, `Alissa-Reads:
<owner>/<repo>@<40-hex sha>` (the code repository it reads, at a pinned SHA)
and `Autonomous-Scope: knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/**`. The
issue edge spawns the ordinary develop session — same name, same caps, same
liveness rules — with `DISCOVERY_DIRECTIVE` (in `loop.py`, byte-pinned in
`test_discovery.py`) instead of `DEV_DIRECTIVE`. Every other issue gets
today's directive byte for byte; a resume of a dead discovery session keeps
`RESUME_DIRECTIVE`.

The directive has the worker:

1. create its `TASK-<n>-<DESC>` branch in the plans repository, the branch
   grammar unchanged;
2. read the code at the pinned SHA, read-only — a `git -C <hub> worktree add
   --detach <scratch> <sha>` when the workspace has a hub of the read
   repository (no branch, removed at the end), otherwise `gh api
   repos/<repo>/contents/<path>?ref=<sha>` and `gh search code`; never a
   clone, never a commit or push there. The read is bounded by the SHA, not a
   file count; the output by the findings schema's limits;
3. write exactly one directory, `knowledge-base/<YYYY>/<MM>/<DD>/TASK-<n>/`,
   holding only `findings.md` (findings schema v1, `schema/findings.v1.json`
   in the plans repository);
4. run `node lint/plan-lint.ts --kb <dir>` locally with its own
   `ALISSA_API_TOKEN` (K6/K7) and never push findings it refuses;
5. open a ready pull request with `Closes #<n>`, both task refs and the
   marker `<!-- alissa-discovery v1 -->` on a line of its own, attach a
   `Findings` evidence item, move its task to `pending_validation`, request
   **no** reviewer (revloop skips such PRs; orcloop merges on the lint of
   record) and end its session.

**The push guard.** Before it enqueues a discovery session (fresh or
resumed), the daemon writes `${ALISSA_DISCOVERY_DIR:-/workspace/.discovery}/
ali-<session>.json` naming the plans repository; a marker that cannot be
written stands the spawn down for the pass. The container's shell guard
(`docker/claude/hooks/guard-shell.py`) finds the marker through
`$ALISSA_TMUX_SESSION` and refuses, with a PreToolUse deny and a reason, any
`git push` from that session whose destination is not the plans repository —
resolved from the remote name or URL in the directory the push runs in
(`-C`, `cd`), and refused outright when it cannot be resolved. Sessions with
no marker are untouched. `ALISSA_SHELL_GUARD=off` disables this guard with
the rest.

## Seat parking: what counts as managed (issue #166)

The idle block on `GET /api/state` (`parking.idle_block`, issue #153) is what
orcloop's D2 invariant reads before it drains and parks the seat: idle only
when `live == managed == queued == owed == 0` with no timer and a fresh pass.
`live` and `managed` come from `alissa tmux ls --json`:

- a row is **live** unless it says otherwise (its `live` flag, else
  `status != "gone"`);
- a row is **managed** when it is live, **or** when it is dead but its
  session still backs a lane the seat holds — the session named by a stage
  of the latest poll snapshot that is owed or in flight (`spawned` /
  `in-flight`), any session the spawn ledgers record on such a stage's lane
  (an earlier attempt the lane may still resume), or a session the alissa
  queue holds an undispatched item for;
- a dead row backing none of these is an **orphan**: it is not managed, and
  is reported in the block's `gone` field as `{count, sessions}` so the
  console and orcloop's logs still show it.

Unknown is never idle: a failed listing leaves `live`, `managed` and `gone`
null, and before the first completed pass (no snapshot to read lanes from)
every dead row stays managed. The console only reports an orphan — it never
kills or reaps one. The fleet-vitals push applies the same rule to
`sessions.managed` and `idle.managed` (Studio's park guard reads both) and
leaves `gone` out, since Studio's idle schema is strict.

## License

Licensed under the
[Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) — the
`LICENSE` and `NOTICE` files shipped with this distribution carry the full text
and the attribution and trademark terms. Both ship inside the sdist and the
wheel's `.dist-info`.

"Alissa" and "Fahera" are trademarks of CORE FAHERA ENTERPRISE HOLDINGS
S. DE R.L. and are not covered by the license — the grant covers the code, not
the names.
