alissa-tools-github-orcloop 0.3.7__tar.gz → 0.3.8__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 (23) hide show
  1. {alissa_tools_github_orcloop-0.3.7/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.8}/PKG-INFO +1 -1
  2. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/alissa_client.py +123 -0
  3. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/loop.py +677 -23
  4. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/state.py +10 -6
  5. alissa_tools_github_orcloop-0.3.8/src/main/alissa/tools/github/orcloop/version +1 -0
  6. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_orcloop-0.3.7/src/main/alissa/tools/github/orcloop/version +0 -1
  8. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/MANIFEST.in +0 -0
  9. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/README.md +0 -0
  10. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/requirements.txt +0 -0
  11. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/setup.cfg +0 -0
  12. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/setup.py +0 -0
  13. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
  14. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/__main__.py +0 -0
  15. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/config.py +0 -0
  16. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
  17. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
  18. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
  19. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa/tools/github/orcloop/version.py +0 -0
  20. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_orcloop-0.3.7 → alissa_tools_github_orcloop-0.3.8}/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.3.7
3
+ Version: 0.3.8
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -127,6 +127,37 @@ would then hold a task whose only dependency is fully validated -- forever,
127
127
  eventually paging ``release-blocked``. Deduplicating matches what the resolved
128
128
  view actually does.
129
129
 
130
+ TASK CONTEXT ATTACHMENTS (O14)
131
+ ==============================
132
+ An operator's decisions about a task do not all fit in the description that was
133
+ written before the work started. Alissa's answer is the task's **context
134
+ attachments** -- ``GET /v1/tasks/<ref>/context`` -> ``{"context": [...]}`` --
135
+ and O14 makes them travel: orcloop renders them into the issue it files, so a
136
+ decision recorded after authoring reaches the implementing worker (repo issue
137
+ #34). Verified live against ``api.alissa.app``:
138
+
139
+ * Each row carries ``_id``, ``title``, ``createdAt`` (ms), ``actorId``/
140
+ ``authorName``, and then AT MOST ONE payload of ``markdownContent`` /
141
+ ``link`` / a vault reference, plus an optional one-line ``note``. The
142
+ OpenAPI schema pins only ``_id`` and ``title`` (everything else is
143
+ ``additionalProperties``), so `get_task_context` treats every payload field
144
+ as optional and an item with none of them still renders as a titled,
145
+ empty subsection rather than being dropped -- a decision the operator
146
+ attached and orcloop silently discarded is the one outcome #34 exists to
147
+ prevent.
148
+ * The task DETAIL payload carries ``contextCount`` (an int), which is what
149
+ lets the release path skip this read entirely for the common zero-context
150
+ task -- see `Task.context_count`.
151
+
152
+ Note for a future reader: the detail payload ALSO embeds the attachments
153
+ themselves under an undocumented ``contextAttachments`` key, so the second
154
+ call could in principle be avoided. orcloop deliberately does not read it. The
155
+ dedicated route is the documented, access-gated one (the OpenAPI ``Task``
156
+ schema pins neither the key nor its item shape), and the release path's
157
+ conservatism depends on being able to tell "there are N attachments" from "I
158
+ read them": a silently-absent embedded array would read as "no context" on
159
+ exactly the payload that declares some.
160
+
130
161
  WHO AM I (O8)
131
162
  =============
132
163
  ``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
@@ -266,6 +297,29 @@ class Dependency:
266
297
  status: str
267
298
 
268
299
 
300
+ @dataclass(frozen=True)
301
+ class ContextItem:
302
+ """One knowledge/reference attachment on a task (O14).
303
+
304
+ The operator's decision channel: whatever was decided AFTER the task was
305
+ authored lands here rather than being rewritten into the description. Every
306
+ field but `id` is optional in the payload, so every one but `id` defaults
307
+ to empty -- an attachment is identified by its title and rendered from
308
+ whichever payload it carries.
309
+
310
+ `markdown` is the write-up, `link` a bare URL, `note` the one-line "why
311
+ this is relevant"; an attachment may carry any combination, including none
312
+ (a vault reference the REST payload does not inline). `created_at` is ms
313
+ since epoch, 0 when the payload omits it -- the renderer orders on it."""
314
+
315
+ id: str
316
+ title: str = ""
317
+ markdown: str = ""
318
+ link: str = ""
319
+ note: str = ""
320
+ created_at: int = 0
321
+
322
+
269
323
  @dataclass(frozen=True)
270
324
  class Task:
271
325
  """A task as the DETAIL endpoint reports it -- full description and DoD,
@@ -293,6 +347,13 @@ class Task:
293
347
  # len(dependencies) when an upstream is invisible to this actor -- the gate
294
348
  # treats that mismatch as "not provably clear".
295
349
  depends_on_count: int = 0
350
+ # How many context attachments the DETAIL payload declares (`contextCount`,
351
+ # O14). Purely a "is the second read worth making" hint for the release
352
+ # path: 0/absent skips `get_task_context` entirely, which is the common
353
+ # case. It is NOT a length contract -- attachments can be added or deleted
354
+ # between the two reads -- so nothing keys on it agreeing with what the
355
+ # context endpoint returns. Only "declares some" vs "declares none" matters.
356
+ context_count: int = 0
296
357
 
297
358
 
298
359
  def _query(params: dict) -> str:
@@ -625,6 +686,17 @@ class AlissaClient:
625
686
  created_raw = task.get("createdAt")
626
687
  created_at = int(created_raw) if isinstance(created_raw, (int, float)) else 0
627
688
 
689
+ # A bool IS an int in Python; `contextCount: true` would read as 1 and
690
+ # buy a context read on a payload that declared nothing countable, so
691
+ # it is excluded explicitly. Negatives clamp to 0 -- "fewer than none"
692
+ # is not a reason to make the read.
693
+ count_raw = task.get("contextCount")
694
+ context_count = (
695
+ max(0, int(count_raw))
696
+ if isinstance(count_raw, (int, float)) and not isinstance(count_raw, bool)
697
+ else 0
698
+ )
699
+
628
700
  number = task.get("taskNumber")
629
701
  return Task(
630
702
  ref=f"TASK-{number}" if isinstance(number, int) else str(ref),
@@ -638,7 +710,58 @@ class AlissaClient:
638
710
  created_at=created_at,
639
711
  dependencies=tuple(dependencies),
640
712
  depends_on_count=depends_on_count,
713
+ context_count=context_count,
714
+ )
715
+
716
+ def get_task_context(self, ref: str) -> list[ContextItem]:
717
+ """The task's context attachments, in the order the endpoint returns
718
+ them (`GET /v1/tasks/<ref>/context`).
719
+
720
+ Ordering is the CALLER's to impose (the renderer sorts on
721
+ `created_at`); this stays a faithful read of the payload. Rows without
722
+ an `_id` are dropped -- an attachment that cannot be addressed cannot be
723
+ cited or edited -- but a row with an id and nothing else is KEPT: the
724
+ payload pins only `_id` and `title`, so treating a shape this adapter
725
+ does not recognise as "no context" would drop the very decisions the
726
+ feature exists to carry.
727
+
728
+ A 2xx whose `context` key is not an array is a contract violation, not
729
+ an empty list, and surfaces as the base `AlissaError` -- the same way
730
+ `get_task` refuses to fabricate a Task. That distinction is load-
731
+ bearing on the release path: it holds the release instead of filing an
732
+ issue that silently lost the operator's decisions.
733
+ """
734
+ payload = self._request(
735
+ "GET", f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/context"
641
736
  )
737
+ rows = payload.get("context") if isinstance(payload, dict) else None
738
+ if not isinstance(rows, list):
739
+ raise AlissaError(200, f"no context array in response for {ref!r}")
740
+
741
+ out: list[ContextItem] = []
742
+ for row in rows:
743
+ if not isinstance(row, dict):
744
+ continue
745
+ item_id = row.get("_id")
746
+ if not isinstance(item_id, str) or not item_id:
747
+ continue
748
+ created_raw = row.get("createdAt")
749
+ out.append(
750
+ ContextItem(
751
+ id=item_id,
752
+ title=str(row.get("title") or ""),
753
+ markdown=str(row.get("markdownContent") or ""),
754
+ link=str(row.get("link") or ""),
755
+ note=str(row.get("note") or ""),
756
+ created_at=(
757
+ int(created_raw)
758
+ if isinstance(created_raw, (int, float))
759
+ and not isinstance(created_raw, bool)
760
+ else 0
761
+ ),
762
+ )
763
+ )
764
+ return out
642
765
 
643
766
  def add_evidence(self, ref: str, title: str, markdown: str, *, item_type: str = "evidence") -> None:
644
767
  """Attach an evidence/deliverable item (outputs surface, not context).
@@ -46,10 +46,40 @@ issues the develop daemon can pick up:
46
46
  * **Ordering** is priority-then-age: the highest-priority ready task goes
47
47
  first, oldest breaking ties, so the queue drains deterministically.
48
48
  * **Rendering** is mechanical: the issue body is the task description with its
49
- control trailers stripped, then the Definition of Done, an acceptance-criteria
50
- checklist, and a canonical trailer block (`Autonomous-Dev`, `Autonomous-Scope`
51
- if any, `Alissa-Task:`). The `Autonomous-*` trailers are re-emitted so lane
52
- occupancy stays derivable from the open issues themselves.
49
+ control trailers stripped, then -- since O14 -- the origin task's CONTEXT
50
+ ATTACHMENTS, then the Definition of Done, an acceptance-criteria checklist,
51
+ and a canonical trailer block (`Autonomous-Dev`, `Autonomous-Scope` if any,
52
+ `Alissa-Task:`). The `Autonomous-*` trailers are re-emitted so lane occupancy
53
+ stays derivable from the open issues themselves.
54
+ * **Context attachments travel** (O14, issue #34). An operator's decisions
55
+ recorded on a task AFTER it was authored used to reach nobody: the issue body
56
+ was rendered from description + DoD + criteria, and a worker cannot read the
57
+ origin task cross-partition. So the release path now reads
58
+ `GET /v1/tasks/<ref>/context` (skipped entirely when the detail's
59
+ `contextCount` is 0) and renders a `## Context` section between the
60
+ description and the DoD. Three properties make it safe to put operator- and
61
+ third-party-authored text into a body the daemon itself parses:
62
+
63
+ - **No line of it can be a trailer.** The canonical block sits at the END of
64
+ the body while every trailer parser is line-anchored and FIRST-match-wins,
65
+ so a context line beginning with a control key would silently win over the
66
+ real one -- retargeting the repo, the scope, or (worst) the origin task the
67
+ close edge advances on merge. Every such line is blockquoted before it is
68
+ rendered; see `escape_control_lines` and `control_keys`.
69
+ - **Loud caps.** Per attachment and per section, both marked in the body with
70
+ `[context truncated: ...]` and counted onto the release log line, with the
71
+ section's ceiling computed from what is LEFT under GitHub's 65,536-char
72
+ issue-body limit.
73
+ - **Hold, don't guess -- then page.** A task that declares attachments but
74
+ whose context read fails is HELD for the pass and retried next poll; the
75
+ rest of the pass proceeds. EVERY read failure holds, not only the transient
76
+ ones, because a permanent one is equally a release whose decisions did not
77
+ travel -- and because that widening admits conditions that never
78
+ self-clear, the hold carries the floor-then-page shape `_dedupe_unreadable`
79
+ uses rather than holding quietly forever. See `_context_unreadable`.
80
+
81
+ Context is a SNAPSHOT taken at release time -- attachments added afterwards
82
+ do not re-render an open issue.
53
83
  * **Create -> ledger -> label**, in that order, with idempotent crash
54
84
  completion: the issue is created WITHOUT the trigger label, the ledger row is
55
85
  written (`labeled=0`), then the label is stamped (`labeled=1`). The label is
@@ -250,7 +280,8 @@ import time
250
280
  from collections import Counter
251
281
  from dataclasses import dataclass
252
282
  from enum import Enum
253
- from typing import Callable
283
+ from functools import lru_cache
284
+ from typing import Callable, Sequence
254
285
 
255
286
  from .alissa_client import (
256
287
  AlissaClient,
@@ -258,6 +289,7 @@ from .alissa_client import (
258
289
  AlissaTransient,
259
290
  BodyOfWork,
260
291
  BowMember,
292
+ ContextItem,
261
293
  Task,
262
294
  )
263
295
  from .config import FEED_BOW, Config
@@ -292,6 +324,60 @@ DEP_CLEAR_STATES = frozenset({"pending_validation", "validated"})
292
324
  TASK_TODO_STATE = "committed"
293
325
  TASK_IN_FLIGHT_STATE = "in_progress"
294
326
 
327
+ # --- the rendered context section (O14, issue #34) -------------------------
328
+ #
329
+ # The third trailer key of the canonical block. Unlike `marker_key`/`scope_key`
330
+ # it is NOT configurable -- `ghclient.TASK_TRAILER_RE` hardcodes it, and both
331
+ # daemons parse it -- so it is a constant here and the renderer emits it from
332
+ # this one place rather than a literal.
333
+ TASK_TRAILER_KEY = "Alissa-Task"
334
+
335
+ # GitHub refuses an issue body longer than this. Everything the context section
336
+ # adds is bounded against what is left of it.
337
+ GITHUB_BODY_LIMIT = 65536
338
+
339
+ # The two context caps. Deliberately CONSTANTS, not config keys: issue #34 asks
340
+ # for documented caps, not tunable ones, and a config key is a compatibility
341
+ # commitment. Sized so the common case never notices (a decision write-up is
342
+ # hundreds of chars, not thousands) while a pathological attachment cannot eat
343
+ # the issue: three full-size attachments still leave ~40k for the operator's own
344
+ # description, DoD and criteria. Widening them later is additive.
345
+ CONTEXT_ITEM_MAX_CHARS = 8000
346
+ CONTEXT_SECTION_MAX_CHARS = 24000
347
+
348
+ CONTEXT_HEADING = "## Context"
349
+ # The two headings `render_issue` emits AFTER the context section. Constants
350
+ # rather than literals because `strip_context_section` terminates the excision
351
+ # on them: renaming a heading at the render site and not the excision site
352
+ # would silently re-open the O13 warning this PR closed, and that is a failure
353
+ # nothing else would catch.
354
+ DOD_HEADING = "## Definition of Done"
355
+ CRITERIA_HEADING = "## Acceptance criteria"
356
+ # The per-attachment subsection heading. Also the structural signature
357
+ # `strip_context_section` anchors the START of the excision on.
358
+ CONTEXT_SUBHEADING = "### "
359
+ # Every truncation marker starts with this, at both levels, so one grep over an
360
+ # issue body answers "did anything get cut".
361
+ CONTEXT_TRUNCATED_PREFIX = "[context truncated:"
362
+ CONTEXT_ESCAPE_PREFIX = "> "
363
+ CONTEXT_UNTITLED = "(untitled attachment)"
364
+ CONTEXT_EMPTY = "_(no inline content — see the attachment on the task)_"
365
+
366
+ # Marks the one attachment the section may cut mid-block (the first, and only
367
+ # when nothing fits whole). Carries its own separator so the room arithmetic can
368
+ # subtract exactly what appending it costs.
369
+ CONTEXT_PARTIAL_MARKER = (
370
+ f"\n\n{CONTEXT_TRUNCATED_PREFIX} attachment cut to fit the remaining "
371
+ f"section budget]"
372
+ )
373
+
374
+ # The floor under a partial first attachment. When even the first block will not
375
+ # fit whole, it is cut to the room that IS left rather than rendering an empty
376
+ # section -- but only if this much of it survives; below that the "attachment"
377
+ # would be a title and a stub, which tells the worker less than the explicit
378
+ # "0 of N rendered" notice does.
379
+ CONTEXT_MIN_PARTIAL_CHARS = 200
380
+
295
381
  # The close edge only mutates a task the loop itself left in one of these two
296
382
  # states. `in_progress` is the NORMAL one since issue #30 -- the release edge
297
383
  # sets it the moment the issue exists, so that is how a task in flight reads.
@@ -340,6 +426,18 @@ ESCALATION_DEDUPE_UNREADABLE = "dedupe-unreadable"
340
426
  # reach of every prefix read of a page kind.
341
427
  DEDUPE_UNREADABLE_SINCE = "marker:dedupe-unreadable"
342
428
 
429
+ # The O14 context read could not answer. Same floor-then-page shape, and for the
430
+ # same reason `_dedupe_unreadable` gives: `_release` holds on EVERY `AlissaError`
431
+ # rather than only the transient ones (an issue filed without the operator's
432
+ # decisions cannot be taken back), and both permanent shapes are reachable -- a
433
+ # 403 on the context route for this actor, and the `no context array` contract
434
+ # violation. Neither self-clears, so without a page the task holds forever on an
435
+ # INFO line. The marker measures THE FAILURE, not the task age, for the same
436
+ # reason again: this is a single I/O outcome, so an old task would page on
437
+ # contact with a self-clearing blip and burn the kind.
438
+ ESCALATION_CONTEXT_UNREADABLE = "context-unreadable"
439
+ CONTEXT_UNREADABLE_SINCE = "marker:context-unreadable"
440
+
343
441
  # How many CLOSED trailer matches the pre-create dedupe will walk looking for a
344
442
  # merged closing PR. Each check is a bounded timeline walk (see
345
443
  # `GitHub.closing_merged_pr`), and the matches are walked newest-first, so this
@@ -478,6 +576,17 @@ DEDUPE_UNREADABLE_COMMENT = (
478
576
  "work by hand) to clear it."
479
577
  )
480
578
 
579
+ CONTEXT_UNREADABLE_COMMENT = (
580
+ "**Orchestrator: context unreadable** — this task declares "
581
+ "{count} context attachment(s), and the orchestrator has been unable to "
582
+ "read them continuously for over {floor_days} day(s): {reason}. The "
583
+ "release is held rather than file a develop issue WITHOUT the decisions "
584
+ "recorded on this task, and it will keep retrying. Usual causes: the "
585
+ "orchestrator's token lost its role on this task, or the context endpoint "
586
+ "is returning an unexpected shape. Restore the read (or delete the "
587
+ "attachments if they are no longer needed) to clear it."
588
+ )
589
+
481
590
  ALREADY_MERGED_ISSUE_COMMENT = (
482
591
  "**Orchestrator: already merged** — this closed issue carries the "
483
592
  "`Alissa-Task:` trailer of a task that is still queued for release. No "
@@ -752,11 +861,18 @@ class Orchestrator:
752
861
  # malformed trailer -- and the two are indistinguishable in
753
862
  # every later line, because from here on the lane only carries
754
863
  # `[]`. Said once per issue per pass, here, where the difference
755
- # is still visible. (A released body keeps the operator's prose
864
+ # is still visible. (A released body keeps the operator's PROSE
756
865
  # verbatim, mid-line fragment and all: `_strip_control_lines`
757
866
  # drops trailers by the same anchored rule, so what was missed
758
- # in the task is missed in the issue too.)
759
- note = self._malformed_trailer(issue.body, self.config.scope_key)
867
+ # in the task is missed in the issue too. It does NOT keep only
868
+ # the operator's prose -- since O14 the body also carries the
869
+ # rendered context section, whose escaped lines are mid-line
870
+ # keys orcloop wrote itself. Hence the excision: the warning
871
+ # must only ever name text a human wrote.)
872
+ note = self._malformed_trailer(
873
+ strip_context_section(issue.body, self.config),
874
+ self.config.scope_key,
875
+ )
760
876
  if note:
761
877
  log.warning(
762
878
  "%s declares no `%s:` trailer, so it occupies the WHOLE "
@@ -1809,8 +1925,12 @@ class Orchestrator:
1809
1925
  # to `_dedupe_result`'s crash-recovery branch rather than back here.
1810
1926
  # What recurs in the malformed-trailer case is the lane hold above,
1811
1927
  # which does carry it.
1928
+ # Context section excised for the same reason as in `_scan_released`:
1929
+ # the issue being adopted may well be one orcloop rendered (a crash
1930
+ # between create and the ledger write leaves exactly that), so its
1931
+ # escaped context lines are mid-line keys the daemon wrote, not a human.
1812
1932
  issue_note = "" if issue_scope else self._malformed_trailer(
1813
- issue.body, self.config.scope_key
1933
+ strip_context_section(issue.body, self.config), self.config.scope_key
1814
1934
  )
1815
1935
  labeled = self.config.label in issue.labels
1816
1936
  if not labeled:
@@ -2083,6 +2203,136 @@ class Orchestrator:
2083
2203
  if self.state.escalated(task_number, kind):
2084
2204
  self.state.clear_escalation(task_number, kind)
2085
2205
 
2206
+ def _context_unreadable(self, task: Task, exc: AlissaError) -> tuple[str, Decision]:
2207
+ """The context read could not answer, so the release is HELD rather
2208
+ than degraded -- with the floor-then-page shape `_dedupe_unreadable`
2209
+ uses, and for the identical reason.
2210
+
2211
+ Why hold at all: the attachments are where the operator's
2212
+ post-authoring decisions live (issue #34), so an issue filed without
2213
+ them briefs the worker on a superseded plan. A held pass is recoverable
2214
+ next poll; that is not.
2215
+
2216
+ Why hold on EVERY `AlissaError` and not just `AlissaTransient` as #34
2217
+ specifies: a permanent failure is still a release whose decisions did
2218
+ not travel. Both permanent shapes are reachable -- a 403 on the context
2219
+ route for this actor, and the `no context array` contract violation --
2220
+ and narrowing the catch would put them on the release path with no
2221
+ route at all, which is the worse of the two.
2222
+
2223
+ Why that widening OBLIGES this method: a permanent failure does not
2224
+ self-clear, so without a page the task holds on an INFO line forever
2225
+ and no operator is ever told. Every other unbounded hold in this loop
2226
+ (`release-blocked`, `dedupe-unreadable`) pages past the floor.
2227
+
2228
+ The floor measures THE FAILURE, not the task age -- `_dedupe_unreadable`
2229
+ argues this at length and the argument transfers exactly: this is a
2230
+ single I/O outcome, so an old task would page on contact with a
2231
+ self-clearing blip and burn the kind for the genuine outage later. So
2232
+ the first failure records `marker:context-unreadable` from the loop's
2233
+ own clock and holds quietly; only once THAT is older than
2234
+ `release_blocked_floor_days` does one `context-unreadable` escalation
2235
+ land. `_read_context` clears both rows the moment a read answers.
2236
+
2237
+ The floor knob is shared with the other two on purpose: all three ask
2238
+ "how long may a release be quietly held before an operator is told",
2239
+ and a fourth knob for the same question is a config surface (and a
2240
+ container floor bump) that buys nothing.
2241
+ """
2242
+ floor_seconds = self.config.release_blocked_floor_days * 86400
2243
+ held = (
2244
+ f"context unreadable ({task.context_count} attachment(s) declared): "
2245
+ f"{exc}; held rather than file an issue without the operator's "
2246
+ f"decisions"
2247
+ )
2248
+ rows = self.state.escalations_matching(task.task_number, CONTEXT_UNREADABLE_SINCE)
2249
+ now = int(self._now())
2250
+ if not rows:
2251
+ if not self.config.dry_run:
2252
+ self.state.record_escalation(
2253
+ task.task_number, CONTEXT_UNREADABLE_SINCE, at=now,
2254
+ )
2255
+ return self._logged(task.ref, Decision(
2256
+ Action.HELD,
2257
+ held + "; first failure, retries next poll",
2258
+ task.task_number,
2259
+ ))
2260
+
2261
+ failing_for = now - int(rows[0]["escalated_at"])
2262
+ if failing_for < floor_seconds:
2263
+ return self._logged(task.ref, Decision(
2264
+ Action.HELD,
2265
+ held + f"; failing for {failing_for // 3600}h, within the "
2266
+ f"{self.config.release_blocked_floor_days}d floor",
2267
+ task.task_number,
2268
+ ))
2269
+
2270
+ self._escalate(
2271
+ task.task_number,
2272
+ ESCALATION_CONTEXT_UNREADABLE,
2273
+ CONTEXT_UNREADABLE_COMMENT.format(
2274
+ count=task.context_count,
2275
+ reason=str(exc),
2276
+ floor_days=self.config.release_blocked_floor_days,
2277
+ ),
2278
+ )
2279
+ return self._logged(task.ref, Decision(
2280
+ Action.ESCALATED,
2281
+ held + f", and failing for {failing_for // 86400}d — past the "
2282
+ f"{self.config.release_blocked_floor_days}d floor",
2283
+ task.task_number,
2284
+ ))
2285
+
2286
+ def _context_answered(self, task_number: int) -> None:
2287
+ """The context read answered, so any recorded failure is OVER: drop the
2288
+ first-seen marker and the page kind together.
2289
+
2290
+ Clearing the page kind is the point, exactly as in `_dedupe_answered`:
2291
+ `_escalate` dedupes per (task, kind), so without a clear a SECOND,
2292
+ unrelated outage would never page. Guarded by a read so the common
2293
+ (working) path does not write on every release."""
2294
+ if self.config.dry_run:
2295
+ return
2296
+ for kind in (CONTEXT_UNREADABLE_SINCE, ESCALATION_CONTEXT_UNREADABLE):
2297
+ if self.state.escalated(task_number, kind):
2298
+ self.state.clear_escalation(task_number, kind)
2299
+
2300
+ def _read_context(self, task: Task) -> list[ContextItem]:
2301
+ """The origin task's context attachments, or [] when it declares none.
2302
+
2303
+ `contextCount` is the skip: a task with 0 (or a payload that omits the
2304
+ key) costs no extra call, which is every task that predates O14 and
2305
+ most that follow. The count is only ever compared against zero -- it is
2306
+ a denormalised counter, the two reads are not atomic, and keying
2307
+ anything on it AGREEING with the context payload would turn an operator
2308
+ deleting an attachment mid-poll into a held release.
2309
+
2310
+ Raises whatever `get_task_context` raises; the caller holds the release
2311
+ on it (`_context_unreadable`).
2312
+
2313
+ Every path that RETURNS clears the failure marker, including the
2314
+ zero-count skip: the condition the marker records is "the release path
2315
+ could not get what it needed", and a task whose attachments were all
2316
+ deleted no longer needs anything. Leaving the row behind would make a
2317
+ much later, unrelated failure inherit an old clock and page immediately.
2318
+ """
2319
+ if task.context_count <= 0:
2320
+ self._context_answered(task.task_number)
2321
+ return []
2322
+ items = self.alissa.get_task_context(task.ref)
2323
+ self._context_answered(task.task_number)
2324
+ if not items:
2325
+ # Not an error and not a hold: the count is denormalised, so this
2326
+ # is what "the last attachment was deleted since the detail read"
2327
+ # looks like. Worth a line, since it is also what a contextCount
2328
+ # that has drifted looks like.
2329
+ log.debug(
2330
+ "%s: declares %d context attachment(s) but the context read "
2331
+ "returned none — releasing without a context section",
2332
+ task.ref, task.context_count,
2333
+ )
2334
+ return items
2335
+
2086
2336
  def _release(
2087
2337
  self,
2088
2338
  task: Task,
@@ -2104,7 +2354,10 @@ class Orchestrator:
2104
2354
 
2105
2355
  Nothing is created until the TRAILER dedupe answers: the pass-level
2106
2356
  scan is label-scoped and cannot see an unlabeled issue that already
2107
- carries this task's trailer (see `_trailer_dedupe`).
2357
+ carries this task's trailer (see `_trailer_dedupe`). The context read
2358
+ (O14) comes AFTER it, in the one branch that renders a body: a
2359
+ candidate that turns out to be a duplicate adopts an existing issue and
2360
+ never renders, so it must not spend the read either.
2108
2361
 
2109
2362
  `note` is the caller's missed-trailer warning (empty unless this is a
2110
2363
  SCOPELESS release with trailer-like text in the body). It rides on the
@@ -2112,13 +2365,22 @@ class Orchestrator:
2112
2365
  the record of a task taking the whole lane -- possibly on the strength
2113
2366
  of a trailer nobody parsed. It is passed in rather than recomputed so
2114
2367
  one candidate can never produce two of them; the dedupe path returns
2115
- above with its own note, computed on the ISSUE body it acts on."""
2368
+ above with its own note, computed on the ISSUE body it acts on. The
2369
+ renderer's own context note (what was rendered, cut or escaped) is
2370
+ appended to it, so one release still produces exactly one line."""
2116
2371
  owner, _, repo = target.partition("/")
2117
2372
  preexisting = self._trailer_dedupe(task, target, occupancy, bow=bow)
2118
2373
  if preexisting is not None:
2119
2374
  return preexisting
2120
2375
 
2121
- body = render_issue_body(task, target, scope, self.config)
2376
+ try:
2377
+ context = self._read_context(task)
2378
+ except AlissaError as exc:
2379
+ return self._context_unreadable(task, exc)
2380
+
2381
+ rendered = render_issue(task, target, scope, self.config, context)
2382
+ body = rendered.body
2383
+ note += rendered.context_note
2122
2384
 
2123
2385
  if self.config.dry_run:
2124
2386
  occupancy.setdefault(target, []).append(scope)
@@ -2424,7 +2686,7 @@ def _strip_control_lines(text: str, config: Config) -> str:
2424
2686
  `render_issue_body`, so this only removes the operator's raw form."""
2425
2687
  marker_re = re.compile(rf"^{re.escape(config.marker_key)}\s*:", re.IGNORECASE)
2426
2688
  scope_re = re.compile(rf"^{re.escape(config.scope_key)}\s*:", re.IGNORECASE)
2427
- task_re = re.compile(r"^Alissa-Task\s*:", re.IGNORECASE)
2689
+ task_re = re.compile(rf"^{re.escape(TASK_TRAILER_KEY)}\s*:", re.IGNORECASE)
2428
2690
  kept: list[str] = []
2429
2691
  for line in text.splitlines():
2430
2692
  stripped = line.strip()
@@ -2436,30 +2698,422 @@ def _strip_control_lines(text: str, config: Config) -> str:
2436
2698
  return "\n".join(kept).strip()
2437
2699
 
2438
2700
 
2439
- def render_issue_body(task: Task, target: str, scope: list[str], config: Config) -> str:
2701
+ def control_keys(config: Config) -> tuple[str, ...]:
2702
+ """Every trailer key a line in the rendered body must not begin with.
2703
+
2704
+ All THREE of the canonical block's keys, not just the two the marker
2705
+ contract owns. `Alissa-Task` is here because it is parsed exactly like the
2706
+ others -- `ghclient.TASK_TRAILER_RE` is line-anchored and first-match-wins
2707
+ -- and a body remapped to a stranger's task number is the worst of the
2708
+ three outcomes: the close edge would drive that task forward on merge.
2709
+ """
2710
+ return (config.marker_key, config.scope_key, TASK_TRAILER_KEY)
2711
+
2712
+
2713
+ @lru_cache(maxsize=None)
2714
+ def _control_line_re(keys: tuple[str, ...]) -> re.Pattern[str]:
2715
+ """A line-start match for any of `keys` followed by a colon.
2716
+
2717
+ Deliberately WIDER than the parsers it defends: case-insensitive (the
2718
+ parsers split on this -- `parse_marker` is case-sensitive,
2719
+ `_strip_control_lines` is not) and tolerant of space before the colon
2720
+ (`_strip_control_lines` allows it, `parse_marker` does not). Escaping a
2721
+ line no parser would have read costs one `> ` in an issue body; missing one
2722
+ a parser WOULD have read costs a retargeted release.
2723
+ """
2724
+ alternation = "|".join(re.escape(key) for key in keys)
2725
+ return re.compile(rf"^(?:{alternation})[ \t]*:", re.IGNORECASE)
2726
+
2727
+
2728
+ def escape_control_lines(text: str, keys: tuple[str, ...]) -> tuple[str, int]:
2729
+ """`(text, n)` with every line that begins with one of `keys` blockquoted.
2730
+
2731
+ Split on `"\\n"` and nothing else: that is exactly the set of line starts
2732
+ `re.MULTILINE`'s `^` recognises, and `str.splitlines()` is not -- it also
2733
+ breaks on `\\v`, `\\f`, `\\x1c`-`\\x1e` and `\\u2028`/`\\u2029`, so it would
2734
+ report a "line" the parsers cannot see and, worse, JOIN back on `\\n` and
2735
+ thereby CREATE one. The escape must not be able to manufacture the
2736
+ condition it exists to remove.
2737
+
2738
+ Blockquoting (`> `) rather than a backslash escape: `\\A` is not a
2739
+ CommonMark escape (only ASCII punctuation is), so it would render as a
2740
+ literal backslash, while a blockquote renders as quoted text and reads as
2741
+ what it is. The prefix is applied even inside a fenced code block -- a
2742
+ fence means nothing to a line-anchored regex, so a code block is exactly
2743
+ where an injected trailer would hide.
2744
+ """
2745
+ pattern = _control_line_re(keys)
2746
+ escaped = 0
2747
+ out: list[str] = []
2748
+ for line in text.split("\n"):
2749
+ if pattern.match(line):
2750
+ line = CONTEXT_ESCAPE_PREFIX + line
2751
+ escaped += 1
2752
+ out.append(line)
2753
+ return "\n".join(out), escaped
2754
+
2755
+
2756
+ def count_escaped_lines(text: str, keys: tuple[str, ...]) -> int:
2757
+ """How many lines of `text` are escaped control lines.
2758
+
2759
+ Counted on RENDERED text rather than accumulated as blocks are built, so
2760
+ the number describes the body on every path -- including the partial cut,
2761
+ which truncates a block after it was escaped and would otherwise report
2762
+ escapes that were cut away with the tail.
2763
+
2764
+ The one over-count: an attachment whose own write-up literally contains
2765
+ `> Autonomous-Dev: ...` is indistinguishable from a line this renderer
2766
+ escaped, and is counted. That line is trailer-like text sitting inert in an
2767
+ issue body either way, so naming it in the log is not misleading.
2768
+ """
2769
+ pattern = _control_line_re(keys)
2770
+ return sum(
2771
+ 1
2772
+ for line in text.split("\n")
2773
+ if line.startswith(CONTEXT_ESCAPE_PREFIX)
2774
+ and pattern.match(line[len(CONTEXT_ESCAPE_PREFIX):])
2775
+ )
2776
+
2777
+
2778
+ def strip_context_section(body: str, config: Config) -> str:
2779
+ """An ISSUE body with the rendered `## Context` section removed.
2780
+
2781
+ For the O13 missed-trailer warning only, and only on issue bodies. That
2782
+ warning means "a human wrote a trailer where the parser cannot see it", and
2783
+ after O14 an issue body contains text no human wrote: every escaped context
2784
+ line (`> Autonomous-Scope: ...`) is, by construction, a key that is not at a
2785
+ line start -- exactly what `find_mid_line_trailer` reports. Left alone, a
2786
+ scopeless release whose attachments merely MENTIONED a trailer warns every
2787
+ poll, for the life of the issue, pointing the operator at a task body with
2788
+ nothing in it to fix.
2789
+
2790
+ Delimited excision rather than "skip lines starting with the escape
2791
+ prefix": a human who hand-writes `> Autonomous-Scope: src/**` into an issue
2792
+ HAS written a trailer the parser cannot see, and silencing that is exactly
2793
+ the signal O13 exists to give. This removes only what orcloop itself
2794
+ generated, and a body whose section a human edited away has nothing to
2795
+ remove.
2796
+
2797
+ THE START ANCHOR. `## Context` is not orcloop's to claim on the way IN any
2798
+ more than `## ` was on the way out: a task description is operator markdown
2799
+ and "Context" is one of the headings an operator most likely writes (this
2800
+ repo's README has one). So the section is the LAST `## Context` before the
2801
+ first terminator that is also STRUCTURALLY one -- first non-blank line
2802
+ under it is a `### ` subsection or the truncation notice, which is what
2803
+ `render_context_section` emits in all three of its shapes. The `last` is
2804
+ what picks orcloop's over an operator's when a body has both; the
2805
+ structural test is what stops a ZERO-attachment body (no section rendered
2806
+ at all -- every task predating O14) from having the operator's own heading
2807
+ excised instead.
2808
+
2809
+ The rule behind both: **when the anchor is ambiguous, do nothing.** Not
2810
+ excising costs a spurious warning an operator can dismiss; excising wrongly
2811
+ costs a warning they never see, and a lost diagnostic is the failure this
2812
+ function's own callers exist to prevent. Every tie breaks toward keeping
2813
+ the signal. Residual: an operator heading `## Context` followed by their own
2814
+ `### ` subheading on a task with no attachments.
2815
+
2816
+ THE TERMINATORS, and why there are two. A bare `## ` test is wrong: an
2817
+ attachment is operator markdown and operator markdown has headings, so the
2818
+ first heading in a write-up would end the excision and hand every escaped
2819
+ line after it back to the warning (PR #35 review, round 2).
2820
+
2821
+ * **A line that begins with a control key** -- the canonical trailer block.
2822
+ Attachment content CANNOT spell this one, and the guarantee comes from
2823
+ the escape itself: `escape_control_lines` leaves no line inside the
2824
+ section beginning with a control key, so a line-start `Autonomous-Dev:`
2825
+ can only be the block `render_issue` emitted. The property added for
2826
+ injection doubles as an unspoofable delimiter, which is why no sentinel
2827
+ markup is needed in the body.
2828
+ * **`DOD_HEADING` / `CRITERIA_HEADING`** -- needed because the trailer
2829
+ terminator alone would swallow those two sections, and they are
2830
+ human-written text where O13 should still warn. These ARE spellable by an
2831
+ attachment; the residual is that a write-up reproducing one of the two
2832
+ verbatim ends the excision early and the warning returns for that issue.
2833
+ The cost is log noise: this function feeds only `_malformed_trailer`, and
2834
+ both call sites still take `parse_scope` from the RAW body, so no lane,
2835
+ dedupe or trailer parse can be moved by anything here.
2836
+ """
2837
+ terminator = _control_line_re(control_keys(config))
2838
+ lines = body.split("\n")
2839
+
2840
+ def ends_the_section(index: int) -> bool:
2841
+ line = lines[index]
2842
+ return (
2843
+ line.strip() in (DOD_HEADING, CRITERIA_HEADING) or bool(terminator.match(line))
2844
+ )
2845
+
2846
+ def renders_a_section(index: int) -> bool:
2847
+ """Whether the `## Context` at `index` is followed by what this
2848
+ renderer puts under one: a `### ` subsection (the normal and
2849
+ partial-cut shapes) or the truncation notice (the nothing-fit shape)."""
2850
+ for line in lines[index + 1:]:
2851
+ if not line.strip():
2852
+ continue
2853
+ return line.startswith(CONTEXT_SUBHEADING) or line.startswith(
2854
+ CONTEXT_TRUNCATED_PREFIX
2855
+ )
2856
+ return False
2857
+
2858
+ start = -1
2859
+ for index in range(len(lines)):
2860
+ if ends_the_section(index):
2861
+ break
2862
+ if lines[index].strip() == CONTEXT_HEADING and renders_a_section(index):
2863
+ start = index # LAST valid candidate wins: see below
2864
+ if start < 0:
2865
+ return body
2866
+
2867
+ end = len(lines)
2868
+ for index in range(start + 1, len(lines)):
2869
+ if ends_the_section(index):
2870
+ end = index
2871
+ break
2872
+ return "\n".join(lines[:start] + lines[end:])
2873
+
2874
+
2875
+ def _context_item_block(item: ContextItem, cap: int) -> tuple[str, bool]:
2876
+ """One attachment as `(markdown, was_truncated)`, before escaping.
2877
+
2878
+ Title, then the `note` (the operator's one-line "why this is relevant") in
2879
+ italics, then the payload: the markdown write-up AND the link. Both, not
2880
+ the first one present -- `markdownContent` and `link` are independent fields
2881
+ and an attachment may carry both, so picking one would silently discard
2882
+ operator content, which is the failure mode this whole feature exists to
2883
+ remove. The link is rendered bare (GitHub autolinks it; angle brackets would
2884
+ break on a `>` inside the URL) and is never truncated: a URL is bounded by
2885
+ nature and it is the pointer back to the full source when the write-up was
2886
+ cut. An attachment whose payload this adapter does not inline (a vault
2887
+ reference) still gets its titled subsection -- the title and the note are
2888
+ themselves the decision often enough.
2889
+
2890
+ Only the write-up is capped, at `cap`. `cap` is the per-attachment ceiling
2891
+ the caller can afford, which is `CONTEXT_ITEM_MAX_CHARS` unless the section
2892
+ budget is tighter than that.
2893
+ """
2894
+ lines = [f"{CONTEXT_SUBHEADING}{item.title.strip() or CONTEXT_UNTITLED}", ""]
2895
+ if item.note.strip():
2896
+ lines.extend([f"_{item.note.strip()}_", ""])
2897
+
2898
+ content = item.markdown.strip()
2899
+ # Captured BEFORE the reassignment below: recomputing it from `item` would
2900
+ # keep reporting the right number only for as long as `content` is derived
2901
+ # exactly one way, and the marker's job is to describe what was actually cut.
2902
+ original = len(content)
2903
+ truncated = original > cap
2904
+ if truncated:
2905
+ content = (
2906
+ content[:cap].rstrip()
2907
+ + f"\n\n{CONTEXT_TRUNCATED_PREFIX} {cap} of {original} chars]"
2908
+ )
2909
+ link = item.link.strip()
2910
+ if link:
2911
+ content = f"{content}\n\n{link}" if content else link
2912
+ lines.append(content or CONTEXT_EMPTY)
2913
+ return "\n".join(lines), truncated
2914
+
2915
+
2916
+ def _omitted_notice(rendered: int, total: int, budget: int) -> str:
2917
+ return (
2918
+ f"{CONTEXT_TRUNCATED_PREFIX} {rendered} of {total} attachments "
2919
+ f"rendered; the section is capped at {budget} chars]"
2920
+ )
2921
+
2922
+
2923
+ def render_context_section(
2924
+ items: Sequence[ContextItem], config: Config, budget: int
2925
+ ) -> tuple[str, str]:
2926
+ """`(section_markdown, note)` for the origin task's context attachments.
2927
+
2928
+ `note` is a one-line summary for the release log -- empty only when there
2929
+ was nothing to render. Nothing here is silent: every attachment that was
2930
+ cut, dropped or escaped is counted into it AND marked in the body.
2931
+
2932
+ ORDER is chronological on `created_at`, sorted stably, so a payload with no
2933
+ timestamps at all (a shape this adapter must not assume away) keeps the
2934
+ endpoint's own order, which is creation order.
2935
+
2936
+ `budget` is the hard character ceiling for everything returned, computed by
2937
+ the caller from what is LEFT under GitHub's issue-body limit -- see
2938
+ `render_issue`. Fitting is by whole attachment: a half-rendered decision
2939
+ reads as a whole one, whereas "2 of 5 rendered" sends the operator to the
2940
+ task. Per-attachment truncation (`CONTEXT_ITEM_MAX_CHARS`) is the one place
2941
+ a single attachment is cut, and it says so where it was cut.
2942
+ """
2943
+ if not items:
2944
+ return "", ""
2945
+
2946
+ keys = control_keys(config)
2947
+ blocks: list[tuple[str, bool]] = []
2948
+ for item in sorted(items, key=lambda entry: entry.created_at):
2949
+ block, truncated = _context_item_block(item, CONTEXT_ITEM_MAX_CHARS)
2950
+ block, _ = escape_control_lines(block, keys)
2951
+ blocks.append((block, truncated))
2952
+
2953
+ total = len(blocks)
2954
+ sep = "\n\n"
2955
+ # An upper bound on the notice: its two counts are both <= `total`, and the
2956
+ # limit it finally reports is <= `budget`, so rendering it with `total`
2957
+ # twice and `budget` can only be at least as wide as the one emitted.
2958
+ reserve = len(sep) + len(_omitted_notice(total, total, budget))
2959
+ if len(CONTEXT_HEADING) + reserve > budget:
2960
+ # No room even to SAY that nothing fit. Reachable only when the rest of
2961
+ # the body has all but exhausted GitHub's limit on its own; the issue
2962
+ # still gets filed, so this line is the only record.
2963
+ log.error(
2964
+ "context section dropped entirely: %d attachment(s) did not fit in "
2965
+ "the %d chars left under GitHub's %d-char issue-body limit — the "
2966
+ "issue is filed WITHOUT them; shorten the task description or the "
2967
+ "definition of done",
2968
+ total, budget, GITHUB_BODY_LIMIT,
2969
+ )
2970
+ return "", f"; NOTE: {total} context attachment(s) dropped — no room under the body limit"
2971
+
2972
+ def fit(limit: int) -> int:
2973
+ used = len(CONTEXT_HEADING)
2974
+ count = 0
2975
+ for block, _ in blocks:
2976
+ cost = len(sep) + len(block)
2977
+ if used + cost > limit:
2978
+ break
2979
+ used += cost
2980
+ count += 1
2981
+ return count
2982
+
2983
+ kept = fit(budget)
2984
+ limit = budget
2985
+ if kept < total:
2986
+ # Something is being omitted, so the notice is going to be emitted and
2987
+ # has to be paid for. Re-fit against the smaller ceiling -- and REPORT
2988
+ # that ceiling, since it is the one that was applied.
2989
+ limit = budget - reserve
2990
+ kept = fit(limit)
2991
+
2992
+ rendered = [block for block, _ in blocks[:kept]]
2993
+ partial = ""
2994
+ if kept == 0 and total:
2995
+ # Nothing fits whole, but there is room to say something. Rendering the
2996
+ # first decision beats rendering none, and the per-attachment cap
2997
+ # already establishes that a marked partial is an acceptable shape --
2998
+ # so the whole-attachment rule yields exactly here, at the point where
2999
+ # keeping it would mean an empty section. Cutting the ESCAPED block from
3000
+ # the end is safe: truncation only removes text, so it cannot produce a
3001
+ # line start the escape has not already seen.
3002
+ room = limit - len(CONTEXT_HEADING) - len(sep) - len(CONTEXT_PARTIAL_MARKER)
3003
+ if room >= CONTEXT_MIN_PARTIAL_CHARS:
3004
+ partial = blocks[0][0][:room].rstrip() + CONTEXT_PARTIAL_MARKER
3005
+ rendered = [partial]
3006
+
3007
+ parts = [CONTEXT_HEADING] + rendered
3008
+ if kept < total:
3009
+ parts.append(_omitted_notice(kept, total, limit))
3010
+ section = sep.join(parts)
3011
+
3012
+ # What actually REACHED the body, which on the partial path is not
3013
+ # `blocks[:kept]` -- that branch renders `blocks[0]` while deliberately
3014
+ # leaving `kept` at 0 for the notice's sake. Counting the slice alone made
3015
+ # the one attachment in the body the one attachment neither counter could
3016
+ # see, which is precisely what this function's docstring promises cannot
3017
+ # happen. `kept` keeps its meaning; only the counters widen.
3018
+ counted = blocks[:1] if partial else blocks[:kept]
3019
+ truncations = sum(1 for _, truncated in counted if truncated)
3020
+ escapes = count_escaped_lines(section, keys)
3021
+ summary = f"{kept} of {total}" if kept < total else str(total)
3022
+ note = f"; context: {summary} attachment(s) rendered"
3023
+ if partial:
3024
+ note += ", 1 rendered partially to fit"
3025
+ if truncations:
3026
+ note += f", {truncations} truncated"
3027
+ if escapes:
3028
+ note += f", {escapes} trailer-like line(s) escaped"
3029
+ return section, note
3030
+
3031
+
3032
+ @dataclass(frozen=True)
3033
+ class RenderedIssue:
3034
+ """The issue body plus what rendering it had to say out loud."""
3035
+
3036
+ body: str
3037
+ # The `; context: ...` suffix for the release line -- empty when the task
3038
+ # had no context attachments (the common case, and the only one that
3039
+ # renders byte-identically to pre-O14).
3040
+ context_note: str = ""
3041
+
3042
+
3043
+ def render_issue(
3044
+ task: Task,
3045
+ target: str,
3046
+ scope: list[str],
3047
+ config: Config,
3048
+ context: Sequence[ContextItem] = (),
3049
+ ) -> RenderedIssue:
2440
3050
  """The mechanical issue body: task description (control trailers stripped)
2441
- + Definition of Done + acceptance-criteria checklist + a canonical trailer
2442
- block. The `Autonomous-*` trailers are re-emitted (line-anchored, git-trailer
2443
- style) so lane occupancy stays derivable from the open issues, and the
2444
- `Alissa-Task:` trailer links the issue back to its origin task for the
2445
- develop daemon and the close loop."""
3051
+ + the origin task's context attachments + Definition of Done +
3052
+ acceptance-criteria checklist + a canonical trailer block. The
3053
+ `Autonomous-*` trailers are re-emitted (line-anchored, git-trailer style) so
3054
+ lane occupancy stays derivable from the open issues, and the `Alissa-Task:`
3055
+ trailer links the issue back to its origin task for the develop daemon and
3056
+ the close loop.
3057
+
3058
+ `context` (O14, issue #34) is a SNAPSHOT taken at release time: the section
3059
+ sits after the description and before the Definition of Done, because it is
3060
+ the operator amending the brief, not adding a requirement. With no
3061
+ attachments -- the common case, and every case before O14 -- the body is
3062
+ byte-identical to what shipped through 0.3.7 (the last release before this
3063
+ section existed).
3064
+
3065
+ The context section's ceiling is what is LEFT under GitHub's issue-body
3066
+ limit after everything else, capped further at `CONTEXT_SECTION_MAX_CHARS`.
3067
+ It yields to the rest of the body rather than the other way around: the
3068
+ description, the DoD and the criteria come from the operator's own task and
3069
+ are the issue's contract, while the context is the part orcloop is adding.
3070
+ """
2446
3071
  sections: list[str] = []
2447
3072
 
2448
3073
  prose = _strip_control_lines(task.description, config)
2449
3074
  if prose:
2450
3075
  sections.append(prose)
3076
+ # Where the context section goes: straight after the description, or first
3077
+ # when there is none.
3078
+ context_at = len(sections)
2451
3079
 
2452
3080
  if task.definition_of_done.strip():
2453
- sections.append("## Definition of Done\n\n" + task.definition_of_done.strip())
3081
+ sections.append(DOD_HEADING + "\n\n" + task.definition_of_done.strip())
2454
3082
 
2455
3083
  if task.criteria:
2456
3084
  checklist = "\n".join(f"- [ ] {c.description}" for c in task.criteria)
2457
- sections.append("## Acceptance criteria\n\n" + checklist)
3085
+ sections.append(CRITERIA_HEADING + "\n\n" + checklist)
2458
3086
 
2459
3087
  trailers = [f"{config.marker_key}: {target}"]
2460
3088
  if scope:
2461
3089
  trailers.append(f"{config.scope_key}: {', '.join(scope)}")
2462
- trailers.append(f"Alissa-Task: {task.ref}")
3090
+ trailers.append(f"{TASK_TRAILER_KEY}: {task.ref}")
2463
3091
  sections.append("\n".join(trailers))
2464
3092
 
2465
- return "\n\n".join(sections) + "\n"
3093
+ body = "\n\n".join(sections) + "\n"
3094
+ if not context:
3095
+ return RenderedIssue(body)
3096
+
3097
+ section, note = render_context_section(
3098
+ context,
3099
+ config,
3100
+ # `body` already accounts for the trailing newline; the section costs
3101
+ # itself plus the "\n\n" that joins it in.
3102
+ min(CONTEXT_SECTION_MAX_CHARS, GITHUB_BODY_LIMIT - len(body) - 2),
3103
+ )
3104
+ if not section:
3105
+ return RenderedIssue(body, note)
3106
+ sections.insert(context_at, section)
3107
+ return RenderedIssue("\n\n".join(sections) + "\n", note)
3108
+
3109
+
3110
+ def render_issue_body(
3111
+ task: Task,
3112
+ target: str,
3113
+ scope: list[str],
3114
+ config: Config,
3115
+ context: Sequence[ContextItem] = (),
3116
+ ) -> str:
3117
+ """`render_issue(...).body` -- the body alone, for callers with nothing to
3118
+ report about how it was rendered."""
3119
+ return render_issue(task, target, scope, config, context).body
@@ -67,12 +67,16 @@ CREATE TABLE IF NOT EXISTS meta (
67
67
  """
68
68
 
69
69
 
70
- # The only `escalations` kinds `clear_escalation` will remove: the O9 dedupe
71
- # outage clock and the page it raises, both of which describe a condition that
72
- # can END (see `clear_escalation`). Spelled as literals here rather than
73
- # imported from `loop`, which imports this module; `test_loop` pins that the
74
- # loop's constants and this set stay the same two strings.
75
- CLEARABLE_KINDS = frozenset({"marker:dedupe-unreadable", "dedupe-unreadable"})
70
+ # The only `escalations` kinds `clear_escalation` will remove: two outage clocks
71
+ # and the two pages they raise -- the O9 dedupe check and the O14 context read --
72
+ # all four of which describe a condition that can END (see `clear_escalation`).
73
+ # Spelled as literals here rather than imported from `loop`, which imports this
74
+ # module; `test_loop` pins that the loop's constants and this set stay the same
75
+ # four strings.
76
+ CLEARABLE_KINDS = frozenset({
77
+ "marker:dedupe-unreadable", "dedupe-unreadable",
78
+ "marker:context-unreadable", "context-unreadable",
79
+ })
76
80
 
77
81
 
78
82
  class State:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-orcloop
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera