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,115 @@
1
+ """Loop-events push: the seat's decisions as idempotent telemetry, one
2
+ best-effort batch per poll (Studio `POST /v1/loop-events`).
3
+
4
+ The shape is orcloop's (`loop_events.py` there): `seat`, a dotted `kind`, `at`
5
+ in epoch MILLISECONDS, and a `dedupeKey` the server is idempotent on -- so a
6
+ resend is a silent duplicate and this module keeps no retry queue. A fact
7
+ still true next poll is re-recorded under the same key; a failed POST costs at
8
+ most the events of one poll, which the design accepts for telemetry.
9
+
10
+ `seat: "genloop"` and the six kinds below are D14's (Studio G3-2 added the
11
+ literal to the `loopEvents` union).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ from typing import Mapping
19
+
20
+ from .alissa_client import AlissaClient, AlissaError
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ SEAT = "genloop"
25
+
26
+ KIND_PLAN_PROPOSED = "plan.proposed"
27
+ KIND_PLAN_SKIPPED = "plan.skipped"
28
+ KIND_PLAN_INVALID = "plan.invalid"
29
+ KIND_PASS_IDLE = "pass.idle"
30
+ KIND_PASS_SILENT = "pass.silent"
31
+ KIND_PASS_STALLED = "pass.stalled"
32
+ KINDS = (
33
+ KIND_PLAN_PROPOSED, KIND_PLAN_SKIPPED, KIND_PLAN_INVALID,
34
+ KIND_PASS_IDLE, KIND_PASS_SILENT, KIND_PASS_STALLED,
35
+ )
36
+
37
+ EVENT_BATCH_MAX = 200
38
+ EVENT_REASON_MAX_CHARS = 2000
39
+ EVENT_REASON_TRUNCATED = " [reason truncated]"
40
+ _KIND_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")
41
+
42
+
43
+ def event(
44
+ kind: str,
45
+ *,
46
+ at: float,
47
+ dedupe_key: str,
48
+ repo: str | None = None,
49
+ pr_number: int | None = None,
50
+ session: str | None = None,
51
+ reason: str | None = None,
52
+ data: Mapping[str, object] | None = None,
53
+ ) -> dict:
54
+ """One event in the ingest contract's shape. `at` is epoch SECONDS here
55
+ (the seat's clock) and milliseconds on the wire."""
56
+ if not _KIND_RE.match(kind):
57
+ raise ValueError(f"kind {kind!r} does not match the server's grammar")
58
+ out: dict = {"seat": SEAT, "kind": kind, "at": int(at * 1000), "dedupeKey": f"{SEAT}:{dedupe_key}"[:256]}
59
+ if repo:
60
+ out["repo"] = repo
61
+ if pr_number is not None:
62
+ out["prNumber"] = int(pr_number)
63
+ if session:
64
+ out["session"] = session
65
+ if reason:
66
+ if len(reason) > EVENT_REASON_MAX_CHARS:
67
+ reason = reason[:EVENT_REASON_MAX_CHARS - len(EVENT_REASON_TRUNCATED)] + EVENT_REASON_TRUNCATED
68
+ out["reason"] = reason
69
+ if data:
70
+ out["data"] = dict(data)
71
+ return out
72
+
73
+
74
+ class LoopEventRecorder:
75
+ """The poll's buffer. `record` is called where a fact is observed;
76
+ `flush` POSTs the batch once per poll and never raises."""
77
+
78
+ def __init__(self, alissa: AlissaClient, *, dry_run: bool = False):
79
+ self.alissa = alissa
80
+ self.dry_run = dry_run
81
+ self._buffer: list[dict] = []
82
+ self._keys: set[str] = set()
83
+
84
+ def record(self, kind: str, **fields: object) -> dict:
85
+ built = event(kind, **fields) # type: ignore[arg-type]
86
+ if built["dedupeKey"] not in self._keys:
87
+ self._keys.add(built["dedupeKey"])
88
+ self._buffer.append(built)
89
+ return built
90
+
91
+ @property
92
+ def pending(self) -> list[dict]:
93
+ return list(self._buffer)
94
+
95
+ def flush(self) -> int:
96
+ """Send what the poll recorded; returns how many were sent. A
97
+ failure is a WARNING and the buffer is dropped (the keys make a
98
+ re-observed fact safe to resend)."""
99
+ batch, self._buffer, self._keys = self._buffer, [], set()
100
+ if not batch:
101
+ return 0
102
+ if self.dry_run:
103
+ for item in batch:
104
+ log.info("[dry-run] would emit %s (%s)", item["kind"], item["dedupeKey"])
105
+ return 0
106
+ sent = 0
107
+ for start in range(0, len(batch), EVENT_BATCH_MAX):
108
+ chunk = batch[start:start + EVENT_BATCH_MAX]
109
+ try:
110
+ self.alissa.post_loop_events(chunk)
111
+ except AlissaError as exc:
112
+ log.warning("loop events: push of %d event(s) failed (%s)", len(chunk), exc)
113
+ continue
114
+ sent += len(chunk)
115
+ return sent
@@ -0,0 +1,325 @@
1
+ """The pass: the directive one `genloop-<stamp>` session works under, and the
2
+ record it leaves for the seat (design D14, §3, §4.1, §5.2, §5.5).
3
+
4
+ The issue names this module `pass.py`; `pass` is a Python keyword, so a module
5
+ of that name cannot be imported with an import statement. It is
6
+ `pass_directive.py` here, with the same content.
7
+
8
+ BYTE-PINNED
9
+ ===========
10
+ `GEN_DIRECTIVE` is the whole contract the session works under, in one string.
11
+ `test_pass_directive` pins its SHA-256 and every sentence a guarantee rests
12
+ on: the skill and its fail-closed record, the direction reads of §4.1, the
13
+ sources of §3 in rank order, the files of §5.2 quoted, `plan-lint` as the
14
+ last step before the push, the record schema, and what the session may NOT do
15
+ -- open the PR, request the reviewer, create tasks, kill other sessions. The
16
+ seat does those. Changing one byte of the template is a deliberate edit to
17
+ the pin, never a drift.
18
+
19
+ The judgment is the session's; the seat never reads the plan's prose.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import re
26
+ from dataclasses import dataclass
27
+ from typing import Sequence
28
+
29
+ SESSION_PREFIX = "genloop-"
30
+ SESSION_TMUX_PREFIX = "ali-" + SESSION_PREFIX
31
+ STAMP_FORMAT = "%Y%m%dT%H%M%SZ"
32
+ PASSES_DIRNAME = "passes"
33
+
34
+ RECORD_VERSION = 1
35
+ VERDICT_PLAN = "plan"
36
+ VERDICT_IDLE = "idle"
37
+ VERDICT_SKILL_MISSING = "skill-missing"
38
+ VERDICTS = (VERDICT_PLAN, VERDICT_IDLE, VERDICT_SKILL_MISSING)
39
+
40
+ PASS_RECORD_SCHEMA = (
41
+ '{"version": 1, "stamp": "<this pass\'s stamp>", '
42
+ '"verdict": "plan" | "idle" | "skill-missing", '
43
+ '"branch": "PLAN-<stamp>-<slug>" (verdict plan only), '
44
+ '"planId": "<yyyy-mm-dd>-<slug>" (verdict plan only), '
45
+ '"skipped": [{"key": "<source key, candidate or check>", "reason": '
46
+ '"budget" | "parked" | "rule" | "dedupe" | "unanchored" | "lint" | "bound" | "<one word>"}], '
47
+ '"amended": [<number of each open plan PR you amended and pushed>]}'
48
+ )
49
+
50
+ GEN_DIRECTIVE = (
51
+ "You are the GENLOOP pass for the loopwork fleet, spawned by the genloop seat as managed "
52
+ "session `{session}` (pass stamp `{stamp}`, UTC day `{day}`). You are the fleet's judgment "
53
+ "seat: you read the operator's direction and the loop's exhaust and write AT MOST ONE plan "
54
+ "as files on a branch. The seat — not you — opens the pull request, requests the reviewer, "
55
+ "records the plan and delivers.\n"
56
+ "\n"
57
+ "Fixed facts for this pass:\n"
58
+ "- Plans repository: `{plans_repo}`. Its worktree hub (the one checkout in this container) "
59
+ "is `{plans_hub}`.\n"
60
+ "- Fed repositories (read them with `gh api` / `gh search` only — never clone): {repos}.\n"
61
+ "- Exhaust window: the last {window_days} days.\n"
62
+ "- Alissa: `$ALISSA_BASE` with `Authorization: Bearer $ALISSA_API_TOKEN` (the planner's own "
63
+ "token — reads only, see step 9).\n"
64
+ "- Budget: {budget_note}\n"
65
+ "- Your pass record: `{record_path}`.\n"
66
+ "\n"
67
+ "1. Load the skill `{skill}`. If it cannot be loaded, do NOT improvise the pass: write "
68
+ '`{record_path}` containing exactly {{"version": 1, "stamp": "{stamp}", "verdict": '
69
+ '"skill-missing", "skipped": [], "amended": []}} and go to step 10 (fail closed).\n'
70
+ "2. Read the direction, in this order (design §4.1): (a) `GET /v1/factory/steering` — the "
71
+ "fleet row first: if `parked` is true, write the record with verdict `idle` and skipped "
72
+ '[{{"key": "fleet", "reason": "parked"}}] and go to step 10; else its `priorities[]`, '
73
+ "`offLimits[]`, `notNow[]`; (b) the active programs: `GET /v1/projects?includeShared=true` "
74
+ "filtered to `status: \"active\"`, then `GET /v1/projects/:projectId` each — the outcome, "
75
+ "the milestones with their status and their open validation criteria (`satisfied: false`), "
76
+ "the observability block; a milestone `at_risk` or `missed` ranks its program first; (c) each "
77
+ "program's steering row (scope `program`): `budgetShare`, `offLimits[]`, `notNow[]`; (d) the "
78
+ "program's objectives, `GET /v1/objectives/:objectiveId`; (e) strategies whose actions name "
79
+ "a program, where any exist — say so when none do; (f) the proposal rules, `GET "
80
+ "/v1/factory/proposal-rules`, which remove candidates and never add direction.\n"
81
+ "3. Budget per candidate program: `GET /v1/code/budget?scope=program&projectId=<id>&window=30d`. "
82
+ "`exhausted`, or `spentUsd >= budgetShare x capUsd` when the program row sets a "
83
+ "`budgetShare`, or a read that fails: plan nothing for that program (skipped, reason "
84
+ "`budget`). `warn`: you may plan, and the plan's *Why now* states the remaining figure.\n"
85
+ "4. Derive the spent-set (D6) before choosing: a source key is SPENT when a plan that lists "
86
+ "it is open or committed (`GET /v1/loop/plans?status=proposed`, `…=parked`, "
87
+ "`…=committed`), when a proposal rule names it or a prefix of it, or when an open issue or "
88
+ "task already carries `Alissa-Source: <key>` (`gh search issues` over the fed repositories, "
89
+ "`gh issue list --state all --search`). Never propose a spent key; list it under `skipped` "
90
+ "with reason `dedupe` or `rule`.\n"
91
+ "5. Open plans first (D13). For every OPEN pull request in `{plans_repo}` on a `PLAN-` "
92
+ "branch by your GitHub login: when its `plan-lint` check run concluded `failure`, or a "
93
+ "`[blocker]` / `[major]` / `[minor]` / `[question]` finding on it has no `[triage:*]` "
94
+ "answer, answer EACH finding with exactly one `[triage:pursue]`, `[triage:ignore]` or "
95
+ "`[triage:later]` reply on its own thread FIRST, then amend the files in a worktree of "
96
+ "that branch, run step 7's lint, and push the branch. Replies before the push, always "
97
+ "(L14). Add the PR's number to `amended`; never request the review yourself — the seat "
98
+ "re-requests it.\n"
99
+ "6. Walk the sources in rank order (design §3) over the fed repositories and the window; "
100
+ "stop at the first plan worth proposing. The parse is a rule, never a reading:\n"
101
+ " 1. Unverified operator's-gate items — `GET /v1/loop-events?kind=close.merged` "
102
+ "(`data.deliveryNotesPresent: true`), then the merged PR body's `## Delivery notes`, the "
103
+ "bullets under **Not verified — operator's gate** → `gate-verification`; key "
104
+ "`gate:<repo>#<pr>:<hash8>` (the first 8 hex of the bullet's SHA-256).\n"
105
+ " 2. `[triage:later]` replies on those merged PRs whose cited task is `draft` or absent "
106
+ "→ `exhaust-followup`; key `later:<repo>#<pr>:<commentId>`.\n"
107
+ " 3. Reviewer `[minor]` / `[nit]` findings on the approving round with no "
108
+ "`[triage:pursue]` reply, folded per PR → `exhaust-followup`; key "
109
+ "`minor:<repo>#<pr>:<commentId>`.\n"
110
+ " 4. Recurring escalations and cap-outs over 14 days (`GET /v1/loop-events?seat=…&kind=…`): "
111
+ "one `(repo, kind)` on >= 3 items, or one item capped twice → `flaky-test` or `drift-fix` "
112
+ "on the mechanism; key `esc:<repo>:<kind>:<n>` or `esc:<repo>:<kind>:*`.\n"
113
+ " 5. Drift — one `gh api …/contents/<path>` read per contract file: the loop labels, the "
114
+ "readiness class enums, the loop-event vocabulary, the session-name grammar, each leaf "
115
+ "Dockerfile's `FROM` tag against `alissa-loopwork`'s `VERSION` → `drift-fix` on the "
116
+ "lagging repo; key `drift:<contract>:<value>`.\n"
117
+ " 6. Stale abandoned tasks — NEVER a plan; list them under `skipped` with reason `stale` "
118
+ "and key `stale:TASK-<n>` for the digest.\n"
119
+ " Only merged work is exhaust. A plan folds keys of ONE class, never several classes.\n"
120
+ " Anchoring (§4.3): every plan cites one anchor — a milestone (every task in that "
121
+ "milestone's body of work), an unsatisfied criterion (*Why now* says what satisfies it), "
122
+ "an active objective (through a program's milestone), or an exhaust class (non-empty "
123
+ "`sources`, every key of that class). Nothing in `offLimits[]` or `notNow[]` is planned. A "
124
+ "plan you cannot anchor is not written (skipped, reason `unanchored`).\n"
125
+ "7. To write the plan: `git -C {plans_hub} fetch origin`, then from `{plans_hub}` run `git "
126
+ "worktree add -b PLAN-{stamp}-<slug> PLAN-{stamp}-<slug> origin/<default branch>` and work "
127
+ "ONLY in that worktree. Create exactly one new directory `plans/{day}-<slug>/` (slug "
128
+ "`[a-z0-9-]{{3,48}}`) holding `plan.md` and 1–8 files `tasks/<id>.md`, and nothing else — "
129
+ "never `materialized.json`, never a file outside the directory. The files (design §5.2), "
130
+ "verbatim in shape:\n"
131
+ "\n"
132
+ "{plan_skeleton}\n"
133
+ "\n"
134
+ "{task_skeleton}\n"
135
+ "\n"
136
+ " Every task carries full task data, never a headline (D15): in a task file everything "
137
+ "above `## Definition of done` is the description (400–8,000 characters), *Definition of "
138
+ "done* is the definition, each item under *Criteria* one criterion. Then lint — the LAST "
139
+ "step before the push: in the worktree run `npm ci` (once), `npm run plan-lint -- --write "
140
+ "plans/{day}-<slug>` (it renders *Summary* and *Dependency graph*), then `npm run plan-lint "
141
+ "-- plans/{day}-<slug>`. Push only when that last run exits 0: commit, then `git push -u "
142
+ "origin PLAN-{stamp}-<slug>`. A plan the lint refuses and you cannot fix is never pushed "
143
+ "(skipped, reason `lint`).\n"
144
+ "8. Write `{record_path}` — always, even when you write no plan. It MUST follow this "
145
+ "schema (verdict `plan` only when you pushed a branch this pass; `idle` otherwise):\n"
146
+ "{schema}\n"
147
+ "9. NEVER open a pull request, NEVER request a reviewer, NEVER create or edit a task, a "
148
+ "project or any other Alissa record (no POST, PUT or DELETE to Alissa at all), NEVER add a "
149
+ "label, merge, close or comment beyond step 5's triage replies, NEVER push to any branch "
150
+ "that is not a `PLAN-` branch, NEVER clone a fed repository, and NEVER kill any session "
151
+ "but your own. The seat opens the PR, requests the reviewer and records the plan; your "
152
+ "output is the branch and the record.\n"
153
+ "10. Your LAST act, after the record is written: run `alissa tmux kill {session}` to end "
154
+ "this session and free the machine.\n"
155
+ )
156
+
157
+ # Design §5.2, quoted.
158
+ PLAN_SKELETON = """\
159
+ `plan.md`:
160
+ ---
161
+ id: <yyyy-mm-dd>-<slug>
162
+ title: <title>
163
+ anchor: { kind: <milestone|criterion|objective|exhaust>, … }
164
+ program: <projectId>
165
+ milestone: <bodyOfWorkId> # absent only on an exhaust anchor (§10 Q3)
166
+ sources:
167
+ - { key: <source key>, evidence: <one line> }
168
+ budget: { estimateUsd: <n>, basis: <one line> }
169
+ supersedes: plans/<dir> # when one
170
+ ---
171
+ # Plan: <title>
172
+ <!-- alissa-genloop:plan v1 -->
173
+
174
+ ## Why now
175
+ ## What it serves
176
+ ## Summary
177
+ <!-- plan-summary:begin -->
178
+ - `<id>` — <task title> (<repo>, <commitClass>)
179
+ <!-- plan-summary:end -->
180
+ ## Dependency graph
181
+ <!-- plan-dag:begin -->
182
+ ```mermaid
183
+ graph LR
184
+ <id> -->|hard| <id>
185
+ ```
186
+ <!-- plan-dag:end -->
187
+ ## Considered and skipped
188
+ ## Open questions for the reviewer"""
189
+
190
+ TASK_SKELETON = """\
191
+ `tasks/<id>.md`:
192
+ ---
193
+ id: <slug>
194
+ title: <title>
195
+ repo: <owner>/<repo>
196
+ milestone: <bodyOfWorkId>
197
+ priority: <low|medium|high|top>
198
+ dependsOn:
199
+ - { id: <slug>, type: <hard|soft> }
200
+ - { task: TASK-<n>, type: <hard|soft> }
201
+ scope:
202
+ - <glob>
203
+ commitClass: <one of §7.1, never unanchored>
204
+ sources:
205
+ - <source key>
206
+ ---
207
+ ## Why
208
+ ## Definition of done
209
+ ## Criteria
210
+ - <criterion>"""
211
+
212
+ # The pin: the template, the two quoted skeletons and the schema, hashed
213
+ # together. `test_pass_directive` asserts this value; the seat logs it with
214
+ # every spawn so a pass can be traced to the exact contract it ran under.
215
+ DIRECTIVE_SHA256 = hashlib.sha256(
216
+ "\x00".join((GEN_DIRECTIVE, PLAN_SKELETON, TASK_SKELETON, PASS_RECORD_SCHEMA)).encode("utf-8")
217
+ ).hexdigest()
218
+
219
+
220
+ def session_name(stamp: str) -> str:
221
+ return SESSION_PREFIX + stamp
222
+
223
+
224
+ def render_directive(
225
+ *,
226
+ stamp: str,
227
+ day: str,
228
+ skill: str,
229
+ plans_repo: str,
230
+ plans_hub: str,
231
+ repos: Sequence[str],
232
+ window_days: int,
233
+ record_path: str,
234
+ budget_note: str,
235
+ ) -> str:
236
+ """The directive for the pass stamped `stamp`. Pure -- the tests pin it
237
+ without a filesystem."""
238
+ return GEN_DIRECTIVE.format(
239
+ session=session_name(stamp),
240
+ stamp=stamp,
241
+ day=day,
242
+ skill=skill,
243
+ plans_repo=plans_repo,
244
+ plans_hub=plans_hub,
245
+ repos=", ".join(f"`{r}`" for r in repos) if repos else "(none — write the record with verdict idle)",
246
+ window_days=window_days,
247
+ record_path=record_path,
248
+ budget_note=budget_note,
249
+ plan_skeleton=PLAN_SKELETON,
250
+ task_skeleton=TASK_SKELETON,
251
+ schema=PASS_RECORD_SCHEMA,
252
+ )
253
+
254
+
255
+ # --- the record ----------------------------------------------------------------
256
+
257
+
258
+ class PassRecordError(ValueError):
259
+ """The record does not follow the schema the directive carries."""
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class Skip:
264
+ key: str
265
+ reason: str
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class PassRecord:
270
+ stamp: str
271
+ verdict: str
272
+ branch: str | None
273
+ plan_id: str | None
274
+ skipped: tuple[Skip, ...]
275
+ amended: tuple[int, ...]
276
+
277
+
278
+ _BRANCH_RE = re.compile(r"^PLAN-(\d{8}T\d{6}Z)-([a-z0-9-]+)$")
279
+ _PLAN_ID_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9][a-z0-9-]{2,47}$")
280
+ _REASON_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
281
+
282
+
283
+ def parse_pass_record(payload: object, *, stamp: str) -> PassRecord:
284
+ """Parse one `passes/<stamp>.json` against the schema; raises
285
+ `PassRecordError` naming the first violation."""
286
+ if not isinstance(payload, dict):
287
+ raise PassRecordError("the record is not a JSON object")
288
+ if payload.get("version") != RECORD_VERSION:
289
+ raise PassRecordError(f"version {payload.get('version')!r} is not {RECORD_VERSION}")
290
+ if payload.get("stamp") != stamp:
291
+ raise PassRecordError(f"stamp {payload.get('stamp')!r} is not the file's {stamp!r}")
292
+ verdict = payload.get("verdict")
293
+ if verdict not in VERDICTS:
294
+ raise PassRecordError(f"verdict {verdict!r} is not one of {', '.join(VERDICTS)}")
295
+ branch = payload.get("branch")
296
+ plan_id = payload.get("planId")
297
+ if verdict == VERDICT_PLAN:
298
+ match = _BRANCH_RE.match(branch) if isinstance(branch, str) else None
299
+ if match is None or match.group(1) != stamp:
300
+ raise PassRecordError(f"branch {branch!r} is not PLAN-{stamp}-<slug>")
301
+ if not isinstance(plan_id, str) or not _PLAN_ID_RE.match(plan_id):
302
+ raise PassRecordError(f"planId {plan_id!r} is not <yyyy-mm-dd>-<slug>")
303
+ else:
304
+ branch = None
305
+ plan_id = None
306
+ skipped: list[Skip] = []
307
+ raw_skipped = payload.get("skipped", [])
308
+ if not isinstance(raw_skipped, list):
309
+ raise PassRecordError("skipped is not a list")
310
+ for index, item in enumerate(raw_skipped):
311
+ if not isinstance(item, dict) or not isinstance(item.get("key"), str) or not item["key"].strip():
312
+ raise PassRecordError(f"skipped[{index}] has no key")
313
+ reason = item.get("reason")
314
+ if not isinstance(reason, str) or not _REASON_RE.match(reason):
315
+ raise PassRecordError(f"skipped[{index}].reason {reason!r} is not one lowercase word")
316
+ skipped.append(Skip(key=item["key"].strip()[:200], reason=reason))
317
+ raw_amended = payload.get("amended", [])
318
+ if not isinstance(raw_amended, list) or not all(
319
+ isinstance(n, int) and not isinstance(n, bool) and n > 0 for n in raw_amended
320
+ ):
321
+ raise PassRecordError("amended is not a list of PR numbers")
322
+ return PassRecord(
323
+ stamp=stamp, verdict=str(verdict), branch=branch, plan_id=plan_id,
324
+ skipped=tuple(skipped), amended=tuple(dict.fromkeys(raw_amended)),
325
+ )